governed_invocation.py 2.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
  1. """Closed, minimized contract for MCP calls made by governed Agents."""
  2. from __future__ import annotations
  3. import copy
  4. import re
  5. import unicodedata
  6. from typing import Any
  7. class InvocationContractError(ValueError):
  8. """Raised when an MCP invocation exceeds its declared contract."""
  9. _ALLOWED = frozenset(
  10. {"interface_type", "tool_name", "action", "arguments_digest", "evidence_refs"}
  11. )
  12. _FORBIDDEN_KEYS = frozenset(
  13. {
  14. "url", "uri", "endpoint", "path", "file_path", "filename", "command",
  15. "shell", "script", "secret", "token", "password", "api_key", "raw_rows",
  16. "rows", "records", "arguments", "payload",
  17. }
  18. )
  19. _TOOL = re.compile(r"^[A-Za-z][A-Za-z0-9_.:-]{1,159}$")
  20. _DIGEST = re.compile(r"^[a-f0-9]{64}$")
  21. def _closed(value: Any, allowed: frozenset[str], label: str) -> dict[str, Any]:
  22. if not isinstance(value, dict):
  23. raise InvocationContractError(f"{label}_invalid")
  24. for key in value:
  25. normalized = unicodedata.normalize("NFKC", str(key))
  26. if normalized != key or normalized.casefold() in _FORBIDDEN_KEYS:
  27. raise InvocationContractError("forbidden_contract_key")
  28. unknown = set(value) - allowed
  29. if unknown:
  30. raise InvocationContractError("unknown_contract_field")
  31. return copy.deepcopy(value)
  32. def _digest(value: Any, label: str) -> str:
  33. if not isinstance(value, str) or not _DIGEST.fullmatch(value):
  34. raise InvocationContractError(f"{label}_invalid")
  35. return value
  36. def normalize_mcp_invocation(value: Any) -> dict[str, Any]:
  37. """Accept hash-only, bounded MCP metadata; never raw arguments or locations."""
  38. body = _closed(value, _ALLOWED, "invocation")
  39. if body.get("interface_type") != "mcp":
  40. raise InvocationContractError("interface_type_invalid")
  41. tool_name = body.get("tool_name")
  42. if not isinstance(tool_name, str) or not _TOOL.fullmatch(tool_name):
  43. raise InvocationContractError("tool_name_invalid")
  44. if body.get("action") not in {"read", "suggest", "execute"}:
  45. raise InvocationContractError("action_invalid")
  46. refs = body.get("evidence_refs")
  47. if not isinstance(refs, list) or len(refs) > 20:
  48. raise InvocationContractError("evidence_refs_invalid")
  49. normalized_refs = []
  50. for item in refs:
  51. ref = _closed(item, frozenset({"evidence_id", "digest"}), "evidence_ref")
  52. evidence_id = ref.get("evidence_id")
  53. if not isinstance(evidence_id, str) or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,120}", evidence_id):
  54. raise InvocationContractError("evidence_id_invalid")
  55. normalized_refs.append({"evidence_id": evidence_id, "digest": _digest(ref.get("digest"), "evidence_digest")})
  56. return {
  57. "interface_type": "mcp",
  58. "tool_name": tool_name,
  59. "action": body["action"],
  60. "arguments_digest": _digest(body.get("arguments_digest"), "arguments_digest"),
  61. "evidence_refs": normalized_refs,
  62. }