agent_governance_routes.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. """Governed Agent registry, authorization decisions and replay APIs."""
  2. from __future__ import annotations
  3. import hashlib
  4. import uuid
  5. from flask import current_app, g, jsonify, request
  6. from app import db
  7. from app.api.knowledge_base import bp
  8. from app.config.config import is_placeholder_env_value
  9. from app.core.llm.agent_governance import AgentGovernanceService
  10. from app.core.llm.agent_governance_repository import (
  11. SqlAlchemyAgentGovernanceRepository,
  12. WorkCenterAgentApprovalGateway,
  13. )
  14. from app.core.llm.runtime_governance_repository import (
  15. SqlAlchemyRuntimeGovernanceRepository,
  16. )
  17. from app.core.llm.runtime_server import (
  18. RuntimeServerContext,
  19. RuntimeServerError,
  20. ServerGovernedInvocationService,
  21. )
  22. from app.core.system.permissions import (
  23. AGENTS_MANAGE,
  24. AGENTS_OPERATE,
  25. AGENTS_READ,
  26. require_permissions,
  27. )
  28. from app.models.result import failed, success
  29. class AgentGovernanceUnavailable(RuntimeError):
  30. """Raised when production lacks a dedicated credential signing secret."""
  31. def _effective_credential_secret() -> tuple[str, bool]:
  32. dedicated = str(current_app.config.get("AGENT_CREDENTIAL_SECRET") or "").strip()
  33. if len(dedicated.encode()) >= 32 and not is_placeholder_env_value(dedicated):
  34. return dedicated, True
  35. fallback = hashlib.sha256(
  36. ("dataops-wp09-local-agent:" + str(current_app.config.get("SECRET_KEY") or "")).encode()
  37. ).hexdigest()
  38. return fallback, False
  39. def _require_credential_secret() -> None:
  40. _secret, dedicated_ready = _effective_credential_secret()
  41. if (
  42. not dedicated_ready
  43. and not current_app.config.get("TESTING")
  44. and str(current_app.config.get("FLASK_ENV") or "").lower() == "production"
  45. ):
  46. raise AgentGovernanceUnavailable(
  47. "dedicated Agent credential secret is required in production"
  48. )
  49. def _service():
  50. secret, _dedicated_ready = _effective_credential_secret()
  51. return AgentGovernanceService(
  52. SqlAlchemyAgentGovernanceRepository(db.session),
  53. approval_gateway=WorkCenterAgentApprovalGateway(db.session),
  54. credential_secret=secret,
  55. commit=db.session.commit,
  56. rollback=db.session.rollback,
  57. )
  58. def _runtime_service():
  59. return ServerGovernedInvocationService(SqlAlchemyRuntimeGovernanceRepository(db.session))
  60. def _expected_version() -> int:
  61. raw = str(request.headers.get("If-Match") or "").strip()
  62. if raw.startswith("W/"):
  63. raw = raw[2:].strip()
  64. raw = raw.strip('"')
  65. if not raw.isdigit():
  66. raise ValueError("missing valid If-Match version")
  67. return int(raw)
  68. def _etag(response, version):
  69. response.headers["ETag"] = f'"{int(version)}"'
  70. return response
  71. def _no_store(response):
  72. response.headers["Cache-Control"] = "no-store"
  73. return response
  74. def _error(exc):
  75. db.session.rollback()
  76. if "If-Match" in str(exc):
  77. status = 428
  78. elif isinstance(exc, LookupError):
  79. status = 404
  80. elif isinstance(exc, (PermissionError, AgentGovernanceUnavailable)):
  81. status = 403 if isinstance(exc, PermissionError) else 503
  82. elif isinstance(exc, RuntimeError):
  83. status = 409
  84. else:
  85. status = 400
  86. return jsonify(failed(str(exc), code=status)), status
  87. @bp.route("/agents", methods=["GET"])
  88. @require_permissions(AGENTS_READ)
  89. def list_governed_agents():
  90. filters = {
  91. key: request.args.get(key)
  92. for key in ("status", "autonomy_level", "owner_uid")
  93. if request.args.get(key)
  94. }
  95. return jsonify(success(_service().list_agents(**filters)))
  96. @bp.route("/agents", methods=["POST"])
  97. @require_permissions(AGENTS_MANAGE)
  98. def register_governed_agent():
  99. try:
  100. result = _service().register_agent(
  101. request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
  102. )
  103. return _etag(jsonify(success(result, "Agent 治理登记已创建", code=201)), 1), 201
  104. except (ValueError, LookupError, RuntimeError) as exc:
  105. return _error(exc)
  106. @bp.route("/agents/<agent_uid>", methods=["GET"])
  107. @require_permissions(AGENTS_READ)
  108. def get_governed_agent(agent_uid):
  109. try:
  110. result = _service().agent_detail(agent_uid)
  111. return _etag(jsonify(success(result)), result["current_version"])
  112. except (ValueError, LookupError) as exc:
  113. return _error(exc)
  114. @bp.route("/agents/<agent_uid>/revisions", methods=["POST"])
  115. @require_permissions(AGENTS_OPERATE)
  116. def revise_governed_agent(agent_uid):
  117. try:
  118. result = _service().revise_agent(
  119. agent_uid, request.get_json(silent=True) or {},
  120. expected_version=_expected_version(), actor_uid=g.current_user["id"],
  121. )
  122. return _etag(jsonify(success(result, "Agent 版本草稿已创建")), result["current_version"])
  123. except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
  124. return _error(exc)
  125. @bp.route("/agents/<agent_uid>/transition", methods=["POST"])
  126. @require_permissions(AGENTS_OPERATE)
  127. def transition_governed_agent(agent_uid):
  128. try:
  129. result = _service().transition_agent(
  130. agent_uid, request.get_json(silent=True) or {},
  131. expected_version=_expected_version(), actor_uid=g.current_user["id"],
  132. )
  133. return _etag(jsonify(success(result, "Agent 生命周期已更新")), result["current_version"])
  134. except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
  135. return _error(exc)
  136. @bp.route("/agents/<agent_uid>/grants", methods=["POST"])
  137. @require_permissions(AGENTS_OPERATE)
  138. def create_agent_tool_grant(agent_uid):
  139. try:
  140. result = _service().create_tool_grant(
  141. agent_uid, request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
  142. )
  143. return jsonify(success(result, "Agent 工具授权已创建", code=201)), 201
  144. except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
  145. return _error(exc)
  146. @bp.route("/agents/<agent_uid>/grants/<grant_uid>/revoke", methods=["POST"])
  147. @require_permissions(AGENTS_OPERATE)
  148. def revoke_agent_tool_grant(agent_uid, grant_uid):
  149. try:
  150. return jsonify(success(_service().revoke_tool_grant(
  151. agent_uid, grant_uid, actor_uid=g.current_user["id"]
  152. ), "Agent 工具授权已撤销"))
  153. except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
  154. return _error(exc)
  155. @bp.route("/agents/<agent_uid>/credentials", methods=["POST"])
  156. @require_permissions(AGENTS_OPERATE)
  157. def issue_agent_credential(agent_uid):
  158. try:
  159. _require_credential_secret()
  160. result = _service().issue_credential(
  161. agent_uid, request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
  162. )
  163. return _no_store(jsonify(success(result, "凭证仅本次返回,请立即安全保存", code=201))), 201
  164. except (ValueError, LookupError, PermissionError, RuntimeError, AgentGovernanceUnavailable) as exc:
  165. return _error(exc)
  166. @bp.route("/agents/<agent_uid>/credentials/revoke", methods=["POST"])
  167. @require_permissions(AGENTS_OPERATE)
  168. def revoke_agent_credentials(agent_uid):
  169. try:
  170. return jsonify(success(_service().revoke_agent_credentials(
  171. agent_uid, actor_uid=g.current_user["id"]
  172. ), "Agent 有效凭证已全部撤销"))
  173. except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
  174. return _error(exc)
  175. @bp.route("/agents/<agent_uid>/actions/authorize", methods=["POST"])
  176. @require_permissions(AGENTS_OPERATE)
  177. def authorize_agent_action(agent_uid):
  178. try:
  179. _require_credential_secret()
  180. token = str(request.headers.get("X-Agent-Credential") or "")
  181. result = _service().authorize_action(
  182. agent_uid, token, request.get_json(silent=True) or {}
  183. )
  184. return _no_store(_etag(jsonify(success(result, "Agent 策略判定已留痕", code=201)), 1)), 201
  185. except (ValueError, LookupError, PermissionError, RuntimeError, AgentGovernanceUnavailable) as exc:
  186. return _error(exc)
  187. @bp.route("/agents/actions", methods=["GET"])
  188. @require_permissions(AGENTS_READ)
  189. def list_agent_actions():
  190. filters = {
  191. key: request.args.get(key)
  192. for key in ("agent_uid", "decision", "risk_level")
  193. if request.args.get(key)
  194. }
  195. return jsonify(success(_service().list_requests(**filters)))
  196. @bp.route("/agents/actions/<request_uid>/reconcile", methods=["POST"])
  197. @require_permissions(AGENTS_MANAGE)
  198. def reconcile_agent_action(request_uid):
  199. try:
  200. result = _service().reconcile_action(
  201. request_uid, expected_version=_expected_version(), actor_uid=g.current_user["id"]
  202. )
  203. return _etag(jsonify(success(result, "审批结果已同步")), result["current_version"])
  204. except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
  205. return _error(exc)
  206. @bp.route("/agents/actions/<request_uid>/complete", methods=["POST"])
  207. @require_permissions(AGENTS_OPERATE)
  208. def complete_agent_action(request_uid):
  209. try:
  210. result = _service().complete_action(
  211. request_uid, request.get_json(silent=True) or {},
  212. expected_version=_expected_version(), actor_uid=g.current_user["id"],
  213. )
  214. return _etag(jsonify(success(result, "Agent 执行证据已记录")), result["current_version"])
  215. except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
  216. return _error(exc)
  217. @bp.route("/agents/actions/<request_uid>/replay", methods=["GET"])
  218. @require_permissions(AGENTS_READ)
  219. def replay_agent_action(request_uid):
  220. try:
  221. return _no_store(jsonify(success(_service().replay(request_uid))))
  222. except (ValueError, LookupError) as exc:
  223. return _error(exc)
  224. @bp.route("/agents/dashboard", methods=["GET"])
  225. @require_permissions(AGENTS_READ)
  226. def agent_governance_dashboard():
  227. try:
  228. return jsonify(success(_service().dashboard()))
  229. except AgentGovernanceUnavailable as exc:
  230. return _error(exc)
  231. @bp.route("/agents/<agent_uid>/runtime/invocations", methods=["POST"])
  232. @require_permissions(AGENTS_OPERATE)
  233. def authorize_runtime_invocation(agent_uid):
  234. """The only HTTP path that binds Agent credentials to a server identity."""
  235. try:
  236. token = str(request.headers.get("X-Agent-Credential") or "")
  237. if not token:
  238. raise PermissionError("Agent credential is required")
  239. _service().validate_credential(agent_uid, token)
  240. result = _runtime_service().authorize(
  241. RuntimeServerContext(
  242. agent_uid=agent_uid,
  243. actor_uid=g.current_user["id"],
  244. roles=frozenset(g.current_user["roles"]),
  245. ),
  246. request.get_json(silent=True) or {},
  247. )
  248. db.session.commit()
  249. return _no_store(jsonify(success(result, code=201))), 201
  250. except (RuntimeServerError, ValueError, LookupError, PermissionError, RuntimeError) as exc:
  251. response, status = _error(exc)
  252. return _no_store(response), status
  253. @bp.route("/agents/<agent_uid>/runtime/invocations/<idempotency_key>/settle", methods=["POST"])
  254. @require_permissions(AGENTS_OPERATE)
  255. def settle_runtime_invocation(agent_uid, idempotency_key):
  256. try:
  257. token = str(request.headers.get("X-Agent-Credential") or "")
  258. if not token:
  259. raise PermissionError("Agent credential is required")
  260. claims = _service().validate_credential(agent_uid, token)
  261. agent = _service().get_agent(agent_uid)
  262. if claims["agent_uid"] != agent_uid or agent["owner_uid"] != g.current_user["id"]:
  263. raise PermissionError("Agent credential principal does not match")
  264. body = request.get_json(silent=True) or {}
  265. allowed = {"lease_fence", "outcome", "actual_tokens", "actual_cost_micros", "actual_tools", "actual_time_ms"}
  266. if set(body) != allowed:
  267. raise ValueError("runtime settlement payload is closed")
  268. scope = _runtime_service().repository.settlement_context(
  269. agent_uid=agent_uid, actor_uid=g.current_user["id"], idempotency_key=idempotency_key
  270. )
  271. result = _runtime_service().repository.settle_claim({
  272. **scope,
  273. "worker_id": f"agent-runtime-http:{g.current_user['id']}",
  274. **body,
  275. })
  276. db.session.commit()
  277. if result.get("rejected"):
  278. return _no_store(jsonify(failed("runtime_settlement_rejected", code=409))), 409
  279. return _no_store(jsonify(success(result, code=201))), 201
  280. except (RuntimeServerError, ValueError, LookupError, PermissionError, RuntimeError) as exc:
  281. response, status = _error(exc)
  282. return _no_store(response), status
  283. @bp.route("/agents/<agent_uid>/runtime/control", methods=["POST"])
  284. @require_permissions(AGENTS_OPERATE)
  285. def control_runtime(agent_uid):
  286. """Execute a recovery/canary change only through an authenticated control claim."""
  287. try:
  288. token = str(request.headers.get("X-Agent-Credential") or "")
  289. if not token:
  290. raise PermissionError("Agent credential is required")
  291. claims = _service().validate_credential(agent_uid, token)
  292. agent = _service().get_agent(agent_uid)
  293. if claims["agent_uid"] != agent_uid or agent["owner_uid"] != g.current_user["id"]:
  294. raise PermissionError("Agent credential principal does not match")
  295. body = request.get_json(silent=True) or {}
  296. forbidden = {"agent_uid", "principal_id"}
  297. required = {
  298. "tenant_id", "business_domain_uid", "environment", "operation", "approval_task_uid",
  299. "idempotency_key", "request_digest", "expected_fence", "incident_uid", "from_state",
  300. "to_state", "generation", "from_generation", "to_generation", "dataset_version",
  301. "dataset_digest", "metrics_digest", "threshold_policy", "issued_at",
  302. }
  303. if set(body) != required or set(body) & forbidden:
  304. raise ValueError("runtime control payload is closed")
  305. credential = _service().repository.get_credential(claims["jti"])
  306. if not credential:
  307. raise PermissionError("Agent credential is revoked or unknown")
  308. payload = {**body, "agent_uid": agent_uid, "principal_id": g.current_user["id"]}
  309. claim_uid = _runtime_service().repository.issue_control_claim({
  310. "claim_uid": str(uuid.uuid4()), "credential_uid": credential["uid"],
  311. "credential_token_digest": hashlib.sha256(token.encode()).hexdigest(),
  312. "nonce": str(uuid.uuid4()), "control_payload": payload,
  313. })
  314. result = _runtime_service().repository.control_claimed(payload, claim_uid)
  315. db.session.commit()
  316. return _no_store(jsonify(success(result, code=201))), 201
  317. except (RuntimeServerError, ValueError, LookupError, PermissionError, RuntimeError) as exc:
  318. response, status = _error(exc)
  319. return _no_store(response), status