agent_governance.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874
  1. """Govern internal Agents without turning the platform into an Agent builder."""
  2. from __future__ import annotations
  3. import base64
  4. import copy
  5. import hashlib
  6. import hmac
  7. import json
  8. import re
  9. import uuid
  10. from collections.abc import Callable
  11. from datetime import datetime, timedelta
  12. from typing import Any
  13. from app.core.common.identifiers import new_governance_uid
  14. from app.core.common.timezone_utils import now_china
  15. AUTONOMY_LEVELS = frozenset(
  16. {"read_only", "suggestion", "approval_execution", "low_risk_automatic"}
  17. )
  18. AGENT_STATUSES = frozenset({"draft", "active", "suspended", "retired"})
  19. INTERFACE_TYPES = frozenset({"api", "mcp"})
  20. TOOL_ACTIONS = frozenset({"read", "suggest", "execute"})
  21. RISK_LEVELS = frozenset({"low", "medium", "high", "critical"})
  22. ENVIRONMENTS = frozenset({"development", "test", "production"})
  23. REQUEST_DECISIONS = frozenset(
  24. {
  25. "authorized",
  26. "denied",
  27. "pending_approval",
  28. "approved_for_manual_execution",
  29. "executed",
  30. }
  31. )
  32. CODE_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{2,119}$")
  33. TOOL_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_.:/-]{1,199}$")
  34. PROHIBITED_TOOLS = frozenset(
  35. {
  36. "drop_database",
  37. "disable_audit",
  38. "export_secrets",
  39. "grant_permission",
  40. "execute_shell",
  41. "modify_security_policy",
  42. }
  43. )
  44. INJECTION_PATTERNS = (
  45. ("ignore_instructions", re.compile(r"(?i)ignore\s+(all\s+)?(previous|prior|system)\s+instructions?")),
  46. ("reveal_secrets", re.compile(r"(?i)(reveal|show|leak|print).{0,30}(secret|api[-_ ]?key|token|password)")),
  47. ("override_tools", re.compile(r"(?i)(bypass|override|disable).{0,30}(permission|policy|guard|audit)")),
  48. ("system_prompt", re.compile(r"(?i)(system\s+prompt|developer\s+message|begin\s+system)")),
  49. ("ignore_instructions_zh", re.compile(r"忽略.{0,12}(指令|规则|系统)")),
  50. ("reveal_secrets_zh", re.compile(r"(泄露|显示|输出).{0,16}(密钥|口令|令牌|密码|api\s*key)", re.I)),
  51. ("bypass_policy_zh", re.compile(r"(绕过|关闭|禁用).{0,16}(权限|策略|审计|防护)")),
  52. )
  53. def _closed(value: Any, allowed: set[str], label: str) -> dict[str, Any]:
  54. if not isinstance(value, dict):
  55. raise ValueError(f"{label} must be an object")
  56. unknown = sorted(set(value) - allowed)
  57. if unknown:
  58. raise ValueError(f"{label} contains unsupported fields: {', '.join(unknown)}")
  59. return copy.deepcopy(value)
  60. def _string(value: Any, label: str, maximum: int = 1000) -> str:
  61. if not isinstance(value, str) or not value.strip():
  62. raise ValueError(f"{label} is required")
  63. result = value.strip()
  64. if len(result) > maximum:
  65. raise ValueError(f"{label} exceeds {maximum} characters")
  66. return result
  67. def _uid(value: Any, label: str) -> str:
  68. try:
  69. return str(uuid.UUID(str(value)))
  70. except (TypeError, ValueError, AttributeError) as error:
  71. raise ValueError(f"{label} must be a UUID") from error
  72. def _list(value: Any, label: str, minimum: int = 0) -> list[Any]:
  73. if not isinstance(value, list) or len(value) < minimum:
  74. raise ValueError(f"{label} must contain at least {minimum} items")
  75. return copy.deepcopy(value)
  76. def _canonical(value: Any) -> bytes:
  77. return json.dumps(
  78. value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
  79. ).encode("utf-8")
  80. def _hash(value: Any) -> str:
  81. return hashlib.sha256(_canonical(value)).hexdigest()
  82. def _urlsafe_encode(value: bytes) -> str:
  83. return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
  84. def _urlsafe_decode(value: str) -> bytes:
  85. return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
  86. def _normalize_prompt_policy(value: Any) -> dict[str, Any]:
  87. body = _closed(
  88. value,
  89. {
  90. "trusted_instruction_sources",
  91. "untrusted_context_mode",
  92. "citation_required",
  93. },
  94. "prompt policy",
  95. )
  96. sources = sorted(
  97. {
  98. _string(item, "trusted instruction source", 80)
  99. for item in _list(
  100. body.get("trusted_instruction_sources"),
  101. "trusted_instruction_sources",
  102. 1,
  103. )
  104. }
  105. )
  106. if set(sources) - {"platform_system", "published_policy", "owner_approved"}:
  107. raise ValueError("unsupported trusted instruction source")
  108. mode = _string(body.get("untrusted_context_mode"), "untrusted_context_mode", 30)
  109. if mode not in {"quote_only", "discard"}:
  110. raise ValueError("untrusted context must be quoted or discarded")
  111. if not isinstance(body.get("citation_required"), bool):
  112. raise ValueError("citation_required must be boolean")
  113. return {
  114. "trusted_instruction_sources": sources,
  115. "untrusted_context_mode": mode,
  116. "citation_required": body["citation_required"],
  117. }
  118. def _normalize_evidence(value: Any, *, required: bool) -> list[dict[str, str]]:
  119. items = _list(value, "evidence_refs", 1 if required else 0)
  120. normalized = []
  121. for item in items:
  122. evidence = _closed(
  123. item,
  124. {"source_type", "source_uid", "version", "point_key"},
  125. "evidence reference",
  126. )
  127. normalized.append(
  128. {
  129. "source_type": _string(evidence.get("source_type"), "source_type", 60),
  130. "source_uid": _uid(evidence.get("source_uid"), "source_uid"),
  131. "version": _string(evidence.get("version"), "evidence version", 80),
  132. "point_key": _string(evidence.get("point_key"), "point_key", 200),
  133. }
  134. )
  135. return normalized
  136. def inspect_prompt(value: Any) -> dict[str, Any]:
  137. prompt = _string(value, "prompt", 8000)
  138. signals = sorted(
  139. {name for name, pattern in INJECTION_PATTERNS if pattern.search(prompt)}
  140. )
  141. return {
  142. "safe": not signals,
  143. "signals": signals,
  144. "prompt_hash": hashlib.sha256(prompt.encode("utf-8")).hexdigest(),
  145. "raw_prompt_retained": False,
  146. }
  147. def _autonomy_actions(level: str) -> frozenset[str]:
  148. return {
  149. "read_only": frozenset({"read"}),
  150. "suggestion": frozenset({"read", "suggest"}),
  151. "approval_execution": frozenset({"read", "suggest", "execute"}),
  152. "low_risk_automatic": frozenset({"read", "suggest", "execute"}),
  153. }[level]
  154. class AgentGovernanceService:
  155. """Version Agents, issue scoped machine credentials and record every decision."""
  156. def __init__(
  157. self,
  158. repository,
  159. *,
  160. approval_gateway,
  161. credential_secret: str,
  162. uid_factory: Callable[[], str] = new_governance_uid,
  163. now_factory: Callable[[], datetime] = now_china,
  164. commit: Callable[[], None] = lambda: None,
  165. rollback: Callable[[], None] = lambda: None,
  166. ):
  167. if not isinstance(credential_secret, str) or len(credential_secret.encode()) < 32:
  168. raise ValueError("Agent credential secret must contain at least 32 bytes")
  169. self.repository = repository
  170. self.approval_gateway = approval_gateway
  171. self.secret = credential_secret.encode("utf-8")
  172. self.uid_factory = uid_factory
  173. self.now_factory = now_factory
  174. self.commit = commit
  175. self.rollback = rollback
  176. def _definition(self, payload: Any) -> dict[str, Any]:
  177. body = _closed(
  178. payload,
  179. {
  180. "code",
  181. "name",
  182. "purpose",
  183. "owner_uid",
  184. "business_domain_uids",
  185. "environments",
  186. "autonomy_level",
  187. "prompt_policy",
  188. "change_reason",
  189. },
  190. "governed Agent",
  191. )
  192. code = _string(body.get("code"), "Agent code", 120).upper()
  193. if not CODE_PATTERN.fullmatch(code):
  194. raise ValueError("Agent code is invalid")
  195. owner_uid = _uid(body.get("owner_uid"), "owner_uid")
  196. domains = sorted(
  197. {
  198. _uid(item, "business_domain_uid")
  199. for item in _list(
  200. body.get("business_domain_uids"), "business_domain_uids", 1
  201. )
  202. }
  203. )
  204. environments = sorted(
  205. {
  206. _string(item, "environment", 30)
  207. for item in _list(body.get("environments"), "environments", 1)
  208. }
  209. )
  210. if not set(environments) <= ENVIRONMENTS:
  211. raise ValueError("unsupported Agent environment")
  212. level = _string(body.get("autonomy_level"), "autonomy_level", 30)
  213. if level not in AUTONOMY_LEVELS:
  214. raise ValueError("unsupported autonomy level")
  215. return {
  216. "code": code,
  217. "name": _string(body.get("name"), "Agent name", 300),
  218. "purpose": _string(body.get("purpose"), "Agent purpose", 2000),
  219. "owner_uid": owner_uid,
  220. "business_domain_uids": domains,
  221. "environments": environments,
  222. "autonomy_level": level,
  223. "prompt_policy": _normalize_prompt_policy(body.get("prompt_policy")),
  224. "change_reason": _string(
  225. body.get("change_reason", "initial registration"),
  226. "change_reason",
  227. 1000,
  228. ),
  229. }
  230. def register_agent(self, payload: Any, *, actor_uid: str):
  231. definition = self._definition(payload)
  232. actor = _uid(actor_uid, "actor_uid")
  233. if self.repository.users_available({actor, definition["owner_uid"]}) != {
  234. actor,
  235. definition["owner_uid"],
  236. }:
  237. raise ValueError("Agent owner or actor is unavailable")
  238. now = self.now_factory().isoformat()
  239. agent_uid = self.uid_factory()
  240. snapshot = {key: value for key, value in definition.items() if key != "change_reason"}
  241. agent = {
  242. "uid": agent_uid,
  243. **snapshot,
  244. "machine_subject": f"agent:{definition['code'].lower()}:{agent_uid}",
  245. "status": "draft",
  246. "current_version": 1,
  247. "created_by": actor,
  248. "created_at": now,
  249. "updated_by": actor,
  250. "updated_at": now,
  251. "retired_at": None,
  252. }
  253. version = {
  254. "uid": self.uid_factory(),
  255. "agent_uid": agent_uid,
  256. "version": 1,
  257. "status": "draft",
  258. "definition": snapshot,
  259. "content_hash": _hash(snapshot),
  260. "change_reason": definition["change_reason"],
  261. "created_by": actor,
  262. "created_at": now,
  263. "published_by": None,
  264. "published_at": None,
  265. }
  266. try:
  267. result = self.repository.create_agent(agent, version)
  268. self.repository.add_event(agent_uid, "agent_registered", actor, 1, {})
  269. self.commit()
  270. return result
  271. except Exception:
  272. self.rollback()
  273. raise
  274. def list_agents(self, **filters):
  275. return self.repository.list_agents(**filters)
  276. def get_agent(self, uid: str):
  277. agent = self.repository.get_agent(_uid(uid, "agent_uid"))
  278. if agent is None:
  279. raise LookupError("governed Agent was not found")
  280. return agent
  281. def agent_detail(self, uid: str):
  282. result = self.repository.agent_detail(_uid(uid, "agent_uid"))
  283. if result is None:
  284. raise LookupError("governed Agent was not found")
  285. return result
  286. def revise_agent(
  287. self, uid: str, payload: Any, *, expected_version: int, actor_uid: str
  288. ):
  289. agent = self.get_agent(uid)
  290. actor = _uid(actor_uid, "actor_uid")
  291. if actor != agent["owner_uid"] or agent["status"] == "retired":
  292. raise PermissionError("only the active Agent owner can revise it")
  293. definition = self._definition(payload)
  294. if definition["owner_uid"] != agent["owner_uid"]:
  295. raise ValueError("Agent ownership transfer must use responsibility governance")
  296. next_version = int(expected_version) + 1
  297. now = self.now_factory().isoformat()
  298. snapshot = {key: value for key, value in definition.items() if key != "change_reason"}
  299. revised = {
  300. **agent,
  301. **snapshot,
  302. "status": "draft",
  303. "current_version": next_version,
  304. "updated_by": actor,
  305. "updated_at": now,
  306. }
  307. version = {
  308. "uid": self.uid_factory(),
  309. "agent_uid": agent["uid"],
  310. "version": next_version,
  311. "status": "draft",
  312. "definition": snapshot,
  313. "content_hash": _hash(snapshot),
  314. "change_reason": definition["change_reason"],
  315. "created_by": actor,
  316. "created_at": now,
  317. "published_by": None,
  318. "published_at": None,
  319. }
  320. try:
  321. result = self.repository.update_agent(
  322. revised, version, int(expected_version), "agent_revised", actor
  323. )
  324. self.repository.revoke_credentials(agent["uid"], actor, now)
  325. self.commit()
  326. return result
  327. except Exception:
  328. self.rollback()
  329. raise
  330. def transition_agent(
  331. self, uid: str, payload: Any, *, expected_version: int, actor_uid: str
  332. ):
  333. body = _closed(payload, {"action", "reason"}, "Agent transition")
  334. agent = self.get_agent(uid)
  335. actor = _uid(actor_uid, "actor_uid")
  336. if actor != agent["owner_uid"]:
  337. raise PermissionError("only the Agent owner can transition it")
  338. action = _string(body.get("action"), "action", 30)
  339. transitions = {
  340. ("draft", "activate"): ("active", "agent_activated"),
  341. ("suspended", "activate"): ("active", "agent_reactivated"),
  342. ("active", "suspend"): ("suspended", "agent_suspended"),
  343. ("draft", "retire"): ("retired", "agent_retired"),
  344. ("active", "retire"): ("retired", "agent_retired"),
  345. ("suspended", "retire"): ("retired", "agent_retired"),
  346. }
  347. target = transitions.get((agent["status"], action))
  348. if target is None:
  349. raise RuntimeError("Agent lifecycle transition is not allowed")
  350. if action == "activate" and not self.repository.active_grants(agent["uid"]):
  351. raise RuntimeError("at least one active tool grant is required")
  352. now = self.now_factory().isoformat()
  353. updated = {
  354. **agent,
  355. "status": target[0],
  356. "current_version": int(expected_version) + 1,
  357. "updated_by": actor,
  358. "updated_at": now,
  359. "retired_at": now if target[0] == "retired" else None,
  360. }
  361. try:
  362. result = self.repository.update_agent(
  363. updated, None, int(expected_version), target[1], actor
  364. )
  365. if target[0] in {"suspended", "retired"}:
  366. self.repository.revoke_credentials(agent["uid"], actor, now)
  367. self.commit()
  368. return result
  369. except Exception:
  370. self.rollback()
  371. raise
  372. def create_tool_grant(self, agent_uid: str, payload: Any, *, actor_uid: str):
  373. body = _closed(
  374. payload,
  375. {
  376. "interface_type",
  377. "tool_name",
  378. "action",
  379. "business_domain_uid",
  380. "environment",
  381. "risk_level",
  382. "requires_approval",
  383. },
  384. "Agent tool grant",
  385. )
  386. agent = self.get_agent(agent_uid)
  387. actor = _uid(actor_uid, "actor_uid")
  388. if actor != agent["owner_uid"] or agent["status"] == "retired":
  389. raise PermissionError("only the Agent owner can grant tools")
  390. interface_type = _string(body.get("interface_type"), "interface_type", 20)
  391. action = _string(body.get("action"), "tool action", 20)
  392. tool_name = _string(body.get("tool_name"), "tool_name", 200)
  393. domain = _uid(body.get("business_domain_uid"), "business_domain_uid")
  394. environment = _string(body.get("environment"), "environment", 30)
  395. risk = _string(body.get("risk_level"), "risk_level", 20)
  396. requires_approval = body.get("requires_approval")
  397. if interface_type not in INTERFACE_TYPES or action not in TOOL_ACTIONS:
  398. raise ValueError("unsupported interface or tool action")
  399. if not TOOL_PATTERN.fullmatch(tool_name):
  400. raise ValueError("tool name is invalid")
  401. if domain not in agent["business_domain_uids"]:
  402. raise ValueError("tool grant domain is outside Agent scope")
  403. if environment not in agent["environments"] or environment not in ENVIRONMENTS:
  404. raise ValueError("tool grant environment is outside Agent scope")
  405. if risk not in RISK_LEVELS:
  406. raise ValueError("unsupported risk level")
  407. if not isinstance(requires_approval, bool):
  408. raise ValueError("requires_approval must be boolean")
  409. if action not in _autonomy_actions(agent["autonomy_level"]):
  410. raise ValueError("tool action exceeds Agent autonomy level")
  411. if risk in {"high", "critical"} and not requires_approval:
  412. raise ValueError("high-risk grants require approval")
  413. if (
  414. agent["autonomy_level"] == "low_risk_automatic"
  415. and action == "execute"
  416. and risk != "low"
  417. and not requires_approval
  418. ):
  419. raise ValueError("automatic execution is limited to low risk")
  420. now = self.now_factory().isoformat()
  421. grant = {
  422. "uid": self.uid_factory(),
  423. "agent_uid": agent["uid"],
  424. "interface_type": interface_type,
  425. "tool_name": tool_name,
  426. "action": action,
  427. "business_domain_uid": domain,
  428. "environment": environment,
  429. "risk_level": risk,
  430. "requires_approval": requires_approval,
  431. "status": "active",
  432. "created_by": actor,
  433. "created_at": now,
  434. "revoked_by": None,
  435. "revoked_at": None,
  436. }
  437. try:
  438. result = self.repository.create_grant(grant)
  439. self.repository.add_event(
  440. agent["uid"], "tool_granted", actor, agent["current_version"],
  441. {"grant_uid": grant["uid"], "tool_name": tool_name, "action": action},
  442. )
  443. self.commit()
  444. return result
  445. except Exception:
  446. self.rollback()
  447. raise
  448. def revoke_tool_grant(self, agent_uid: str, grant_uid: str, *, actor_uid: str):
  449. agent = self.get_agent(agent_uid)
  450. actor = _uid(actor_uid, "actor_uid")
  451. if actor != agent["owner_uid"] or agent["status"] == "retired":
  452. raise PermissionError("only the Agent owner can revoke tools")
  453. try:
  454. result = self.repository.revoke_grant(
  455. agent["uid"], _uid(grant_uid, "grant_uid"), actor,
  456. self.now_factory().isoformat(),
  457. )
  458. self.repository.add_event(
  459. agent["uid"], "tool_revoked", actor, agent["current_version"],
  460. {"grant_uid": grant_uid},
  461. )
  462. self.commit()
  463. return result
  464. except Exception:
  465. self.rollback()
  466. raise
  467. def issue_credential(self, agent_uid: str, payload: Any, *, actor_uid: str):
  468. body = _closed(payload, {"ttl_seconds"}, "Agent credential request")
  469. agent = self.get_agent(agent_uid)
  470. actor = _uid(actor_uid, "actor_uid")
  471. if actor != agent["owner_uid"] or agent["status"] != "active":
  472. raise PermissionError("only the active Agent owner can issue credentials")
  473. try:
  474. ttl = int(body.get("ttl_seconds"))
  475. except (TypeError, ValueError) as error:
  476. raise ValueError("ttl_seconds must be an integer") from error
  477. if ttl < 60 or ttl > 900:
  478. raise ValueError("ttl_seconds must be between 60 and 900")
  479. now = self.now_factory()
  480. expires_at = now + timedelta(seconds=ttl)
  481. jti = self.uid_factory()
  482. claims = {
  483. "jti": jti,
  484. "sub": agent["machine_subject"],
  485. "agent_uid": agent["uid"],
  486. "business_domain_uids": agent["business_domain_uids"],
  487. "environments": agent["environments"],
  488. "iat": int(now.timestamp()),
  489. "exp": int(expires_at.timestamp()),
  490. }
  491. encoded = _urlsafe_encode(_canonical(claims))
  492. signature = _urlsafe_encode(hmac.new(self.secret, encoded.encode(), hashlib.sha256).digest())
  493. token = f"{encoded}.{signature}"
  494. record = {
  495. "uid": self.uid_factory(),
  496. "agent_uid": agent["uid"],
  497. "jti": jti,
  498. "token_digest": hashlib.sha256(token.encode()).hexdigest(),
  499. "issued_by": actor,
  500. "issued_at": now.isoformat(),
  501. "expires_at": expires_at.isoformat(),
  502. "status": "active",
  503. "revoked_by": None,
  504. "revoked_at": None,
  505. }
  506. try:
  507. self.repository.create_credential(record)
  508. self.repository.add_event(
  509. agent["uid"], "credential_issued", actor, agent["current_version"],
  510. {"credential_uid": record["uid"], "jti": jti, "expires_at": record["expires_at"]},
  511. )
  512. self.commit()
  513. return {"token": token, "jti": jti, "expires_at": record["expires_at"]}
  514. except Exception:
  515. self.rollback()
  516. raise
  517. def validate_credential(self, agent_uid: str, token: str) -> dict[str, Any]:
  518. if not isinstance(token, str) or token.count(".") != 1:
  519. raise PermissionError("Agent credential is malformed")
  520. encoded, supplied_signature = token.split(".", 1)
  521. expected_signature = _urlsafe_encode(
  522. hmac.new(self.secret, encoded.encode(), hashlib.sha256).digest()
  523. )
  524. if not hmac.compare_digest(supplied_signature, expected_signature):
  525. raise PermissionError("Agent credential signature is invalid")
  526. try:
  527. claims = json.loads(_urlsafe_decode(encoded))
  528. except (ValueError, json.JSONDecodeError) as error:
  529. raise PermissionError("Agent credential payload is invalid") from error
  530. uid = _uid(agent_uid, "agent_uid")
  531. if claims.get("agent_uid") != uid:
  532. raise PermissionError("Agent credential subject does not match")
  533. if int(claims.get("exp", 0)) <= int(self.now_factory().timestamp()):
  534. raise PermissionError("Agent credential has expired")
  535. stored = self.repository.get_credential(claims.get("jti"))
  536. if (
  537. not stored
  538. or stored.get("status") != "active"
  539. or stored.get("agent_uid") != uid
  540. or not hmac.compare_digest(
  541. stored.get("token_digest", ""), hashlib.sha256(token.encode()).hexdigest()
  542. )
  543. ):
  544. raise PermissionError("Agent credential is revoked or unknown")
  545. return claims
  546. def revoke_agent_credentials(self, agent_uid: str, *, actor_uid: str):
  547. agent = self.get_agent(agent_uid)
  548. actor = _uid(actor_uid, "actor_uid")
  549. if actor != agent["owner_uid"]:
  550. raise PermissionError("only the Agent owner can revoke credentials")
  551. now = self.now_factory().isoformat()
  552. try:
  553. count = self.repository.revoke_credentials(agent["uid"], actor, now)
  554. self.repository.add_event(
  555. agent["uid"], "credentials_revoked", actor,
  556. agent["current_version"], {"revoked_count": count},
  557. )
  558. self.commit()
  559. return {"agent_uid": agent["uid"], "revoked_count": count}
  560. except Exception:
  561. self.rollback()
  562. raise
  563. def _decision_record(
  564. self,
  565. agent: dict[str, Any],
  566. request_body: dict[str, Any],
  567. *,
  568. grant: dict[str, Any] | None,
  569. prompt_guard: dict[str, Any],
  570. evidence_refs: list[dict[str, str]],
  571. decision: str,
  572. reason_code: str,
  573. approval_task_uid: str | None = None,
  574. automatic_execution_allowed: bool = False,
  575. ) -> dict[str, Any]:
  576. if decision not in REQUEST_DECISIONS:
  577. raise ValueError("unsupported Agent decision")
  578. now = self.now_factory().isoformat()
  579. return {
  580. "uid": self.uid_factory(),
  581. "agent_uid": agent["uid"],
  582. "agent_version": agent["current_version"],
  583. "grant_uid": grant.get("uid") if grant else None,
  584. "correlation_id": request_body["correlation_id"],
  585. "interface_type": request_body["interface_type"],
  586. "tool_name": request_body["tool_name"],
  587. "action": request_body["action"],
  588. "business_domain_uid": request_body["business_domain_uid"],
  589. "environment": request_body["environment"],
  590. "risk_level": request_body["risk_level"],
  591. "input_digest": prompt_guard["prompt_hash"],
  592. "prompt_guard": prompt_guard,
  593. "evidence_refs": evidence_refs,
  594. "decision": decision,
  595. "reason_code": reason_code,
  596. "approval_task_uid": approval_task_uid,
  597. "automatic_execution_allowed": automatic_execution_allowed,
  598. "output_digest": None,
  599. "current_version": 1,
  600. "created_at": now,
  601. "updated_at": now,
  602. }
  603. def authorize_action(self, agent_uid: str, token: str, payload: Any):
  604. body = _closed(
  605. payload,
  606. {
  607. "interface_type",
  608. "tool_name",
  609. "action",
  610. "business_domain_uid",
  611. "environment",
  612. "risk_level",
  613. "prompt",
  614. "evidence_refs",
  615. "workflow_uid",
  616. "correlation_id",
  617. },
  618. "Agent action",
  619. )
  620. agent = self.get_agent(agent_uid)
  621. request_body = {
  622. "interface_type": _string(body.get("interface_type"), "interface_type", 20),
  623. "tool_name": _string(body.get("tool_name"), "tool_name", 200),
  624. "action": _string(body.get("action"), "action", 20),
  625. "business_domain_uid": _uid(body.get("business_domain_uid"), "business_domain_uid"),
  626. "environment": _string(body.get("environment"), "environment", 30),
  627. "risk_level": _string(body.get("risk_level"), "risk_level", 20),
  628. "correlation_id": _uid(body.get("correlation_id", self.uid_factory()), "correlation_id"),
  629. }
  630. if request_body["interface_type"] not in INTERFACE_TYPES:
  631. raise ValueError("unsupported interface type")
  632. if request_body["action"] not in TOOL_ACTIONS:
  633. raise ValueError("unsupported tool action")
  634. if request_body["risk_level"] not in RISK_LEVELS:
  635. raise ValueError("unsupported risk level")
  636. prompt_guard = inspect_prompt(body.get("prompt"))
  637. evidence_refs = _normalize_evidence(
  638. body.get("evidence_refs", []),
  639. required=(
  640. request_body["action"] == "suggest"
  641. and agent["prompt_policy"]["citation_required"]
  642. and prompt_guard["safe"]
  643. ),
  644. )
  645. reason = None
  646. claims = None
  647. try:
  648. claims = self.validate_credential(agent["uid"], token)
  649. except PermissionError:
  650. reason = "credential_invalid"
  651. if claims and (
  652. request_body["business_domain_uid"] not in claims["business_domain_uids"]
  653. or request_body["environment"] not in claims["environments"]
  654. ):
  655. reason = "credential_scope_denied"
  656. if agent["status"] != "active":
  657. reason = "agent_not_active"
  658. if request_body["tool_name"] in PROHIBITED_TOOLS:
  659. reason = "prohibited_action"
  660. elif not prompt_guard["safe"]:
  661. reason = "prompt_injection_detected"
  662. elif request_body["action"] not in _autonomy_actions(agent["autonomy_level"]):
  663. reason = "autonomy_level_denied"
  664. grant = None
  665. if reason is None:
  666. grant = self.repository.find_grant(
  667. agent["uid"],
  668. request_body["interface_type"],
  669. request_body["tool_name"],
  670. request_body["action"],
  671. request_body["business_domain_uid"],
  672. request_body["environment"],
  673. )
  674. if grant is None:
  675. reason = "tool_not_granted"
  676. elif grant["risk_level"] != request_body["risk_level"]:
  677. reason = "risk_classification_mismatch"
  678. if reason is not None:
  679. record = self._decision_record(
  680. agent,
  681. request_body,
  682. grant=grant,
  683. prompt_guard=prompt_guard,
  684. evidence_refs=evidence_refs,
  685. decision="denied",
  686. reason_code=reason,
  687. )
  688. else:
  689. needs_approval = (
  690. grant["requires_approval"]
  691. or request_body["risk_level"] in {"high", "critical"}
  692. or (
  693. request_body["action"] == "execute"
  694. and agent["autonomy_level"] == "approval_execution"
  695. )
  696. )
  697. automatic = bool(
  698. request_body["action"] == "execute"
  699. and request_body["risk_level"] == "low"
  700. and agent["autonomy_level"] == "low_risk_automatic"
  701. and not needs_approval
  702. )
  703. record = self._decision_record(
  704. agent,
  705. request_body,
  706. grant=grant,
  707. prompt_guard=prompt_guard,
  708. evidence_refs=evidence_refs,
  709. decision="pending_approval" if needs_approval else "authorized",
  710. reason_code="approval_required" if needs_approval else "policy_allowed",
  711. automatic_execution_allowed=automatic,
  712. )
  713. if needs_approval:
  714. workflow_uid = _uid(body.get("workflow_uid"), "workflow_uid")
  715. task = self.approval_gateway.create_agent_task(
  716. record, workflow_uid, agent["owner_uid"]
  717. )
  718. record["approval_task_uid"] = task["uid"]
  719. try:
  720. result = self.repository.create_request(record)
  721. self.commit()
  722. return result
  723. except Exception:
  724. self.rollback()
  725. raise
  726. def reconcile_action(
  727. self, request_uid: str, *, expected_version: int, actor_uid: str
  728. ):
  729. request_record = self.repository.get_request(_uid(request_uid, "request_uid"))
  730. if request_record is None:
  731. raise LookupError("Agent action request was not found")
  732. if request_record["decision"] != "pending_approval":
  733. raise RuntimeError("Agent action is not waiting for approval")
  734. actor = _uid(actor_uid, "actor_uid")
  735. task = self.approval_gateway.get_task(request_record["approval_task_uid"])
  736. if not task or task["status"] not in {"approved", "rejected"}:
  737. raise RuntimeError("Agent approval has no final decision")
  738. if task["status"] == "rejected":
  739. decision, reason = "denied", "approval_rejected"
  740. elif request_record["risk_level"] in {"high", "critical"}:
  741. route = task.get("route_snapshot") or {}
  742. reviewers = {
  743. item.get("reviewer_uid")
  744. for item in task.get("reviews", [])
  745. if item.get("decision") == "approve"
  746. }
  747. if (
  748. route.get("approval_mode") != "dual_control"
  749. or int(route.get("min_approvals", 0)) < 2
  750. or len(reviewers) < 2
  751. ):
  752. raise RuntimeError("high-risk Agent action requires dual control")
  753. decision, reason = (
  754. "approved_for_manual_execution",
  755. "dual_control_approved_manual_only",
  756. )
  757. else:
  758. decision, reason = "authorized", "approval_granted"
  759. updated = {
  760. **request_record,
  761. "decision": decision,
  762. "reason_code": reason,
  763. "automatic_execution_allowed": False,
  764. "updated_at": self.now_factory().isoformat(),
  765. }
  766. try:
  767. result = self.repository.update_request(
  768. updated, int(expected_version), "approval_reconciled", actor
  769. )
  770. self.commit()
  771. return result
  772. except Exception:
  773. self.rollback()
  774. raise
  775. def complete_action(
  776. self,
  777. request_uid: str,
  778. payload: Any,
  779. *,
  780. expected_version: int,
  781. actor_uid: str,
  782. ):
  783. body = _closed(payload, {"output", "evidence_refs"}, "Agent action result")
  784. request_record = self.repository.get_request(_uid(request_uid, "request_uid"))
  785. if request_record is None:
  786. raise LookupError("Agent action request was not found")
  787. if request_record["risk_level"] in {"high", "critical"}:
  788. raise RuntimeError("high-risk automatic execution is disabled")
  789. if request_record["decision"] != "authorized":
  790. raise RuntimeError("Agent action is not authorized for completion")
  791. output = body.get("output")
  792. if not isinstance(output, dict):
  793. raise ValueError("Agent output must be an object")
  794. evidence_refs = _normalize_evidence(body.get("evidence_refs"), required=True)
  795. updated = {
  796. **request_record,
  797. "decision": "executed",
  798. "reason_code": "execution_evidence_recorded",
  799. "evidence_refs": evidence_refs,
  800. "output_digest": _hash(output),
  801. "updated_at": self.now_factory().isoformat(),
  802. }
  803. actor = _uid(actor_uid, "actor_uid")
  804. try:
  805. result = self.repository.update_request(
  806. updated, int(expected_version), "execution_recorded", actor
  807. )
  808. self.commit()
  809. return result
  810. except Exception:
  811. self.rollback()
  812. raise
  813. def replay(self, request_uid: str):
  814. result = self.repository.replay(_uid(request_uid, "request_uid"))
  815. if result is None:
  816. raise LookupError("Agent action replay was not found")
  817. return result
  818. def list_requests(self, **filters):
  819. return self.repository.list_requests(**filters)
  820. def dashboard(self):
  821. return self.repository.dashboard()