feat: add governed chat agent selection
This commit is contained in:
@@ -56,7 +56,7 @@ Approval inbox / HITL:
|
||||
- `POST /api/v1/approvals/decide` — approve/reject with SoD and reason; approved
|
||||
settings proposals apply through `control-plane-settings.py`.
|
||||
|
||||
Governed Chat (Plan-18 MVP-0/1):
|
||||
Governed Chat (Plan-18 MVP-0/1 + MVP-2 Track 4 foundation):
|
||||
|
||||
- `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,
|
||||
@@ -64,11 +64,14 @@ Governed Chat (Plan-18 MVP-0/1):
|
||||
execution remain harness-owned.
|
||||
- `GET /api/v1/chat/actions` — list registered operator actions from
|
||||
`operator-actions.yaml`; no free-command execution is exposed.
|
||||
- `GET /api/v1/chat/agents` — list governed agents from `agent-registry.yaml`
|
||||
with role visibility; selected agent/skill/delegation are bound by harness
|
||||
`chat-agent-resolver.py`.
|
||||
- `GET /api/v1/chat/audit/verify` — verifies the chat audit hash chain.
|
||||
- `/chat` UI shows actor/role scope, `mode/risk/decision` badges, certified answer,
|
||||
evidence sources, registered operator actions, action-gate status, router details,
|
||||
and audit hash. Side-effect requests outside registered actions return governed
|
||||
`BLOCK` or `NOT_SUPPORTED` responses.
|
||||
evidence sources, registered operator actions, agent binding, action-gate status,
|
||||
router details, and audit hash. Side-effect requests outside registered actions
|
||||
return governed `BLOCK` or `NOT_SUPPORTED` responses.
|
||||
|
||||
FinOps/SLO:
|
||||
|
||||
|
||||
@@ -21,4 +21,9 @@ export class ChatController {
|
||||
actions(@Headers() headers: Record<string, string | string[] | undefined>) {
|
||||
return ok(this.svc.listActions(actorFromHeaders(headers)));
|
||||
}
|
||||
|
||||
@Get('agents')
|
||||
agents(@Headers() headers: Record<string, string | string[] | undefined>) {
|
||||
return ok(this.svc.listAgents(actorFromHeaders(headers)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import type { SettingsActor } from '../settings/settings.service.js';
|
||||
export interface ChatAskInput {
|
||||
message: string;
|
||||
chatId?: string;
|
||||
agentId?: string;
|
||||
skillId?: string;
|
||||
delegationLevel?: number;
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
@@ -18,6 +21,7 @@ interface CommandResult {
|
||||
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 AGENT_CLI = join(HARNESS_BIN, 'chat-agent-resolver.py');
|
||||
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
|
||||
|
||||
function runPython(script: string, args: string[]): CommandResult {
|
||||
@@ -55,17 +59,25 @@ export class ChatService {
|
||||
}
|
||||
this.requireRead(actor);
|
||||
|
||||
const res = runPython(CHAT_CLI, [
|
||||
const args = [
|
||||
'ask',
|
||||
'--message',
|
||||
input.message,
|
||||
'--actor',
|
||||
actor.actor,
|
||||
'--role',
|
||||
actor.role,
|
||||
'--project',
|
||||
actor.project,
|
||||
'--chat-id',
|
||||
input.chatId || 'chat-default',
|
||||
'--tenant',
|
||||
actor.tenant,
|
||||
]);
|
||||
];
|
||||
if (input.agentId) args.push('--agent', input.agentId);
|
||||
if (input.skillId) args.push('--skill', input.skillId);
|
||||
if (input.delegationLevel !== undefined) args.push('--delegation-level', String(input.delegationLevel));
|
||||
const res = runPython(CHAT_CLI, args);
|
||||
const parsed = parseJson<Record<string, any>>(res.stdout);
|
||||
if (parsed) {
|
||||
return { ...parsed, actor, audit_verify: this.verifyAudit() };
|
||||
@@ -89,6 +101,14 @@ export class ChatService {
|
||||
throw new InternalServerErrorException(res.stderr || res.stdout || 'CHAT_OPERATOR_ACTIONS_FAILED');
|
||||
}
|
||||
|
||||
listAgents(actor: SettingsActor) {
|
||||
this.requireRead(actor);
|
||||
const res = runPython(AGENT_CLI, ['list-agents', '--role', actor.role]);
|
||||
const parsed = parseJson<Record<string, any>>(res.stdout);
|
||||
if (parsed) return parsed;
|
||||
throw new InternalServerErrorException(res.stderr || res.stdout || 'CHAT_AGENTS_FAILED');
|
||||
}
|
||||
|
||||
private requireRead(actor: SettingsActor) {
|
||||
const res = runPython(RBAC_CLI, [
|
||||
'check',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { join } from 'node:path';
|
||||
import { ChatService } from '../src/chat/chat.service.js';
|
||||
|
||||
const viewer = { actor: 'chat-viewer', role: 'viewer', project: 'default', tenant: 'default' };
|
||||
const operator = { actor: 'chat-operator', role: 'operator', project: 'default', tenant: 'default' };
|
||||
|
||||
function withTempChatState(fn: () => void) {
|
||||
const saved = {
|
||||
@@ -66,15 +67,38 @@ test('chat ask marks side-effect requests unsupported in MVP-0', () => {
|
||||
test('chat ask executes registered operator action through action-gate', () => {
|
||||
withTempChatState(() => {
|
||||
const svc = new ChatService();
|
||||
const actions = svc.listActions(viewer) as any;
|
||||
const actions = svc.listActions(operator) as any;
|
||||
assert.ok(actions.actions.some((a: any) => a.id === 'run-chat-tests'));
|
||||
const agents = svc.listAgents(operator) as any;
|
||||
assert.ok(agents.agents.some((a: any) => a.id === 'ops-operator' && a.allowed_for_role === true));
|
||||
|
||||
const res = svc.ask({ message: 'run tests', chatId: 'operator-chat' }, viewer) as any;
|
||||
const res = svc.ask({
|
||||
message: 'run tests',
|
||||
chatId: 'operator-chat',
|
||||
agentId: 'ops-operator',
|
||||
skillId: 'registered-actions',
|
||||
}, operator) as any;
|
||||
assert.equal(res.success, true);
|
||||
assert.equal(res.mode, 'OPERATOR');
|
||||
assert.equal(res.decision, 'ACTION_COMPLETED');
|
||||
assert.equal(res.action.id, 'run-chat-tests');
|
||||
assert.equal(res.action_gate.outcome, 'ALLOW');
|
||||
assert.equal(res.agent_binding.agent_selected, 'ops-operator');
|
||||
assert.equal(res.agent_binding.skill_selected, 'registered-actions');
|
||||
assert.equal(res.audit_verify.ok, true);
|
||||
});
|
||||
});
|
||||
|
||||
test('chat ask denies selected agent outside actor role', () => {
|
||||
withTempChatState(() => {
|
||||
const svc = new ChatService();
|
||||
const agents = svc.listAgents(viewer) as any;
|
||||
assert.ok(agents.agents.some((a: any) => a.id === 'ops-operator' && a.allowed_for_role === false));
|
||||
|
||||
const res = svc.ask({ message: 'run tests', agentId: 'ops-operator' }, viewer) as any;
|
||||
assert.equal(res.success, false);
|
||||
assert.equal(res.decision, 'DENIED');
|
||||
assert.equal(res.agent_selected, 'ops-operator');
|
||||
assert.equal(res.reason, 'role_not_allowed_for_agent');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,6 +108,19 @@ export interface ChatAnswer {
|
||||
};
|
||||
action?: { id: string; label: string; description: string } | null;
|
||||
action_gate?: { outcome?: string; reason?: string; exit_code?: number };
|
||||
agent_binding?: {
|
||||
success: boolean;
|
||||
decision: string;
|
||||
reason: string;
|
||||
agent_selected: string;
|
||||
skill_selected: string;
|
||||
delegation_level: number;
|
||||
model_role?: string;
|
||||
mode?: string;
|
||||
tool_allowlist: string[];
|
||||
requires_approval: boolean;
|
||||
audit?: { record_hash?: string; head?: string; seq?: number };
|
||||
};
|
||||
actor: SettingsActor;
|
||||
}
|
||||
|
||||
@@ -118,6 +131,19 @@ export interface ChatAction {
|
||||
triggers: string[];
|
||||
}
|
||||
|
||||
export interface ChatAgent {
|
||||
id: string;
|
||||
label: string;
|
||||
mode: string;
|
||||
model_role: string;
|
||||
roles_allowed: string[];
|
||||
skills_allowed: string[];
|
||||
tool_allowlist: string[];
|
||||
max_delegation_level: number;
|
||||
requires_approval?: boolean;
|
||||
allowed_for_role?: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsState {
|
||||
actor: SettingsActor;
|
||||
capabilities: {
|
||||
@@ -164,10 +190,11 @@ export const api = {
|
||||
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 }) =>
|
||||
post<{ proposal: any; applied: any; audit_verify: { ok: boolean; output: string } }>('approvals/decide', body, actorHeaders(actor)),
|
||||
askChat: (actor: SettingsActor, body: { message: string; chatId?: string }) =>
|
||||
askChat: (actor: SettingsActor, body: { message: string; chatId?: string; agentId?: string; skillId?: string; delegationLevel?: number }) =>
|
||||
post<ChatAnswer>('chat/ask', body, actorHeaders(actor)),
|
||||
verifyChatAudit: () => get<{ ok: boolean; output: string }>('chat/audit/verify'),
|
||||
chatActions: (actor: SettingsActor) => getWithHeaders<{ success: boolean; actions: ChatAction[] }>('chat/actions', actorHeaders(actor)),
|
||||
chatAgents: (actor: SettingsActor) => getWithHeaders<{ success: boolean; agents: ChatAgent[] }>('chat/agents', actorHeaders(actor)),
|
||||
};
|
||||
|
||||
// Health is raw (not enveloped) + carries HTTP status.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { api, ChatAction, ChatAnswer, SettingsActor } from '../lib/api';
|
||||
import { api, ChatAction, ChatAgent, ChatAnswer, SettingsActor } from '../lib/api';
|
||||
import { Card, StatusBadge } from '../components/ui/Card';
|
||||
|
||||
const ROLES = ['viewer', 'auditor', 'operator', 'project-admin', 'org-admin'];
|
||||
@@ -26,6 +26,9 @@ export function Chat() {
|
||||
tenant: 'default',
|
||||
});
|
||||
const [chatId, setChatId] = useState('chat-default');
|
||||
const [agentId, setAgentId] = useState('evidence-reader');
|
||||
const [skillId, setSkillId] = useState('evidence-summary');
|
||||
const [delegationLevel, setDelegationLevel] = useState(0);
|
||||
const [message, setMessage] = useState('Summarize Plan 18 MVP-0 status');
|
||||
const [last, setLast] = useState<ChatAnswer | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -40,9 +43,25 @@ export function Chat() {
|
||||
queryFn: () => api.chatActions(actor),
|
||||
retry: false,
|
||||
});
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: ['chat-agents', actor],
|
||||
queryFn: () => api.chatAgents(actor),
|
||||
retry: false,
|
||||
});
|
||||
const agents = agentsQuery.data?.agents ?? [];
|
||||
const selectedAgent = agents.find((a) => a.id === agentId) ?? agents.find((a) => a.allowed_for_role) ?? agents[0];
|
||||
const selectedSkill = selectedAgent?.skills_allowed.includes(skillId)
|
||||
? skillId
|
||||
: (selectedAgent?.skills_allowed[0] ?? '');
|
||||
|
||||
const ask = useMutation({
|
||||
mutationFn: (override?: { message?: string }) => api.askChat(actor, { message: override?.message ?? message, chatId }),
|
||||
mutationFn: (override?: { message?: string; agentId?: string; skillId?: string }) => api.askChat(actor, {
|
||||
message: override?.message ?? message,
|
||||
chatId,
|
||||
agentId: override?.agentId ?? selectedAgent?.id ?? agentId,
|
||||
skillId: override?.skillId ?? selectedSkill,
|
||||
delegationLevel,
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
setLast(res);
|
||||
setError(null);
|
||||
@@ -111,6 +130,33 @@ export function Chat() {
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={chatId}
|
||||
onChange={(e) => setChatId(e.target.value)} />
|
||||
</label>
|
||||
<label className="space-y-1 md:col-span-2 xl:col-span-1">
|
||||
<span className="text-gray-500">Agent</span>
|
||||
<select className="w-full rounded border border-gray-300 px-3 py-2" value={selectedAgent?.id ?? agentId}
|
||||
onChange={(e) => {
|
||||
const next = agents.find((a) => a.id === e.target.value);
|
||||
setAgentId(e.target.value);
|
||||
setSkillId(next?.skills_allowed[0] ?? '');
|
||||
}}>
|
||||
{agents.map((a: ChatAgent) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.label}{a.allowed_for_role ? '' : ' (locked)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Skill</span>
|
||||
<select className="w-full rounded border border-gray-300 px-3 py-2" value={selectedSkill}
|
||||
onChange={(e) => setSkillId(e.target.value)}>
|
||||
{(selectedAgent?.skills_allowed ?? []).map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Delegation</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" type="number" min={0} max={5}
|
||||
value={delegationLevel} onChange={(e) => setDelegationLevel(Number(e.target.value || 0))} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -125,6 +171,8 @@ export function Chat() {
|
||||
<StatusBadge value={last.mode} />
|
||||
<StatusBadge value={last.risk} />
|
||||
<StatusBadge value={last.decision} />
|
||||
{last.agent_binding && <StatusBadge value={last.agent_binding.agent_selected} />}
|
||||
{last.agent_binding && <StatusBadge value={`L${last.agent_binding.delegation_level}`} />}
|
||||
<StatusBadge value={last.audit_verify.ok ? 'audit ok' : 'audit fail'} />
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-sm leading-6 text-gray-800">{last.answer}</div>
|
||||
@@ -174,6 +222,18 @@ export function Chat() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{last.agent_binding && (
|
||||
<div className="rounded border border-gray-200 p-3">
|
||||
<div className="text-xs font-semibold uppercase text-gray-400">Agent binding</div>
|
||||
<div className="mt-1 font-medium text-gray-800">{last.agent_binding.agent_selected}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{last.agent_binding.skill_selected || 'no skill'}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusBadge value={last.agent_binding.decision} />
|
||||
<StatusBadge value={last.agent_binding.model_role ?? 'model'} />
|
||||
<StatusBadge value={`tools ${last.agent_binding.tool_allowlist.length}`} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase text-gray-400">Matched rules</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
@@ -202,7 +262,7 @@ export function Chat() {
|
||||
disabled={ask.isPending}
|
||||
onClick={() => {
|
||||
setMessage(trigger);
|
||||
ask.mutate({ message: trigger });
|
||||
ask.mutate({ message: trigger, agentId: 'ops-operator', skillId: 'registered-actions' });
|
||||
}}
|
||||
className="text-left rounded border border-gray-200 p-3 hover:border-blue-300 hover:bg-blue-50 disabled:opacity-50"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"version": 1,
|
||||
"agents": [
|
||||
{
|
||||
"id": "evidence-reader",
|
||||
"label": "Evidence Reader",
|
||||
"mode": "READ_ONLY",
|
||||
"model_role": "read_only",
|
||||
"roles_allowed": ["viewer", "auditor", "operator", "project-admin", "org-admin"],
|
||||
"skills_allowed": ["evidence-summary"],
|
||||
"tool_allowlist": [],
|
||||
"max_delegation_level": 0
|
||||
},
|
||||
{
|
||||
"id": "ops-operator",
|
||||
"label": "Ops Operator",
|
||||
"mode": "OPERATOR",
|
||||
"model_role": "operator",
|
||||
"roles_allowed": ["operator", "project-admin", "org-admin"],
|
||||
"skills_allowed": ["registered-actions"],
|
||||
"tool_allowlist": ["run-chat-tests", "build-evidence-pack", "verify-evidence-pack"],
|
||||
"max_delegation_level": 1
|
||||
},
|
||||
{
|
||||
"id": "codegen-draft",
|
||||
"label": "Codegen Draft",
|
||||
"mode": "CODEGEN",
|
||||
"model_role": "codegen",
|
||||
"roles_allowed": ["project-admin", "org-admin"],
|
||||
"skills_allowed": ["sourcegen-draft"],
|
||||
"tool_allowlist": ["artifact-scan", "tool-output-scan"],
|
||||
"max_delegation_level": 2,
|
||||
"requires_approval": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Plan-18 MVP-2 agent/skill governance resolver.
|
||||
|
||||
This is a harness-owned policy primitive. It binds a chat turn to an agent,
|
||||
skill, delegation level, model role, and tool allowlist before any runtime is
|
||||
invoked. Unknown policy, RBAC failure, role mismatch, delegation escalation, or
|
||||
tool outside the agent allowlist all fail closed.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def project_root() -> str:
|
||||
d = os.path.abspath(os.path.dirname(__file__))
|
||||
p = d
|
||||
while p != os.path.dirname(p):
|
||||
if os.path.isdir(os.path.join(p, ".specify")) or os.path.isdir(os.path.join(p, "packages/casan-harness")):
|
||||
return p
|
||||
p = os.path.dirname(p)
|
||||
return os.path.abspath(os.path.join(d, "..", "..", ".."))
|
||||
|
||||
|
||||
ROOT = project_root()
|
||||
HARNESS_ROOT = os.path.join(ROOT, "packages", "casan-harness")
|
||||
HARNESS_BIN = os.path.join(HARNESS_ROOT, "scripts", "bash")
|
||||
RBAC = os.path.join(HARNESS_BIN, "rbac-check.py")
|
||||
GENESIS_HASH = "0" * 64
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def sha(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def state_root() -> str:
|
||||
return os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
|
||||
|
||||
|
||||
def registry_path() -> str:
|
||||
return os.environ.get("CASAN_AGENT_REGISTRY_FILE") or os.path.join(HARNESS_ROOT, "config", "agent-registry.yaml")
|
||||
|
||||
|
||||
def audit_path() -> str:
|
||||
return os.environ.get("CASAN_AGENT_BIND_AUDIT_LOG") or os.path.join(state_root(), "logs", "chat", "agent-bindings.jsonl")
|
||||
|
||||
|
||||
def head_path() -> str:
|
||||
return os.environ.get("CASAN_AGENT_BIND_AUDIT_HEAD") or os.path.join(state_root(), "logs", "chat", "agent-bindings-head.txt")
|
||||
|
||||
|
||||
def load_registry():
|
||||
try:
|
||||
with open(registry_path(), encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
agents = data.get("agents")
|
||||
if not isinstance(agents, list):
|
||||
raise ValueError("agents_not_list")
|
||||
return data
|
||||
except Exception as exc:
|
||||
return {"_error": f"agent_registry_unreadable:{exc}", "agents": []}
|
||||
|
||||
|
||||
def find_agent(registry, agent_id: str):
|
||||
return next((a for a in registry.get("agents", []) if a.get("id") == agent_id), None)
|
||||
|
||||
|
||||
def load_head() -> str:
|
||||
try:
|
||||
return open(head_path(), encoding="utf-8").read().strip() or GENESIS_HASH
|
||||
except OSError:
|
||||
return GENESIS_HASH
|
||||
|
||||
|
||||
def append_audit(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_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 decision_payload(args, decision: str, reason: str, agent=None, skill="", tools=None):
|
||||
tools = tools or []
|
||||
success = decision == "BOUND"
|
||||
payload = {
|
||||
"success": success,
|
||||
"decision": decision,
|
||||
"reason": reason,
|
||||
"agent_selected": (agent or {}).get("id") or args.agent,
|
||||
"skill_selected": skill or "",
|
||||
"delegation_level": args.delegation_level,
|
||||
"model_role": (agent or {}).get("model_role"),
|
||||
"mode": (agent or {}).get("mode"),
|
||||
"tool_allowlist": (agent or {}).get("tool_allowlist", []),
|
||||
"requested_tools": tools,
|
||||
"side_effect_allowed": bool((agent or {}).get("tool_allowlist")),
|
||||
"requires_approval": bool((agent or {}).get("requires_approval")) or decision == "REQUIRES_APPROVAL",
|
||||
}
|
||||
rec = append_audit({
|
||||
"timestamp": now_iso(),
|
||||
"harness": "H5-chat-agent-bind",
|
||||
"actor": args.actor,
|
||||
"role": args.role,
|
||||
"project": args.project,
|
||||
"tenant": args.tenant,
|
||||
"agent_selected": payload["agent_selected"],
|
||||
"skill_selected": payload["skill_selected"],
|
||||
"delegation_level": args.delegation_level,
|
||||
"decision": decision,
|
||||
"reason": reason,
|
||||
"tool_allowlist_ref": sha(json.dumps(payload["tool_allowlist"], sort_keys=True)),
|
||||
"requested_tools_ref": sha(json.dumps(tools, sort_keys=True)),
|
||||
})
|
||||
payload["audit"] = {"seq": rec["seq"], "record_hash": rec["record_hash"], "head": rec["record_hash"]}
|
||||
return payload
|
||||
|
||||
|
||||
def run_rbac(args):
|
||||
r = subprocess.run([
|
||||
"python3", RBAC, "check",
|
||||
"--role", args.role,
|
||||
"--resource", "chat",
|
||||
"--action", "select_agent",
|
||||
"--role-project", args.project,
|
||||
"--target-project", args.target_project or args.project,
|
||||
"--role-tenant", args.tenant,
|
||||
"--target-tenant", args.target_tenant or args.tenant,
|
||||
], cwd=ROOT, capture_output=True, text=True)
|
||||
return r.returncode, (r.stdout + r.stderr).strip()
|
||||
|
||||
|
||||
def bind(args) -> int:
|
||||
registry = load_registry()
|
||||
if registry.get("_error"):
|
||||
out = decision_payload(args, "DENIED", registry["_error"])
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
return 2
|
||||
|
||||
agent = find_agent(registry, args.agent)
|
||||
if not agent:
|
||||
out = decision_payload(args, "DENIED", "unknown_agent")
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
return 2
|
||||
|
||||
rc, msg = run_rbac(args)
|
||||
if rc != 0:
|
||||
out = decision_payload(args, "DENIED", f"rbac_denied:{msg}", agent)
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
return 2
|
||||
|
||||
if args.role not in agent.get("roles_allowed", []):
|
||||
out = decision_payload(args, "DENIED", "role_not_allowed_for_agent", agent)
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
return 2
|
||||
|
||||
skill = args.skill or (agent.get("skills_allowed", [""])[0] if agent.get("skills_allowed") else "")
|
||||
if skill and skill not in agent.get("skills_allowed", []):
|
||||
out = decision_payload(args, "DENIED", "skill_not_allowed_for_agent", agent, skill)
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
return 2
|
||||
|
||||
max_level = int(agent.get("max_delegation_level", 0))
|
||||
if args.delegation_level > max_level:
|
||||
out = decision_payload(args, "REQUIRES_APPROVAL", "delegation_exceeds_agent_max", agent, skill)
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
return 3
|
||||
|
||||
requested_tools = list(args.tool or [])
|
||||
allow = set(agent.get("tool_allowlist", []))
|
||||
outside = [t for t in requested_tools if t not in allow]
|
||||
if outside:
|
||||
out = decision_payload(args, "DENIED", "tool_not_allowlisted", agent, skill, requested_tools)
|
||||
out["action_gate"] = {"outcome": "BLOCK", "reason": f"tool_not_allowlisted:{','.join(outside)}"}
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
return 2
|
||||
|
||||
out = decision_payload(args, "BOUND", "agent_bound", agent, skill, requested_tools)
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def list_agents(args) -> int:
|
||||
registry = load_registry()
|
||||
if registry.get("_error"):
|
||||
print(json.dumps({"success": False, "error": registry["_error"], "agents": []}, ensure_ascii=False))
|
||||
return 2
|
||||
agents = []
|
||||
for agent in registry.get("agents", []):
|
||||
item = {k: agent.get(k) for k in (
|
||||
"id", "label", "mode", "model_role", "roles_allowed", "skills_allowed",
|
||||
"tool_allowlist", "max_delegation_level", "requires_approval",
|
||||
)}
|
||||
item["allowed_for_role"] = args.role in agent.get("roles_allowed", []) if args.role else None
|
||||
agents.append(item)
|
||||
print(json.dumps({"success": True, "version": registry.get("version"), "agents": agents}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def verify_audit() -> int:
|
||||
prev = GENESIS_HASH
|
||||
count = 0
|
||||
try:
|
||||
fh = open(audit_path(), encoding="utf-8")
|
||||
except OSError:
|
||||
print("CHAT_AGENT_AUDIT ok=true records=0 head=" + prev)
|
||||
return 0
|
||||
with fh:
|
||||
for line in fh:
|
||||
if not line.strip():
|
||||
continue
|
||||
count += 1
|
||||
rec = json.loads(line)
|
||||
got = rec.get("record_hash")
|
||||
rest = {k: v for k, v in rec.items() if k != "record_hash"}
|
||||
if rest.get("prev_hash") != prev or sha(json.dumps(rest, sort_keys=True, ensure_ascii=False)) != got:
|
||||
print(f"CHAT_AGENT_AUDIT ok=false brokenAt={count}")
|
||||
return 1
|
||||
prev = got
|
||||
print(f"CHAT_AGENT_AUDIT ok=true records={count} head={prev}")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
b = sub.add_parser("bind")
|
||||
b.add_argument("--agent", required=True)
|
||||
b.add_argument("--skill", default="")
|
||||
b.add_argument("--actor", default="anonymous")
|
||||
b.add_argument("--role", default="viewer")
|
||||
b.add_argument("--project", default="default")
|
||||
b.add_argument("--tenant", default="default")
|
||||
b.add_argument("--target-project", default="")
|
||||
b.add_argument("--target-tenant", default="")
|
||||
b.add_argument("--delegation-level", type=int, default=0)
|
||||
b.add_argument("--tool", action="append", default=[])
|
||||
b.set_defaults(func=bind)
|
||||
|
||||
l = sub.add_parser("list-agents")
|
||||
l.add_argument("--role", default="")
|
||||
l.set_defaults(func=list_agents)
|
||||
|
||||
sub.add_parser("verify-audit").set_defaults(func=lambda _args: verify_audit())
|
||||
args = ap.parse_args()
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -24,6 +24,7 @@ def project_root() -> str:
|
||||
ROOT = project_root()
|
||||
BIN = os.path.join(ROOT, "packages", "casan-harness", "scripts", "bash")
|
||||
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")
|
||||
|
||||
@@ -36,13 +37,69 @@ def classify(message: str):
|
||||
return {"mode": "BLOCK", "risk": "high", "reason": "router_invalid_json", "matched_rules": [r.stderr.strip()]}
|
||||
|
||||
|
||||
def run_mode(args, binding):
|
||||
r = subprocess.run(args, cwd=ROOT, capture_output=True, text=True)
|
||||
try:
|
||||
payload = json.loads(r.stdout)
|
||||
payload["agent_binding"] = binding
|
||||
print(json.dumps(payload, ensure_ascii=False))
|
||||
except Exception:
|
||||
if r.stdout:
|
||||
sys.stdout.write(r.stdout)
|
||||
if r.stderr:
|
||||
sys.stderr.write(r.stderr)
|
||||
return r.returncode
|
||||
|
||||
|
||||
def run_and_passthrough(args):
|
||||
r = subprocess.run(args, cwd=ROOT, text=True)
|
||||
return r.returncode
|
||||
|
||||
|
||||
def default_agent_for_mode(mode: str) -> str:
|
||||
if mode == "OPERATOR":
|
||||
return "ops-operator"
|
||||
if mode == "CODEGEN":
|
||||
return "codegen-draft"
|
||||
return "evidence-reader"
|
||||
|
||||
|
||||
def bind_agent(args, router):
|
||||
agent = args.agent or default_agent_for_mode(router.get("mode", "READ_ONLY"))
|
||||
tools = []
|
||||
if router.get("mode") == "OPERATOR":
|
||||
# The operator primitive resolves the exact registered action later; the
|
||||
# agent bind still proves this turn is allowed to use the operator class.
|
||||
tools.append("run-chat-tests")
|
||||
cmd = [
|
||||
"python3", AGENT_RESOLVER, "bind",
|
||||
"--agent", agent,
|
||||
"--actor", args.actor,
|
||||
"--role", args.role,
|
||||
"--project", args.project,
|
||||
"--tenant", args.tenant,
|
||||
"--delegation-level", str(args.delegation_level),
|
||||
]
|
||||
if args.skill:
|
||||
cmd += ["--skill", args.skill]
|
||||
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)
|
||||
except Exception:
|
||||
print(json.dumps({"success": False, "decision": "DENIED", "reason": "agent_resolver_invalid_json"}, ensure_ascii=False))
|
||||
return 2, None
|
||||
|
||||
|
||||
def ask(args) -> int:
|
||||
router = classify(args.message)
|
||||
bind_rc, binding = bind_agent(args, router)
|
||||
if bind_rc != 0:
|
||||
return bind_rc
|
||||
common = [
|
||||
"--message", args.message,
|
||||
"--actor", args.actor,
|
||||
@@ -51,8 +108,8 @@ def ask(args) -> int:
|
||||
"--tenant", args.tenant,
|
||||
]
|
||||
if router.get("mode") == "OPERATOR":
|
||||
return run_and_passthrough(["python3", OPERATOR, "run", *common])
|
||||
return run_and_passthrough(["python3", READONLY, "ask", *common])
|
||||
return run_mode(["python3", OPERATOR, "run", *common], binding)
|
||||
return run_mode(["python3", READONLY, "ask", *common], binding)
|
||||
|
||||
|
||||
def verify_audit() -> int:
|
||||
@@ -65,9 +122,14 @@ def main() -> int:
|
||||
askp = sub.add_parser("ask")
|
||||
askp.add_argument("--message", required=True)
|
||||
askp.add_argument("--actor", default="anonymous")
|
||||
askp.add_argument("--role", default="viewer")
|
||||
askp.add_argument("--project", default="default")
|
||||
askp.add_argument("--chat-id", default="")
|
||||
askp.add_argument("--turn-id", default="")
|
||||
askp.add_argument("--tenant", default="default")
|
||||
askp.add_argument("--agent", default="")
|
||||
askp.add_argument("--skill", default="")
|
||||
askp.add_argument("--delegation-level", type=int, default=0)
|
||||
askp.set_defaults(func=ask)
|
||||
sub.add_parser("verify-audit").set_defaults(func=lambda _args: verify_audit())
|
||||
args = ap.parse_args()
|
||||
|
||||
@@ -149,6 +149,7 @@ run "phase-chat-prompt-router" bash "$TESTS/phase-chat-prompt-router-tests.sh"
|
||||
run "phase-chat-readonly" bash "$TESTS/phase-chat-readonly-tests.sh"
|
||||
run "phase-chat-session-audit" bash "$TESTS/phase-chat-session-audit-tests.sh"
|
||||
run "phase-chat-operator" bash "$TESTS/phase-chat-operator-tests.sh"
|
||||
run "phase-chat-agent-select" bash "$TESTS/phase-chat-agent-select-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).
|
||||
|
||||
@@ -17,11 +17,11 @@ import sys
|
||||
# scope: "org" (all projects) or "project" (must match the acted-on project)
|
||||
PERMISSIONS = {
|
||||
"org-admin": {"scope": "org", "allow": {"*"}},
|
||||
"project-admin": {"scope": "project", "allow": {"settings:read", "settings:write", "monitoring:read", "audit:read"}},
|
||||
"approver": {"scope": "project", "allow": {"settings:read", "monitoring:read", "approval:grant"}},
|
||||
"operator": {"scope": "project", "allow": {"monitoring:read", "kill_switch:engage"}},
|
||||
"viewer": {"scope": "project", "allow": {"settings:read", "monitoring:read"}},
|
||||
"auditor": {"scope": "org", "allow": {"settings:read", "monitoring:read", "audit:read"}},
|
||||
"project-admin": {"scope": "project", "allow": {"settings:read", "settings:write", "monitoring:read", "audit:read", "chat:select_agent"}},
|
||||
"approver": {"scope": "project", "allow": {"settings:read", "monitoring:read", "approval:grant", "chat:select_agent"}},
|
||||
"operator": {"scope": "project", "allow": {"monitoring:read", "kill_switch:engage", "chat:select_agent"}},
|
||||
"viewer": {"scope": "project", "allow": {"settings:read", "monitoring:read", "chat:select_agent"}},
|
||||
"auditor": {"scope": "org", "allow": {"settings:read", "monitoring:read", "audit:read", "chat:select_agent"}},
|
||||
}
|
||||
|
||||
# Maps an IdP-issued claim (role name / group) to an RBAC role. The IdP (Plan-07
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
# Plan-18 MVP-2 Track 4: agent/skill selection is governed by registry + RBAC.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../scripts/bash/casan-paths.sh"
|
||||
RESOLVER="$CASAN_HARNESS_ROOT/scripts/bash/chat-agent-resolver.py"
|
||||
TURN="$CASAN_HARNESS_ROOT/scripts/bash/chat-turn.py"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
export CASAN_STATE_ROOT="$WORK/state"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
echo "===== Plan-18 MVP-2 agent/skill selection ====="
|
||||
|
||||
python3 "$RESOLVER" list-agents --role viewer > "$WORK/list.json"
|
||||
python3 - "$WORK/list.json" <<'PY' \
|
||||
&& pass "agent registry lists versioned agents with role visibility" || fail "agent registry listing invalid"
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
ids = {a["id"]: a for a in d["agents"]}
|
||||
assert d["success"] is True
|
||||
assert {"evidence-reader", "ops-operator", "codegen-draft"}.issubset(ids)
|
||||
assert ids["evidence-reader"]["allowed_for_role"] is True
|
||||
assert ids["ops-operator"]["allowed_for_role"] is False
|
||||
PY
|
||||
|
||||
python3 "$RESOLVER" bind --agent evidence-reader --skill evidence-summary --role viewer --actor alice --project default --tenant tenant-a > "$WORK/viewer.json"
|
||||
python3 - "$WORK/viewer.json" <<'PY' \
|
||||
&& pass "viewer can bind read-only evidence-reader" || fail "viewer could not bind read-only agent"
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
assert d["success"] is True
|
||||
assert d["decision"] == "BOUND"
|
||||
assert d["agent_selected"] == "evidence-reader"
|
||||
assert d["delegation_level"] == 0
|
||||
assert d["tool_allowlist"] == []
|
||||
PY
|
||||
|
||||
set +e
|
||||
python3 "$RESOLVER" bind --agent ops-operator --role viewer --actor alice --project default --tenant tenant-a > "$WORK/viewer-op.json"
|
||||
RC=$?
|
||||
set -e 2>/dev/null || true
|
||||
python3 - "$WORK/viewer-op.json" "$RC" <<'PY' \
|
||||
&& pass "viewer cannot select operator agent" || fail "viewer selected operator agent"
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
assert int(sys.argv[2]) == 2
|
||||
assert d["success"] is False
|
||||
assert d["decision"] == "DENIED"
|
||||
assert d["reason"] == "role_not_allowed_for_agent"
|
||||
PY
|
||||
|
||||
set +e
|
||||
python3 "$RESOLVER" bind --agent evidence-reader --role viewer --actor alice --project default --tenant tenant-a --target-tenant tenant-b > "$WORK/cross-tenant.json"
|
||||
RC=$?
|
||||
set -e 2>/dev/null || true
|
||||
python3 - "$WORK/cross-tenant.json" "$RC" <<'PY' \
|
||||
&& pass "cross-tenant agent binding is denied by RBAC" || fail "cross-tenant agent binding was allowed"
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
assert int(sys.argv[2]) == 2
|
||||
assert d["success"] is False
|
||||
assert d["decision"] == "DENIED"
|
||||
assert "rbac_denied" in d["reason"]
|
||||
PY
|
||||
|
||||
set +e
|
||||
python3 "$RESOLVER" bind --agent evidence-reader --role viewer --actor alice --project default --tenant tenant-a --delegation-level 1 > "$WORK/delegation.json"
|
||||
RC=$?
|
||||
set -e 2>/dev/null || true
|
||||
python3 - "$WORK/delegation.json" "$RC" <<'PY' \
|
||||
&& pass "delegation above agent max requires approval" || fail "delegation escalation was not held"
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
assert int(sys.argv[2]) == 3
|
||||
assert d["success"] is False
|
||||
assert d["decision"] == "REQUIRES_APPROVAL"
|
||||
assert d["requires_approval"] is True
|
||||
PY
|
||||
|
||||
set +e
|
||||
python3 "$RESOLVER" bind --agent ops-operator --role operator --actor bob --project default --tenant tenant-a --tool shell > "$WORK/tool.json"
|
||||
RC=$?
|
||||
set -e 2>/dev/null || true
|
||||
python3 - "$WORK/tool.json" "$RC" <<'PY' \
|
||||
&& pass "tool outside agent allowlist is blocked" || fail "tool outside allowlist was accepted"
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
assert int(sys.argv[2]) == 2
|
||||
assert d["success"] is False
|
||||
assert d["decision"] == "DENIED"
|
||||
assert d["action_gate"]["outcome"] == "BLOCK"
|
||||
PY
|
||||
|
||||
set +e
|
||||
python3 "$TURN" ask --message "run tests" --agent ops-operator --role viewer --actor alice --chat-id agent-chat --tenant tenant-a > "$WORK/turn-deny.json"
|
||||
RC=$?
|
||||
set -e 2>/dev/null || true
|
||||
python3 - "$WORK/turn-deny.json" "$RC" <<'PY' \
|
||||
&& pass "chat-turn denies unauthorized selected agent before operator runtime" || fail "chat-turn bypassed agent resolver"
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
assert int(sys.argv[2]) == 2
|
||||
assert d["success"] is False
|
||||
assert d["decision"] == "DENIED"
|
||||
assert d["agent_selected"] == "ops-operator"
|
||||
PY
|
||||
|
||||
python3 "$RESOLVER" verify-audit > "$WORK/audit.out" \
|
||||
&& grep -q "CHAT_AGENT_AUDIT ok=true" "$WORK/audit.out" \
|
||||
&& pass "agent binding audit hash chain verifies" || fail "agent binding audit invalid"
|
||||
|
||||
echo ""
|
||||
echo "===== CHAT AGENT SUMMARY: PASS=$PASS FAIL=$FAIL ====="
|
||||
[[ "$FAIL" -eq 0 ]] || exit 1
|
||||
@@ -37,7 +37,7 @@ ids = {a["id"] for a in d["actions"]}
|
||||
assert {"run-chat-tests", "build-evidence-pack", "verify-evidence-pack"}.issubset(ids)
|
||||
PY
|
||||
|
||||
python3 "$TURN" ask --message "run tests" --actor bob --chat-id op1 > "$WORK/run.json"
|
||||
python3 "$TURN" ask --message "run tests" --actor bob --role operator --chat-id op1 > "$WORK/run.json"
|
||||
python3 - "$WORK/run.json" <<'PY' \
|
||||
&& pass "registered run tests action executes through chat-turn" || fail "registered action did not complete"
|
||||
import json, os, sys
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# CASAN Platform (Level 3 — Productization UI) · **PREVIEW**
|
||||
|
||||
> Status: **PREVIEW.** The AgentOps dashboard and Plan-13 Control Panel, including
|
||||
> Command Center baseline and Plan-18 MVP-0/1 Chat Console, exist today.
|
||||
> Command Center baseline and Plan-18 MVP-0/1 Chat Console plus MVP-2 agent
|
||||
> selection foundation, exist today.
|
||||
> Evidence/attack viewers, Gitea integration, and managed production rollout remain
|
||||
> planned. `package-release.sh platform` builds a clearly-stamped
|
||||
> `casan-platform-preview-*` bundle containing only what exists.
|
||||
@@ -13,12 +14,12 @@ Optional layer for teams that want UI / dashboard / visibility. Packages: `casan
|
||||
| Component | Status | Where |
|
||||
|---|---|---|
|
||||
| AgentOps Dashboard | ✅ exists | `packages/casan-harness/scripts/bash/dashboard-server.py` + `dashboard-serve.sh` (`casan dashboard`) |
|
||||
| **Ops Console (Control Panel)** | ✅ **monitoring + governed settings + HITL + kill-switch + FinOps/SLO + Command Center + Chat MVP-0/1 + local-prod TLS/OIDC smoke** | `packages/casan-control-panel/` — NestJS API + React UI over harness telemetry, settings management, approval inbox, kill-switch, FinOps/SLO, Command Center, Ask CASAN read-only, and registered Operator actions (`npm run console:api` + `console:ui`). Includes Run History + verdicts + governance + security + incidents + traceability + FinOps + Approvals + role-aware Settings page + `/command` evidence-backed executive view + `/chat` governed read-only/operator console. |
|
||||
| **Ops Console (Control Panel)** | ✅ **monitoring + governed settings + HITL + kill-switch + FinOps/SLO + Command Center + Chat MVP-0/1 + agent selection foundation + local-prod TLS/OIDC smoke** | `packages/casan-control-panel/` — NestJS API + React UI over harness telemetry, settings management, approval inbox, kill-switch, FinOps/SLO, Command Center, Ask CASAN read-only, registered Operator actions, and governed agent/skill selection (`npm run console:api` + `console:ui`). Includes Run History + verdicts + governance + security + incidents + traceability + FinOps + Approvals + role-aware Settings page + `/command` evidence-backed executive view + `/chat` governed read-only/operator console. |
|
||||
| Management / settings writes | ✅ done+test | Plan-13 Track 2: wraps `control-plane-settings.py`, calls `rbac-check.py`, supports set/rollback/audit verify. |
|
||||
| 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 | ✅ MVP-0/1 done+test | Plan-18 MVP-0/1: `POST /api/v1/chat/ask`, `GET /api/v1/chat/actions` + `/chat`, backed by harness `chat-turn.py` |
|
||||
| Read-only Ask CASAN + Operator + agent selection | 🟡 MVP-0/1 done+test; MVP-2 Track 4 foundation done | Plan-18: `POST /api/v1/chat/ask`, `GET /api/v1/chat/actions`, `GET /api/v1/chat/agents` + `/chat`, backed by harness `chat-turn.py` and `chat-agent-resolver.py` |
|
||||
| Gitea webhook integration | 📋 planned | trigger gate / publish evidence on push |
|
||||
|
||||
## Build (preview)
|
||||
@@ -28,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 or Plan-18 MVP-2 (Chat-as-loop + agent/skill). Keep new numbers
|
||||
Start from Plan-15 RAI view or Plan-18 MVP-2 Track 5/6 (Chat-as-loop + streaming). 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