graph-agents-cli 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. graph_agents_cli/__init__.py +26 -0
  2. graph_agents_cli/_api_policy.py +2145 -0
  3. graph_agents_cli/_approvals.py +400 -0
  4. graph_agents_cli/_build.py +186 -0
  5. graph_agents_cli/_build_info.json +7 -0
  6. graph_agents_cli/_chat_client.py +462 -0
  7. graph_agents_cli/_click.py +157 -0
  8. graph_agents_cli/_defaults.py +139 -0
  9. graph_agents_cli/_experiments.py +64 -0
  10. graph_agents_cli/_http.py +192 -0
  11. graph_agents_cli/_output.py +83 -0
  12. graph_agents_cli/_project.py +462 -0
  13. graph_agents_cli/_remote.py +220 -0
  14. graph_agents_cli/_response_schema.py +264 -0
  15. graph_agents_cli/_runner.py +319 -0
  16. graph_agents_cli/_skills_check.py +274 -0
  17. graph_agents_cli/_tools.py +189 -0
  18. graph_agents_cli/_trust.py +66 -0
  19. graph_agents_cli/api/__init__.py +15 -0
  20. graph_agents_cli/api/_changes.py +506 -0
  21. graph_agents_cli/api/_files.py +658 -0
  22. graph_agents_cli/api/cmd_api.py +2480 -0
  23. graph_agents_cli/deploy/__init__.py +15 -0
  24. graph_agents_cli/deploy/_config.py +171 -0
  25. graph_agents_cli/deploy/_image.py +128 -0
  26. graph_agents_cli/deploy/_kube.py +286 -0
  27. graph_agents_cli/deploy/_modes.py +234 -0
  28. graph_agents_cli/deploy/_preflight.py +370 -0
  29. graph_agents_cli/deploy/_values.py +168 -0
  30. graph_agents_cli/deploy/cmd_deploy.py +1866 -0
  31. graph_agents_cli/deploy/gitops.py +562 -0
  32. graph_agents_cli/deploy/local_load.py +273 -0
  33. graph_agents_cli/dev/__init__.py +13 -0
  34. graph_agents_cli/dev/cmd_build.py +131 -0
  35. graph_agents_cli/dev/cmd_install.py +78 -0
  36. graph_agents_cli/dev/cmd_lint.py +119 -0
  37. graph_agents_cli/dev/cmd_playground.py +297 -0
  38. graph_agents_cli/dev/policy_check.py +1287 -0
  39. graph_agents_cli/eval/__init__.py +22 -0
  40. graph_agents_cli/eval/_client.py +670 -0
  41. graph_agents_cli/eval/_common.py +177 -0
  42. graph_agents_cli/eval/_judge.py +168 -0
  43. graph_agents_cli/eval/_judge_runner.py +238 -0
  44. graph_agents_cli/eval/_paths.py +212 -0
  45. graph_agents_cli/eval/checks.py +581 -0
  46. graph_agents_cli/eval/cmd_analyze.py +278 -0
  47. graph_agents_cli/eval/cmd_compare.py +284 -0
  48. graph_agents_cli/eval/cmd_eval_group.py +80 -0
  49. graph_agents_cli/eval/cmd_generate.py +558 -0
  50. graph_agents_cli/eval/cmd_grade.py +466 -0
  51. graph_agents_cli/eval/cmd_metric.py +156 -0
  52. graph_agents_cli/eval/cmd_run.py +370 -0
  53. graph_agents_cli/eval/cmd_submit.py +400 -0
  54. graph_agents_cli/eval/config.py +435 -0
  55. graph_agents_cli/eval/dataset.py +350 -0
  56. graph_agents_cli/eval/gate.py +420 -0
  57. graph_agents_cli/eval/transcript.py +192 -0
  58. graph_agents_cli/extension/__init__.py +13 -0
  59. graph_agents_cli/extension/_compat.py +86 -0
  60. graph_agents_cli/extension/_loader.py +293 -0
  61. graph_agents_cli/extension/_manifest.py +135 -0
  62. graph_agents_cli/extension/_overrides.py +195 -0
  63. graph_agents_cli/extension/_paths.py +91 -0
  64. graph_agents_cli/extension/_refs.py +193 -0
  65. graph_agents_cli/extension/_resolver.py +453 -0
  66. graph_agents_cli/extension/_schema.py +106 -0
  67. graph_agents_cli/extension/_spec.py +253 -0
  68. graph_agents_cli/extension/_sync.py +102 -0
  69. graph_agents_cli/extension/_trust.py +58 -0
  70. graph_agents_cli/extension/cmd_extension_add.py +259 -0
  71. graph_agents_cli/extension/cmd_extension_group.py +57 -0
  72. graph_agents_cli/extension/cmd_extension_list.py +56 -0
  73. graph_agents_cli/extension/cmd_extension_remove.py +61 -0
  74. graph_agents_cli/extension/cmd_extension_update.py +195 -0
  75. graph_agents_cli/info/__init__.py +13 -0
  76. graph_agents_cli/info/cmd_info.py +222 -0
  77. graph_agents_cli/infra/__init__.py +15 -0
  78. graph_agents_cli/infra/checks.py +1169 -0
  79. graph_agents_cli/infra/cmd_infra.py +103 -0
  80. graph_agents_cli/main.py +591 -0
  81. graph_agents_cli/peer/__init__.py +15 -0
  82. graph_agents_cli/peer/_generate.py +254 -0
  83. graph_agents_cli/peer/cmd_peer.py +1151 -0
  84. graph_agents_cli/run/__init__.py +13 -0
  85. graph_agents_cli/run/_local_server.py +1157 -0
  86. graph_agents_cli/run/_signals.py +141 -0
  87. graph_agents_cli/run/cmd_approvals.py +530 -0
  88. graph_agents_cli/run/cmd_run.py +1421 -0
  89. graph_agents_cli/scaffold/__init__.py +19 -0
  90. graph_agents_cli/scaffold/agents/README.md +24 -0
  91. graph_agents_cli/scaffold/agents/empty_py/.template/templateconfig.yaml +22 -0
  92. graph_agents_cli/scaffold/agents/langgraph/.env.example +292 -0
  93. graph_agents_cli/scaffold/agents/langgraph/.template/templateconfig.yaml +28 -0
  94. graph_agents_cli/scaffold/agents/langgraph/Dockerfile +59 -0
  95. graph_agents_cli/scaffold/agents/langgraph/Dockerfile.langgraph-server +59 -0
  96. graph_agents_cli/scaffold/agents/langgraph/README.md +571 -0
  97. graph_agents_cli/scaffold/agents/langgraph/api-policy.yaml +60 -0
  98. graph_agents_cli/scaffold/agents/langgraph/app/__init__.py +20 -0
  99. graph_agents_cli/scaffold/agents/langgraph/app/agent.py +174 -0
  100. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/__init__.py +15 -0
  101. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a.py +2162 -0
  102. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a_client.py +1167 -0
  103. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/api_client.py +4220 -0
  104. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/approvals.py +1349 -0
  105. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/auth.py +1986 -0
  106. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/chat.py +2962 -0
  107. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/checkpointer.py +432 -0
  108. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/content.py +569 -0
  109. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/db.py +580 -0
  110. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/limits.py +203 -0
  111. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/metrics.py +231 -0
  112. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/middleware.py +361 -0
  113. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/model.py +611 -0
  114. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/playground.py +230 -0
  115. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/run_locks.py +459 -0
  116. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/structured.py +755 -0
  117. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/telemetry.py +681 -0
  118. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/threads.py +493 -0
  119. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/token_exchange.py +959 -0
  120. graph_agents_cli/scaffold/agents/langgraph/app/fast_api_app.py +770 -0
  121. graph_agents_cli/scaffold/agents/langgraph/app/policies/__init__.py +55 -0
  122. graph_agents_cli/scaffold/agents/langgraph/app/policies/custom.py +97 -0
  123. graph_agents_cli/scaffold/agents/langgraph/app/tools/__init__.py +46 -0
  124. graph_agents_cli/scaffold/agents/langgraph/app/tools/example_api.py +92 -0
  125. graph_agents_cli/scaffold/agents/langgraph/app/tools/weather.py +33 -0
  126. graph_agents_cli/scaffold/agents/langgraph/langgraph.json +14 -0
  127. graph_agents_cli/scaffold/agents/langgraph/pyproject.toml +78 -0
  128. graph_agents_cli/scaffold/agents/langgraph/tests/conftest.py +376 -0
  129. graph_agents_cli/scaffold/agents/langgraph/tests/eval/datasets/basic-dataset.json +53 -0
  130. graph_agents_cli/scaffold/agents/langgraph/tests/eval/eval_config.yaml +32 -0
  131. graph_agents_cli/scaffold/agents/langgraph/tests/integration/approval_graph.py +137 -0
  132. graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_issuer.py +216 -0
  133. graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_openai.py +357 -0
  134. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_outcomes.py +569 -0
  135. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_relay.py +479 -0
  136. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_api_surface.py +812 -0
  137. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals.py +1367 -0
  138. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals_server.py +794 -0
  139. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor.py +497 -0
  140. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor_server.py +247 -0
  141. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_history_repair.py +278 -0
  142. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_model_apis.py +242 -0
  143. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_postgres.py +637 -0
  144. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_resilience_postgres.py +770 -0
  145. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_runtime_guardrails.py +854 -0
  146. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_e2e.py +340 -0
  147. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_runtime.py +989 -0
  148. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_answers.py +584 -0
  149. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_server.py +222 -0
  150. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_token_exchange_issuer.py +650 -0
  151. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/.results/.placeholder +0 -0
  152. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/README.md +22 -0
  153. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/conftest.py +21 -0
  154. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/load_test.py +81 -0
  155. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_client.py +824 -0
  156. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_scoping.py +724 -0
  157. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client.py +1214 -0
  158. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client_hardening.py +716 -0
  159. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_policy_rpc.py +767 -0
  160. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_approval_ledger.py +1536 -0
  161. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_fake_model.py +115 -0
  162. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_jwt_policy.py +991 -0
  163. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_limits.py +310 -0
  164. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging.py +148 -0
  165. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging_hardening.py +271 -0
  166. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_policy.py +378 -0
  167. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_resilience.py +610 -0
  168. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_server_auth.py +702 -0
  169. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_structured.py +673 -0
  170. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_telemetry.py +404 -0
  171. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_thread_listing.py +255 -0
  172. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_threads.py +268 -0
  173. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_token_exchange.py +1320 -0
  174. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_untrusted_content.py +393 -0
  175. graph_agents_cli/scaffold/agents/langgraph/uv-fastapi.lock +2084 -0
  176. graph_agents_cli/scaffold/agents/langgraph/uv-langgraph-server.lock +2106 -0
  177. graph_agents_cli/scaffold/agents/langgraph/{{cookiecutter.agent_guidance_filename}} +129 -0
  178. graph_agents_cli/scaffold/base_templates/_shared/graph-agents-cli-manifest.yaml +36 -0
  179. graph_agents_cli/scaffold/base_templates/python/.dockerignore +32 -0
  180. graph_agents_cli/scaffold/base_templates/python/.github/CODEOWNERS +30 -0
  181. graph_agents_cli/scaffold/base_templates/python/.github/agent.env +7 -0
  182. graph_agents_cli/scaffold/base_templates/python/.github/workflows/pr_checks.yaml +214 -0
  183. graph_agents_cli/scaffold/base_templates/python/.gitignore +209 -0
  184. graph_agents_cli/scaffold/base_templates/python/tests/unit/test_dummy.py +23 -0
  185. graph_agents_cli/scaffold/base_templates/python/{{cookiecutter.agent_guidance_filename}} +35 -0
  186. graph_agents_cli/scaffold/cmd_scaffold_group.py +49 -0
  187. graph_agents_cli/scaffold/commands/__init__.py +13 -0
  188. graph_agents_cli/scaffold/commands/create.py +1424 -0
  189. graph_agents_cli/scaffold/commands/enhance.py +1652 -0
  190. graph_agents_cli/scaffold/commands/upgrade.py +570 -0
  191. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/agent.env +12 -0
  192. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/promote-to-prod.yaml +371 -0
  193. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/staging.yaml +450 -0
  194. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-dev.yaml +43 -0
  195. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-prod.yaml +41 -0
  196. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-staging.yaml +43 -0
  197. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/.helmignore +14 -0
  198. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/Chart.yaml +21 -0
  199. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/examples/networkpolicy.yaml +103 -0
  200. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/NOTES.txt +48 -0
  201. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/_helpers.tpl +189 -0
  202. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/certificate.yaml +15 -0
  203. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/configmap.yaml +10 -0
  204. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/deployment.yaml +199 -0
  205. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/hpa.yaml +22 -0
  206. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/httproute.yaml +30 -0
  207. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/ingress.yaml +39 -0
  208. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/networkpolicy.yaml +48 -0
  209. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/pdb.yaml +13 -0
  210. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/postgresql-secret.yaml +37 -0
  211. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/service.yaml +15 -0
  212. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/serviceaccount.yaml +13 -0
  213. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/servicemonitor.yaml +42 -0
  214. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-dev.yaml +22 -0
  215. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-prod.yaml +45 -0
  216. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-staging.yaml +29 -0
  217. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values.yaml +396 -0
  218. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/tests/integration/test_chart.py +269 -0
  219. graph_agents_cli/scaffold/deployment_targets/none/README.md +5 -0
  220. graph_agents_cli/scaffold/deployment_targets/none/python/README.md +6 -0
  221. graph_agents_cli/scaffold/utils/__init__.py +13 -0
  222. graph_agents_cli/scaffold/utils/backup.py +212 -0
  223. graph_agents_cli/scaffold/utils/build_record.py +257 -0
  224. graph_agents_cli/scaffold/utils/cli_options.py +184 -0
  225. graph_agents_cli/scaffold/utils/fs.py +83 -0
  226. graph_agents_cli/scaffold/utils/generate_locks.py +214 -0
  227. graph_agents_cli/scaffold/utils/generation_metadata.py +88 -0
  228. graph_agents_cli/scaffold/utils/keyedit.py +768 -0
  229. graph_agents_cli/scaffold/utils/keymerge.py +537 -0
  230. graph_agents_cli/scaffold/utils/language.py +138 -0
  231. graph_agents_cli/scaffold/utils/lock_utils.py +94 -0
  232. graph_agents_cli/scaffold/utils/logging.py +77 -0
  233. graph_agents_cli/scaffold/utils/manifest.py +292 -0
  234. graph_agents_cli/scaffold/utils/merge.py +970 -0
  235. graph_agents_cli/scaffold/utils/merge3.py +216 -0
  236. graph_agents_cli/scaffold/utils/openapi_seed.py +199 -0
  237. graph_agents_cli/scaffold/utils/remote_template.py +376 -0
  238. graph_agents_cli/scaffold/utils/template.py +1352 -0
  239. graph_agents_cli/scaffold/utils/upgrade.py +894 -0
  240. graph_agents_cli/scaffold/utils/version.py +438 -0
  241. graph_agents_cli/secrets/__init__.py +15 -0
  242. graph_agents_cli/secrets/_apply.py +954 -0
  243. graph_agents_cli/secrets/_required.py +188 -0
  244. graph_agents_cli/secrets/cmd_secrets.py +211 -0
  245. graph_agents_cli/setup/__init__.py +13 -0
  246. graph_agents_cli/setup/_antigravity.py +221 -0
  247. graph_agents_cli/setup/cmd_auth.py +1030 -0
  248. graph_agents_cli/setup/cmd_dev_token.py +513 -0
  249. graph_agents_cli/setup/cmd_setup.py +428 -0
  250. graph_agents_cli/setup/cmd_update.py +140 -0
  251. graph_agents_cli/skills/__init__.py +13 -0
  252. graph_agents_cli/skills/_bundle.py +65 -0
  253. graph_agents_cli/skills/data/README.md +19 -0
  254. graph_agents_cli/skills/data/graph-agents-cli-deploy/SKILL.md +357 -0
  255. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/github-settings.md +113 -0
  256. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/gitops.md +137 -0
  257. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/kubernetes.md +315 -0
  258. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/secrets.md +160 -0
  259. graph_agents_cli/skills/data/graph-agents-cli-eval/SKILL.md +303 -0
  260. graph_agents_cli/skills/data/graph-agents-cli-eval/references/dataset_schema.md +282 -0
  261. graph_agents_cli/skills/data/graph-agents-cli-eval/references/metrics-guide.md +143 -0
  262. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/SKILL.md +659 -0
  263. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langchain-models.md +124 -0
  264. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langgraph.md +235 -0
  265. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/template-contract.md +477 -0
  266. graph_agents_cli/skills/data/graph-agents-cli-observability/SKILL.md +231 -0
  267. graph_agents_cli/skills/data/graph-agents-cli-observability/references/langsmith.md +46 -0
  268. graph_agents_cli/skills/data/graph-agents-cli-observability/references/otel.md +59 -0
  269. graph_agents_cli/skills/data/graph-agents-cli-scaffold/SKILL.md +414 -0
  270. graph_agents_cli/skills/data/graph-agents-cli-scaffold/references/flags.md +134 -0
  271. graph_agents_cli/skills/data/graph-agents-cli-workflow/SKILL.md +478 -0
  272. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/brainstorming.md +118 -0
  273. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/commands.md +419 -0
  274. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/extension.md +156 -0
  275. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/internals.md +67 -0
  276. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/spec-template.md +56 -0
  277. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/terminology.md +119 -0
  278. graph_agents_cli/system/__init__.py +15 -0
  279. graph_agents_cli/system/_apply.py +519 -0
  280. graph_agents_cli/system/_checks.py +1023 -0
  281. graph_agents_cli/system/_deploy.py +215 -0
  282. graph_agents_cli/system/_model.py +363 -0
  283. graph_agents_cli/system/_system.py +664 -0
  284. graph_agents_cli/system/_views.py +208 -0
  285. graph_agents_cli/system/cmd_system.py +423 -0
  286. graph_agents_cli-0.3.1.dist-info/METADATA +162 -0
  287. graph_agents_cli-0.3.1.dist-info/RECORD +291 -0
  288. graph_agents_cli-0.3.1.dist-info/WHEEL +4 -0
  289. graph_agents_cli-0.3.1.dist-info/entry_points.txt +2 -0
  290. graph_agents_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
  291. graph_agents_cli-0.3.1.dist-info/licenses/NOTICE +19 -0
@@ -0,0 +1,4220 @@
1
+ # Copyright 2026 graph-agents-cli contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Policy-enforcing HTTP clients for the external APIs the agent's tools call.
16
+
17
+ `api-policy.yaml` (path from `API_POLICY_PATH`, default `./api-policy.yaml`,
18
+ else the one next to the project's `pyproject.toml`) declares every API a tool
19
+ may call: where it lives (`base_url_env`), how requests authenticate
20
+ (`auth: none | bearer | forward | exchange`) and which methods and operations
21
+ are allowed. `get_client(name)` returns a client for one declared API; every
22
+ request outside the policy raises `ApiPolicyError` before anything is sent.
23
+
24
+ Fail closed: without a policy file, with an invalid one, or for an API the
25
+ file does not declare, `get_client` raises `ApiPolicyError`; there is no
26
+ unrestricted fallback. `auth: bearer` sends `Authorization: Bearer
27
+ $<token_env>`. `auth: forward` sends the calling principal's own credential for
28
+ that API, `principal.attributes["credentials"][<name>]`, in `forward_header`
29
+ (default `Authorization`), or, with `forward_audience`, the caller's own
30
+ verified bearer token when it was minted for that audience too; the principal
31
+ comes from the run context the server sets for the graph run, and nothing is
32
+ sent when the caller has no credential. `auth: exchange` sends `Bearer
33
+ <token>` in `forward_header`, a token the issuer mints for the API's
34
+ `exchange.audience` in exchange for the caller's own (RFC 8693,
35
+ `token_exchange.py`): asked for just before the call is sent, after every
36
+ check, the approval and the limits, and never for a refused or paused call.
37
+ An exchanged token that names no actor (no `act` claim, or one that is not a
38
+ readable JWT) is refused and nothing is sent, unless the API sets
39
+ `exchange.allow_actorless: true`.
40
+ A call to such an API that would loop back to this agent, or to an agent
41
+ already in the request's delegation chain, is refused before anything else.
42
+
43
+ An API's `protocol` (`http` by default) says how its calls are judged. For
44
+ `protocol: jsonrpc` (a JSON-RPC 2.0 API) and `protocol: a2a` (another agent,
45
+ over A2A 1.0 JSON-RPC), every POST must send one JSON-RPC request object, and
46
+ the client reads what it is from the body sent, never from the tool
47
+ (`derive_rpc`): its method (`rpc_method`; under `a2a` an A2A 0.3 name is read
48
+ as its 1.0 name) and, for an A2A message whose parts name an approval, what it
49
+ decides (`a2a_operation`: `reject` only when every such part rejects, else
50
+ `approve`). Operation entries may pin `rpc_method` and `a2a_operation`: an
51
+ allow must match them, and a denial or approval gate naming them covers every
52
+ call they describe, whatever its path or label. A tool's `operation_id` that
53
+ names an entry pinning another method or decision is refused
54
+ (`label_problem`), as is a message that approves without an approval gate or
55
+ a denial holding it: an agent never decides, on its own, an approval the agent
56
+ it calls waits for.
57
+
58
+ Every method the policy allows can be sent (`request()`, or `get`, `head`,
59
+ `post`, `put`, `patch`, `delete`, `options`), with a JSON body, query
60
+ parameters and extra headers. The app adds its correlation headers to every
61
+ call of another agent (`protocol: a2a`) or of an `auth: forward` or `auth:
62
+ exchange` API, and of no other (`set_outbound_headers`: the request id and,
63
+ under OTLP tracing, the W3C trace context; `propagates`), unless the tool sets
64
+ the same header; they differ per request, so an approval does not bind them. An API's optional `limits` cap the calls before
65
+ they are sent: `max_calls_per_run` counts the calls to that API within one
66
+ agent run (the run id of the LangGraph run, else the request's; calls made
67
+ outside any run share one count), and `rate_per_minute` is a token bucket per
68
+ process, so each replica allows that rate. Counters are dropped when a `/chat`
69
+ or A2A run ends (`end_run`), and otherwise (LangGraph Server runs included)
70
+ after `RUN_COUNTER_TTL_S` without a call or beyond `MAX_TRACKED_RUNS` runs, so
71
+ memory does not grow across runs. `limits.max_response_bytes` caps each answer:
72
+ the body is read (decoded) up to that many bytes and discarded past it, and the
73
+ call fails; unset, answers are not capped. Under a cap the client asks for gzip
74
+ or deflate at most and decodes them itself, never past the cap, so a small
75
+ compressed body cannot fill memory; an answer in another content encoding
76
+ (zstd, br) is refused unread.
77
+
78
+ An API's optional `approval` block names the calls a human must approve before
79
+ they are sent (`gated`, `ApiPolicy.gate`). It is one rule, or a list of rules
80
+ when different calls need different approvers: the first rule in file order
81
+ that covers a call gates it, and the approval is asked of that rule's approvers
82
+ (they are recorded with it and decide it). A call the first rule covers only
83
+ because it names no operation id, and that a later rule with other approvers
84
+ also covers, is refused: it could be either rule's call. Approval never widens
85
+ access: a gated call must pass the policy first, and denials still win. A gated
86
+ call pauses the agent run before anything is sent: the client describes the
87
+ exact request (`canonical_call`: API, method, URL with the rendered path,
88
+ query, JSON body, operation id and the tool's own headers), hashes it
89
+ (`call_hash`) and calls LangGraph's `interrupt()` with the approval payload
90
+ (the call, with the fields named in `redact=` masked, the tool and the model's
91
+ stated purpose, the approvers; for an A2A message that approves one of another
92
+ agent's approvals, that approval as the message carries it, `nested`, and the
93
+ call that will then happen, `effect`: `relayed_approval`, `approval_effect`).
94
+ The chat runtime records the approval and ends
95
+ the stream awaiting a decision (see `approvals.py`). When the run resumes, the
96
+ tool runs again from its start and this client rebuilds the request: it is sent
97
+ only when the decision approves exactly this request (the same hash) and the
98
+ approvals ledger marks that approval used (`set_approval_ledger`), so an
99
+ approval is sent once, never replayed. A rejected or expired approval, a
100
+ request that changed after it was approved, a used approval, or a gated call
101
+ made outside an agent run (nothing can pause it) raises `ApiPolicyError` and
102
+ sends nothing. After an approved call was sent, a second gated call in the same
103
+ tool call is refused (on its resume the tool would run again and meet the
104
+ first, already used, approval): make it in a new tool call. Code before a gated
105
+ call runs again on resume, so keep other side effects after it.
106
+
107
+ A decision is bound to the call it was taken for, not to the policy of the
108
+ moment: when the resumed tool rebuilds a request to the same API, method and
109
+ path as the paused call (and, on a JSON-RPC API, the same JSON-RPC method and
110
+ A2A decision, so a read sent first on resume does not take an approve
111
+ message's decision, and a message that rejects is not stopped by the
112
+ rejection it reports), the decision applies whatever the policy now says
113
+ about gating it (`_decision_waiting`, `call_identity`). A rejected or expired call is never
114
+ sent, even when the policy no longer gates it (a new image while the call
115
+ waited, or a typo that un-gates it); a call whose approval is still pending
116
+ pauses again for that same approval. An approved call is sent only when the
117
+ current policy still allows it (a later denial, or narrower
118
+ `allowed_methods`/`allowed_operations`, refuses it first), still gates it with
119
+ the same approvers (those of the rule that gates it now: a reordered or edited
120
+ list of rules that hands the call to other approvers does not keep the
121
+ approval), and the request is exactly the approved one; otherwise
122
+ nothing is sent and the model is told to ask again. A call a decision stopped
123
+ stays stopped for the rest of that tool call.
124
+
125
+ The ledger binds a decision to its call too, for a tool call that runs again
126
+ with no decision (LangGraph Server's own API can continue a paused run
127
+ without input, or replay it from a checkpoint, and a copied thread keeps its
128
+ tool calls): before a call is sent without a decision waiting for it, the
129
+ ledger is asked for the approvals recorded for this tool call (the model
130
+ message that made it and its call id, or the task's interrupt), and a call
131
+ one was asked for is refused, gated or not now: a rejected, expired or
132
+ pending one is not sent, and an approved one is sent only by the run its
133
+ decision resumed, once (`_bound_approvals`). Keep the agent's middleware
134
+ (`tool_call_scope`), which names the tool call.
135
+
136
+ Every tool module declares `API_CALLS`, a module-level list of
137
+ `{"api", "method", "operation_id", "path"}` dicts naming each call it makes
138
+ (plus `rpc_method`, and `a2a_operation` for a message that decides, on a
139
+ JSON-RPC API); `graph-agents-cli lint` checks those declarations against the
140
+ same rules.
141
+ """
142
+
143
+ from __future__ import annotations
144
+
145
+ import contextlib
146
+ import hashlib
147
+ import ipaddress
148
+ import json
149
+ import logging
150
+ import os
151
+ import re
152
+ import threading
153
+ import time
154
+ import zlib
155
+ from collections import OrderedDict
156
+ from collections.abc import Callable, Iterable, Iterator, Mapping
157
+ from contextvars import ContextVar
158
+ from dataclasses import dataclass, field
159
+ from pathlib import Path
160
+ from typing import Any, NamedTuple, Protocol
161
+ from urllib.parse import quote, unquote
162
+
163
+ import httpx
164
+ import yaml
165
+
166
+ logger = logging.getLogger(__name__)
167
+ # This module logs each outbound call itself (API, method, operation, status);
168
+ # the HTTP client's own INFO lines would add the full URL with its values.
169
+ # Here as well as in the logging setup (`telemetry.QUIET_LOGGERS`, the same
170
+ # names): under langgraph-server the graph can run in a process that never
171
+ # loads the app.
172
+ for _name in ("httpx", "httpcore", "httpx2", "httpcore2"):
173
+ logging.getLogger(_name).setLevel(logging.WARNING)
174
+
175
+ POLICY_PATH_ENV = "API_POLICY_PATH"
176
+
177
+ # --- BEGIN SHARED API POLICY RULES ---
178
+ # Identical in graph-agents-cli (graph_agents_cli/_api_policy.py) and in every
179
+ # scaffolded project (app_utils/api_client.py). A CLI test keeps the two copies
180
+ # byte-identical: change both or neither.
181
+
182
+ POLICY_FILENAME = "api-policy.yaml"
183
+ AUTH_MODES = ("none", "bearer", "forward", "exchange")
184
+ # The modes that send the caller's identity in `forward_header` (default Authorization):
185
+ # `forward` the caller's own credential, `exchange` a token the issuer mints for the API in
186
+ # exchange for the caller's (RFC 8693, configured by the API's `exchange` block).
187
+ HEADER_AUTH_MODES = ("forward", "exchange")
188
+ EXCHANGE_KEY = "exchange"
189
+ # `exchange.allow_actorless: true` lets an `auth: exchange` API be called with an exchanged
190
+ # token that names no actor (no `act` claim, or one that is not a readable JWT); the calling
191
+ # agent refuses such tokens otherwise, since the agent behind the API would read them as the
192
+ # user's own unless it sets AUTH_JWT_DIRECT_CLIENTS.
193
+ ALLOW_ACTORLESS_KEY = "allow_actorless"
194
+ HTTP_METHODS = ("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
195
+ ANY_METHOD = "*"
196
+ DEFAULT_FORWARD_HEADER = "Authorization"
197
+ DEFAULT_TIMEOUTS_MS = (("connect", 2000), ("read", 5000))
198
+
199
+ API_NAME_RE = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
200
+ API_NAME_RULE = "lowercase letters, digits and underscores, starting with a letter, 1-32 characters"
201
+ ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
202
+ HEADER_NAME_RE = re.compile(r"^[A-Za-z0-9-]+$")
203
+ _PATH_SEGMENT_RE = re.compile(r"^(?:[^/?#\s{}]|\{[A-Za-z_][A-Za-z0-9_]*\})+$")
204
+ _PLACEHOLDER_SPLIT_RE = re.compile(r"(\{[^/{}]+\})")
205
+ _SPACE_BY_DOT_RE = re.compile(r"\s\.|\.\s")
206
+
207
+ _ESCAPE_RE = re.compile(r"%[0-9A-Fa-f]{2}")
208
+ _UNRESERVED = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
209
+
210
+ # An API's `protocol` says how its calls are judged: `http` (the default) by method, path
211
+ # and the operation id the tool names; `jsonrpc` also by the JSON-RPC request every POST
212
+ # sends, whose method (`rpc_method`) the policy client reads from the body, never from the
213
+ # tool; `a2a` (another agent, over A2A 1.0 JSON-RPC) as `jsonrpc`, plus what a message
214
+ # decides (`a2a_operation`: `approve` or `reject` a pending approval of that agent). A
215
+ # JSON-RPC API allows GET, POST and HEAD only, and an `a2a` one names its endpoint
216
+ # (`a2a.path`) and must gate or deny `a2a_operation: approve` if it can send messages.
217
+ PROTOCOL_KEY = "protocol"
218
+ PROTOCOL_HTTP = "http"
219
+ PROTOCOL_JSONRPC = "jsonrpc"
220
+ PROTOCOL_A2A = "a2a"
221
+ PROTOCOLS = (PROTOCOL_HTTP, PROTOCOL_JSONRPC, PROTOCOL_A2A)
222
+ DEFAULT_PROTOCOL = PROTOCOL_HTTP
223
+ RPC_PROTOCOLS = (PROTOCOL_JSONRPC, PROTOCOL_A2A)
224
+ RPC_HTTP_METHODS = ("GET", "POST", "HEAD")
225
+ A2A_KEY = "a2a"
226
+ _A2A_KEYS = ("path",)
227
+ DESCRIPTION_KEY = "description"
228
+ DESCRIPTION_MAX_CHARS = 300
229
+ RPC_METHOD_KEY = "rpc_method"
230
+ A2A_OPERATION_KEY = "a2a_operation"
231
+ A2A_APPROVE = "approve"
232
+ A2A_REJECT = "reject"
233
+ A2A_OPERATIONS = (A2A_APPROVE, A2A_REJECT)
234
+ # A JSON-RPC method name as an operation entry pins it.
235
+ _RPC_METHOD_RE = re.compile(r"[A-Za-z][A-Za-z0-9_/.]{0,63}")
236
+ # The A2A 0.3 method names and the A2A 1.0 names they are read as under `protocol: a2a`,
237
+ # so a 0.3 spelling of a call cannot slip past an entry that names it.
238
+ A2A_V03_METHODS = {
239
+ "message/send": "SendMessage",
240
+ "message/stream": "SendStreamingMessage",
241
+ "tasks/get": "GetTask",
242
+ "tasks/list": "ListTasks",
243
+ "tasks/cancel": "CancelTask",
244
+ "tasks/resubscribe": "SubscribeToTask",
245
+ "tasks/pushNotificationConfig/set": "CreateTaskPushNotificationConfig",
246
+ "tasks/pushNotificationConfig/get": "GetTaskPushNotificationConfig",
247
+ "tasks/pushNotificationConfig/list": "ListTaskPushNotificationConfigs",
248
+ "tasks/pushNotificationConfig/delete": "DeleteTaskPushNotificationConfig",
249
+ "agent/getAuthenticatedExtendedCard": "GetExtendedAgentCard",
250
+ }
251
+ # The A2A methods that send a message, which may carry a decision on an approval.
252
+ A2A_MESSAGE_METHODS = ("SendMessage", "SendStreamingMessage")
253
+ # Their names in any letter case, 0.3 spellings included: a message sent under any of them
254
+ # is read for a decision (failing closed toward a server that matched names loosely).
255
+ _A2A_MESSAGE_NAMES = frozenset(
256
+ name.casefold() for name in (*A2A_MESSAGE_METHODS, "message/send", "message/stream")
257
+ )
258
+ # The members of one JSON-RPC 2.0 request object.
259
+ _JSONRPC_KEYS = ("jsonrpc", "method", "params", "id")
260
+
261
+ _POLICY_KEYS = ("apis",)
262
+ _API_KEYS = (
263
+ DESCRIPTION_KEY,
264
+ PROTOCOL_KEY,
265
+ A2A_KEY,
266
+ "base_url_env",
267
+ "auth",
268
+ "token_env",
269
+ "forward_header",
270
+ "forward_audience",
271
+ EXCHANGE_KEY,
272
+ "allowed_methods",
273
+ "allowed_operations",
274
+ "denied_operations",
275
+ "openapi",
276
+ "timeouts_ms",
277
+ "pagination",
278
+ "limits",
279
+ "approval",
280
+ )
281
+ _OPERATION_KEYS = ("operationId", "path", "methods", RPC_METHOD_KEY, A2A_OPERATION_KEY)
282
+ _TIMEOUT_KEYS = ("connect", "read")
283
+ _PAGINATION_KEYS = ("page_size_param", "max_page_size")
284
+ _LIMIT_KEYS = ("max_calls_per_run", "rate_per_minute", "max_response_bytes")
285
+ # `limits.max_response_bytes`: the most a response body may hold (decoded) before the
286
+ # client stops reading it and discards it. Unset: no cap (as in 0.2).
287
+ MAX_RESPONSE_BYTES_LIMIT = 67108864
288
+ _APPROVAL_KEYS = ("required_for", "approvers", "timeout_s", "decide_with", "relayers")
289
+ _REQUIRED_FOR_KEYS = ("methods", "operations")
290
+ _EXCHANGE_KEYS = ("audience", "scope", "resource", ALLOW_ACTORLESS_KEY)
291
+ # An RFC 6749 scope: space-separated scope tokens (printable ASCII but space, " " and "\").
292
+ _SCOPE_RE = re.compile(r"[\x21\x23-\x5b\x5d-\x7e]+(?: [\x21\x23-\x5b\x5d-\x7e]+)*")
293
+ # An absolute URI (RFC 8707 `resource`): a scheme, then no whitespace and no fragment.
294
+ _ABSOLUTE_URI_RE = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:[^\s#\x00-\x1f\x7f]+")
295
+
296
+ # An API's `approval` block names the calls a human must approve before they are
297
+ # sent (`required_for`), who may approve them (`approvers`) and how long a
298
+ # pending approval waits before it expires, which rejects the call
299
+ # (`timeout_s`). It is one such rule (a mapping), or a non-empty list of rules
300
+ # of that same shape when different calls need different approvers: a call is
301
+ # gated by the FIRST rule, in file order, whose `required_for` covers it, and a
302
+ # later rule that also covers it does not apply to it. A call that an earlier
303
+ # rule covers only because it leaves out what the rule knows the operation by
304
+ # (no operation id, no path), and that a later rule with other approvers also
305
+ # covers, is refused (`ApprovalRuleConflict`): it could be either rule's call.
306
+ # It never widens access: a gated call must still be allowed, and denials still
307
+ # win. It belongs to the API only: on an operation entry the key is refused,
308
+ # with a pointer to `approval.required_for.operations`. A rule may also say how
309
+ # the requester decides (`decide_with`): `direct` (the default: with their own
310
+ # credentials, at this agent), or `relayed`, where the agents `relayers` names
311
+ # (by their actor ids) may deliver the requester's decision from another agent.
312
+ APPROVAL_KEY = "approval"
313
+ REQUESTER_APPROVER = "requester" # the principal who started the run confirms
314
+ ROLE_APPROVER_PREFIX = "role:" # any principal holding the role decides
315
+ DEFAULT_APPROVAL_TIMEOUT_S = 900
316
+ MIN_APPROVAL_TIMEOUT_S = 30
317
+ MAX_APPROVAL_TIMEOUT_S = 86400
318
+ # How approvers decide once an approval rule takes `decide_with` (0.3): `direct` by
319
+ # default, each with their own credential. `relayed`, with the `relayers` it names, lets
320
+ # those agents deliver the requester's decision: an opt-in that each callee's gate reviews.
321
+ DEFAULT_DECIDE_WITH = "direct"
322
+ DECIDE_DIRECT = "direct"
323
+ DECIDE_RELAYED = "relayed"
324
+ DECIDE_WITH_VALUES = (DECIDE_DIRECT, DECIDE_RELAYED)
325
+ # A value kept for a later release: a decision signed by the identity provider.
326
+ DECIDE_STEP_UP = "step_up"
327
+ # Role names and actor ids (`relayers`): 1-256 characters, no whitespace, commas or
328
+ # control characters.
329
+ _ROLE_NAME_RE = re.compile(r"[^\s,\x00-\x1f\x7f]{1,256}")
330
+
331
+ LEGACY_POLICY_HINT = (
332
+ "product_api: is the retired single-API format: move its fields under "
333
+ "apis: <name>: (for example apis: example:), add the now required "
334
+ "allowed_methods (for example [GET]), write auth: forward instead of "
335
+ "forwarded-session, and name the file api-policy.yaml"
336
+ )
337
+
338
+
339
+ class PolicyLoader(yaml.SafeLoader):
340
+ """``yaml.SafeLoader`` that refuses a key repeated within one mapping, at any level.
341
+
342
+ Plain ``safe_load`` silently keeps the last duplicate, so a reviewer reading
343
+ ``allowed_methods: [GET, POST]`` would miss a later ``allowed_methods: ["*"]``
344
+ that is the one applied. Merge keys (``<<: *anchor``) still work.
345
+ """
346
+
347
+ def construct_mapping(self, node: Any, deep: bool = False) -> Any:
348
+ if isinstance(node, yaml.MappingNode):
349
+ seen: set[Any] = set()
350
+ for key_node, _value_node in node.value:
351
+ if key_node.tag == "tag:yaml.org,2002:merge":
352
+ continue
353
+ key = self.construct_object(key_node, deep=deep)
354
+ try:
355
+ duplicate = key in seen
356
+ except TypeError: # an unhashable key: the base loader reports it
357
+ continue
358
+ if duplicate:
359
+ raise yaml.constructor.ConstructorError(
360
+ "while constructing a mapping",
361
+ node.start_mark,
362
+ f"found duplicate key {key!r}",
363
+ key_node.start_mark,
364
+ )
365
+ seen.add(key)
366
+ return super().construct_mapping(node, deep=deep)
367
+
368
+
369
+ def parse_policy_yaml(text: str) -> tuple[Any, list[str]]:
370
+ """Parse api-policy.yaml text: ``(data, [])``, or ``(None, [error])`` when it is
371
+ not valid YAML (a duplicate key included)."""
372
+ try:
373
+ return yaml.load(text, Loader=PolicyLoader), []
374
+ except yaml.YAMLError as exc:
375
+ return None, [f"not valid YAML: {exc}"]
376
+
377
+
378
+ def policy_errors(data: Any) -> list[str]:
379
+ """Every schema error in a parsed api-policy.yaml document; empty when it is valid.
380
+
381
+ Strict: unknown keys at any level are errors, so a typo can never widen access.
382
+ """
383
+ if not isinstance(data, Mapping):
384
+ return ["the document must be a mapping with a top-level 'apis' key"]
385
+ errors: list[str] = []
386
+ if "product_api" in data:
387
+ errors.append(LEGACY_POLICY_HINT)
388
+ for key in sorted(set(data) - set(_POLICY_KEYS) - {"product_api"}, key=str):
389
+ errors.append(f"unknown top-level key {key!r} (allowed: apis)")
390
+ if "apis" not in data:
391
+ if "product_api" not in data:
392
+ errors.append("apis: required (a mapping of API name to its settings)")
393
+ return errors
394
+ apis = data["apis"]
395
+ if not isinstance(apis, Mapping) or not apis:
396
+ errors.append("apis: must be a non-empty mapping of API name to its settings")
397
+ return errors
398
+ for name, api in apis.items():
399
+ errors.extend(_api_errors(name, api))
400
+ return errors
401
+
402
+
403
+ def _is_env_name(value: Any) -> bool:
404
+ return isinstance(value, str) and ENV_NAME_RE.match(value) is not None
405
+
406
+
407
+ def _is_audience(value: Any) -> bool:
408
+ """An audience (a token's `aud`): 1-256 characters, no whitespace, commas or control
409
+ characters (the target's `AUTH_JWT_AUDIENCE` is a comma list of them)."""
410
+ return isinstance(value, str) and _ROLE_NAME_RE.fullmatch(value) is not None
411
+
412
+
413
+ def _exchange_errors(where: str, value: Any) -> list[str]:
414
+ """Errors of an API's `exchange` block (`auth: exchange`, RFC 8693)."""
415
+ if not isinstance(value, Mapping):
416
+ return [
417
+ f"{where}: must be a mapping with audience, and optionally scope, resource and "
418
+ f"{ALLOW_ACTORLESS_KEY}"
419
+ ]
420
+ errors = [
421
+ f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_EXCHANGE_KEYS), key=str)
422
+ ]
423
+ if "audience" not in value:
424
+ errors.append(
425
+ f"{where}.audience: required (the audience the issuer mints the token for: the "
426
+ "target's AUTH_JWT_AUDIENCE)"
427
+ )
428
+ elif not _is_audience(value["audience"]):
429
+ errors.append(
430
+ f"{where}.audience: must be an audience (1-256 characters without spaces, commas or "
431
+ "control characters)"
432
+ )
433
+ if "scope" in value:
434
+ scope = value["scope"]
435
+ if not (isinstance(scope, str) and _SCOPE_RE.fullmatch(scope)):
436
+ errors.append(
437
+ f"{where}.scope: must be scopes separated by single spaces (RFC 6749: printable "
438
+ "ASCII, no quotes or backslashes)"
439
+ )
440
+ if "resource" in value:
441
+ resource = value["resource"]
442
+ if not (isinstance(resource, str) and _ABSOLUTE_URI_RE.fullmatch(resource)):
443
+ errors.append(
444
+ f"{where}.resource: must be an absolute URI without a fragment (RFC 8707), "
445
+ "such as https://orders.example.com"
446
+ )
447
+ if ALLOW_ACTORLESS_KEY in value and not isinstance(value[ALLOW_ACTORLESS_KEY], bool):
448
+ errors.append(
449
+ f"{where}.{ALLOW_ACTORLESS_KEY}: must be true or false (true: accept exchanged "
450
+ "tokens that name no actor, once the agent behind the API sets "
451
+ "AUTH_JWT_DIRECT_CLIENTS)"
452
+ )
453
+ return errors
454
+
455
+
456
+ def _api_errors(name: Any, api: Any) -> list[str]:
457
+ where = f"apis.{name}"
458
+ errors: list[str] = []
459
+ if not isinstance(name, str) or not API_NAME_RE.match(name):
460
+ errors.append(f"{where}: invalid API name ({API_NAME_RULE})")
461
+ if not isinstance(api, Mapping):
462
+ errors.append(f"{where}: must be a mapping")
463
+ return errors
464
+ for key in sorted(set(api) - set(_API_KEYS), key=str):
465
+ errors.append(f"{where}: unknown key {key!r}")
466
+
467
+ if "base_url_env" not in api:
468
+ errors.append(f"{where}.base_url_env: required")
469
+ elif not _is_env_name(api["base_url_env"]):
470
+ errors.append(f"{where}.base_url_env: must be an environment variable name")
471
+
472
+ auth = api.get("auth")
473
+ modes = ", ".join(AUTH_MODES)
474
+ if "auth" not in api:
475
+ errors.append(f"{where}.auth: required (one of {modes})")
476
+ elif auth not in AUTH_MODES:
477
+ errors.append(f"{where}.auth: must be one of {modes} (got {auth!r})")
478
+
479
+ if auth == "bearer":
480
+ if "token_env" not in api:
481
+ errors.append(f"{where}.token_env: required when auth is bearer")
482
+ elif not _is_env_name(api["token_env"]):
483
+ errors.append(f"{where}.token_env: must be an environment variable name")
484
+ elif "token_env" in api:
485
+ errors.append(f"{where}.token_env: only valid with auth: bearer")
486
+
487
+ if "forward_header" in api:
488
+ header = api["forward_header"]
489
+ if auth not in HEADER_AUTH_MODES:
490
+ errors.append(f"{where}.forward_header: only valid with auth: forward or exchange")
491
+ elif not (isinstance(header, str) and HEADER_NAME_RE.match(header)):
492
+ errors.append(f"{where}.forward_header: must be an HTTP header name")
493
+
494
+ if "forward_audience" in api:
495
+ if auth != "forward":
496
+ errors.append(f"{where}.forward_audience: only valid with auth: forward")
497
+ elif not _is_audience(api["forward_audience"]):
498
+ errors.append(
499
+ f"{where}.forward_audience: must be an audience (1-256 characters without "
500
+ "spaces, commas or control characters)"
501
+ )
502
+
503
+ if auth == "exchange":
504
+ if EXCHANGE_KEY not in api:
505
+ errors.append(
506
+ f"{where}.{EXCHANGE_KEY}: required when auth is exchange (a mapping with the "
507
+ "audience the issuer mints the token for, and optionally scope, resource and "
508
+ f"{ALLOW_ACTORLESS_KEY})"
509
+ )
510
+ else:
511
+ errors.extend(_exchange_errors(f"{where}.{EXCHANGE_KEY}", api[EXCHANGE_KEY]))
512
+ elif EXCHANGE_KEY in api:
513
+ errors.append(f"{where}.{EXCHANGE_KEY}: only valid with auth: exchange")
514
+
515
+ protocol = api.get(PROTOCOL_KEY, DEFAULT_PROTOCOL)
516
+ errors.extend(_protocol_errors(where, api, protocol, auth))
517
+
518
+ if "allowed_methods" not in api:
519
+ errors.append(f'{where}.allowed_methods: required (a list of HTTP methods, or ["*"])')
520
+ else:
521
+ method_errors = _methods_errors(f"{where}.allowed_methods", api["allowed_methods"], True)
522
+ errors.extend(method_errors)
523
+ if protocol in RPC_PROTOCOLS and not method_errors:
524
+ outside = [
525
+ str(m).upper()
526
+ for m in api["allowed_methods"]
527
+ if str(m).upper() not in RPC_HTTP_METHODS
528
+ ]
529
+ if outside:
530
+ errors.append(
531
+ f"{where}.allowed_methods: protocol {protocol} allows GET, POST and HEAD "
532
+ f"only (a JSON-RPC request is a POST), not {', '.join(outside)}"
533
+ )
534
+
535
+ if "allowed_operations" in api:
536
+ errors.extend(
537
+ _operations_errors(
538
+ f"{where}.allowed_operations",
539
+ api["allowed_operations"],
540
+ where,
541
+ "must not be empty; omit the key to allow every operation within allowed_methods",
542
+ protocol=protocol,
543
+ )
544
+ )
545
+ if "denied_operations" in api:
546
+ errors.extend(
547
+ _operations_errors(
548
+ f"{where}.denied_operations", api["denied_operations"], where, protocol=protocol
549
+ )
550
+ )
551
+
552
+ if "openapi" in api:
553
+ openapi = api["openapi"]
554
+ if not (isinstance(openapi, str) and openapi.strip()):
555
+ errors.append(f"{where}.openapi: must be a file path")
556
+
557
+ if "timeouts_ms" in api:
558
+ errors.extend(_timeouts_errors(f"{where}.timeouts_ms", api["timeouts_ms"]))
559
+ if "pagination" in api:
560
+ errors.extend(_pagination_errors(f"{where}.pagination", api["pagination"]))
561
+ if "limits" in api:
562
+ errors.extend(_limits_errors(f"{where}.limits", api["limits"]))
563
+ if APPROVAL_KEY in api:
564
+ errors.extend(_approval_errors(where, api[APPROVAL_KEY], protocol=protocol))
565
+ if not errors:
566
+ errors.extend(_approve_errors(where, api))
567
+ return errors
568
+
569
+
570
+ def _protocol_errors(where: str, api: Mapping[str, Any], protocol: Any, auth: Any) -> list[str]:
571
+ """Errors of an API's `protocol`, `a2a` and `description`."""
572
+ errors: list[str] = []
573
+ if protocol not in PROTOCOLS:
574
+ errors.append(
575
+ f"{where}.{PROTOCOL_KEY}: must be one of {', '.join(PROTOCOLS)} (got {protocol!r})"
576
+ )
577
+ if protocol == PROTOCOL_A2A:
578
+ if A2A_KEY not in api:
579
+ errors.append(
580
+ f"{where}.{A2A_KEY}: required with protocol a2a (a mapping with path, the "
581
+ "agent's A2A endpoint, such as /a2a/orders)"
582
+ )
583
+ else:
584
+ errors.extend(_a2a_errors(f"{where}.{A2A_KEY}", api[A2A_KEY]))
585
+ if auth == "none":
586
+ errors.append(
587
+ f"{where}.auth: protocol a2a needs a credential (bearer, forward or exchange): "
588
+ "an agent's A2A endpoint authenticates its callers"
589
+ )
590
+ elif A2A_KEY in api:
591
+ errors.append(f"{where}.{A2A_KEY}: only valid with protocol a2a")
592
+ if DESCRIPTION_KEY in api:
593
+ description = api[DESCRIPTION_KEY]
594
+ if not (
595
+ isinstance(description, str)
596
+ and description.strip()
597
+ and len(description) <= DESCRIPTION_MAX_CHARS
598
+ and not any(ord(c) < 0x20 or 0x7F <= ord(c) < 0xA0 for c in description)
599
+ ):
600
+ errors.append(
601
+ f"{where}.{DESCRIPTION_KEY}: must be text of 1-{DESCRIPTION_MAX_CHARS} "
602
+ "characters without control characters"
603
+ )
604
+ return errors
605
+
606
+
607
+ def _a2a_errors(where: str, value: Any) -> list[str]:
608
+ """Errors of an API's `a2a` block: `path`, the literal path of the agent's A2A endpoint."""
609
+ if not isinstance(value, Mapping):
610
+ return [f"{where}: must be a mapping with path (the agent's A2A endpoint, /a2a/<name>)"]
611
+ errors = [
612
+ f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_A2A_KEYS), key=str)
613
+ ]
614
+ if "path" not in value:
615
+ errors.append(f"{where}.path: required (the agent's A2A endpoint, such as /a2a/orders)")
616
+ return errors
617
+ path = value["path"]
618
+ problem = path_template_problem(path)
619
+ if problem:
620
+ errors.append(f"{where}.path: {problem}")
621
+ elif "{" in path or path.rstrip("/") == "":
622
+ errors.append(
623
+ f"{where}.path: must be the literal path of one endpoint (no placeholders), such "
624
+ "as /a2a/orders"
625
+ )
626
+ return errors
627
+
628
+
629
+ def api_protocol(api: Mapping[str, Any]) -> str:
630
+ """An API's `protocol`: `http` when it sets none."""
631
+ return str(api.get(PROTOCOL_KEY) or DEFAULT_PROTOCOL)
632
+
633
+
634
+ def _rpc_pins(entry: Mapping[str, Any]) -> bool:
635
+ """Whether an operation entry pins what a JSON-RPC request is (`rpc_method`, `a2a_operation`)."""
636
+ return entry.get(RPC_METHOD_KEY) is not None or entry.get(A2A_OPERATION_KEY) is not None
637
+
638
+
639
+ def _may_send_approve(api: Mapping[str, Any]) -> bool:
640
+ """Whether an A2A API's allow-list may let through a message that approves (`approve`)."""
641
+ methods = {str(m).upper() for m in api.get("allowed_methods") or []}
642
+ if "POST" not in methods and ANY_METHOD not in methods:
643
+ return False
644
+ allowed = api.get("allowed_operations")
645
+ if allowed is None:
646
+ return True
647
+ return any(
648
+ _methods_match(entry, "POST")
649
+ and entry.get(RPC_METHOD_KEY) in (None, *A2A_MESSAGE_METHODS)
650
+ and entry.get(A2A_OPERATION_KEY) in (None, A2A_APPROVE)
651
+ for entry in allowed
652
+ )
653
+
654
+
655
+ def _covers_every_approve(entry: Mapping[str, Any]) -> bool:
656
+ """Whether a denial or gate entry covers every message that approves, on any path."""
657
+ return entry.get(A2A_OPERATION_KEY) == A2A_APPROVE and _methods_match(entry, "POST")
658
+
659
+
660
+ def approve_is_held(api: Mapping[str, Any]) -> bool:
661
+ """Whether every message that approves waits for a human approval, or is denied.
662
+
663
+ An approval rule gating POST (or `"*"`), or an entry `a2a_operation: approve` (with no
664
+ methods, or POST among them) in a rule's `required_for.operations` or in
665
+ `denied_operations`. An entry pinning `rpc_method: SendMessage` does not count: it
666
+ leaves `SendStreamingMessage` out.
667
+ """
668
+ for rule in approval_rules(api):
669
+ required_for = rule.get("required_for") or {}
670
+ methods = {str(m).upper() for m in required_for.get("methods") or []}
671
+ if "POST" in methods or ANY_METHOD in methods:
672
+ return True
673
+ if any(_covers_every_approve(entry) for entry in required_for.get("operations") or []):
674
+ return True
675
+ return any(_covers_every_approve(entry) for entry in api.get("denied_operations") or [])
676
+
677
+
678
+ def _approve_errors(where: str, api: Mapping[str, Any]) -> list[str]:
679
+ """A `protocol: a2a` API that may send a message must gate or deny `approve`: otherwise
680
+ this agent could decide, on its own, the approvals the agent behind it waits for."""
681
+ if api_protocol(api) != PROTOCOL_A2A or not _may_send_approve(api) or approve_is_held(api):
682
+ return []
683
+ name = where.split(".", 1)[1] if "." in where else where
684
+ agent = str((api.get(A2A_KEY) or {}).get("path") or "").rstrip("/").rsplit("/", 1)[-1]
685
+ return [
686
+ f"{where}: protocol a2a allows SendMessage, so this agent could decide approvals at "
687
+ f"{agent or name}: gate them (graph-agents-cli api approval {name} --a2a-operations "
688
+ f"approve --approvers requester) or deny them (graph-agents-cli api deny {name} "
689
+ "--a2a-operation approve)"
690
+ ]
691
+
692
+
693
+ def _methods_errors(where: str, value: Any, allow_any: bool) -> list[str]:
694
+ if not isinstance(value, list) or not value:
695
+ return [f"{where}: must be a non-empty list of HTTP methods"]
696
+ errors: list[str] = []
697
+ if allow_any and ANY_METHOD in value and len(value) != 1:
698
+ errors.append(f'{where}: "*" must be the only entry when present')
699
+ for method in value:
700
+ if allow_any and method == ANY_METHOD:
701
+ continue
702
+ if not isinstance(method, str) or method.upper() not in HTTP_METHODS:
703
+ errors.append(
704
+ f"{where}: unknown HTTP method {method!r} (allowed: {', '.join(HTTP_METHODS)})"
705
+ )
706
+ return errors
707
+
708
+
709
+ def _operations_errors(
710
+ where: str,
711
+ value: Any,
712
+ api_where: str,
713
+ empty_error: str | None = None,
714
+ *,
715
+ protocol: Any = DEFAULT_PROTOCOL,
716
+ ) -> list[str]:
717
+ """Errors of a list of operation entries; ``empty_error`` refuses an empty list.
718
+
719
+ ``rpc_method`` is valid only with `protocol` jsonrpc or a2a, and ``a2a_operation``
720
+ only with a2a.
721
+ """
722
+ if not isinstance(value, list):
723
+ return [f"{where}: must be a list of operations"]
724
+ if not value and empty_error:
725
+ return [f"{where}: {empty_error}"]
726
+ errors: list[str] = []
727
+ for index, entry in enumerate(value):
728
+ at = f"{where}[{index}]"
729
+ if not isinstance(entry, Mapping):
730
+ errors.append(f"{at}: must be a mapping with operationId and/or path")
731
+ continue
732
+ for key in sorted(set(entry) - set(_OPERATION_KEYS) - {APPROVAL_KEY}, key=str):
733
+ errors.append(f"{at}: unknown key {key!r}")
734
+ if APPROVAL_KEY in entry:
735
+ errors.append(
736
+ f"{at}.{APPROVAL_KEY}: not valid on an operation entry; gate the operation "
737
+ f"with {api_where}.{APPROVAL_KEY}.required_for.operations"
738
+ )
739
+ if protocol in RPC_PROTOCOLS:
740
+ if not any(key in entry for key in _OPERATION_KEYS if key != "methods"):
741
+ errors.append(f"{at}: needs operationId, path, rpc_method and/or a2a_operation")
742
+ elif "operationId" not in entry and "path" not in entry:
743
+ errors.append(f"{at}: needs operationId and/or path")
744
+ errors.extend(_rpc_entry_errors(at, entry, protocol))
745
+ if "operationId" in entry:
746
+ op_id = entry["operationId"]
747
+ if not isinstance(op_id, str) or not op_id or any(c.isspace() for c in op_id):
748
+ errors.append(f"{at}.operationId: must be a non-empty string without spaces")
749
+ if "path" in entry:
750
+ problem = path_template_problem(entry["path"])
751
+ if problem:
752
+ errors.append(f"{at}.path: {problem}")
753
+ if "methods" in entry:
754
+ errors.extend(_methods_errors(f"{at}.methods", entry["methods"], False))
755
+ return errors
756
+
757
+
758
+ def _rpc_entry_errors(at: str, entry: Mapping[str, Any], protocol: Any) -> list[str]:
759
+ """Errors of an operation entry's `rpc_method` and `a2a_operation`."""
760
+ errors: list[str] = []
761
+ rpc_method = entry.get(RPC_METHOD_KEY)
762
+ if RPC_METHOD_KEY in entry:
763
+ if protocol not in RPC_PROTOCOLS:
764
+ errors.append(f"{at}.{RPC_METHOD_KEY}: only valid with protocol jsonrpc or a2a")
765
+ elif not (isinstance(rpc_method, str) and _RPC_METHOD_RE.fullmatch(rpc_method)):
766
+ errors.append(
767
+ f"{at}.{RPC_METHOD_KEY}: must be a JSON-RPC method name (a letter, then up to 63 "
768
+ "letters, digits, '_', '/' or '.')"
769
+ )
770
+ elif protocol == PROTOCOL_A2A and rpc_method in A2A_V03_METHODS:
771
+ errors.append(
772
+ f"{at}.{RPC_METHOD_KEY}: {rpc_method} is the A2A 0.3 name; write "
773
+ f"{A2A_V03_METHODS[rpc_method]} (a 0.3 name in a request is read as its 1.0 name)"
774
+ )
775
+ if A2A_OPERATION_KEY in entry:
776
+ operation = entry[A2A_OPERATION_KEY]
777
+ if protocol != PROTOCOL_A2A:
778
+ errors.append(f"{at}.{A2A_OPERATION_KEY}: only valid with protocol a2a")
779
+ elif operation not in A2A_OPERATIONS:
780
+ errors.append(f"{at}.{A2A_OPERATION_KEY}: must be approve or reject")
781
+ elif rpc_method is not None and rpc_method not in A2A_MESSAGE_METHODS:
782
+ errors.append(
783
+ f"{at}.{A2A_OPERATION_KEY}: goes with rpc_method SendMessage or "
784
+ f"SendStreamingMessage (the messages that decide an approval), not {rpc_method}"
785
+ )
786
+ return errors
787
+
788
+
789
+ def _is_positive_int(value: Any) -> bool:
790
+ return isinstance(value, int) and not isinstance(value, bool) and value > 0
791
+
792
+
793
+ def _timeouts_errors(where: str, value: Any) -> list[str]:
794
+ if not isinstance(value, Mapping):
795
+ return [f"{where}: must be a mapping with connect and/or read (milliseconds)"]
796
+ errors = [
797
+ f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_TIMEOUT_KEYS), key=str)
798
+ ]
799
+ for key in _TIMEOUT_KEYS:
800
+ if key in value and not _is_positive_int(value[key]):
801
+ errors.append(f"{where}.{key}: must be a positive integer (milliseconds)")
802
+ return errors
803
+
804
+
805
+ def _pagination_errors(where: str, value: Any) -> list[str]:
806
+ if not isinstance(value, Mapping):
807
+ return [f"{where}: must be a mapping with page_size_param and max_page_size"]
808
+ errors = [
809
+ f"{where}: unknown key {key!r}"
810
+ for key in sorted(set(value) - set(_PAGINATION_KEYS), key=str)
811
+ ]
812
+ param = value.get("page_size_param")
813
+ if "page_size_param" not in value:
814
+ errors.append(f"{where}.page_size_param: required")
815
+ elif not (isinstance(param, str) and param.strip()):
816
+ errors.append(f"{where}.page_size_param: must be a non-empty string")
817
+ if "max_page_size" not in value:
818
+ errors.append(f"{where}.max_page_size: required")
819
+ elif not _is_positive_int(value["max_page_size"]):
820
+ errors.append(f"{where}.max_page_size: must be a positive integer")
821
+ return errors
822
+
823
+
824
+ def _limits_errors(where: str, value: Any) -> list[str]:
825
+ if not isinstance(value, Mapping) or not value:
826
+ return [
827
+ f"{where}: must be a mapping with max_calls_per_run, rate_per_minute and/or "
828
+ "max_response_bytes"
829
+ ]
830
+ errors = [
831
+ f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_LIMIT_KEYS), key=str)
832
+ ]
833
+ for key in _LIMIT_KEYS:
834
+ if key in value and not _is_positive_int(value[key]):
835
+ errors.append(f"{where}.{key}: must be an integer >= 1")
836
+ size = value.get("max_response_bytes")
837
+ if _is_positive_int(size) and size > MAX_RESPONSE_BYTES_LIMIT:
838
+ errors.append(
839
+ f"{where}.max_response_bytes: must be an integer from 1 to "
840
+ f"{MAX_RESPONSE_BYTES_LIMIT} (bytes; 64 MiB at most)"
841
+ )
842
+ return errors
843
+
844
+
845
+ def _approval_errors(api_where: str, value: Any, *, protocol: Any = DEFAULT_PROTOCOL) -> list[str]:
846
+ """Errors of an API's `approval`: one rule (a mapping), or a non-empty list of rules."""
847
+ where = f"{api_where}.{APPROVAL_KEY}"
848
+ if isinstance(value, list):
849
+ if not value:
850
+ return [f"{where}: must not be empty; omit the key when no call needs approval"]
851
+ errors: list[str] = []
852
+ for index, rule in enumerate(value):
853
+ errors.extend(
854
+ _approval_rule_errors(f"{where}[{index}]", rule, api_where, protocol=protocol)
855
+ )
856
+ return errors
857
+ if not isinstance(value, Mapping):
858
+ return [
859
+ f"{where}: must be a mapping with required_for and approvers, or a non-empty "
860
+ "list of such mappings (rules; the first that covers a call gates it)"
861
+ ]
862
+ return _approval_rule_errors(where, value, api_where, protocol=protocol)
863
+
864
+
865
+ def _approval_rule_errors(
866
+ where: str, value: Any, api_where: str, *, protocol: Any = DEFAULT_PROTOCOL
867
+ ) -> list[str]:
868
+ """Errors of one approval rule (``where``: ``apis.<name>.approval`` or ``...approval[i]``)."""
869
+ if not isinstance(value, Mapping):
870
+ return [f"{where}: must be a mapping with required_for and approvers"]
871
+ errors = [
872
+ f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_APPROVAL_KEYS), key=str)
873
+ ]
874
+ if "required_for" not in value:
875
+ errors.append(f"{where}.required_for: required (the methods and/or operations it gates)")
876
+ else:
877
+ errors.extend(
878
+ _required_for_errors(
879
+ f"{where}.required_for", value["required_for"], api_where, protocol=protocol
880
+ )
881
+ )
882
+ if "approvers" not in value:
883
+ errors.append(f'{where}.approvers: required (a list of "requester" and/or "role:<name>")')
884
+ else:
885
+ errors.extend(_approvers_errors(f"{where}.approvers", value["approvers"]))
886
+ if "timeout_s" in value:
887
+ timeout = value["timeout_s"]
888
+ if not (
889
+ isinstance(timeout, int)
890
+ and not isinstance(timeout, bool)
891
+ and MIN_APPROVAL_TIMEOUT_S <= timeout <= MAX_APPROVAL_TIMEOUT_S
892
+ ):
893
+ errors.append(
894
+ f"{where}.timeout_s: must be an integer from {MIN_APPROVAL_TIMEOUT_S} to "
895
+ f"{MAX_APPROVAL_TIMEOUT_S} (seconds)"
896
+ )
897
+ errors.extend(_decide_with_errors(where, value))
898
+ return errors
899
+
900
+
901
+ def _decide_with_errors(where: str, rule: Mapping[str, Any]) -> list[str]:
902
+ """Errors of a rule's `decide_with` and `relayers`."""
903
+ decide_with = rule.get("decide_with", DEFAULT_DECIDE_WITH)
904
+ errors: list[str] = []
905
+ if decide_with == DECIDE_STEP_UP:
906
+ errors.append(f"{where}.decide_with: step_up is not supported yet (direct or relayed)")
907
+ elif decide_with not in DECIDE_WITH_VALUES:
908
+ errors.append(f"{where}.decide_with: must be direct or relayed (got {decide_with!r})")
909
+ if decide_with != DECIDE_RELAYED:
910
+ if "relayers" in rule:
911
+ errors.append(f"{where}.relayers: only valid with decide_with: relayed")
912
+ return errors
913
+ approvers = rule.get("approvers")
914
+ if isinstance(approvers, list) and REQUESTER_APPROVER not in approvers:
915
+ errors.append(
916
+ f"{where}.decide_with: relayed needs requester in approvers (role approvers always "
917
+ "decide with their own direct credentials, never relayed)"
918
+ )
919
+ if "relayers" not in rule:
920
+ errors.append(
921
+ f"{where}.relayers: required with decide_with: relayed (the agents, by actor id, "
922
+ "that may deliver the requester's decision)"
923
+ )
924
+ return errors
925
+ relayers = rule["relayers"]
926
+ if not isinstance(relayers, list) or not relayers:
927
+ return [*errors, f"{where}.relayers: must be a non-empty list of agent (actor) ids"]
928
+ for index, relayer in enumerate(relayers):
929
+ if not (isinstance(relayer, str) and _ROLE_NAME_RE.fullmatch(relayer)):
930
+ errors.append(
931
+ f"{where}.relayers[{index}]: {relayer!r} is not an agent id (1-256 characters "
932
+ "without spaces, commas or control characters)"
933
+ )
934
+ return errors
935
+
936
+
937
+ def _required_for_errors(
938
+ where: str, value: Any, api_where: str, *, protocol: Any = DEFAULT_PROTOCOL
939
+ ) -> list[str]:
940
+ if not isinstance(value, Mapping) or not value:
941
+ return [f"{where}: must be a mapping with methods and/or operations"]
942
+ errors = [
943
+ f"{where}: unknown key {key!r}"
944
+ for key in sorted(set(value) - set(_REQUIRED_FOR_KEYS), key=str)
945
+ ]
946
+ if "methods" not in value and "operations" not in value:
947
+ errors.append(f"{where}: needs methods and/or operations")
948
+ if "methods" in value:
949
+ errors.extend(_methods_errors(f"{where}.methods", value["methods"], True))
950
+ if "operations" in value:
951
+ errors.extend(
952
+ _operations_errors(
953
+ f"{where}.operations",
954
+ value["operations"],
955
+ api_where,
956
+ "must not be empty; omit the key when no operation needs approval",
957
+ protocol=protocol,
958
+ )
959
+ )
960
+ return errors
961
+
962
+
963
+ def _approvers_errors(where: str, value: Any) -> list[str]:
964
+ if not isinstance(value, list) or not value:
965
+ return [f'{where}: must be a non-empty list of "requester" and/or "role:<name>"']
966
+ errors: list[str] = []
967
+ for index, approver in enumerate(value):
968
+ if approver == REQUESTER_APPROVER:
969
+ continue
970
+ if (
971
+ isinstance(approver, str)
972
+ and approver.startswith(ROLE_APPROVER_PREFIX)
973
+ and _ROLE_NAME_RE.fullmatch(approver[len(ROLE_APPROVER_PREFIX) :])
974
+ ):
975
+ continue
976
+ errors.append(
977
+ f'{where}[{index}]: {approver!r} is not an approver ("requester", or "role:<name>" '
978
+ "with a role name of 1-256 characters without spaces or commas)"
979
+ )
980
+ return errors
981
+
982
+
983
+ def segment_text_problem(text: str) -> str | None:
984
+ """Why a path segment's text (percent-decoded) is refused, or None.
985
+
986
+ Each of these would send a call to an endpoint other than the one the
987
+ policy judged: a control character anywhere (``%00``: servers that end a
988
+ path at a NUL route it to the part before); whitespace at either end
989
+ (``cancel%20``: servers that trim path segments route it to ``cancel``)
990
+ or next to a dot (``cancel%20.json``, ``cancel%20%2e``: servers that
991
+ trim the name before a format suffix, or strip trailing dots and spaces,
992
+ route it to ``cancel``); a backslash or a slash (``%5C``, ``%2F``:
993
+ servers that decode them before routing split the segment); a ``;``
994
+ (``%3B``: servers that strip path parameters route ``cancel;x`` to
995
+ ``cancel``). Whitespace elsewhere in a segment (``red%20shirt``) is kept:
996
+ trimming does not touch it.
997
+ """
998
+ if any(ord(char) < 0x20 or ord(char) == 0x7F for char in text):
999
+ return "holds a control character (also percent-encoded, such as %00)"
1000
+ if text[:1].isspace() or text[-1:].isspace():
1001
+ return "starts or ends with whitespace (also percent-encoded, such as %20)"
1002
+ if _SPACE_BY_DOT_RE.search(text):
1003
+ return "has whitespace next to a dot (also percent-encoded, such as cancel%20.json)"
1004
+ if "/" in text or "\\" in text:
1005
+ return "holds a backslash or a percent-encoded slash (%5C, %2F)"
1006
+ if ";" in text:
1007
+ return "holds ';' (also percent-encoded, %3B), which some servers strip with what follows"
1008
+ return None
1009
+
1010
+
1011
+ def path_template_problem(path: Any) -> str | None:
1012
+ """Why ``path`` is not a valid path template, or None.
1013
+
1014
+ A template starts with ``/``; each segment holds literal characters and
1015
+ ``{name}`` placeholders only (no query, fragment, spaces, empty, ``.`` or
1016
+ ``..`` segments, also percent-encoded), and none of the characters a
1017
+ sent path is refused for, percent-encoded or not (``segment_text_problem``:
1018
+ control characters, whitespace at either end or next to a dot, a
1019
+ backslash or an encoded slash, ``;``), so lint passes no declared call the
1020
+ client would refuse to send. One trailing slash is allowed.
1021
+ """
1022
+ if not isinstance(path, str) or not path.startswith("/"):
1023
+ return "must be a string starting with /"
1024
+ body = path[1:]
1025
+ if body.endswith("/"):
1026
+ body = body[:-1]
1027
+ if not body:
1028
+ return None
1029
+ for segment in body.split("/"):
1030
+ if segment in ("", ".", ".."):
1031
+ return "must not contain empty, '.' or '..' segments"
1032
+ if not _PATH_SEGMENT_RE.match(segment):
1033
+ return (
1034
+ "segments may hold literal characters and {name} placeholders only "
1035
+ "(no query, fragment or whitespace)"
1036
+ )
1037
+ text = unquote(_PLACEHOLDER_SPLIT_RE.sub("x", segment))
1038
+ if text in (".", ".."):
1039
+ return "must not contain '.' or '..' segments, also percent-encoded (%2E)"
1040
+ problem = segment_text_problem(text)
1041
+ if problem:
1042
+ return f"has a segment that {problem}"
1043
+ return None
1044
+
1045
+
1046
+ def normalize_path(path: str) -> str:
1047
+ """``path`` in the form policy paths are compared in.
1048
+
1049
+ Percent-encoded unreserved characters are decoded (``/%61dmin`` is
1050
+ ``/admin``), other escapes are upper-cased (``%2f`` is ``%2F``), and one
1051
+ trailing slash is dropped (``/items/1/`` is ``/items/1``), so equivalent
1052
+ spellings of a path match the same entries.
1053
+ """
1054
+
1055
+ def _escape(match: re.Match[str]) -> str:
1056
+ char = chr(int(match.group(0)[1:], 16))
1057
+ return char if char in _UNRESERVED else match.group(0).upper()
1058
+
1059
+ path = _ESCAPE_RE.sub(_escape, path)
1060
+ return path[:-1] if len(path) > 1 and path.endswith("/") else path
1061
+
1062
+
1063
+ def path_matches(
1064
+ template: str, path: str, *, ignore_case: bool = False, suffixes: bool = False
1065
+ ) -> bool:
1066
+ """Whether ``path`` is covered by ``template``.
1067
+
1068
+ A ``{name}`` placeholder matches exactly one non-empty segment, so
1069
+ ``/items/{item_id}`` covers ``/items/42``, ``/items/{id}`` and itself.
1070
+ Both sides are compared normalised (``normalize_path``). Letter case
1071
+ counts unless ``ignore_case``: denials ignore it, so ``/ADMIN/1`` cannot
1072
+ slip past a denial of ``/admin/{x}`` on a case-insensitive server. With
1073
+ ``suffixes`` (denials and approval gates, which fail closed), a segment
1074
+ that ends in literal text also covers that segment with a dot suffix:
1075
+ ``/orders/{id}/cancel`` covers ``/orders/7/cancel.json`` and
1076
+ ``/orders/7/cancel.`` (also spelled ``cancel%2e``), which servers that
1077
+ route format suffixes (``.json``) or drop a trailing dot send to the
1078
+ same endpoint. An allow never matches that way: it must be shown.
1079
+ """
1080
+ template, path = normalize_path(template), normalize_path(path)
1081
+ if template == path or (ignore_case and template.casefold() == path.casefold()):
1082
+ return True
1083
+ segments = []
1084
+ for segment in template.split("/"):
1085
+ parts = _PLACEHOLDER_SPLIT_RE.split(segment)
1086
+ pattern = "".join(
1087
+ "[^/]+" if part.startswith("{") and part.endswith("}") else re.escape(part)
1088
+ for part in parts
1089
+ )
1090
+ if suffixes and parts[-1] and not parts[-1].endswith("}"):
1091
+ pattern += r"(?:\.[^/]*)?"
1092
+ segments.append(pattern)
1093
+ flags = re.IGNORECASE if ignore_case else 0
1094
+ return re.fullmatch("/".join(segments), path, flags) is not None
1095
+
1096
+
1097
+ def _methods_match(entry: Mapping[str, Any], method: str) -> bool:
1098
+ methods = entry.get("methods")
1099
+ return not methods or method.upper() in {str(m).upper() for m in methods}
1100
+
1101
+
1102
+ def operation_matches(
1103
+ entry: Mapping[str, Any],
1104
+ method: str,
1105
+ operation_id: str | None,
1106
+ path: str | None,
1107
+ *,
1108
+ rpc_method: str | None = None,
1109
+ a2a_operation: str | None = None,
1110
+ ) -> bool:
1111
+ """Whether an ``allowed_operations`` entry covers the call.
1112
+
1113
+ AND semantics: every field the entry pins (``operationId``, ``path``,
1114
+ ``methods``, and on a JSON-RPC API ``rpc_method`` and ``a2a_operation``,
1115
+ compared with the values derived from the request body) must match. A
1116
+ call that does not name a pinned field (no operation id, no path, no
1117
+ JSON-RPC method or no decision) does not match: an allow must be shown.
1118
+ """
1119
+ if not _methods_match(entry, method):
1120
+ return False
1121
+ pinned_id = entry.get("operationId")
1122
+ if pinned_id is not None and operation_id != pinned_id:
1123
+ return False
1124
+ pinned_path = entry.get("path")
1125
+ if pinned_path is not None and (path is None or not path_matches(pinned_path, path)):
1126
+ return False
1127
+ pinned_rpc = entry.get(RPC_METHOD_KEY)
1128
+ if pinned_rpc is not None and rpc_method != pinned_rpc:
1129
+ return False
1130
+ pinned_operation = entry.get(A2A_OPERATION_KEY)
1131
+ if pinned_operation is not None and a2a_operation != pinned_operation:
1132
+ return False
1133
+ return (
1134
+ pinned_id is not None
1135
+ or pinned_path is not None
1136
+ or pinned_rpc is not None
1137
+ or pinned_operation is not None
1138
+ )
1139
+
1140
+
1141
+ def denial_match(
1142
+ entry: Mapping[str, Any],
1143
+ method: str,
1144
+ operation_id: str | None,
1145
+ path: str | None,
1146
+ *,
1147
+ rpc_method: str | None = None,
1148
+ a2a_operation: str | None = None,
1149
+ ) -> str | None:
1150
+ """How a ``denied_operations`` entry covers the call: None when it does not.
1151
+
1152
+ A denial names an endpoint and must hold whatever label a call gives it,
1153
+ so, unlike an allow, the fields it pins are alternatives, not
1154
+ requirements. With its ``methods`` (when pinned) covering the call's
1155
+ method, it covers a call whose path its ``path`` covers, whatever
1156
+ operation id the call names, and a call that names its ``operationId``
1157
+ (both return ``""``). Failing closed, it also covers a call that leaves
1158
+ out what the denial knows the operation by: no path when it pins
1159
+ ``path`` (returns ``"path"``), no operation id when it pins
1160
+ ``operationId`` alone (returns ``"operation_id"``). A denial by
1161
+ ``operationId`` alone knows only that label: pin ``path`` too so it holds
1162
+ on the wire. Operation ids and paths are compared ignoring letter case,
1163
+ and a path's literal segments also cover their dot-suffixed spellings
1164
+ (``cancel.json``, ``cancel.``: ``path_matches`` with ``suffixes``).
1165
+
1166
+ An entry of a JSON-RPC API that pins ``rpc_method`` or ``a2a_operation``
1167
+ (the values derived from the request body) covers, with its ``methods``,
1168
+ a call whose JSON-RPC method is its ``rpc_method`` (ignoring letter case),
1169
+ a call that decides as its ``a2a_operation`` says, and a call that names
1170
+ its ``operationId``, whatever the path: its ``path``, if any, neither
1171
+ widens nor narrows it. Every POST to such an API names its method (a body
1172
+ that does not is refused first), so there is nothing left unnamed.
1173
+ """
1174
+ if not _methods_match(entry, method):
1175
+ return None
1176
+ pinned_id = entry.get("operationId")
1177
+ if _rpc_pins(entry):
1178
+ pinned_rpc = entry.get(RPC_METHOD_KEY)
1179
+ if (
1180
+ pinned_rpc is not None
1181
+ and rpc_method is not None
1182
+ and str(rpc_method).casefold() == str(pinned_rpc).casefold()
1183
+ ):
1184
+ return ""
1185
+ pinned_operation = entry.get(A2A_OPERATION_KEY)
1186
+ if pinned_operation is not None and a2a_operation == pinned_operation:
1187
+ return ""
1188
+ if (
1189
+ pinned_id is not None
1190
+ and operation_id is not None
1191
+ and str(operation_id).casefold() == str(pinned_id).casefold()
1192
+ ):
1193
+ return ""
1194
+ return None
1195
+ pinned_path = entry.get("path")
1196
+ if (
1197
+ pinned_path is not None
1198
+ and path is not None
1199
+ and path_matches(pinned_path, path, ignore_case=True, suffixes=True)
1200
+ ):
1201
+ return ""
1202
+ if (
1203
+ pinned_id is not None
1204
+ and operation_id is not None
1205
+ and str(operation_id).casefold() == str(pinned_id).casefold()
1206
+ ):
1207
+ return ""
1208
+ if pinned_path is not None and path is None:
1209
+ return "path"
1210
+ if pinned_id is not None and pinned_path is None and operation_id is None:
1211
+ return "operation_id"
1212
+ return None
1213
+
1214
+
1215
+ def denial_matches(
1216
+ entry: Mapping[str, Any],
1217
+ method: str,
1218
+ operation_id: str | None,
1219
+ path: str | None,
1220
+ *,
1221
+ rpc_method: str | None = None,
1222
+ a2a_operation: str | None = None,
1223
+ ) -> bool:
1224
+ """Whether a ``denied_operations`` entry covers the call (see ``denial_match``)."""
1225
+ return (
1226
+ denial_match(
1227
+ entry, method, operation_id, path, rpc_method=rpc_method, a2a_operation=a2a_operation
1228
+ )
1229
+ is not None
1230
+ )
1231
+
1232
+
1233
+ def describe_operation(entry: Mapping[str, Any]) -> str:
1234
+ """``operationId=updateOrder path=/orders/{order_id} methods=['PATCH']`` for messages
1235
+ (and ``rpc_method=GetTask a2a_operation=approve`` for an entry that pins them)."""
1236
+ parts = []
1237
+ if entry.get("operationId") is not None:
1238
+ parts.append(f"operationId={entry['operationId']}")
1239
+ if entry.get("path") is not None:
1240
+ parts.append(f"path={entry['path']}")
1241
+ if entry.get("methods"):
1242
+ parts.append(f"methods={sorted(str(m).upper() for m in entry['methods'])}")
1243
+ for key in (RPC_METHOD_KEY, A2A_OPERATION_KEY):
1244
+ if entry.get(key) is not None:
1245
+ parts.append(f"{key}={entry[key]}")
1246
+ return " ".join(parts)
1247
+
1248
+
1249
+ def refusal_reason(
1250
+ api: Mapping[str, Any],
1251
+ method: str,
1252
+ operation_id: str | None = None,
1253
+ path: str | None = None,
1254
+ *,
1255
+ rpc_method: str | None = None,
1256
+ a2a_operation: str | None = None,
1257
+ ) -> str | None:
1258
+ """Why the API's policy refuses the call, or None when it is allowed.
1259
+
1260
+ Every rule must pass: the method is in ``allowed_methods`` (``["*"]``
1261
+ allows every method); no ``denied_operations`` entry may cover the call
1262
+ (denials win: a denial pinning a path refuses every call to that path,
1263
+ whatever operation id it names, and a call that leaves out what a denial
1264
+ knows the operation by is refused by it: ``denial_match``); and, when
1265
+ ``allowed_operations`` is present, one of its entries matches
1266
+ (``operation_matches``: every field it pins). On a JSON-RPC API
1267
+ (``protocol: jsonrpc|a2a``) ``rpc_method`` and ``a2a_operation`` are the
1268
+ values ``derive_rpc`` reads from the request body.
1269
+ """
1270
+ method = method.upper()
1271
+ operation_id = operation_id or None
1272
+ path = path or None
1273
+ rpc = {"rpc_method": rpc_method or None, "a2a_operation": a2a_operation or None}
1274
+ allowed = [str(m).upper() for m in api.get("allowed_methods") or []]
1275
+ if ANY_METHOD not in allowed and method not in allowed:
1276
+ return f"method {method} is not in allowed_methods {allowed}"
1277
+ for entry in api.get("denied_operations") or []:
1278
+ unnamed = denial_match(entry, method, operation_id, path, **rpc)
1279
+ if unnamed is not None:
1280
+ reason = f"denied by denied_operations ({describe_operation(entry)})"
1281
+ if unnamed:
1282
+ reason += (
1283
+ f": the call names no {unnamed}, so it cannot be ruled out; name it on "
1284
+ "the call and in API_CALLS"
1285
+ )
1286
+ return reason
1287
+ allowed_operations = api.get("allowed_operations")
1288
+ if allowed_operations is not None and not any(
1289
+ operation_matches(entry, method, operation_id, path, **rpc) for entry in allowed_operations
1290
+ ):
1291
+ return "not in allowed_operations"
1292
+ return None
1293
+
1294
+
1295
+ @dataclass(frozen=True)
1296
+ class ApprovalGate:
1297
+ """The human approval an API's policy requires before a call is sent (``gated``)."""
1298
+
1299
+ # "requester" and/or "role:<name>" entries of the rule that gates the call,
1300
+ # in the policy's order.
1301
+ approvers: tuple[str, ...]
1302
+ # Seconds a pending approval waits for a decision; then it expires (= rejected).
1303
+ timeout_s: int
1304
+ # The rule and the part of its required_for that gates the call, for messages
1305
+ # ("approval.required_for.methods ['POST']", "approval[1].required_for.operations (...)").
1306
+ rule: str
1307
+ # The gating rule's index when `approval` is a list of rules; None for one mapping.
1308
+ index: int | None = None
1309
+ # Later rules that also cover the call; they do not apply to it (the first one does).
1310
+ also: tuple[int, ...] = ()
1311
+ # How the requester decides: `direct`, or `relayed` by the agents `relayers` names.
1312
+ decide_with: str = DEFAULT_DECIDE_WITH
1313
+ relayers: tuple[str, ...] = ()
1314
+
1315
+ def deciders(self) -> tuple[frozenset[str], str, frozenset[str]]:
1316
+ """Who decides, and how: what an approval is bound to (`rule_deciders`)."""
1317
+ return frozenset(self.approvers), self.decide_with, frozenset(self.relayers)
1318
+
1319
+
1320
+ def approval_rules(api: Mapping[str, Any]) -> list[Mapping[str, Any]]:
1321
+ """An API's approval rules in file order: none, its one ``approval`` mapping, or its list."""
1322
+ approval = api.get(APPROVAL_KEY)
1323
+ if approval is None:
1324
+ return []
1325
+ return list(approval) if isinstance(approval, list) else [approval]
1326
+
1327
+
1328
+ def rule_deciders(rule: Mapping[str, Any]) -> tuple[frozenset[str], str, frozenset[str]]:
1329
+ """Who decides the calls a rule gates, and how: its approvers, `decide_with` and
1330
+ relayers. Two rules with other deciders are two gates, and an approval taken under one
1331
+ does not cover the other's calls."""
1332
+ return (
1333
+ frozenset(str(a) for a in rule.get("approvers") or ()),
1334
+ str(rule.get("decide_with", DEFAULT_DECIDE_WITH)),
1335
+ frozenset(str(r) for r in rule.get("relayers") or ()),
1336
+ )
1337
+
1338
+
1339
+ def describe_deciders(rule: Mapping[str, Any]) -> str:
1340
+ """``requester, role:ops``, or ``requester; relayed by concierge`` for a relayed rule."""
1341
+ approvers = ", ".join(str(a) for a in rule.get("approvers") or ())
1342
+ if rule.get("decide_with", DEFAULT_DECIDE_WITH) != DECIDE_RELAYED:
1343
+ return approvers
1344
+ return f"{approvers}; relayed by {', '.join(str(r) for r in rule.get('relayers') or ())}"
1345
+
1346
+
1347
+ def approval_rule_label(api: Mapping[str, Any], index: int) -> str:
1348
+ """``approval`` for an API's one approval mapping, ``approval[<index>]`` in a list of rules."""
1349
+ if isinstance(api.get(APPROVAL_KEY), list):
1350
+ return f"{APPROVAL_KEY}[{index}]"
1351
+ return APPROVAL_KEY
1352
+
1353
+
1354
+ class ApprovalRuleConflict(ValueError):
1355
+ """``gated``: the call cannot be given to one approval rule, so it is refused.
1356
+
1357
+ The message says why (the rule that cannot rule the call out, the later
1358
+ rule with other approvers that covers it) and what to name. ``unnamed``
1359
+ is what the call leaves out (``"operation_id"`` or ``"path"``), ``index``
1360
+ and ``later`` are the two rules' indexes.
1361
+ """
1362
+
1363
+ def __init__(self, message: str, *, unnamed: str, index: int, later: int) -> None:
1364
+ super().__init__(message)
1365
+ self.unnamed = unnamed
1366
+ self.index = index
1367
+ self.later = later
1368
+
1369
+
1370
+ def _rule_match(
1371
+ rule: Mapping[str, Any],
1372
+ method: str,
1373
+ operation_id: str | None,
1374
+ path: str | None,
1375
+ rpc: Mapping[str, str | None] | None = None,
1376
+ ) -> tuple[str, str] | None:
1377
+ """How one approval rule covers the call: ``(part, unnamed)``, or None.
1378
+
1379
+ ``part`` names what covers it, for messages. ``unnamed`` is ``""`` when
1380
+ the rule surely covers the call, else what the call leaves out
1381
+ (``"operation_id"``, ``"path"``) that the covering entry knows the
1382
+ operation by: the rule covers it only because it cannot be ruled out. A
1383
+ sure match wins over one that only cannot be ruled out.
1384
+ """
1385
+ required_for = rule.get("required_for") or {}
1386
+ methods = [str(m).upper() for m in required_for.get("methods") or []]
1387
+ if ANY_METHOD in methods or method in methods:
1388
+ return f"required_for.methods {methods}", ""
1389
+ unsure: tuple[str, str] | None = None
1390
+ for entry in required_for.get("operations") or []:
1391
+ unnamed = denial_match(entry, method, operation_id, path, **(rpc or {}))
1392
+ if unnamed is None:
1393
+ continue
1394
+ part = f"required_for.operations ({describe_operation(entry)})"
1395
+ if not unnamed:
1396
+ return part, ""
1397
+ if unsure is None:
1398
+ unsure = (part, unnamed)
1399
+ return unsure
1400
+
1401
+
1402
+ def _unsure(part: str, unnamed: str) -> str:
1403
+ """``part``, and why it covers the call when the call only leaves out what it names."""
1404
+ return f"{part}: the call names no {unnamed}, so it cannot be ruled out" if unnamed else part
1405
+
1406
+
1407
+ def rule_covers(
1408
+ rule: Mapping[str, Any],
1409
+ method: str,
1410
+ operation_id: str | None,
1411
+ path: str | None,
1412
+ *,
1413
+ rpc_method: str | None = None,
1414
+ a2a_operation: str | None = None,
1415
+ ) -> str | None:
1416
+ """Which part of one approval rule's ``required_for`` covers the call, or None.
1417
+
1418
+ ``required_for.methods`` covers a call with one of its methods (``["*"]``:
1419
+ every method). An entry of ``required_for.operations`` covers a call as a
1420
+ denial does (``denial_match``: fail closed), not as an allow; an entry
1421
+ that surely covers it is named before one that only cannot rule it out.
1422
+ """
1423
+ rpc = {"rpc_method": rpc_method or None, "a2a_operation": a2a_operation or None}
1424
+ match = _rule_match(rule, method.upper(), operation_id or None, path or None, rpc)
1425
+ return None if match is None else _unsure(*match)
1426
+
1427
+
1428
+ def gated(
1429
+ api: Mapping[str, Any],
1430
+ method: str,
1431
+ operation_id: str | None = None,
1432
+ path: str | None = None,
1433
+ *,
1434
+ template: str | None = None,
1435
+ rpc_method: str | None = None,
1436
+ a2a_operation: str | None = None,
1437
+ ) -> ApprovalGate | None:
1438
+ """The approval the API's policy (a validated one) requires before the call, or None.
1439
+
1440
+ Ask it only about a call ``refusal_reason`` allows: approval never widens
1441
+ access, so a refused call stays refused whatever its gate, and denials
1442
+ still win. A rule covers the call when its ``required_for.methods`` holds
1443
+ the call's method (``["*"]``: every method), or when an entry of its
1444
+ ``required_for.operations`` covers it (``rule_covers``). Such an entry
1445
+ fails closed, as a denial does (``denial_match``), not as an allow: with
1446
+ its ``methods`` (when pinned) covering the call's method, its ``path``
1447
+ gates every call to that path whatever operation id the call names, its
1448
+ ``operationId`` gates the calls that name it, and a call that leaves out
1449
+ what the entry knows the operation by is gated too. Paths are compared
1450
+ normalised and ignoring letter case, and a literal segment also covers its
1451
+ dot-suffixed spellings (``cancel.json``, ``cancel.``), as for a denial.
1452
+
1453
+ ``approval`` is one rule, or a list of rules: the FIRST rule in file order
1454
+ that covers the call gates it, with that rule's approvers and timeout, and
1455
+ the later rules that also cover it are listed in ``also`` (they do not
1456
+ apply to it). Failing closed across rules: when the first rule covers the
1457
+ call only because the call leaves out what the rule knows the operation by
1458
+ (no operation id, no path), and a later rule with other approvers also
1459
+ covers it, the call may be that later rule's, so neither rule's approvers
1460
+ get it: ``ApprovalRuleConflict`` is raised and the call is refused. At
1461
+ runtime, ask with the path that is sent and, when there is one, the
1462
+ ``template`` it was rendered from: a rule covers the call when it covers
1463
+ either (surely, when it surely covers either). On a JSON-RPC API an entry
1464
+ pinning ``rpc_method`` or ``a2a_operation`` covers the call as a denial
1465
+ does (``denial_match``), by the values derived from the request body.
1466
+ """
1467
+ rules = approval_rules(api)
1468
+ if not rules:
1469
+ return None
1470
+ method = method.upper()
1471
+ operation_id = operation_id or None
1472
+ path = path or None
1473
+ template = template or None
1474
+ rpc = {"rpc_method": rpc_method or None, "a2a_operation": a2a_operation or None}
1475
+ covering: list[tuple[int, str, str]] = []
1476
+ for index, rule in enumerate(rules):
1477
+ match = _rule_match(rule, method, operation_id, path, rpc)
1478
+ if template is not None and (match is None or match[1]):
1479
+ other = _rule_match(rule, method, operation_id, template, rpc)
1480
+ if other is not None and (match is None or not other[1]):
1481
+ match = other
1482
+ if match is not None:
1483
+ covering.append((index, *match))
1484
+ if not covering:
1485
+ return None
1486
+ index, part, unnamed = covering[0]
1487
+ rule = rules[index]
1488
+ approvers = tuple(str(a) for a in rule.get("approvers") or ())
1489
+ label = approval_rule_label(api, index)
1490
+ if unnamed:
1491
+ pin = f", or pin path and methods in {label}" if unnamed == "operation_id" else ""
1492
+ for later, _part, _unnamed in covering[1:]:
1493
+ if rule_deciders(rules[later]) != rule_deciders(rule):
1494
+ raise ApprovalRuleConflict(
1495
+ f"{label} (approved by {describe_deciders(rule)}) covers it only because the "
1496
+ f"call names no {unnamed} ({label}.{part}), and "
1497
+ f"{approval_rule_label(api, later)} (approved by "
1498
+ f"{describe_deciders(rules[later])}) also covers it: it could be either "
1499
+ "rule's call, so neither rule's approvers are asked; name the "
1500
+ f"{unnamed} on the call and in API_CALLS{pin}",
1501
+ unnamed=unnamed,
1502
+ index=index,
1503
+ later=later,
1504
+ )
1505
+ listed = isinstance(api.get(APPROVAL_KEY), list)
1506
+ return ApprovalGate(
1507
+ approvers=approvers,
1508
+ timeout_s=int(rule.get("timeout_s", DEFAULT_APPROVAL_TIMEOUT_S)),
1509
+ rule=f"{label}.{_unsure(part, unnamed)}",
1510
+ index=index if listed else None,
1511
+ also=tuple(i for i, _, _ in covering[1:]),
1512
+ decide_with=str(rule.get("decide_with", DEFAULT_DECIDE_WITH)),
1513
+ relayers=tuple(str(r) for r in rule.get("relayers") or ()),
1514
+ )
1515
+
1516
+
1517
+ class RpcRequestError(ValueError):
1518
+ """A request a JSON-RPC API (``protocol: jsonrpc|a2a``) refuses to send (``derive_rpc``)."""
1519
+
1520
+
1521
+ @dataclass(frozen=True)
1522
+ class RpcCall:
1523
+ """What a request to a JSON-RPC API is, read from its body (``derive_rpc``).
1524
+
1525
+ ``rpc_method``: the JSON-RPC method of a POST (an A2A 0.3 name read as its 1.0
1526
+ name under ``protocol: a2a``); None for GET and HEAD, and for any call to an
1527
+ ``http`` API. ``a2a_operation``: under ``protocol: a2a``, ``approve`` or
1528
+ ``reject`` for a message that decides a pending approval, else None.
1529
+ """
1530
+
1531
+ rpc_method: str | None = None
1532
+ a2a_operation: str | None = None
1533
+
1534
+
1535
+ def canonical_rpc_method(protocol: str, name: str) -> str:
1536
+ """``name`` as the policy compares it: an A2A 0.3 name as its 1.0 name under a2a."""
1537
+ return A2A_V03_METHODS.get(name, name) if protocol == PROTOCOL_A2A else name
1538
+
1539
+
1540
+ def _is_rpc_id(value: Any) -> bool:
1541
+ return isinstance(value, str) or (isinstance(value, int) and not isinstance(value, bool))
1542
+
1543
+
1544
+ def _names_approval(data: Any) -> bool:
1545
+ """Whether a message part's data names an approval (as the called agent reads it)."""
1546
+ return isinstance(data, Mapping) and ("approval_id" in data or "decision" in data)
1547
+
1548
+
1549
+ def _a2a_operation(protocol: str, params: Any) -> str | None:
1550
+ """What an A2A message decides: ``reject`` only when every part that names an approval
1551
+ says ``reject``, ``approve`` when any other does (approve wins), None when none does."""
1552
+ message = params.get("message") if isinstance(params, Mapping) else None
1553
+ parts = message.get("parts", []) if isinstance(message, Mapping) else None
1554
+ if not isinstance(parts, list):
1555
+ raise RpcRequestError(
1556
+ f"protocol {protocol}: a message request needs params.message with a list of parts"
1557
+ )
1558
+ decisions = [
1559
+ part["data"].get("decision")
1560
+ for part in parts
1561
+ if isinstance(part, Mapping) and _names_approval(part.get("data"))
1562
+ ]
1563
+ if not decisions:
1564
+ return None
1565
+ return A2A_REJECT if all(d == A2A_REJECT for d in decisions) else A2A_APPROVE
1566
+
1567
+
1568
+ def _rpc_body_problem(sent: Any) -> str | None:
1569
+ """Why a parsed JSON body is not one JSON-RPC 2.0 request object, or None."""
1570
+ if isinstance(sent, list):
1571
+ return "a batch"
1572
+ if not isinstance(sent, dict):
1573
+ return "not a JSON-RPC request object"
1574
+ if set(sent) - set(_JSONRPC_KEYS):
1575
+ return "members other than jsonrpc, method, params and id"
1576
+ if sent.get("jsonrpc") != "2.0":
1577
+ return 'jsonrpc is not "2.0"'
1578
+ if not isinstance(sent.get("method"), str) or not sent["method"]:
1579
+ return "no method name"
1580
+ if "id" not in sent:
1581
+ return "a notification (no id)"
1582
+ if not _is_rpc_id(sent["id"]):
1583
+ return "an id that is not a string or an integer"
1584
+ if "params" in sent and not isinstance(sent["params"], dict | list):
1585
+ return "params that are not an object or an array"
1586
+ return None
1587
+
1588
+
1589
+ def derive_rpc(api: Mapping[str, Any], method: str, body: Any) -> RpcCall:
1590
+ """What a request to ``api`` is, read from the body sent (never from the tool's labels).
1591
+
1592
+ Nothing for an ``http`` API. For ``protocol: jsonrpc|a2a``: a POST must send
1593
+ one JSON-RPC 2.0 request object (``jsonrpc: "2.0"``, a method name, an ``id``
1594
+ that is a string or an integer, optional ``params`` that are an object or an
1595
+ array, and no other member), read as the server reads the JSON sent; a
1596
+ batch, a notification (no ``id``), a body that is not plain JSON or any
1597
+ other body raises ``RpcRequestError``, as does a GET or HEAD with a body.
1598
+ Its ``rpc_method`` is the request's method (under ``a2a``, an A2A 0.3 name as
1599
+ its 1.0 name). Under ``a2a``, a ``SendMessage`` or ``SendStreamingMessage``
1600
+ whose parts name an approval (a data part with ``approval_id`` or
1601
+ ``decision``) is ``a2a_operation: reject`` only when every such part says
1602
+ ``reject``, and ``approve`` otherwise: failing closed, approve wins. A
1603
+ message method in another letter case (``sendmessage``) is read for a
1604
+ decision too, though an A2A server answers it as an unknown method.
1605
+ """
1606
+ protocol = api_protocol(api)
1607
+ if protocol not in RPC_PROTOCOLS:
1608
+ return RpcCall()
1609
+ method = method.upper()
1610
+ if method != "POST":
1611
+ if body is not None:
1612
+ raise RpcRequestError(
1613
+ f"protocol {protocol}: a {method} sends no body (a JSON-RPC request is a POST)"
1614
+ )
1615
+ return RpcCall()
1616
+ try:
1617
+ # The JSON the server reads: tuples become lists, keys strings (never trust a
1618
+ # Python object that serializes as something other than it looks).
1619
+ sent = json.loads(json.dumps(body, allow_nan=False))
1620
+ except (TypeError, ValueError):
1621
+ sent = None
1622
+ problem: str | None = "not plain JSON"
1623
+ else:
1624
+ problem = _rpc_body_problem(sent)
1625
+ if problem is not None:
1626
+ raise RpcRequestError(
1627
+ f"protocol {protocol} sends one JSON-RPC request per call (a batch or non-request "
1628
+ f"body refused: {problem})"
1629
+ )
1630
+ name = canonical_rpc_method(protocol, sent["method"])
1631
+ if protocol != PROTOCOL_A2A or name.casefold() not in _A2A_MESSAGE_NAMES:
1632
+ return RpcCall(rpc_method=name)
1633
+ return RpcCall(rpc_method=name, a2a_operation=_a2a_operation(protocol, sent.get("params")))
1634
+
1635
+
1636
+ def _operation_entries(api: Mapping[str, Any]) -> list[Mapping[str, Any]]:
1637
+ """Every operation entry of an API: allowed, denied and those its approval rules gate."""
1638
+ entries = [*(api.get("allowed_operations") or []), *(api.get("denied_operations") or [])]
1639
+ for rule in approval_rules(api):
1640
+ entries.extend((rule.get("required_for") or {}).get("operations") or [])
1641
+ return [entry for entry in entries if isinstance(entry, Mapping)]
1642
+
1643
+
1644
+ def label_problem(
1645
+ api: Mapping[str, Any],
1646
+ operation_id: str | None,
1647
+ rpc_method: str | None = None,
1648
+ a2a_operation: str | None = None,
1649
+ ) -> str | None:
1650
+ """Why a tool's ``operation_id`` does not name the request it labels, or None.
1651
+
1652
+ On a JSON-RPC API, an entry that pins ``operationId`` with ``rpc_method``
1653
+ or ``a2a_operation`` says what a call so labelled is. A call labelled so
1654
+ whose request (``derive_rpc``) is something else is refused, so a label
1655
+ never carries a decision past a rule written for another request.
1656
+ Operation ids and JSON-RPC methods are compared ignoring letter case.
1657
+ """
1658
+ if not operation_id or api_protocol(api) not in RPC_PROTOCOLS:
1659
+ return None
1660
+ label = str(operation_id).casefold()
1661
+ for entry in _operation_entries(api):
1662
+ pinned_id = entry.get("operationId")
1663
+ if pinned_id is None or str(pinned_id).casefold() != label:
1664
+ continue
1665
+ pinned_rpc = entry.get(RPC_METHOD_KEY)
1666
+ if pinned_rpc is not None and (
1667
+ rpc_method is None or str(pinned_rpc).casefold() != str(rpc_method).casefold()
1668
+ ):
1669
+ return (
1670
+ f"operation_id {operation_id!r} does not match the request (rpc_method "
1671
+ f"{rpc_method or 'none'}); refused"
1672
+ )
1673
+ pinned_operation = entry.get(A2A_OPERATION_KEY)
1674
+ if pinned_operation is not None and pinned_operation != a2a_operation:
1675
+ return (
1676
+ f"operation_id {operation_id!r} does not match the request (a2a_operation "
1677
+ f"{a2a_operation or 'none'}); refused"
1678
+ )
1679
+ return None
1680
+
1681
+
1682
+ # --- END SHARED API POLICY RULES ---
1683
+
1684
+
1685
+ class ApiPolicyError(Exception):
1686
+ """Refused by the policy: no or invalid policy file, an undeclared API, or a
1687
+ request outside the policy. Always raised before anything is sent."""
1688
+
1689
+ def __init__(
1690
+ self, message: str, errors: list[str] | None = None, *, reason: str | None = None
1691
+ ) -> None:
1692
+ super().__init__(message)
1693
+ self.errors = list(errors or [])
1694
+ # The policy rule that refused a call (policy-derived text only, never the
1695
+ # call's arguments), for the log line; None for other refusals.
1696
+ self.reason = reason
1697
+
1698
+
1699
+ # How much of an error response an `ApiCallError` keeps (`body`) and puts in
1700
+ # its message (the part the model reads).
1701
+ ERROR_BODY_MAX_CHARS = 2000
1702
+ ERROR_MESSAGE_BODY_CHARS = 300
1703
+ _CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
1704
+
1705
+
1706
+ class ApiCallError(Exception):
1707
+ """A declared call that could not be made or failed: missing configuration or
1708
+ credential, a transport error, or a non-2xx response.
1709
+
1710
+ For a non-2xx response, `status_code` is the HTTP status and `body` the
1711
+ start of the response body (at most `ERROR_BODY_MAX_CHARS` characters,
1712
+ with the credential the call sent replaced by `<redacted>`), so a tool can
1713
+ branch on the status (a 404 as "not found", a 409 as a conflict) and the
1714
+ model reads the upstream's reason from the message. Both are None when no
1715
+ response came back. The body is the upstream's text, as untrusted as any
1716
+ other API data.
1717
+ """
1718
+
1719
+ def __init__(
1720
+ self,
1721
+ message: str,
1722
+ *,
1723
+ status_code: int | None = None,
1724
+ body: str | None = None,
1725
+ reason: str | None = None,
1726
+ ) -> None:
1727
+ super().__init__(message)
1728
+ self.status_code = status_code
1729
+ self.body = body
1730
+ # Why nothing was sent (a fixed phrase, never a value), for the log line.
1731
+ self.reason = reason
1732
+
1733
+
1734
+ class ResponseTooLarge(Exception):
1735
+ """A response body over the API's `limits.max_response_bytes` (the client discards it)."""
1736
+
1737
+
1738
+ class ResponseEncodingRefused(Exception):
1739
+ """A capped response in a content encoding the client cannot decode within the cap."""
1740
+
1741
+
1742
+ # Under `limits.max_response_bytes` the client asks for these content encodings only and
1743
+ # decodes them itself, never more than the cap at a time (zlib's `max_length`). httpx
1744
+ # decodes each network read (up to 64 KiB) whole before a cap could be checked: 32 KiB of
1745
+ # zstd decodes to 1 GiB, and 64 KiB of gzip to 64 MiB. An answer in any other encoding
1746
+ # (zstd, br, an unknown one, or two stacked) is refused unread, whatever was asked for.
1747
+ CAPPED_ACCEPT_ENCODING = "gzip, deflate"
1748
+ _CAPPED_CODINGS = ("gzip", "deflate")
1749
+
1750
+
1751
+ class CappedBody:
1752
+ """A response body read, decoded, up to `cap` bytes.
1753
+
1754
+ `content_encoding` is the response's `Content-Encoding`: none or `identity`, `gzip`,
1755
+ or `deflate` (zlib-wrapped, or raw as some servers send it, as httpx reads it).
1756
+ Anything else raises `ResponseEncodingRefused` before a byte is read. `feed` each raw
1757
+ chunk as it arrives, then `finish`; `ResponseTooLarge` as soon as the decoded body
1758
+ passes the cap, so at most `cap + 1` decoded bytes are ever held. A corrupt compressed
1759
+ body raises `zlib.error`."""
1760
+
1761
+ def __init__(self, content_encoding: str, cap: int) -> None:
1762
+ codings = [
1763
+ coding
1764
+ for coding in (part.strip().lower() for part in content_encoding.split(","))
1765
+ if coding not in ("", "identity")
1766
+ ]
1767
+ if len(codings) > 1 or (codings and codings[0] not in _CAPPED_CODINGS):
1768
+ raise ResponseEncodingRefused
1769
+ self.coding = codings[0] if codings else None
1770
+ self.cap = cap
1771
+ self.size = 0
1772
+ self._parts: list[bytes] = []
1773
+ self._first = True
1774
+ self._zlib: Any = None
1775
+ if self.coding == "gzip":
1776
+ self._zlib = zlib.decompressobj(zlib.MAX_WBITS | 16)
1777
+ elif self.coding == "deflate":
1778
+ self._zlib = zlib.decompressobj()
1779
+
1780
+ def feed(self, data: bytes) -> None:
1781
+ if self._zlib is None:
1782
+ self._take(data)
1783
+ return
1784
+ while data:
1785
+ # At most one byte past the cap per call (a `max_length` of 0 would mean no bound);
1786
+ # the input left over waits in `unconsumed_tail`.
1787
+ self._take(self._decompress(data, self.cap - self.size + 1))
1788
+ data = self._zlib.unconsumed_tail
1789
+
1790
+ def finish(self) -> bytes:
1791
+ if self._zlib is not None:
1792
+ self._take(self._zlib.flush()) # every input byte is consumed: nothing unbounded
1793
+ return b"".join(self._parts)
1794
+
1795
+ def _decompress(self, data: bytes, room: int) -> bytes:
1796
+ first, self._first = self._first, False
1797
+ try:
1798
+ return self._zlib.decompress(data, room)
1799
+ except zlib.error:
1800
+ if not (first and self.coding == "deflate"):
1801
+ raise
1802
+ self._zlib = zlib.decompressobj(-zlib.MAX_WBITS) # raw deflate, no zlib header
1803
+ return self._zlib.decompress(data, room)
1804
+
1805
+ def _take(self, piece: bytes) -> None:
1806
+ self.size += len(piece)
1807
+ if self.size > self.cap:
1808
+ raise ResponseTooLarge
1809
+ if piece:
1810
+ self._parts.append(piece)
1811
+
1812
+
1813
+ def error_body_excerpt(text: str, secrets: tuple[str, ...] = ()) -> str:
1814
+ """`text` bounded to `ERROR_BODY_MAX_CHARS`, control characters dropped, `secrets` redacted."""
1815
+ for secret in secrets:
1816
+ if secret:
1817
+ text = text.replace(secret, "<redacted>")
1818
+ text = _CONTROL_CHARS.sub("", text)
1819
+ return text[:ERROR_BODY_MAX_CHARS]
1820
+
1821
+
1822
+ # Request headers a tool may not set: they would change the request's routing
1823
+ # or method after the policy check (`Host`, `X-HTTP-Method-Override` and the
1824
+ # like), or belong to the connection rather than the request (hop-by-hop).
1825
+ # They are dropped before sending, with a warning naming them.
1826
+ FORBIDDEN_HEADERS = frozenset(
1827
+ {
1828
+ "host",
1829
+ "x-http-method-override",
1830
+ "x-http-method",
1831
+ "x-method-override",
1832
+ "x-original-url",
1833
+ "x-rewrite-url",
1834
+ "x-original-method",
1835
+ "forwarded",
1836
+ "connection",
1837
+ "keep-alive",
1838
+ "proxy-authenticate",
1839
+ "proxy-authorization",
1840
+ "proxy-connection",
1841
+ "te",
1842
+ "trailer",
1843
+ "transfer-encoding",
1844
+ "upgrade",
1845
+ "content-length",
1846
+ }
1847
+ )
1848
+ FORBIDDEN_HEADER_PREFIXES = ("x-forwarded-",)
1849
+ # A `_method` query parameter or top-level JSON body key turns a POST into
1850
+ # another method on servers that honour it (Rails, Laravel, method-override
1851
+ # middleware); the policy checks the request's own method, so it is refused.
1852
+ METHOD_OVERRIDE_PARAM = "_method"
1853
+
1854
+
1855
+ def forbidden_header(name: str) -> bool:
1856
+ """Whether a tool may not set header `name`.
1857
+
1858
+ Underscores count as hyphens: CGI and WSGI servers (and proxies that do
1859
+ not drop such headers) map `X_HTTP_METHOD_OVERRIDE` and
1860
+ `X-HTTP-Method-Override` to the same variable.
1861
+ """
1862
+ lowered = name.strip().lower().replace("_", "-")
1863
+ return lowered in FORBIDDEN_HEADERS or lowered.startswith(FORBIDDEN_HEADER_PREFIXES)
1864
+
1865
+
1866
+ # ---------------------------------------------------------------------------
1867
+ # The policy
1868
+ # ---------------------------------------------------------------------------
1869
+
1870
+
1871
+ @dataclass
1872
+ class ApiPolicy:
1873
+ """A validated `api-policy.yaml`: API name -> its settings."""
1874
+
1875
+ apis: dict[str, dict[str, Any]]
1876
+ source: Path | None = None
1877
+
1878
+ @property
1879
+ def file(self) -> str:
1880
+ return self.source.name if self.source else POLICY_FILENAME
1881
+
1882
+ @classmethod
1883
+ def from_dict(cls, data: Any, source: Path | None = None) -> ApiPolicy:
1884
+ """Validate `data` with the strict schema; raise `ApiPolicyError` listing every error."""
1885
+ errors = policy_errors(data)
1886
+ if errors:
1887
+ name = source.name if source else POLICY_FILENAME
1888
+ raise ApiPolicyError(f"invalid {name}: " + "; ".join(errors), errors)
1889
+ return cls(apis={str(k): dict(v) for k, v in data["apis"].items()}, source=source)
1890
+
1891
+ @classmethod
1892
+ def load(cls, path: str | Path) -> ApiPolicy:
1893
+ policy_path = Path(path)
1894
+ if not policy_path.is_file():
1895
+ raise ApiPolicyError(
1896
+ f"{policy_path} not found: outbound API calls are refused until the project "
1897
+ f"declares them in {POLICY_FILENAME} (set {POLICY_PATH_ENV} to use another path)."
1898
+ )
1899
+ try:
1900
+ text = policy_path.read_text(encoding="utf-8")
1901
+ except OSError as exc:
1902
+ raise ApiPolicyError(f"cannot read {policy_path}: {exc}") from exc
1903
+ data, errors = parse_policy_yaml(text)
1904
+ if errors:
1905
+ raise ApiPolicyError(f"invalid {policy_path.name}: " + "; ".join(errors), errors)
1906
+ return cls.from_dict(data, source=policy_path)
1907
+
1908
+ def api(self, name: str) -> dict[str, Any]:
1909
+ """The settings of API `name`; `ApiPolicyError` when it is not declared."""
1910
+ settings = self.apis.get(name)
1911
+ if settings is None:
1912
+ declared = ", ".join(sorted(self.apis)) or "none"
1913
+ raise ApiPolicyError(
1914
+ f"API {name!r} is not declared in {self.file} (declared: {declared})."
1915
+ )
1916
+ return settings
1917
+
1918
+ def check(
1919
+ self,
1920
+ api_name: str,
1921
+ method: str,
1922
+ operation_id: str | None = None,
1923
+ path: str | None = None,
1924
+ *,
1925
+ rpc: RpcCall | None = None,
1926
+ ) -> None:
1927
+ """Raise `ApiPolicyError` when the call is outside the API's policy.
1928
+
1929
+ The message is what the model reads (a tool error): the API, the
1930
+ call and the rule that refused it, without file names. `rpc` is what
1931
+ a request to a JSON-RPC API is (`derive_rpc`: its method and decision).
1932
+ """
1933
+ rpc = rpc or RpcCall()
1934
+ reason = refusal_reason(
1935
+ self.api(api_name),
1936
+ method,
1937
+ operation_id,
1938
+ path,
1939
+ rpc_method=rpc.rpc_method,
1940
+ a2a_operation=rpc.a2a_operation,
1941
+ )
1942
+ if reason:
1943
+ what = (operation_id or path or "<unnamed operation>") + describe_rpc(rpc)
1944
+ raise ApiPolicyError(
1945
+ f"{api_name}: {method.upper()} {what} refused by the API policy: {reason}.",
1946
+ reason=reason,
1947
+ )
1948
+
1949
+ def gate(
1950
+ self,
1951
+ api_name: str,
1952
+ method: str,
1953
+ operation_id: str | None = None,
1954
+ path: str | None = None,
1955
+ *,
1956
+ template: str | None = None,
1957
+ rpc: RpcCall | None = None,
1958
+ ) -> ApprovalGate | None:
1959
+ """The human approval the API's policy requires before the call is sent, or None.
1960
+
1961
+ See `gated`: with a list of approval rules, the first rule in file order
1962
+ that covers the call (its sent `path`, or the `template` it was rendered
1963
+ from) gates it, with that rule's approvers. Ask only after `check`
1964
+ passed: approval never widens access.
1965
+
1966
+ Fails closed across rules: a call the first covering rule cannot rule
1967
+ out only because it names no operation id, and that a later rule with
1968
+ other approvers also covers, could be either rule's call. It raises
1969
+ `ApiPolicyError` (refused, nothing is sent) instead of asking either
1970
+ rule's approvers.
1971
+ """
1972
+ rpc = rpc or RpcCall()
1973
+ try:
1974
+ return gated(
1975
+ self.api(api_name),
1976
+ method,
1977
+ operation_id,
1978
+ path,
1979
+ template=template,
1980
+ rpc_method=rpc.rpc_method,
1981
+ a2a_operation=rpc.a2a_operation,
1982
+ )
1983
+ except ApprovalRuleConflict as exc:
1984
+ reason = str(exc)
1985
+ what = (operation_id or path or "<unnamed operation>") + describe_rpc(rpc)
1986
+ raise ApiPolicyError(
1987
+ f"{api_name}: {method.upper()} {what} refused by the API policy: {reason}.",
1988
+ reason=reason,
1989
+ ) from None
1990
+
1991
+
1992
+ def describe_rpc(rpc: RpcCall) -> str:
1993
+ """` (rpc_method GetTask)`, ` (rpc_method SendMessage, a2a_operation approve)`, or ""."""
1994
+ parts = [
1995
+ f"{key} {value}"
1996
+ for key, value in ((RPC_METHOD_KEY, rpc.rpc_method), (A2A_OPERATION_KEY, rpc.a2a_operation))
1997
+ if value
1998
+ ]
1999
+ return f" ({', '.join(parts)})" if parts else ""
2000
+
2001
+
2002
+ def _beside_pyproject() -> Path | None:
2003
+ """The policy path beside the project's `pyproject.toml` (found once, at import)."""
2004
+ for parent in Path(__file__).resolve().parents:
2005
+ if (parent / "pyproject.toml").is_file():
2006
+ return parent / POLICY_FILENAME
2007
+ return None
2008
+
2009
+
2010
+ _PROJECT_POLICY_PATH = _beside_pyproject()
2011
+
2012
+
2013
+ def resolve_policy_path() -> Path:
2014
+ """`API_POLICY_PATH` when set; else `./api-policy.yaml`, else the one beside `pyproject.toml`."""
2015
+ configured = os.environ.get(POLICY_PATH_ENV, "").strip()
2016
+ if configured:
2017
+ return Path(configured)
2018
+ local = Path(POLICY_FILENAME)
2019
+ if local.is_file():
2020
+ return local
2021
+ return _PROJECT_POLICY_PATH or local
2022
+
2023
+
2024
+ _cache: dict[tuple[int, int, int, int], ApiPolicy] = {}
2025
+
2026
+
2027
+ def load_policy() -> ApiPolicy:
2028
+ """The project's policy (cached until the file changes); `ApiPolicyError` when absent or invalid.
2029
+
2030
+ Tools call this inside the server's event loop: it never resolves the
2031
+ working directory (LangGraph's dev server refuses `os.getcwd()` there as
2032
+ a blocking call). The cache is keyed by the file's identity and mtime.
2033
+ """
2034
+ path = resolve_policy_path()
2035
+ try:
2036
+ stat = path.stat()
2037
+ except OSError:
2038
+ return ApiPolicy.load(path) # raises the not-found error
2039
+ key = (stat.st_dev, stat.st_ino, stat.st_mtime_ns, stat.st_size)
2040
+ policy = _cache.get(key)
2041
+ if policy is None:
2042
+ policy = ApiPolicy.load(path)
2043
+ _cache.clear()
2044
+ _cache[key] = policy
2045
+ return policy
2046
+
2047
+
2048
+ def reset_policy_cache() -> None:
2049
+ """For tests that switch `API_POLICY_PATH` or rewrite the file."""
2050
+ _cache.clear()
2051
+
2052
+
2053
+ # ---------------------------------------------------------------------------
2054
+ # Paths
2055
+ # ---------------------------------------------------------------------------
2056
+
2057
+ _PLACEHOLDER = re.compile(r"\{([^/{}]+)\}")
2058
+
2059
+
2060
+ def render_path(template: str, path_params: Mapping[str, Any]) -> str:
2061
+ """Fill `{name}` placeholders with percent-encoded values; refuse anything that changes the shape.
2062
+
2063
+ A value is one opaque path segment: an empty, `.` or `..` value (which
2064
+ would step out of the template) and a value containing `/` or `\\` (a
2065
+ server decoding `%2F` would traverse) are refused; other reserved
2066
+ characters are percent-encoded. Unknown or unfilled parameters are refused too.
2067
+ """
2068
+ names = _PLACEHOLDER.findall(template)
2069
+ unknown = sorted(set(path_params) - set(names))
2070
+ if unknown:
2071
+ raise ApiPolicyError(f"path {template!r} has no parameter(s) {', '.join(unknown)}.")
2072
+ missing = [n for n in names if n not in path_params]
2073
+ if missing:
2074
+ raise ApiPolicyError(f"path {template!r} needs value(s) for {', '.join(missing)}.")
2075
+
2076
+ def _fill(match: re.Match[str]) -> str:
2077
+ name = match.group(1)
2078
+ value = str(path_params[name])
2079
+ if value in ("", ".", "..") or value.strip() != value or "/" in value or "\\" in value:
2080
+ raise ApiPolicyError(
2081
+ f"path parameter {name}={value!r} is not a valid path segment (refused before sending)."
2082
+ )
2083
+ return quote(value, safe="")
2084
+
2085
+ return _PLACEHOLDER.sub(_fill, template)
2086
+
2087
+
2088
+ def validate_concrete_path(path: str) -> None:
2089
+ """Refuse a path that httpx would normalise or a server would resolve elsewhere.
2090
+
2091
+ Dot segments (`.`/`..`, also percent-encoded), an encoded slash or
2092
+ backslash inside a segment, empty segments (`//`), a `;` (also
2093
+ percent-encoded), a control character anywhere, or whitespace at either
2094
+ end of a segment or next to a dot (also percent-encoded:
2095
+ `segment_text_problem`, which lint applies to declared paths too), and
2096
+ a query or fragment in the path (send them through `params=`) are
2097
+ refused: the policy check would otherwise pass a template while the wire
2098
+ path lands on another endpoint (for example `/items/1/../../admin` ->
2099
+ `/admin`, `/orders/7/cancel;x=1`, which servers that strip path
2100
+ parameters route to `/orders/7/cancel`, `/orders/7/cancel%20` and
2101
+ `/orders/7/cancel%20.json`, which servers that trim a segment, or the
2102
+ name before its suffix, route there too, or `/orders/7%00/cancel`,
2103
+ which servers that end a path at a NUL route to `/orders/7`).
2104
+ """
2105
+ if not path.startswith("/"):
2106
+ raise ApiPolicyError(f"path {path!r} must start with /.")
2107
+ if "?" in path or "#" in path:
2108
+ raise ApiPolicyError(f"path {path!r} must not carry a query or fragment; use params=.")
2109
+ body = path[1:]
2110
+ if body.endswith("/"):
2111
+ body = body[:-1]
2112
+ if not body:
2113
+ return
2114
+ for segment in body.split("/"):
2115
+ decoded = unquote(segment)
2116
+ if segment == "":
2117
+ raise ApiPolicyError(f"path {path!r} contains an empty segment (refused).")
2118
+ if decoded in (".", ".."):
2119
+ raise ApiPolicyError(f"path {path!r} contains a dot segment (refused).")
2120
+ if "/" in decoded or "\\" in decoded:
2121
+ raise ApiPolicyError(f"path {path!r} contains an encoded slash (refused).")
2122
+ if ";" in decoded:
2123
+ raise ApiPolicyError(
2124
+ f"path {path!r} contains ';' (a path parameter, which some servers strip "
2125
+ "before routing): refused."
2126
+ )
2127
+ problem = segment_text_problem(decoded)
2128
+ if problem:
2129
+ raise ApiPolicyError(
2130
+ f"path {path!r} has a segment that {problem}, which some servers route to "
2131
+ "another endpoint: refused."
2132
+ )
2133
+
2134
+
2135
+ # ---------------------------------------------------------------------------
2136
+ # Limits (`limits: {max_calls_per_run, rate_per_minute}`)
2137
+ # ---------------------------------------------------------------------------
2138
+
2139
+ # A run's counters are dropped after this long without a call to a limited API
2140
+ # (and when the run ends, see `end_run`); at most MAX_TRACKED_RUNS runs are
2141
+ # tracked, the least recently active dropped first.
2142
+ RUN_COUNTER_TTL_S = 3600.0
2143
+ MAX_TRACKED_RUNS = 10_000
2144
+ # Calls made outside any run (no LangGraph run id, no request context) share
2145
+ # this one count: never looser than counting per run.
2146
+ NO_RUN = "<no run>"
2147
+
2148
+
2149
+ class CallLimiter:
2150
+ """Per-run call counts and per-API token buckets, in this process only."""
2151
+
2152
+ def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
2153
+ self._clock = clock
2154
+ self._lock = threading.Lock()
2155
+ # run key -> (last call time, {api name: calls})
2156
+ self._runs: OrderedDict[str, tuple[float, dict[str, int]]] = OrderedDict()
2157
+ # (api name, rate) -> (tokens, last refill time)
2158
+ self._buckets: dict[tuple[str, int], tuple[float, float]] = {}
2159
+
2160
+ def acquire(self, api: str, limits: Mapping[str, Any], run_key: str) -> str | None:
2161
+ """Count one call to `api` in `run_key`; the reason it is refused, or None."""
2162
+ max_calls = limits.get("max_calls_per_run")
2163
+ rate = limits.get("rate_per_minute")
2164
+ with self._lock:
2165
+ now = self._clock()
2166
+ self._evict(now)
2167
+ counts = self._runs.get(run_key, (now, {}))[1]
2168
+ if max_calls and counts.get(api, 0) >= max_calls:
2169
+ return (
2170
+ f"limits.max_calls_per_run ({max_calls}) reached: this run already made "
2171
+ f"{counts.get(api, 0)} call(s) to this API"
2172
+ )
2173
+ if rate:
2174
+ tokens, updated = self._buckets.get((api, rate), (float(rate), now))
2175
+ tokens = min(float(rate), tokens + (now - updated) * rate / 60.0)
2176
+ if tokens < 1.0:
2177
+ self._buckets[(api, rate)] = (tokens, now)
2178
+ wait = (1.0 - tokens) * 60.0 / rate
2179
+ return (
2180
+ f"limits.rate_per_minute ({rate}) exceeded in this process; "
2181
+ f"retry in {wait:.1f} s"
2182
+ )
2183
+ self._buckets[(api, rate)] = (tokens - 1.0, now)
2184
+ if max_calls:
2185
+ counts[api] = counts.get(api, 0) + 1
2186
+ self._runs[run_key] = (now, counts)
2187
+ self._runs.move_to_end(run_key)
2188
+ self._evict(now)
2189
+ return None
2190
+
2191
+ def end_run(self, run_key: str) -> None:
2192
+ with self._lock:
2193
+ self._runs.pop(run_key, None)
2194
+
2195
+ def tracked_runs(self) -> int:
2196
+ with self._lock:
2197
+ return len(self._runs)
2198
+
2199
+ def _evict(self, now: float) -> None:
2200
+ while self._runs:
2201
+ key, (seen, _counts) = next(iter(self._runs.items()))
2202
+ if len(self._runs) <= MAX_TRACKED_RUNS and now - seen <= RUN_COUNTER_TTL_S:
2203
+ break
2204
+ del self._runs[key]
2205
+
2206
+
2207
+ _limiter = CallLimiter()
2208
+
2209
+
2210
+ def reset_limits(clock: Callable[[], float] = time.monotonic) -> CallLimiter:
2211
+ """Start from empty counters (tests); returns the new limiter."""
2212
+ global _limiter
2213
+ _limiter = CallLimiter(clock)
2214
+ return _limiter
2215
+
2216
+
2217
+ def end_run(run_id: str | None) -> None:
2218
+ """Drop the call counts of a finished run (the chat runtime calls it)."""
2219
+ if run_id:
2220
+ _limiter.end_run(str(run_id))
2221
+
2222
+
2223
+ def current_run_id() -> str | None:
2224
+ """The id of the agent run making the call.
2225
+
2226
+ The LangGraph run's (`run_id` in the run's config metadata, which the chat
2227
+ runtime and LangGraph Server set), else the request context's (the
2228
+ `run_id`, then the `request_id`, bound for the current request's logs),
2229
+ else None.
2230
+ """
2231
+ try:
2232
+ from langgraph.config import get_config
2233
+
2234
+ config = get_config()
2235
+ except Exception: # outside a graph run
2236
+ config = None
2237
+ if isinstance(config, Mapping):
2238
+ for section in ("metadata", "configurable"):
2239
+ values = config.get(section)
2240
+ if isinstance(values, Mapping) and values.get("run_id"):
2241
+ return str(values["run_id"])
2242
+ try:
2243
+ from .telemetry import LOG_CONTEXT
2244
+ except ImportError: # loaded outside its package
2245
+ return None
2246
+ for name in ("run_id", "request_id"):
2247
+ value = LOG_CONTEXT[name].get()
2248
+ if value:
2249
+ return str(value)
2250
+ return None
2251
+
2252
+
2253
+ # ---------------------------------------------------------------------------
2254
+ # Human approval (`approval` in api-policy.yaml)
2255
+ # ---------------------------------------------------------------------------
2256
+
2257
+ # The `type` of the interrupt value a gated call raises, and of the resume value
2258
+ # the chat runtime answers it with.
2259
+ APPROVAL_INTERRUPT = "api_approval"
2260
+ APPROVAL_DECISION = "api_approval_decision"
2261
+ DECISION_APPROVE = "approve"
2262
+ DECISION_REJECT = "reject"
2263
+ DECISION_EXPIRED = "expired"
2264
+ # Not a decision: the answer a resume gives a paused call whose approval is still
2265
+ # pending (another call's decision resumed the run), so that call pauses again
2266
+ # for its own approval instead of being rebuilt under a policy that changed.
2267
+ DECISION_PENDING = "pending"
2268
+ # What a field named in `redact=` shows the approver instead of its value.
2269
+ REDACTED = "<redacted>"
2270
+ # How much of the model's text before a tool call the approval shows as its purpose.
2271
+ PURPOSE_MAX_CHARS = 500
2272
+ COMMENT_MAX_CHARS = 300
2273
+ _UNPRINTABLE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
2274
+
2275
+ # The graph-agents-cli A2A extension. In a message's metadata under this URI, a calling agent
2276
+ # puts `origin` (the user's own words it forwards: `text`, `truncated`, `hops`) and, in a
2277
+ # decision it relays, `approving` (the called agent's approval it is about, read from that
2278
+ # agent). An agent card that lists it in `capabilities.extensions` reads `origin`.
2279
+ A2A_ORIGIN_EXTENSION = "https://ss7172.github.io/graph-agents-cli/a2a/ext/origin/v1"
2280
+ ORIGIN_KEY = "origin"
2281
+ APPROVING_KEY = "approving"
2282
+ # The `type` of the data part an agent's failed (or refused) A2A task carries, with its `code`.
2283
+ A2A_ERROR_PART_TYPE = "error"
2284
+ # `A2A_ORIGIN_MAX_CHARS`: at most this much of the user's words is forwarded (and read).
2285
+ DEFAULT_A2A_ORIGIN_MAX_CHARS = 4000
2286
+
2287
+
2288
+ def origin_max_chars() -> int:
2289
+ """`A2A_ORIGIN_MAX_CHARS` (default 4000, at least 1); `SettingsError` otherwise."""
2290
+ from .limits import SettingsError
2291
+
2292
+ raw = (os.environ.get("A2A_ORIGIN_MAX_CHARS") or "").strip()
2293
+ if not raw:
2294
+ return DEFAULT_A2A_ORIGIN_MAX_CHARS
2295
+ try:
2296
+ value = int(raw)
2297
+ except ValueError:
2298
+ raise SettingsError(f"A2A_ORIGIN_MAX_CHARS={raw!r} is not a whole number.") from None
2299
+ if value < 1:
2300
+ raise SettingsError(f"A2A_ORIGIN_MAX_CHARS={value} must be at least 1.")
2301
+ return value
2302
+
2303
+
2304
+ # How deep a relayed approval may nest (`AUTH_MAX_DELEGATION_DEPTH` allows at most 8 agents).
2305
+ NESTED_MAX_DEPTH = 8
2306
+ # The keys of a relayed approval (`nested`) and of the call it holds; any other is dropped.
2307
+ NESTED_KEYS = (
2308
+ "agent",
2309
+ "approval_id",
2310
+ "decide_with",
2311
+ "digest",
2312
+ "reason",
2313
+ "expires_at",
2314
+ "reported_by",
2315
+ )
2316
+ NESTED_CALL_KEYS = (
2317
+ "api",
2318
+ "method",
2319
+ "path",
2320
+ "operation_id",
2321
+ "rpc_method",
2322
+ "a2a_operation",
2323
+ "query",
2324
+ "body",
2325
+ )
2326
+
2327
+
2328
+ def relayed_approval(body: Any, depth: int = 0) -> dict[str, Any] | None:
2329
+ """The approval a relayed A2A decision is about (`nested`), from the body it sends, or None.
2330
+
2331
+ Read from `params.message.metadata[A2A_ORIGIN_EXTENSION]["approving"]` of the
2332
+ JSON-RPC request (the approve message the relay tool builds from the called
2333
+ agent's own record of its approval), so the person approves exactly what is
2334
+ sent: the body is bound by the call hash. Only the known keys are kept, and
2335
+ a chain deeper than `NESTED_MAX_DEPTH` stops there.
2336
+ """
2337
+ if depth == 0:
2338
+ params = body.get("params") if isinstance(body, Mapping) else None
2339
+ message = params.get("message") if isinstance(params, Mapping) else None
2340
+ metadata = message.get("metadata") if isinstance(message, Mapping) else None
2341
+ extension = metadata.get(A2A_ORIGIN_EXTENSION) if isinstance(metadata, Mapping) else None
2342
+ body = extension.get(APPROVING_KEY) if isinstance(extension, Mapping) else None
2343
+ if not isinstance(body, Mapping) or depth >= NESTED_MAX_DEPTH:
2344
+ return None
2345
+ nested: dict[str, Any] = {key: body.get(key) for key in NESTED_KEYS}
2346
+ call = body.get("call")
2347
+ nested["call"] = (
2348
+ {key: call[key] for key in NESTED_CALL_KEYS if key in call}
2349
+ if isinstance(call, Mapping)
2350
+ else None
2351
+ )
2352
+ nested["nested"] = relayed_approval(body.get("nested"), depth + 1)
2353
+ return nested
2354
+
2355
+
2356
+ def approval_effect(nested: Mapping[str, Any] | None) -> dict[str, Any] | None:
2357
+ """The call that will actually happen when a relayed approval is approved (`effect`).
2358
+
2359
+ The innermost `nested` level's call, the agent that makes it, and `via`: the
2360
+ agents the decision passes through to get there, the called one first (a
2361
+ single hop: just that agent).
2362
+ """
2363
+ if not isinstance(nested, Mapping):
2364
+ return None
2365
+ via: list[Any] = []
2366
+ level: Mapping[str, Any] = nested
2367
+ while True:
2368
+ via.append(level.get("agent"))
2369
+ inner = level.get("nested")
2370
+ if not isinstance(inner, Mapping) or len(via) >= NESTED_MAX_DEPTH:
2371
+ break
2372
+ level = inner
2373
+ call = level.get("call") if isinstance(level.get("call"), Mapping) else {}
2374
+ effect: dict[str, Any] = {"agent": level.get("agent"), "via": via}
2375
+ for key in NESTED_CALL_KEYS:
2376
+ if key in call:
2377
+ effect[key] = call[key]
2378
+ effect["reason"] = level.get("reason")
2379
+ effect["expires_at"] = level.get("expires_at")
2380
+ return effect
2381
+
2382
+
2383
+ class BoundApproval(NamedTuple):
2384
+ """An approval the ledger recorded for a tool call: the call it was asked for and its state."""
2385
+
2386
+ api: str
2387
+ method: str
2388
+ path: str
2389
+ # "pending", "approved", "rejected" or "expired" (a pending one past its expiry).
2390
+ status: str
2391
+ # Whether its approved call was sent (an approval is used once).
2392
+ used: bool
2393
+ # A call to a JSON-RPC API: the request its body was (`derive_rpc`), part of which
2394
+ # call it is (`call_identity`). None for other calls.
2395
+ rpc_method: str | None = None
2396
+ a2a_operation: str | None = None
2397
+
2398
+
2399
+ class ApprovalLedger(Protocol):
2400
+ """Where approvals are recorded (the chat runtime's approvals table)."""
2401
+
2402
+ async def consume(
2403
+ self, approval_id: str, call_hash: str, thread_id: str | None = None
2404
+ ) -> str | None:
2405
+ """Mark the approval used for the request `call_hash` (of thread `thread_id`):
2406
+ None when it may be sent now (approved, for exactly this request on this
2407
+ thread, never used before), else why not."""
2408
+ ...
2409
+
2410
+ async def bound_approvals(
2411
+ self, *, tool_call: tuple[str, str] | None = None, interrupt_id: str | None = None
2412
+ ) -> list[BoundApproval]:
2413
+ """The approvals recorded for a tool call, newest first: the ones asked by the
2414
+ tool call `(message id, tool call id)` or by the interrupt `interrupt_id`,
2415
+ on whichever thread (a copied thread keeps its tool calls)."""
2416
+ ...
2417
+
2418
+
2419
+ _ledger: ApprovalLedger | None = None
2420
+
2421
+
2422
+ def set_approval_ledger(ledger: ApprovalLedger | None) -> None:
2423
+ """Install the process's approvals ledger (the chat runtime does it when it starts).
2424
+
2425
+ Without one, an approved call is refused (nothing sent): no approval can be
2426
+ checked for single use.
2427
+ """
2428
+ global _ledger
2429
+ _ledger = ledger
2430
+
2431
+
2432
+ def approval_ledger() -> ApprovalLedger | None:
2433
+ return _ledger
2434
+
2435
+
2436
+ # Headers that follow a request to the services it calls (see set_outbound_headers).
2437
+ _outbound_headers: Callable[[], Mapping[str, str]] | None = None
2438
+ # Whether every API receives them, not only those `propagates` names (see set_outbound_headers).
2439
+ _outbound_everywhere: Callable[[], bool] | None = None
2440
+
2441
+ # The `auth` modes whose APIs receive the correlation headers (see `propagates`): the
2442
+ # modes that act for the calling user, `forward` and `exchange` (a token exchanged for the
2443
+ # user's, RFC 8693: the owner's decision of 2026-09-28).
2444
+ PROPAGATING_AUTH_MODES = frozenset({"forward", "exchange"})
2445
+ # The protocols whose APIs receive them whatever their `auth`: `a2a`, another agent (an A2A
2446
+ # peer, the owner's decision of 2026-09-27: "only to A2A peers and auth: forward APIs").
2447
+ PROPAGATING_PROTOCOLS = frozenset({PROTOCOL_A2A})
2448
+
2449
+
2450
+ def set_outbound_headers(
2451
+ provider: Callable[[], Mapping[str, str]] | None,
2452
+ everywhere: Callable[[], bool] | None = None,
2453
+ ) -> None:
2454
+ """Install what adds the correlation headers to the calls that carry them.
2455
+
2456
+ The app installs `telemetry.outbound_trace_headers` (this request's
2457
+ `X-Request-ID` and, under OTLP tracing, its W3C trace context), so an agent
2458
+ this call reaches logs the same request id and continues the same trace.
2459
+ Only the APIs `propagates` names receive them; any other API receives
2460
+ neither. A header the tool sets itself wins, and a header a tool may not set
2461
+ (`forbidden_header`) is never added. The headers differ for every request,
2462
+ so they are not part of the request an approval binds (`canonical_call`).
2463
+ `everywhere` (the app installs `telemetry.propagate_to_every_api`,
2464
+ `PROPAGATE_TRACE_HEADERS=all`) says when every API receives them.
2465
+ """
2466
+ global _outbound_headers, _outbound_everywhere
2467
+ _outbound_headers = provider
2468
+ _outbound_everywhere = everywhere
2469
+
2470
+
2471
+ def propagates(api_settings: Mapping[str, Any]) -> bool:
2472
+ """Whether calls to this API carry the request's correlation headers.
2473
+
2474
+ Only the APIs that are part of the same request do: another agent
2475
+ (`protocol: a2a`, `PROPAGATING_PROTOCOLS`), whatever its `auth`, and the
2476
+ APIs that act for the calling user (`PROPAGATING_AUTH_MODES`), `auth:
2477
+ forward` and `auth: exchange` ones, as another agent does when it is
2478
+ reached with the caller's own credential or a token exchanged for it. Any
2479
+ other API (`auth: bearer` or `none` over `http` or `jsonrpc`) is a third
2480
+ party that never learns this request's id or trace. This is the one place
2481
+ that decides under `PROPAGATE_TRACE_HEADERS=peers` (the default); `all`
2482
+ sends the headers to every API, and `off` to none.
2483
+ """
2484
+ return (
2485
+ api_settings.get("auth") in PROPAGATING_AUTH_MODES
2486
+ or api_protocol(api_settings) in PROPAGATING_PROTOCOLS
2487
+ )
2488
+
2489
+
2490
+ def outbound_headers(api_settings: Mapping[str, Any]) -> dict[str, str]:
2491
+ """The correlation headers for a call to this API made now: empty for an API that
2492
+ does not receive them (`propagates`) or without a provider."""
2493
+ provider, everywhere = _outbound_headers, _outbound_everywhere
2494
+ if provider is None:
2495
+ return {}
2496
+ try:
2497
+ if not (propagates(api_settings) or (everywhere is not None and everywhere())):
2498
+ return {}
2499
+ headers = {str(name): str(value) for name, value in dict(provider()).items()}
2500
+ except Exception as exc: # correlation never stops a call
2501
+ logger.warning("api call: no correlation headers (%s)", type(exc).__name__)
2502
+ return {}
2503
+ return {name: value for name, value in headers.items() if not forbidden_header(name)}
2504
+
2505
+
2506
+ def _run_thread_id() -> str | None:
2507
+ """The thread of the current graph run (its `configurable.thread_id`), if any."""
2508
+ try:
2509
+ from langgraph.config import get_config
2510
+
2511
+ thread_id = (get_config().get("configurable") or {}).get("thread_id")
2512
+ except Exception: # outside a graph run
2513
+ return None
2514
+ return str(thread_id) if thread_id else None
2515
+
2516
+
2517
+ @dataclass
2518
+ class ToolCallScope:
2519
+ """The tool call a gated request is made for (set by the agent's middleware)."""
2520
+
2521
+ name: str
2522
+ call_id: str | None = None
2523
+ # The id of the model message that made the tool call: with `call_id`, it names
2524
+ # this tool call in the approvals ledger (a model may reuse call ids across turns).
2525
+ message_id: str | None = None
2526
+ # The text the model wrote with the tool call: its stated purpose, when any.
2527
+ purpose: str | None = None
2528
+ # An approved call was sent in this tool call (a second gated call is refused).
2529
+ gated_sent: bool = False
2530
+ # How many of the task's resume values this tool call took (`interrupt()` calls).
2531
+ resumes_taken: int = 0
2532
+ # The calls (`call_identity`) a decision stopped in this tool call.
2533
+ stopped: set[tuple[str, ...]] = field(default_factory=set)
2534
+
2535
+
2536
+ _TOOL_CALL: ContextVar[ToolCallScope | None] = ContextVar("api_tool_call", default=None)
2537
+ # The same, for a tool run without the middleware's scope (in its own context).
2538
+ _GATED_SENT: ContextVar[bool] = ContextVar("api_gated_sent", default=False)
2539
+ _RESUMES_TAKEN: ContextVar[int] = ContextVar("api_resumes_taken", default=0)
2540
+ _STOPPED: ContextVar[frozenset[tuple[str, ...]]] = ContextVar(
2541
+ "api_stopped_calls", default=frozenset()
2542
+ )
2543
+
2544
+
2545
+ def call_identity(
2546
+ api: str,
2547
+ method: str,
2548
+ path: str,
2549
+ rpc_method: str | None = None,
2550
+ a2a_operation: str | None = None,
2551
+ ) -> tuple[str, ...]:
2552
+ """Which call a decision is bound to: the API, the method and the path sent.
2553
+
2554
+ The path is compared normalised and ignoring letter case, as a gate
2555
+ compares it, so a rebuilt request that differs only in spelling is the
2556
+ same call (a body or query that changed is caught by the call hash).
2557
+
2558
+ A call to a JSON-RPC API (`protocol: jsonrpc|a2a`) is also known by the
2559
+ request its body is (`derive_rpc`): its JSON-RPC method and A2A decision.
2560
+ Every call to such an API shares one method and path, so without them a
2561
+ `GetTask` the resumed tool sends first would take the decision an approve
2562
+ message waits for, and a call a rejection stopped would stop the `reject`
2563
+ message that tells the other agent. A call to an `http` API keeps the
2564
+ three fields: its label is the tool's, not the request's.
2565
+ """
2566
+ identity: tuple[str, ...] = (
2567
+ str(api),
2568
+ str(method).upper(),
2569
+ normalize_path(str(path)).casefold(),
2570
+ )
2571
+ if rpc_method is None and a2a_operation is None:
2572
+ return identity
2573
+ return (*identity, str(rpc_method or ""), str(a2a_operation or ""))
2574
+
2575
+
2576
+ def _is_decision(value: Any) -> bool:
2577
+ return isinstance(value, Mapping) and value.get("type") == APPROVAL_DECISION
2578
+
2579
+
2580
+ def _decision_identity(decision: Mapping[str, Any]) -> tuple[str, ...] | None:
2581
+ api, method, path = decision.get("api"), decision.get("method"), decision.get("path")
2582
+ if not (isinstance(api, str) and isinstance(method, str) and isinstance(path, str)):
2583
+ return None
2584
+ rpc_method, a2a_operation = decision.get(RPC_METHOD_KEY), decision.get(A2A_OPERATION_KEY)
2585
+ if not all(value is None or isinstance(value, str) for value in (rpc_method, a2a_operation)):
2586
+ return None
2587
+ return call_identity(api, method, path, rpc_method, a2a_operation)
2588
+
2589
+
2590
+ # Where LangGraph keeps a task's resume values in the run config (its interrupt()
2591
+ # reads them there); the constant's module is internal, so it is looked up lazily.
2592
+ _SCRATCHPAD_KEY_FALLBACK = "__pregel_scratchpad"
2593
+
2594
+
2595
+ def _scratchpad_key() -> str:
2596
+ try:
2597
+ from langgraph._internal._constants import CONFIG_KEY_SCRATCHPAD
2598
+ except ImportError: # moved in another LangGraph version: the name it has always had
2599
+ return _SCRATCHPAD_KEY_FALLBACK
2600
+ return str(CONFIG_KEY_SCRATCHPAD)
2601
+
2602
+
2603
+ def _next_resume_value(taken: int) -> Any:
2604
+ """What the task's next `interrupt()` returns, without taking it; None when nothing.
2605
+
2606
+ The resume values of the current LangGraph task (the decisions a resumed
2607
+ run brought for its paused calls) in the order `interrupt()` hands them
2608
+ out; `taken` is how many this tool call took already. Outside a LangGraph
2609
+ task (no run, or a tool invoked directly) there are none. A task whose
2610
+ resume values cannot be read (another LangGraph version) is refused: a
2611
+ decision waiting there could not be honoured.
2612
+ """
2613
+ try:
2614
+ from langgraph.config import get_config
2615
+
2616
+ configurable = get_config().get("configurable") or {}
2617
+ except Exception: # outside a graph run
2618
+ return None
2619
+ scratchpad = configurable.get(_scratchpad_key()) if isinstance(configurable, Mapping) else None
2620
+ if scratchpad is None:
2621
+ return None
2622
+ try:
2623
+ resumed = list(scratchpad.resume)
2624
+ if taken < len(resumed):
2625
+ return resumed[taken]
2626
+ return scratchpad.get_null_resume(False) if taken == len(resumed) else None
2627
+ except (AttributeError, TypeError) as exc:
2628
+ raise ApiPolicyError(
2629
+ "cannot read the run's approval decisions (an unsupported LangGraph version): "
2630
+ "refused, nothing was sent.",
2631
+ reason="approval decisions unreadable",
2632
+ ) from exc
2633
+
2634
+
2635
+ def _task_interrupt_id() -> str | None:
2636
+ """The id an `interrupt()` of the current LangGraph task gets (None outside a task).
2637
+
2638
+ LangGraph derives it from the task's checkpoint namespace, so the task a
2639
+ run continues without input interrupts with the same id as when it paused.
2640
+ """
2641
+ try:
2642
+ from langgraph.config import get_config
2643
+ from langgraph.types import Interrupt
2644
+
2645
+ namespace = (get_config().get("configurable") or {}).get("checkpoint_ns")
2646
+ if not isinstance(namespace, str) or not namespace:
2647
+ return None
2648
+ return str(Interrupt.from_ns(None, namespace).id)
2649
+ except Exception: # outside a graph run, or another LangGraph version
2650
+ return None
2651
+
2652
+
2653
+ def _resumes_taken() -> int:
2654
+ scope = _TOOL_CALL.get()
2655
+ return scope.resumes_taken if scope is not None else _RESUMES_TAKEN.get()
2656
+
2657
+
2658
+ def _took_resume() -> None:
2659
+ scope = _TOOL_CALL.get()
2660
+ if scope is not None:
2661
+ scope.resumes_taken += 1
2662
+ else:
2663
+ _RESUMES_TAKEN.set(_RESUMES_TAKEN.get() + 1)
2664
+
2665
+
2666
+ def _stopped_calls() -> frozenset[tuple[str, ...]] | set[tuple[str, ...]]:
2667
+ scope = _TOOL_CALL.get()
2668
+ return scope.stopped if scope is not None else _STOPPED.get()
2669
+
2670
+
2671
+ def _stop_call(identity: tuple[str, ...]) -> None:
2672
+ scope = _TOOL_CALL.get()
2673
+ if scope is not None:
2674
+ scope.stopped.add(identity)
2675
+ else:
2676
+ _STOPPED.set(_STOPPED.get() | {identity})
2677
+
2678
+
2679
+ def _decision_waiting(identity: tuple[str, ...]) -> Mapping[str, Any] | None:
2680
+ """The decision the resumed run brought for this call, when the next resume value is one.
2681
+
2682
+ A request the tool rebuilds on resume is the paused call when it has the
2683
+ paused call's API, method and path, and on a JSON-RPC API its JSON-RPC
2684
+ method and A2A decision (`call_identity`); the decision then applies to
2685
+ it whatever the policy now says about gating it.
2686
+ """
2687
+ waiting = _next_resume_value(_resumes_taken())
2688
+ if _is_decision(waiting) and _decision_identity(waiting) == identity:
2689
+ return waiting
2690
+ return None
2691
+
2692
+
2693
+ def _decided_with(value: Mapping[str, Any]) -> tuple[str, tuple[str, ...]]:
2694
+ """`(decide_with, relayers)` an interrupt or a decision carries (`direct` when absent: a
2695
+ call paused before 0.3)."""
2696
+ decide_with = value.get("decide_with")
2697
+ relayers = value.get("relayers")
2698
+ return (
2699
+ decide_with if isinstance(decide_with, str) else DEFAULT_DECIDE_WITH,
2700
+ tuple(str(r) for r in relayers) if isinstance(relayers, list | tuple) else (),
2701
+ )
2702
+
2703
+
2704
+ def _context_actor() -> str | None:
2705
+ """The agent the current run acts through (its principal's `@actor`), or None."""
2706
+ context = current_context()
2707
+ attributes = getattr(context, "attributes", None)
2708
+ if attributes is None and isinstance(context, Mapping):
2709
+ attributes = context.get("attributes")
2710
+ actor = attributes.get("@actor") if isinstance(attributes, Mapping) else None
2711
+ actor_id = actor.get("id") if isinstance(actor, Mapping) else None
2712
+ return actor_id if isinstance(actor_id, str) and actor_id else None
2713
+
2714
+
2715
+ def _bound_refusal(bound: list[BoundApproval]) -> tuple[str, str]:
2716
+ """What the model reads, and the log reason, for a call refused by its recorded approval.
2717
+
2718
+ `bound` is newest first; a used approval wins (the call was sent once).
2719
+ """
2720
+ record = next((b for b in bound if b.used), bound[0])
2721
+ if record.used:
2722
+ return (
2723
+ "was sent already with its approval, which is used once (the tool call ran "
2724
+ "again without a new decision)",
2725
+ "approval already used",
2726
+ )
2727
+ if record.status == "rejected":
2728
+ return "was not approved: an approver rejected it", "approval rejected"
2729
+ if record.status == "expired":
2730
+ return (
2731
+ "was not approved: the approval request expired before anyone decided",
2732
+ "approval expired",
2733
+ )
2734
+ if record.status == "approved":
2735
+ return (
2736
+ "was approved, but only the run its decision resumed may send it (the tool "
2737
+ "call ran again without that decision)",
2738
+ "approval not resumed",
2739
+ )
2740
+ return (
2741
+ "needs human approval, and the run was resumed without an approval decision",
2742
+ "resumed without a decision",
2743
+ )
2744
+
2745
+
2746
+ def _plain(text: Any, limit: int) -> str | None:
2747
+ """`text` as one printable line of at most `limit` characters, or None when empty."""
2748
+ if isinstance(text, list):
2749
+ text = "".join(
2750
+ str(block.get("text", "")) if isinstance(block, Mapping) else str(block)
2751
+ for block in text
2752
+ )
2753
+ if not isinstance(text, str):
2754
+ return None
2755
+ cleaned = " ".join(_UNPRINTABLE.sub("", text).split())
2756
+ if not cleaned:
2757
+ return None
2758
+ return cleaned if len(cleaned) <= limit else cleaned[: limit - 3].rstrip() + "..."
2759
+
2760
+
2761
+ def _field(message: Any, name: str) -> Any:
2762
+ return message.get(name) if isinstance(message, Mapping) else getattr(message, name, None)
2763
+
2764
+
2765
+ def calling_message(messages: Iterable[Any], call_id: str | None) -> Any:
2766
+ """The assistant message that made tool call `call_id` (the latest one), or None."""
2767
+ if not call_id:
2768
+ return None
2769
+ for message in reversed(list(messages or [])):
2770
+ if any(
2771
+ (c.get("id") if isinstance(c, Mapping) else getattr(c, "id", None)) == call_id
2772
+ for c in _field(message, "tool_calls") or []
2773
+ ):
2774
+ return message
2775
+ return None
2776
+
2777
+
2778
+ def stated_purpose(messages: Iterable[Any], call_id: str | None) -> str | None:
2779
+ """The text of the assistant message that made tool call `call_id`, when it wrote any.
2780
+
2781
+ This is the model's own account of why it makes the call (the default
2782
+ system prompt asks for one before an action that needs approval). It is
2783
+ shown to the approver as the model's statement, beside the exact request.
2784
+ """
2785
+ message = calling_message(messages, call_id)
2786
+ return None if message is None else _plain(_field(message, "content"), PURPOSE_MAX_CHARS)
2787
+
2788
+
2789
+ @contextlib.contextmanager
2790
+ def tool_call_scope(request: Any) -> Iterator[ToolCallScope]:
2791
+ """Name the tool call a middleware is about to run (`request` is its ToolCallRequest).
2792
+
2793
+ A gated request made inside names that tool, and the model's stated
2794
+ purpose, in its approval.
2795
+ """
2796
+ call = getattr(request, "tool_call", None) or {}
2797
+ state = getattr(request, "state", None)
2798
+ messages = state.get("messages") if isinstance(state, Mapping) else None
2799
+ call_id = call.get("id") if isinstance(call, Mapping) else None
2800
+ message = calling_message(messages or [], call_id)
2801
+ message_id = _field(message, "id") if message is not None else None
2802
+ scope = ToolCallScope(
2803
+ name=str((call.get("name") if isinstance(call, Mapping) else "") or ""),
2804
+ call_id=str(call_id) if call_id else None,
2805
+ message_id=str(message_id) if message_id else None,
2806
+ purpose=None if message is None else _plain(_field(message, "content"), PURPOSE_MAX_CHARS),
2807
+ )
2808
+ token = _TOOL_CALL.set(scope)
2809
+ try:
2810
+ yield scope
2811
+ finally:
2812
+ _TOOL_CALL.reset(token)
2813
+
2814
+
2815
+ def redact_fields(value: Any, names: frozenset[str]) -> Any:
2816
+ """`value` with the value of every key in `names` (any depth, any letter case) masked."""
2817
+ if not names:
2818
+ return value
2819
+ if isinstance(value, Mapping):
2820
+ return {
2821
+ key: REDACTED if str(key).casefold() in names else redact_fields(item, names)
2822
+ for key, item in value.items()
2823
+ }
2824
+ if isinstance(value, list | tuple):
2825
+ return [redact_fields(item, names) for item in value]
2826
+ return value
2827
+
2828
+
2829
+ def _query_view(query: httpx.QueryParams | None, names: frozenset[str]) -> dict[str, Any]:
2830
+ """The query as the approver sees it: a repeated key lists its values."""
2831
+ view: dict[str, Any] = {}
2832
+ for key, value in query.multi_items() if query is not None else ():
2833
+ shown = REDACTED if key.casefold() in names else value
2834
+ if key not in view:
2835
+ view[key] = shown
2836
+ elif isinstance(view[key], list):
2837
+ view[key].append(shown)
2838
+ else:
2839
+ view[key] = [view[key], shown]
2840
+ return view
2841
+
2842
+
2843
+ def canonical_call(
2844
+ api: str,
2845
+ method: str,
2846
+ url: httpx.URL | str,
2847
+ query: httpx.QueryParams | None,
2848
+ json_body: Any,
2849
+ operation_id: str | None,
2850
+ headers: Iterable[tuple[str, str]] = (),
2851
+ ) -> dict[str, Any]:
2852
+ """Everything that decides what a request does, in one comparable form.
2853
+
2854
+ The URL carries the rendered path under the API's base URL; the query keeps
2855
+ the order it is sent in; `headers` are the tool's own (the policy's
2856
+ credential is not part of the call: it may be renewed between the approval
2857
+ and the send).
2858
+ """
2859
+ return {
2860
+ "api": api,
2861
+ "method": method.upper(),
2862
+ "url": str(url),
2863
+ "query": [[key, value] for key, value in (query.multi_items() if query else ())],
2864
+ "body": json_body,
2865
+ "operation_id": operation_id or None,
2866
+ "headers": sorted([name.lower(), value] for name, value in headers),
2867
+ }
2868
+
2869
+
2870
+ def call_hash(call: Mapping[str, Any]) -> str:
2871
+ """SHA-256 of `canonical_call`: equal exactly when the requests are the same.
2872
+
2873
+ Raises `TypeError` or `ValueError` for a body that is not plain JSON (a NaN,
2874
+ an object JSON has no form for).
2875
+ """
2876
+ text = json.dumps(
2877
+ call, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False
2878
+ )
2879
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
2880
+
2881
+
2882
+ @dataclass
2883
+ class PreparedRequest:
2884
+ """A request that passed every policy check (`ApiClient._prepare`)."""
2885
+
2886
+ wire_path: str
2887
+ url: httpx.URL
2888
+ query: httpx.QueryParams | None
2889
+ headers: httpx.Headers
2890
+ # The tool's own headers (without the policy's credential), bound by an approval.
2891
+ tool_headers: list[tuple[str, str]]
2892
+ secrets: tuple[str, ...]
2893
+ gate: ApprovalGate | None
2894
+ # What a request to a JSON-RPC API is, read from its body (`derive_rpc`).
2895
+ rpc: RpcCall = field(default_factory=RpcCall)
2896
+
2897
+
2898
+ @dataclass(frozen=True)
2899
+ class SubjectToken:
2900
+ """The caller's own verified bearer token, as the auth policy kept it.
2901
+
2902
+ `credentials["@subject_token"]`, with its audiences (`@subject_aud`) and
2903
+ expiry (`@subject_exp`, epoch seconds; None when unknown). What `auth:
2904
+ exchange` exchanges and `auth: forward` with `forward_audience` forwards.
2905
+ """
2906
+
2907
+ # Out of repr: a repr ends up in logs and tracebacks.
2908
+ token: str = field(repr=False)
2909
+ audience: tuple[str, ...] = ()
2910
+ expires_at: float | None = None
2911
+
2912
+
2913
+ # Where the delegation-carrying APIs send the caller's identity: `forward` and
2914
+ # `exchange` in `forward_header`, `bearer` in Authorization.
2915
+ _CREDENTIAL_HEADER_MODES = ("bearer", *HEADER_AUTH_MODES)
2916
+ # Hosts a credential-carrying call to another agent may reach over plain http outside
2917
+ # APP_ENV=dev: loopback, a single-label service name, a cluster-internal name.
2918
+ _INTERNAL_SUFFIXES = (".svc", ".svc.cluster.local")
2919
+
2920
+
2921
+ def internal_host(host: str) -> bool:
2922
+ """Whether `host` is loopback, a single-label name or a cluster-internal (`.svc`) name."""
2923
+ host = host.strip("[]").lower().rstrip(".")
2924
+ if host == "localhost" or ("." not in host and ":" not in host):
2925
+ return True
2926
+ if host.endswith(_INTERNAL_SUFFIXES):
2927
+ return True
2928
+ try:
2929
+ return ipaddress.ip_address(host).is_loopback
2930
+ except ValueError:
2931
+ return False
2932
+
2933
+
2934
+ def peer_transport_problem(settings: Mapping[str, Any], url: httpx.URL, env: str) -> str | None:
2935
+ """Why a call to another agent may not use this base URL, or None.
2936
+
2937
+ Outside `APP_ENV=dev`, an A2A peer (`protocol: a2a`) reached with a
2938
+ credential (`bearer`, `forward` or `exchange`) refuses a plain http base
2939
+ URL unless the host is internal (`internal_host`). Other APIs are not
2940
+ checked (a later release may extend it).
2941
+ """
2942
+ if settings.get("protocol") != "a2a" or settings.get("auth") not in _CREDENTIAL_HEADER_MODES:
2943
+ return None
2944
+ if url.scheme != "http" or os.environ.get("APP_ENV") == "dev" or internal_host(url.host):
2945
+ return None
2946
+ return f"{env} must use https outside APP_ENV=dev to carry credentials"
2947
+
2948
+
2949
+ # ---------------------------------------------------------------------------
2950
+ # The client
2951
+ # ---------------------------------------------------------------------------
2952
+
2953
+
2954
+ class ApiClient:
2955
+ """Policy-enforcing async HTTP client for one declared API."""
2956
+
2957
+ def __init__(
2958
+ self,
2959
+ policy: ApiPolicy,
2960
+ name: str,
2961
+ *,
2962
+ credential: str | None = None,
2963
+ transport: httpx.AsyncBaseTransport | None = None,
2964
+ run_id: str | None = None,
2965
+ subject: SubjectToken | None = None,
2966
+ actor_chain: tuple[str, ...] = (),
2967
+ ) -> None:
2968
+ self.policy = policy
2969
+ self.name = name
2970
+ self.settings = policy.api(name)
2971
+ self._credential = credential
2972
+ self._transport = transport
2973
+ self._run_id = run_id
2974
+ # `auth: exchange`: the caller's own token, exchanged just before sending.
2975
+ self._subject = subject
2976
+ # The agents the request came through, current first (loop refusal).
2977
+ self._actor_chain = tuple(actor_chain)
2978
+
2979
+ # -- configuration ------------------------------------------------------
2980
+
2981
+ def base_url(self) -> httpx.URL:
2982
+ env = self.settings["base_url_env"]
2983
+ raw = os.environ.get(env, "").strip()
2984
+ if not raw:
2985
+ raise ApiCallError(f"{env} is not set; cannot reach API {self.name!r}.")
2986
+ try:
2987
+ url = httpx.URL(raw)
2988
+ except httpx.InvalidURL as exc:
2989
+ raise ApiCallError(f"{env} is not a valid URL: {exc}") from exc
2990
+ if url.scheme not in ("http", "https") or not url.host:
2991
+ raise ApiCallError(f"{env} must be an absolute http(s) URL.")
2992
+ if url.query or url.fragment or url.userinfo:
2993
+ raise ApiCallError(f"{env} must not carry credentials, a query or a fragment.")
2994
+ problem = peer_transport_problem(self.settings, url, env)
2995
+ if problem:
2996
+ raise ApiCallError(f"{problem}; nothing was sent to API {self.name!r}.")
2997
+ return url
2998
+
2999
+ def credential_header(self) -> str | None:
3000
+ """The header the policy's credential goes in (None: `auth: none`).
3001
+
3002
+ A tool header of that name is never sent nor bound by an approval: the
3003
+ policy's credential replaces it.
3004
+ """
3005
+ mode = self.settings["auth"]
3006
+ if mode in HEADER_AUTH_MODES:
3007
+ return str(self.settings.get("forward_header") or DEFAULT_FORWARD_HEADER)
3008
+ return "Authorization" if mode == "bearer" else None
3009
+
3010
+ def delegation_target(self) -> str | None:
3011
+ """The agent (audience) a call to this API acts for the caller at, or None.
3012
+
3013
+ `exchange.audience` for `auth: exchange`, `forward_audience` for `auth:
3014
+ forward`: the calls that carry the caller's identity onward.
3015
+ """
3016
+ exchange = self.settings.get(EXCHANGE_KEY)
3017
+ if self.settings["auth"] == "exchange" and isinstance(exchange, Mapping):
3018
+ return str(exchange.get("audience") or "") or None
3019
+ if self.settings["auth"] == "forward" and self.settings.get("forward_audience"):
3020
+ return str(self.settings["forward_audience"])
3021
+ return None
3022
+
3023
+ def check_loop(self) -> None:
3024
+ """Refuse (`ApiPolicyError`) a call that would come back to an agent of this request.
3025
+
3026
+ That is this agent itself, or an agent already in the request's
3027
+ delegation chain (A -> B -> A), named by the API's delegation target.
3028
+ """
3029
+ target = self.delegation_target()
3030
+ if target is None:
3031
+ return
3032
+ try:
3033
+ from .token_exchange import loop_problem
3034
+ except ImportError: # loaded outside its package: fail closed
3035
+ raise ApiPolicyError(
3036
+ f"{self.name}: the delegation loop check is unavailable; nothing was sent.",
3037
+ reason="delegation loop check unavailable",
3038
+ ) from None
3039
+ problem = loop_problem(target, self._actor_chain)
3040
+ if problem:
3041
+ raise ApiPolicyError(
3042
+ f"{self.name}: refused: {problem}; nothing was sent.", reason="delegation loop"
3043
+ )
3044
+
3045
+ async def exchanged_credential(self) -> tuple[str, str] | None:
3046
+ """`(header, "Bearer <token>")` for an `auth: exchange` API; None for any other.
3047
+
3048
+ The caller's own token exchanged for one minted for the API's
3049
+ `exchange.audience` (`token_exchange.py`: cached, single flight, failures
3050
+ remembered briefly). Raises `ApiCallError` (nothing sent) when the run
3051
+ has no user token, it has expired, the issuer refuses or fails, or the
3052
+ token names no actor and the API does not set `exchange.allow_actorless`.
3053
+ """
3054
+ if self.settings["auth"] != "exchange":
3055
+ return None
3056
+ if self._subject is None:
3057
+ raise ApiCallError(
3058
+ f"API {self.name!r} uses auth: exchange, but this run has no user token to "
3059
+ "exchange (shared-bearer, or a run resumed by another principal); nothing was "
3060
+ "sent.",
3061
+ reason="no user token to exchange",
3062
+ )
3063
+ try:
3064
+ from .token_exchange import TokenExchangeError, exchanger
3065
+ except ImportError: # loaded outside its package: fail closed
3066
+ raise ApiCallError(
3067
+ f"API {self.name!r} uses auth: exchange, but token exchange is unavailable; "
3068
+ "nothing was sent.",
3069
+ reason="token exchange unavailable",
3070
+ ) from None
3071
+ exchange = self.settings[EXCHANGE_KEY]
3072
+ try:
3073
+ token = await exchanger().token(
3074
+ self.name,
3075
+ self._subject.token,
3076
+ audience=str(exchange["audience"]),
3077
+ scope=exchange.get("scope"),
3078
+ resource=exchange.get("resource"),
3079
+ subject_expires_at=self._subject.expires_at,
3080
+ allow_actorless=exchange.get(ALLOW_ACTORLESS_KEY) is True,
3081
+ )
3082
+ except TokenExchangeError as exc:
3083
+ raise ApiCallError(str(exc), reason=exc.reason) from None
3084
+ return self.credential_header() or DEFAULT_FORWARD_HEADER, f"Bearer {token}"
3085
+
3086
+ def auth_headers(self) -> dict[str, str]:
3087
+ mode = self.settings["auth"]
3088
+ if mode == "bearer":
3089
+ env = self.settings["token_env"]
3090
+ token = os.environ.get(env, "")
3091
+ if not token:
3092
+ raise ApiCallError(
3093
+ f"{env} is not set (API {self.name!r} uses auth: bearer); nothing was sent."
3094
+ )
3095
+ return {"Authorization": f"Bearer {token}"}
3096
+ if mode == "forward":
3097
+ if not self._credential:
3098
+ raise ApiCallError(
3099
+ f"the caller has no credential for API {self.name!r} "
3100
+ f"(principal.attributes['credentials'][{self.name!r}]); nothing was sent."
3101
+ )
3102
+ header = self.settings.get("forward_header") or DEFAULT_FORWARD_HEADER
3103
+ return {header: self._credential}
3104
+ return {}
3105
+
3106
+ def timeout(self) -> httpx.Timeout:
3107
+ timeouts = dict(DEFAULT_TIMEOUTS_MS)
3108
+ timeouts.update(self.settings.get("timeouts_ms") or {})
3109
+ return httpx.Timeout(
3110
+ connect=timeouts["connect"] / 1000,
3111
+ read=timeouts["read"] / 1000,
3112
+ write=10.0,
3113
+ pool=10.0,
3114
+ )
3115
+
3116
+ def query(self, params: Any) -> httpx.QueryParams | None:
3117
+ """`params` as the query that will be sent, with `pagination.max_page_size` enforced.
3118
+
3119
+ `params` may be anything httpx accepts (a mapping, a list of pairs or a
3120
+ query string); it is converted once and the converted query is what is
3121
+ sent. Every value of the page-size parameter is checked, whatever the
3122
+ letter case of its name, and each must be a plain number from 1 to the cap.
3123
+ A `_method` parameter (a method override) is refused.
3124
+ """
3125
+ if params is None:
3126
+ return None
3127
+ try:
3128
+ query = httpx.QueryParams(params)
3129
+ except (TypeError, ValueError) as exc:
3130
+ raise ApiPolicyError(f"{self.name}: params are not a valid query ({exc}).") from exc
3131
+ for key in query:
3132
+ if key.casefold() == METHOD_OVERRIDE_PARAM:
3133
+ raise ApiPolicyError(
3134
+ f"{self.name}: the query parameter {key!r} refused: it overrides the "
3135
+ "request method on some servers, and the policy checks the method sent."
3136
+ )
3137
+ pagination = self.settings.get("pagination")
3138
+ if not pagination:
3139
+ return query
3140
+ param = pagination["page_size_param"]
3141
+ cap = pagination["max_page_size"]
3142
+ for key, value in query.multi_items():
3143
+ if key.casefold() != param.casefold():
3144
+ continue
3145
+ digits = value.isascii() and value.isdigit() and len(value) <= len(str(cap))
3146
+ if not (digits and 1 <= int(value) <= cap):
3147
+ raise ApiPolicyError(
3148
+ f"{self.name}: {key}={value!r} refused: page size must be 1-{cap} "
3149
+ "(the API's pagination.max_page_size)."
3150
+ )
3151
+ return query
3152
+
3153
+ def take_limits(self, method: str, what: str) -> None:
3154
+ """Count the call against the API's `limits`; `ApiPolicyError` when one is exceeded."""
3155
+ limits = self.settings.get("limits")
3156
+ if not limits:
3157
+ return
3158
+ run_key = self._run_id or current_run_id() or NO_RUN
3159
+ reason = _limiter.acquire(self.name, limits, run_key)
3160
+ if reason:
3161
+ raise ApiPolicyError(
3162
+ f"{self.name}: {method} {what} refused by the API policy: {reason}.", reason=reason
3163
+ )
3164
+
3165
+ # -- requests -------------------------------------------------------------
3166
+
3167
+ async def request(
3168
+ self,
3169
+ method: str,
3170
+ path: str,
3171
+ *,
3172
+ operation_id: str | None = None,
3173
+ path_params: Mapping[str, Any] | None = None,
3174
+ params: Any = None,
3175
+ json_body: Any = None,
3176
+ headers: Mapping[str, str] | None = None,
3177
+ redact: Iterable[str] = (),
3178
+ ) -> Any:
3179
+ """Send `method` on `path` after the policy check; return the JSON body or the text.
3180
+
3181
+ Pass `path` as the template declared in `API_CALLS` (for example
3182
+ `/orders/{order_id}`) with the values in `path_params`: the policy is
3183
+ checked against the template and against the rendered path, and the
3184
+ client encodes each value as one segment, so model-chosen input cannot
3185
+ change which endpoint is hit. A concrete `path` is accepted too but
3186
+ validated (no dot segments, encoded slashes, empty segments, query or
3187
+ fragment); policy paths match it after decoding percent-encoded
3188
+ unreserved characters and ignoring one trailing slash, and denials
3189
+ also ignore letter case. A denial that pins a path refuses every call
3190
+ to that path whatever `operation_id` it names; name `operation_id`
3191
+ whenever the API has a denial by `operationId` alone, which refuses a
3192
+ call without one.
3193
+ `params` (a mapping, a list of pairs or a query string) is checked
3194
+ against `pagination.max_page_size`. The API's base URL may carry a
3195
+ path prefix (`https://host/v2`); `path` is joined under it.
3196
+ `json_body` is sent as JSON with any method the policy allows. The
3197
+ API's `limits` are counted last, just before sending. Redirects are
3198
+ never followed. With `limits.max_response_bytes`, the body is read
3199
+ only up to that many bytes (decoded): past it the response is
3200
+ discarded and `ApiCallError` raised; the call asks for gzip or deflate
3201
+ at most, and an answer in another content encoding is refused unread
3202
+ (`ApiCallError`). An empty response body returns "". Raises
3203
+ `ApiPolicyError` (nothing sent) or `ApiCallError` (with `status_code`
3204
+ and a bounded `body` for a non-2xx response).
3205
+
3206
+ `headers` may not change where the request goes or which method the
3207
+ server applies: `Host`, method-override (`X-HTTP-Method-Override`,
3208
+ `X-HTTP-Method`, `X-Method-Override`), `X-Forwarded-*`, `Forwarded`,
3209
+ `X-Original-URL`, `X-Rewrite-URL` and hop-by-hop headers are dropped
3210
+ (`FORBIDDEN_HEADERS`), and a `_method` query parameter or top-level
3211
+ JSON body key is refused. Each call is logged by API, method,
3212
+ operation id and path template (never the query, the concrete path
3213
+ or the body).
3214
+
3215
+ A call the API's `approval` gates pauses the run for a human decision
3216
+ before it is sent (see the module docstring); `redact` names body and
3217
+ query fields (any depth, any letter case) the approver sees masked,
3218
+ for values they need not read (a card number, say). The approval is
3219
+ bound to the request as sent, masked fields included, and a decision
3220
+ to the call it was taken for: on resume it applies to that call even
3221
+ when the policy no longer gates it, and a tool call that runs again
3222
+ without a decision does not send a call an approval was asked for (a
3223
+ rejected call is never sent, an approved one once).
3224
+ """
3225
+ method = method.upper()
3226
+ label = operation_id or (path if path_params is not None else "<concrete path>")
3227
+ log_fields = {
3228
+ "api": self.name,
3229
+ "method": method,
3230
+ "operation_id": operation_id,
3231
+ "path_template": path if path_params is not None else None,
3232
+ }
3233
+ try:
3234
+ prepared = self._prepare(
3235
+ method, path, operation_id, path_params, params, json_body, headers
3236
+ )
3237
+ bound = await self._bound_approvals(prepared, method, operation_id or label)
3238
+ approved = self._await_approval(
3239
+ prepared, method, operation_id, label, json_body, redact, log_fields, bound
3240
+ )
3241
+ # Counted just before sending: a call held for approval counts once, when sent.
3242
+ self.take_limits(method, operation_id or path)
3243
+ if approved is not None:
3244
+ await self._use_approval(approved, method, operation_id or label, log_fields)
3245
+ wire_path, url, query = prepared.wire_path, prepared.url, prepared.query
3246
+ request_headers, secrets = prepared.headers, prepared.secrets
3247
+ # Last, just before sending: a refused or paused call never exchanges a token.
3248
+ exchanged = await self.exchanged_credential()
3249
+ if exchanged is not None:
3250
+ header, value = exchanged
3251
+ request_headers[header] = value
3252
+ secrets = (*secrets, value, value.split(" ", 1)[1])
3253
+ except ApiPolicyError as exc:
3254
+ logger.warning(
3255
+ "api call refused: %s %s %s: %s",
3256
+ self.name,
3257
+ method,
3258
+ label,
3259
+ exc.reason or "invalid request",
3260
+ extra=log_fields,
3261
+ )
3262
+ raise
3263
+ except ApiCallError as exc:
3264
+ logger.warning(
3265
+ "api call not sent: %s %s %s: %s",
3266
+ self.name,
3267
+ method,
3268
+ label,
3269
+ exc.reason or "not configured",
3270
+ extra=log_fields,
3271
+ )
3272
+ raise
3273
+ started = time.perf_counter()
3274
+ cap = self.response_cap()
3275
+ async with httpx.AsyncClient(
3276
+ transport=self._transport, timeout=self.timeout(), follow_redirects=False
3277
+ ) as client:
3278
+ try:
3279
+ if cap is None:
3280
+ response = await client.request(
3281
+ method, url, params=query, json=json_body, headers=request_headers
3282
+ )
3283
+ else:
3284
+ request = client.build_request(
3285
+ method, url, params=query, json=json_body, headers=request_headers
3286
+ )
3287
+ response = await self._send_capped(client, request, cap)
3288
+ response.raise_for_status()
3289
+ except ResponseTooLarge:
3290
+ self._log_call(label, "too large", started, log_fields, failed=True)
3291
+ raise ApiCallError(
3292
+ f"{self.name} answered with more than {cap} bytes; discarded",
3293
+ reason="response over limits.max_response_bytes",
3294
+ ) from None
3295
+ except ResponseEncodingRefused:
3296
+ self._log_call(label, "encoding refused", started, log_fields, failed=True)
3297
+ raise ApiCallError(
3298
+ f"{self.name} answered in a content encoding other than gzip or deflate, "
3299
+ "which limits.max_response_bytes cannot bound; discarded",
3300
+ reason="response encoding not readable within limits.max_response_bytes",
3301
+ ) from None
3302
+ except httpx.HTTPStatusError as exc:
3303
+ status = exc.response.status_code
3304
+ self._log_call(label, status, started, log_fields, failed=True)
3305
+ body = error_body_excerpt(exc.response.text, secrets) or None
3306
+ reason = f": {' '.join(body.split())[:ERROR_MESSAGE_BODY_CHARS]}" if body else ""
3307
+ raise ApiCallError(
3308
+ f"{self.name}: {method} {wire_path} -> HTTP {status}{reason}",
3309
+ status_code=status,
3310
+ body=body,
3311
+ ) from exc
3312
+ except httpx.HTTPError as exc:
3313
+ self._log_call(label, type(exc).__name__, started, log_fields, failed=True)
3314
+ raise ApiCallError(
3315
+ f"{self.name}: {method} {wire_path} failed: {type(exc).__name__}"
3316
+ ) from exc
3317
+ self._log_call(label, response.status_code, started, log_fields)
3318
+ if response.content and "json" in response.headers.get("content-type", ""):
3319
+ try:
3320
+ return response.json()
3321
+ except ValueError as exc:
3322
+ raise ApiCallError(
3323
+ f"{self.name}: {method} {wire_path} returned invalid JSON",
3324
+ status_code=response.status_code,
3325
+ ) from exc
3326
+ return response.text
3327
+
3328
+ def response_cap(self) -> int | None:
3329
+ """`limits.max_response_bytes`: the most a response body may hold, or None (no cap)."""
3330
+ cap = (self.settings.get("limits") or {}).get("max_response_bytes")
3331
+ return cap if isinstance(cap, int) and not isinstance(cap, bool) and cap > 0 else None
3332
+
3333
+ @staticmethod
3334
+ async def _send_capped(
3335
+ client: httpx.AsyncClient, request: httpx.Request, cap: int
3336
+ ) -> httpx.Response:
3337
+ """Send `request` and read at most `cap` bytes of its body (decoded, as the tool gets
3338
+ it); `ResponseTooLarge` past that, with the rest never read.
3339
+
3340
+ The request asks for gzip or deflate at most (`CAPPED_ACCEPT_ENCODING`, in place of
3341
+ httpx's default or the tool's own `Accept-Encoding`), and the raw body is decoded
3342
+ here by `CappedBody`, never more than the cap at a time: an answer in any other
3343
+ encoding raises `ResponseEncodingRefused` unread. A declared `Content-Length` over
3344
+ the cap (an uncompressed body) is refused before reading."""
3345
+ request.headers["Accept-Encoding"] = CAPPED_ACCEPT_ENCODING
3346
+ response = await client.send(request, stream=True)
3347
+ try:
3348
+ body = CappedBody(response.headers.get("content-encoding", ""), cap)
3349
+ declared = response.headers.get("content-length", "")
3350
+ if body.coding is None and declared.isdigit() and int(declared) > cap:
3351
+ raise ResponseTooLarge
3352
+ try:
3353
+ if response.is_stream_consumed:
3354
+ # Already read whole and decoded by httpx: a response built with its
3355
+ # content, as a MockTransport handler returns it (a network answer is
3356
+ # always streamed). Only the cap is left to check.
3357
+ body = CappedBody("", cap)
3358
+ body.feed(response.content)
3359
+ else:
3360
+ async for chunk in response.aiter_raw():
3361
+ body.feed(chunk)
3362
+ content = body.finish()
3363
+ except zlib.error as exc:
3364
+ raise httpx.DecodingError(str(exc), request=request) from exc
3365
+ finally:
3366
+ await response.aclose()
3367
+ # The body as read (decoded): a response that holds it, for the usual handling.
3368
+ headers = [
3369
+ (name, value)
3370
+ for name, value in response.headers.multi_items()
3371
+ if name.lower() not in ("content-encoding", "content-length", "transfer-encoding")
3372
+ ]
3373
+ return httpx.Response(
3374
+ response.status_code, headers=headers, content=content, request=request
3375
+ )
3376
+
3377
+ def _prepare(
3378
+ self,
3379
+ method: str,
3380
+ path: str,
3381
+ operation_id: str | None,
3382
+ path_params: Mapping[str, Any] | None,
3383
+ params: Any,
3384
+ json_body: Any,
3385
+ headers: Mapping[str, str] | None,
3386
+ ) -> PreparedRequest:
3387
+ """Every policy check before sending: the path, URL, query, headers, the credential
3388
+ sent, and the approval the call needs (`gate`, None when none)."""
3389
+ if method not in HTTP_METHODS:
3390
+ raise ApiPolicyError(f"{self.name}: unknown HTTP method {method!r}.")
3391
+ self.check_loop()
3392
+ # What the request is, read from the body sent (a JSON-RPC API): a malformed one is
3393
+ # refused before any rule is asked, and no rule trusts the tool's label for it.
3394
+ rpc = self.rpc_call(method, json_body)
3395
+ if path_params is not None:
3396
+ self.policy.check(self.name, method, operation_id, path, rpc=rpc)
3397
+ wire_path = render_path(path, path_params)
3398
+ elif _PLACEHOLDER.search(path):
3399
+ raise ApiPolicyError(f"path {path!r} has unfilled parameters; pass path_params=.")
3400
+ else:
3401
+ wire_path = path
3402
+ validate_concrete_path(wire_path)
3403
+ self.policy.check(self.name, method, operation_id, wire_path, rpc=rpc)
3404
+ mislabelled = label_problem(self.settings, operation_id, rpc.rpc_method, rpc.a2a_operation)
3405
+ if mislabelled:
3406
+ raise ApiPolicyError(
3407
+ f"{self.name}: {mislabelled}.", reason="operation_id does not match the request"
3408
+ )
3409
+ query = self.query(params)
3410
+ if isinstance(json_body, Mapping) and any(
3411
+ isinstance(k, str) and k.casefold() == METHOD_OVERRIDE_PARAM for k in json_body
3412
+ ):
3413
+ raise ApiPolicyError(
3414
+ f"{self.name}: the JSON body key {METHOD_OVERRIDE_PARAM!r} refused: it overrides "
3415
+ "the request method on some servers, and the policy checks the method sent."
3416
+ )
3417
+
3418
+ base = self.base_url()
3419
+ prefix = base.raw_path.decode("ascii").split("?", 1)[0].rstrip("/")
3420
+ expected = f"{prefix}/{wire_path.lstrip('/')}"
3421
+ url = httpx.URL(f"{str(base).rstrip('/')}/{wire_path.lstrip('/')}")
3422
+ if (
3423
+ url.raw_path.decode("ascii").split("?", 1)[0] != expected
3424
+ or url.host != base.host
3425
+ or url.port != base.port
3426
+ or url.scheme != base.scheme
3427
+ ):
3428
+ raise ApiPolicyError(f"path {wire_path!r} would be rewritten before sending (refused).")
3429
+
3430
+ request_headers = httpx.Headers(headers or {})
3431
+ dropped = sorted({name for name in request_headers if forbidden_header(name)})
3432
+ for name in dropped:
3433
+ del request_headers[name]
3434
+ if dropped:
3435
+ logger.warning(
3436
+ "api call: dropped header(s) a tool may not set: %s",
3437
+ ", ".join(dropped),
3438
+ extra={"api": self.name},
3439
+ )
3440
+ credentials = self.auth_headers()
3441
+ credential_header = self.credential_header()
3442
+ excluded = {c.lower() for c in credentials}
3443
+ if credential_header is not None:
3444
+ excluded.add(credential_header.lower())
3445
+ if credential_header not in credentials and credential_header in request_headers:
3446
+ # `exchange`: the token is added just before sending; never the tool's own.
3447
+ del request_headers[credential_header]
3448
+ tool_headers = [
3449
+ (name, value)
3450
+ for name, value in request_headers.multi_items()
3451
+ if name.lower() not in excluded
3452
+ ]
3453
+ for name, value in outbound_headers(self.settings).items():
3454
+ if name not in request_headers: # the tool's own header wins
3455
+ request_headers[name] = value # not in tool_headers: no approval binds it
3456
+ for name, value in credentials.items():
3457
+ request_headers[name] = value # the policy's credential always wins
3458
+ secrets = tuple(credentials.values()) + tuple(
3459
+ value.split(" ", 1)[1] for value in credentials.values() if " " in value
3460
+ )
3461
+ gate = self.gate_for(method, operation_id, path, wire_path, path_params is not None, rpc)
3462
+ if rpc.a2a_operation == A2A_APPROVE and gate is None:
3463
+ # The policy's validator already refuses an A2A API that could send one: this
3464
+ # holds even for a policy that was never validated.
3465
+ reason = "a message that approves must wait for an approval or be denied"
3466
+ raise ApiPolicyError(
3467
+ f"{self.name}: {method} {(operation_id or wire_path) + describe_rpc(rpc)} refused "
3468
+ f"by the API policy: {reason} (gate or deny a2a_operation: approve).",
3469
+ reason=reason,
3470
+ )
3471
+ return PreparedRequest(
3472
+ wire_path=wire_path,
3473
+ url=url,
3474
+ query=query,
3475
+ headers=request_headers,
3476
+ tool_headers=tool_headers,
3477
+ secrets=secrets,
3478
+ gate=gate,
3479
+ rpc=rpc,
3480
+ )
3481
+
3482
+ def rpc_call(self, method: str, json_body: Any) -> RpcCall:
3483
+ """What a request to this API is, read from the JSON body sent (`derive_rpc`).
3484
+
3485
+ Empty for an `http` API. A body a JSON-RPC API cannot send as one
3486
+ JSON-RPC request raises `ApiPolicyError`, before anything is sent.
3487
+ """
3488
+ try:
3489
+ return derive_rpc(self.settings, method, json_body)
3490
+ except RpcRequestError as exc:
3491
+ raise ApiPolicyError(
3492
+ f"{self.name}: {exc}.", reason="not one JSON-RPC request"
3493
+ ) from None
3494
+
3495
+ def gate_for(
3496
+ self,
3497
+ method: str,
3498
+ operation_id: str | None,
3499
+ path: str,
3500
+ wire_path: str,
3501
+ templated: bool,
3502
+ rpc: RpcCall | None = None,
3503
+ ) -> ApprovalGate | None:
3504
+ """The approval the API's `approval` requires before this call, or None.
3505
+
3506
+ A rule covers the call when it covers the sent path or the template it
3507
+ was rendered from; the first such rule in file order gates it, and its
3508
+ approvers are the ones the approval is asked of (and bound to). On a
3509
+ JSON-RPC API an entry naming `rpc_method` or `a2a_operation` covers
3510
+ the call by what its body is (`rpc`).
3511
+ Asked only once the policy allowed the call: approval never widens access.
3512
+ """
3513
+ return self.policy.gate(
3514
+ self.name,
3515
+ method,
3516
+ operation_id,
3517
+ wire_path,
3518
+ template=path if templated else None,
3519
+ rpc=rpc,
3520
+ )
3521
+
3522
+ async def _bound_approvals(
3523
+ self, prepared: PreparedRequest, method: str, what: str
3524
+ ) -> list[BoundApproval]:
3525
+ """The approvals the ledger holds for this call in this tool call, when none is resumed now.
3526
+
3527
+ Asked when no decision waits for the call in this run: the tool call
3528
+ runs again without one (a run continued without input, or replayed
3529
+ from a checkpoint, through LangGraph Server's own API; a copy of the
3530
+ thread). The ledger then answers, by the tool call (the model message
3531
+ and the call id) or by the task's interrupt, with the approvals it
3532
+ recorded, and `_await_approval` refuses the call when one was asked
3533
+ for it, whatever the policy now says about gating it. Nothing to ask
3534
+ (no ledger, outside a tool call and a task): empty. A ledger that
3535
+ cannot answer refuses the call.
3536
+ """
3537
+ identity = self._identity(method, prepared)
3538
+ if identity in _stopped_calls() or _decision_waiting(identity) is not None:
3539
+ return [] # `_await_approval` refuses it, or applies its decision
3540
+ ledger = _ledger
3541
+ scope = _TOOL_CALL.get()
3542
+ tool_call = (
3543
+ (scope.message_id, scope.call_id)
3544
+ if scope is not None and scope.message_id and scope.call_id
3545
+ else None
3546
+ )
3547
+ interrupt_id = _task_interrupt_id()
3548
+ if ledger is None or (tool_call is None and interrupt_id is None):
3549
+ return []
3550
+ try:
3551
+ found = await ledger.bound_approvals(tool_call=tool_call, interrupt_id=interrupt_id)
3552
+ except Exception as exc:
3553
+ raise ApiPolicyError(
3554
+ f"{self.name}: {method} {what}: the approvals of this tool call could not be "
3555
+ f"read ({type(exc).__name__}), so it may have been decided already; nothing "
3556
+ "was sent.",
3557
+ reason="approvals unreadable",
3558
+ ) from exc
3559
+ return [
3560
+ b
3561
+ for b in found
3562
+ if call_identity(b.api, b.method, b.path, b.rpc_method, b.a2a_operation) == identity
3563
+ ]
3564
+
3565
+ def _identity(self, method: str, prepared: PreparedRequest) -> tuple[str, ...]:
3566
+ """Which call this is, for the decisions bound to calls (`call_identity`)."""
3567
+ return call_identity(
3568
+ self.name,
3569
+ method,
3570
+ prepared.wire_path,
3571
+ prepared.rpc.rpc_method,
3572
+ prepared.rpc.a2a_operation,
3573
+ )
3574
+
3575
+ def _await_approval(
3576
+ self,
3577
+ prepared: PreparedRequest,
3578
+ method: str,
3579
+ operation_id: str | None,
3580
+ label: str,
3581
+ json_body: Any,
3582
+ redact: Iterable[str],
3583
+ log_fields: Mapping[str, Any],
3584
+ bound: list[BoundApproval] | None = None,
3585
+ ) -> tuple[str, str] | None:
3586
+ """Hold a gated call for a human decision (see the module doc); None when not gated.
3587
+
3588
+ The first time, `interrupt()` pauses the run with the approval payload
3589
+ (raising LangGraph's `GraphInterrupt`, which ends the tool call). On
3590
+ resume it returns the decision: this returns `(approval_id, call_hash)`
3591
+ only for an approval of exactly this request (`_use_approval` then
3592
+ marks it used); anything else raises `ApiPolicyError`, nothing sent.
3593
+ The decision is taken for the call it was made for even when the
3594
+ policy no longer gates it (`_decision_waiting`), and a call a decision
3595
+ stopped is refused again for the rest of the tool call. A call the
3596
+ ledger holds an approval for (`bound`, from `_bound_approvals`) while
3597
+ no decision waits for it is refused, gated or not: it is sent only
3598
+ through its own decision, once.
3599
+ """
3600
+ identity = self._identity(method, prepared)
3601
+ what = operation_id or label
3602
+ gate = prepared.gate
3603
+ if identity in _stopped_calls():
3604
+ raise ApiPolicyError(
3605
+ f"{self.name}: {method} {what} was stopped by its approval decision earlier "
3606
+ "in this tool call; nothing was sent.",
3607
+ reason="stopped by an approval decision",
3608
+ )
3609
+ waiting = _decision_waiting(identity)
3610
+ if waiting is None and bound:
3611
+ why, reason = _bound_refusal(bound)
3612
+ _stop_call(identity)
3613
+ raise ApiPolicyError(
3614
+ f"{self.name}: {method} {what} {why}; nothing was sent.", reason=reason
3615
+ )
3616
+ if gate is None and waiting is None:
3617
+ return None
3618
+ if gate is not None:
3619
+ approvers: tuple[str, ...] = gate.approvers
3620
+ timeout_s, rule = gate.timeout_s, gate.rule
3621
+ decide_with, relayers = gate.decide_with, gate.relayers
3622
+ else:
3623
+ # The paused call's gate is gone from the policy: its decision still binds it.
3624
+ assert waiting is not None
3625
+ asked = waiting.get("approvers")
3626
+ approvers = tuple(str(a) for a in asked) if isinstance(asked, list | tuple) else ()
3627
+ timeout_s = DEFAULT_APPROVAL_TIMEOUT_S
3628
+ rule = "the approval this call was paused for (the policy no longer gates it)"
3629
+ decide_with, relayers = _decided_with(waiting)
3630
+ rule_reason = f"approval required by {rule}"
3631
+
3632
+ def refuse(why: str, reason: str = rule_reason) -> ApiPolicyError:
3633
+ return ApiPolicyError(
3634
+ f"{self.name}: {method} {what} {why}; nothing was sent.", reason=reason
3635
+ )
3636
+
3637
+ call = canonical_call(
3638
+ self.name,
3639
+ method,
3640
+ prepared.url,
3641
+ prepared.query,
3642
+ json_body,
3643
+ operation_id,
3644
+ prepared.tool_headers,
3645
+ )
3646
+ try:
3647
+ digest = call_hash(call)
3648
+ except (TypeError, ValueError):
3649
+ raise refuse(
3650
+ "needs human approval, but its JSON body is not plain JSON, so it cannot be "
3651
+ "shown for approval (refused)"
3652
+ ) from None
3653
+ scope = _TOOL_CALL.get()
3654
+ if scope.gated_sent if scope is not None else _GATED_SENT.get():
3655
+ raise refuse(
3656
+ "needs human approval, and this tool call already sent an approved call: a tool "
3657
+ "call sends at most one (refused). Make this call in a new tool call"
3658
+ )
3659
+ names = frozenset(str(n).casefold() for n in redact or ())
3660
+ tool = scope.name if scope is not None and scope.name else None
3661
+ purpose = scope.purpose if scope is not None else None
3662
+ payload = {
3663
+ "type": APPROVAL_INTERRUPT,
3664
+ "api": self.name,
3665
+ "method": method,
3666
+ "path": prepared.wire_path,
3667
+ "query": _query_view(prepared.query, names),
3668
+ "body": redact_fields(json_body, names),
3669
+ "operation_id": operation_id or None,
3670
+ "tool": tool,
3671
+ "tool_call_id": scope.call_id if scope is not None else None,
3672
+ "message_id": scope.message_id if scope is not None else None,
3673
+ "reason": f"{tool}: {purpose}" if tool and purpose else (tool or purpose or None),
3674
+ "approvers": list(approvers),
3675
+ # How the requester decides (bound, as the approvers are: see `_check_decision`),
3676
+ # and the agent this run acts through (None: the user directly).
3677
+ "decide_with": decide_with,
3678
+ "relayers": list(relayers),
3679
+ "requester_actor": _context_actor(),
3680
+ "timeout_s": timeout_s,
3681
+ "rule": rule,
3682
+ "call_hash": digest,
3683
+ }
3684
+ # A JSON-RPC call: the request its body is, which is part of which call it is.
3685
+ for key, value in (
3686
+ (RPC_METHOD_KEY, prepared.rpc.rpc_method),
3687
+ (A2A_OPERATION_KEY, prepared.rpc.a2a_operation),
3688
+ ):
3689
+ if value is not None:
3690
+ payload[key] = value
3691
+ if prepared.rpc.a2a_operation == A2A_APPROVE:
3692
+ # A decision relayed to another agent: the person sees the approval it decides
3693
+ # there (`nested`) and the call that will then happen (`effect`), as sent.
3694
+ nested = relayed_approval(json_body)
3695
+ if nested is not None:
3696
+ payload["nested"] = redact_fields(nested, names)
3697
+ payload["effect"] = approval_effect(payload["nested"])
3698
+ outside_run = refuse(
3699
+ f"needs human approval ({', '.join(approvers)}) before it is sent, which "
3700
+ "is possible only inside an agent run: refused"
3701
+ )
3702
+ try:
3703
+ from langgraph.errors import GraphBubbleUp
3704
+ from langgraph.types import interrupt
3705
+ except ImportError: # loaded where LangGraph is not installed: nothing can pause
3706
+ raise outside_run from None
3707
+ try:
3708
+ decision = interrupt(payload)
3709
+ _took_resume()
3710
+ # Another call's decision resumed the run while this one still waits:
3711
+ # pause again, for the same approval.
3712
+ while _is_decision(decision) and decision.get("decision") == DECISION_PENDING:
3713
+ decision = interrupt(payload)
3714
+ _took_resume()
3715
+ except GraphBubbleUp:
3716
+ logger.info(
3717
+ "api call held for approval: %s %s %s (%s)",
3718
+ self.name,
3719
+ method,
3720
+ label,
3721
+ ", ".join(approvers),
3722
+ extra=dict(log_fields),
3723
+ )
3724
+ raise
3725
+ except (RuntimeError, KeyError):
3726
+ # Outside an agent run (`get_config` fails): nothing can pause and ask.
3727
+ raise outside_run from None
3728
+ try:
3729
+ approval_id = self._check_decision(decision, digest, gate, refuse)
3730
+ except ApiPolicyError:
3731
+ _stop_call(identity)
3732
+ raise
3733
+ return approval_id, digest
3734
+
3735
+ async def _use_approval(
3736
+ self, approved: tuple[str, str], method: str, what: str, log_fields: Mapping[str, Any]
3737
+ ) -> None:
3738
+ """Mark the approval used in the ledger, once, just before the call is sent."""
3739
+ approval_id, digest = approved
3740
+ ledger = _ledger
3741
+ problem = (
3742
+ "this process has no approvals ledger to mark the approval used"
3743
+ if ledger is None
3744
+ else await ledger.consume(approval_id, digest, _run_thread_id())
3745
+ )
3746
+ if problem:
3747
+ raise ApiPolicyError(
3748
+ f"{self.name}: {method} {what} was approved, but the approval cannot be used: "
3749
+ f"{problem}; nothing was sent.",
3750
+ reason=f"approval not usable: {problem}",
3751
+ )
3752
+ scope = _TOOL_CALL.get()
3753
+ if scope is not None:
3754
+ scope.gated_sent = True
3755
+ else:
3756
+ _GATED_SENT.set(True)
3757
+ logger.info(
3758
+ "api call approved: %s %s %s (approval %s)",
3759
+ self.name,
3760
+ method,
3761
+ what,
3762
+ approval_id,
3763
+ extra=dict(log_fields),
3764
+ )
3765
+
3766
+ @staticmethod
3767
+ def _check_decision(
3768
+ decision: Any,
3769
+ digest: str,
3770
+ gate: ApprovalGate | None,
3771
+ refuse: Callable[..., ApiPolicyError],
3772
+ ) -> str:
3773
+ """The approval id of a decision that approves the request `digest`; else raise.
3774
+
3775
+ `gate` is what the policy requires of the call now (None: it no longer
3776
+ gates it). A rejection or an expiry refuses whatever the policy says.
3777
+ An approval must also have been asked of the approvers the policy's
3778
+ gate names now, to be decided the same way (`decide_with`, `relayers`):
3779
+ a policy that changed while the call waited (a new image with other
3780
+ approvers, one that lets other agents relay the decision, or one that
3781
+ no longer gates the call) is not satisfied by a decision taken under
3782
+ the old one.
3783
+ """
3784
+ if not isinstance(decision, Mapping) or decision.get("type") != APPROVAL_DECISION:
3785
+ raise refuse(
3786
+ "needs human approval, and the run was resumed without an approval decision",
3787
+ reason="resumed without a decision",
3788
+ )
3789
+ verdict = decision.get("decision")
3790
+ if verdict == DECISION_REJECT:
3791
+ comment = _plain(decision.get("comment"), COMMENT_MAX_CHARS)
3792
+ raise refuse(
3793
+ "was not approved: an approver rejected it"
3794
+ + (f" (their comment: {comment})" if comment else ""),
3795
+ reason="approval rejected",
3796
+ )
3797
+ if verdict == DECISION_EXPIRED:
3798
+ raise refuse(
3799
+ "was not approved: the approval request expired before anyone decided",
3800
+ reason="approval expired",
3801
+ )
3802
+ approval_id = decision.get("approval_id")
3803
+ if verdict != DECISION_APPROVE or not isinstance(approval_id, str) or not approval_id:
3804
+ raise refuse("was not approved (an unknown decision)", reason="unknown decision")
3805
+ if decision.get("call_hash") != digest:
3806
+ raise refuse(
3807
+ "differs from the request that was approved (it changed after the approval), "
3808
+ "so the approval does not cover it",
3809
+ reason="request differs from the approved one",
3810
+ )
3811
+ asked = decision.get("approvers")
3812
+ if gate is None:
3813
+ raise refuse(
3814
+ "was approved under an approval gate the policy no longer has (it changed "
3815
+ "while the call waited), so the approval does not cover it; ask again",
3816
+ reason="approval gate changed",
3817
+ )
3818
+ if not isinstance(asked, list | tuple) or {str(a) for a in asked} != set(gate.approvers):
3819
+ raise refuse(
3820
+ "was approved under an approval gate that has changed since (its approvers "
3821
+ "differ from the policy's now), so the approval does not cover it; ask again",
3822
+ reason="approval gate changed",
3823
+ )
3824
+ decide_with, relayers = _decided_with(decision)
3825
+ if (decide_with, frozenset(relayers)) != (gate.decide_with, frozenset(gate.relayers)):
3826
+ raise refuse(
3827
+ "was approved under an approval gate that has changed since (how its approvers "
3828
+ "decide, decide_with or relayers, differs from the policy's now), so the "
3829
+ "approval does not cover it; ask again",
3830
+ reason="approval gate changed",
3831
+ )
3832
+ return approval_id
3833
+
3834
+ def _log_call(
3835
+ self,
3836
+ label: str,
3837
+ outcome: int | str,
3838
+ started: float,
3839
+ fields: Mapping[str, Any],
3840
+ *,
3841
+ failed: bool = False,
3842
+ ) -> None:
3843
+ latency_ms = int((time.perf_counter() - started) * 1000)
3844
+ logger.log(
3845
+ logging.WARNING if failed else logging.INFO,
3846
+ "api call %s: %s %s %s -> %s (%d ms)",
3847
+ "failed" if failed else "done",
3848
+ self.name,
3849
+ fields["method"],
3850
+ label,
3851
+ outcome,
3852
+ latency_ms,
3853
+ extra={**fields, "outcome": str(outcome), "latency_ms": latency_ms},
3854
+ )
3855
+
3856
+ async def get(self, path: str, **kwargs: Any) -> Any:
3857
+ return await self.request("GET", path, **kwargs)
3858
+
3859
+ async def head(self, path: str, **kwargs: Any) -> Any:
3860
+ return await self.request("HEAD", path, **kwargs)
3861
+
3862
+ async def post(self, path: str, **kwargs: Any) -> Any:
3863
+ return await self.request("POST", path, **kwargs)
3864
+
3865
+ async def put(self, path: str, **kwargs: Any) -> Any:
3866
+ return await self.request("PUT", path, **kwargs)
3867
+
3868
+ async def patch(self, path: str, **kwargs: Any) -> Any:
3869
+ return await self.request("PATCH", path, **kwargs)
3870
+
3871
+ async def delete(self, path: str, **kwargs: Any) -> Any:
3872
+ return await self.request("DELETE", path, **kwargs)
3873
+
3874
+ async def options(self, path: str, **kwargs: Any) -> Any:
3875
+ return await self.request("OPTIONS", path, **kwargs)
3876
+
3877
+
3878
+ # ---------------------------------------------------------------------------
3879
+ # Entry point for tools
3880
+ # ---------------------------------------------------------------------------
3881
+
3882
+
3883
+ def current_context() -> Any:
3884
+ """The run context LangGraph set for the current graph run (who is calling), or None."""
3885
+ try:
3886
+ from langgraph.runtime import get_runtime
3887
+
3888
+ return get_runtime().context
3889
+ except Exception: # outside a graph run
3890
+ return None
3891
+
3892
+
3893
+ def _context_credentials(context: Any) -> Mapping[str, Any]:
3894
+ attributes = getattr(context, "attributes", None)
3895
+ if attributes is None and isinstance(context, Mapping):
3896
+ attributes = context.get("attributes")
3897
+ credentials = attributes.get("credentials") if isinstance(attributes, Mapping) else None
3898
+ return credentials if isinstance(credentials, Mapping) else {}
3899
+
3900
+
3901
+ def subject_token_of(context: Any) -> SubjectToken | None:
3902
+ """The calling principal's own verified bearer token (`credentials["@subject_token"]`).
3903
+
3904
+ Kept by the `jwt` policy only when an API acts with it (`auth: exchange`,
3905
+ or `auth: forward` with `forward_audience`), or by a custom policy's
3906
+ `keep_subject_token`; None otherwise (and never under langgraph-server,
3907
+ whose run context carries no credentials).
3908
+ """
3909
+ credentials = _context_credentials(context)
3910
+ token = credentials.get("@subject_token")
3911
+ if not isinstance(token, str) or not token:
3912
+ return None
3913
+ raw_aud = credentials.get("@subject_aud")
3914
+ audience = (
3915
+ tuple(str(a) for a in raw_aud if isinstance(a, str))
3916
+ if isinstance(raw_aud, list | tuple)
3917
+ else (raw_aud,)
3918
+ if isinstance(raw_aud, str)
3919
+ else ()
3920
+ )
3921
+ raw_exp = credentials.get("@subject_exp")
3922
+ expires_at = (
3923
+ float(raw_exp)
3924
+ if isinstance(raw_exp, int | float) and not isinstance(raw_exp, bool)
3925
+ else None
3926
+ )
3927
+ return SubjectToken(token=token, audience=audience, expires_at=expires_at)
3928
+
3929
+
3930
+ def _context_actor_chain(context: Any) -> tuple[str, ...]:
3931
+ """The agents the run's request came through (its `@actor` chain), current first."""
3932
+ actor = _context_attributes(context).get("@actor")
3933
+ if not isinstance(actor, Mapping):
3934
+ return ()
3935
+ chain = actor.get("chain")
3936
+ if isinstance(chain, list | tuple) and chain:
3937
+ return tuple(str(a) for a in chain)
3938
+ actor_id = actor.get("id")
3939
+ return (actor_id,) if isinstance(actor_id, str) and actor_id else ()
3940
+
3941
+
3942
+ def forwarded_credential(
3943
+ api_name: str, context: Any, forward_audience: str | None = None
3944
+ ) -> str | None:
3945
+ """The credential `auth: forward` sends for API `api_name`, or None (nothing is sent).
3946
+
3947
+ In order: the calling principal's `attributes["credentials"][api_name]` (a
3948
+ policy set it); else, with `forward_audience`, `Bearer <the caller's own
3949
+ token>` when its `aud` names that audience (the issuer minted it for the
3950
+ target too): a token minted only for this agent is never replayed at another.
3951
+ """
3952
+ credentials = _context_credentials(context)
3953
+ value = credentials.get(api_name)
3954
+ if isinstance(value, str) and value:
3955
+ return value
3956
+ if forward_audience:
3957
+ subject = subject_token_of(context)
3958
+ if subject is not None and forward_audience in subject.audience:
3959
+ return f"Bearer {subject.token}"
3960
+ return None
3961
+
3962
+
3963
+ def get_client(
3964
+ api_name: str,
3965
+ *,
3966
+ context: Any = None,
3967
+ transport: httpx.AsyncBaseTransport | None = None,
3968
+ run_id: str | None = None,
3969
+ ) -> ApiClient:
3970
+ """A policy-enforcing client for API `api_name` of `api-policy.yaml`.
3971
+
3972
+ `context` is the run context holding the calling principal (a tool's
3973
+ `runtime.context`); by default it is read from the current graph run. For
3974
+ an `auth: forward` API it gives the credential sent; for an `auth:
3975
+ exchange` API, the caller's own token (`subject_token_of`), exchanged when a
3976
+ request is sent, not here. For both, the delegation chain the loop check
3977
+ reads. `run_id` names the run `limits.max_calls_per_run` counts against; by
3978
+ default it is read from the current run (`current_run_id`).
3979
+ Raises `ApiPolicyError` when the policy file is missing or invalid, or does
3980
+ not declare `api_name`.
3981
+ """
3982
+ policy = load_policy()
3983
+ settings = policy.api(api_name)
3984
+ credential = None
3985
+ subject = None
3986
+ actor_chain: tuple[str, ...] = ()
3987
+ if settings["auth"] in HEADER_AUTH_MODES:
3988
+ ctx = context if context is not None else current_context()
3989
+ actor_chain = _context_actor_chain(ctx)
3990
+ if settings["auth"] == "forward":
3991
+ credential = forwarded_credential(api_name, ctx, settings.get("forward_audience"))
3992
+ else:
3993
+ subject = subject_token_of(ctx)
3994
+ return ApiClient(
3995
+ policy,
3996
+ api_name,
3997
+ credential=credential,
3998
+ transport=transport,
3999
+ run_id=run_id,
4000
+ subject=subject,
4001
+ actor_chain=actor_chain,
4002
+ )
4003
+
4004
+
4005
+ # ---------------------------------------------------------------------------
4006
+ # The caller: act only on what the calling user may act on
4007
+ # ---------------------------------------------------------------------------
4008
+ #
4009
+ # The API policy decides which endpoints a tool may call, not on whose behalf.
4010
+ # Text a tool returns (a customer's free-text note, an upstream error) reaches
4011
+ # the model too, and can ask it to act on some other record. Prefer per-user
4012
+ # authorization upstream (`auth: forward`, so the API itself refuses what the
4013
+ # user may not do); where a shared service token is used, the tool is the only
4014
+ # place that knows the caller, so write tools check it themselves with these.
4015
+
4016
+ # The principal of a run context the server did not fill in (AgentContext's default).
4017
+ ANONYMOUS_PRINCIPAL = "anonymous"
4018
+
4019
+
4020
+ @dataclass(frozen=True)
4021
+ class Caller:
4022
+ """The principal a run acts for, as tools see it (from the run context).
4023
+
4024
+ `principal_id` is the user (the subject). When another agent presents the
4025
+ request for that user, `actor` names that agent and `actor_chain` every
4026
+ agent in between, current first (`delegated`); `roles` are then only
4027
+ those `AUTH_DELEGATED_ROLES` lends to agents.
4028
+ """
4029
+
4030
+ principal_id: str
4031
+ roles: frozenset[str]
4032
+ actor: str | None = None
4033
+ actor_chain: tuple[str, ...] = ()
4034
+
4035
+ def has_role(self, *roles: str) -> bool:
4036
+ return bool(self.roles.intersection(roles))
4037
+
4038
+ @property
4039
+ def delegated(self) -> bool:
4040
+ """Whether an agent presents this request for the user (see `actor`)."""
4041
+ return self.actor is not None
4042
+
4043
+
4044
+ def _context_attributes(ctx: Any) -> Mapping[str, Any]:
4045
+ attributes = getattr(ctx, "attributes", None)
4046
+ if attributes is None and isinstance(ctx, Mapping):
4047
+ attributes = ctx.get("attributes")
4048
+ return attributes if isinstance(attributes, Mapping) else {}
4049
+
4050
+
4051
+ def _context_origin(ctx: Any) -> str | None:
4052
+ """The user's own words the calling agent forwarded (`credentials["@origin"]`), if any."""
4053
+ credentials = _context_attributes(ctx).get("credentials")
4054
+ origin = credentials.get("@origin") if isinstance(credentials, Mapping) else None
4055
+ text = origin.get("text") if isinstance(origin, Mapping) else None
4056
+ return text if isinstance(text, str) else None
4057
+
4058
+
4059
+ def current_caller(context: Any = None) -> Caller:
4060
+ """The calling principal of the current run (or of `context`, a tool's `runtime.context`).
4061
+
4062
+ Fails closed: without an authenticated principal in the run context it
4063
+ raises `ApiPolicyError` (which the agent turns into a tool error), so a
4064
+ tool never acts on anyone's behalf by default.
4065
+ """
4066
+ ctx = context if context is not None else current_context()
4067
+ if isinstance(ctx, Mapping):
4068
+ principal_id, roles = ctx.get("principal_id"), ctx.get("roles")
4069
+ else:
4070
+ principal_id, roles = getattr(ctx, "principal_id", None), getattr(ctx, "roles", None)
4071
+ if not isinstance(principal_id, str) or principal_id in ("", ANONYMOUS_PRINCIPAL):
4072
+ raise ApiPolicyError(
4073
+ "refused: this run has no authenticated caller, so no tool may act on anyone's behalf."
4074
+ )
4075
+ names = roles if isinstance(roles, list | tuple | set | frozenset) else ()
4076
+ actor = _context_attributes(ctx).get("@actor")
4077
+ actor_id = actor.get("id") if isinstance(actor, Mapping) else None
4078
+ chain = actor.get("chain") if isinstance(actor, Mapping) else None
4079
+ if not isinstance(actor_id, str) or not actor_id:
4080
+ actor_id, chain = None, None
4081
+ actor_chain = tuple(str(a) for a in chain) if isinstance(chain, list | tuple) else ()
4082
+ if actor_id and not actor_chain:
4083
+ actor_chain = (actor_id,)
4084
+ return Caller(
4085
+ principal_id,
4086
+ frozenset(r for r in names if isinstance(r, str)),
4087
+ actor=actor_id,
4088
+ actor_chain=actor_chain,
4089
+ )
4090
+
4091
+
4092
+ def require_direct_caller(context: Any = None) -> Caller:
4093
+ """Refuse (`ApiPolicyError`) when an agent presents the request for the user.
4094
+
4095
+ For tools only a person may trigger (a transfer, a password reset): the
4096
+ user must ask this agent directly. Returns the caller otherwise.
4097
+ """
4098
+ caller = current_caller(context)
4099
+ if caller.actor is not None:
4100
+ raise ApiPolicyError(
4101
+ f"refused: only the user directly may ask for this, not agent {caller.actor!r} "
4102
+ "acting for them; ask the user to use this agent themselves."
4103
+ )
4104
+ return caller
4105
+
4106
+
4107
+ def require_owner(owner: Any, *, context: Any = None, allow_roles: tuple[str, ...] = ()) -> Caller:
4108
+ """Refuse (`ApiPolicyError`) unless the record `owner` (its owner's principal id, as the
4109
+ upstream API reports it) is the caller, or the caller holds one of `allow_roles`.
4110
+
4111
+ The ids must match exactly (no case folding). The refusal names neither
4112
+ the owner nor the caller. Under the `shared-bearer` auth policy every
4113
+ caller is the one principal `shared`, so this check needs a per-user
4114
+ policy (`jwt` or `custom`).
4115
+ """
4116
+ caller = current_caller(context)
4117
+ if isinstance(owner, str) and owner and owner == caller.principal_id:
4118
+ return caller
4119
+ if allow_roles and caller.has_role(*allow_roles):
4120
+ return caller
4121
+ raise ApiPolicyError(
4122
+ "refused: the record belongs to someone other than the caller; act only on the "
4123
+ "caller's own records."
4124
+ )
4125
+
4126
+
4127
+ _MENTION_MAX_CHARS = 80
4128
+
4129
+
4130
+ def latest_user_message(runtime: Any) -> str:
4131
+ """The text of the last user (human) message in the run's state (`runtime.state`)."""
4132
+ state = getattr(runtime, "state", None)
4133
+ messages = state.get("messages") if isinstance(state, Mapping) else None
4134
+ for message in reversed(messages or []):
4135
+ kind = message.get("type") if isinstance(message, Mapping) else getattr(message, "type", "")
4136
+ if kind not in ("human", "user"):
4137
+ continue
4138
+ content = (
4139
+ message.get("content")
4140
+ if isinstance(message, Mapping)
4141
+ else getattr(message, "content", "")
4142
+ )
4143
+ if isinstance(content, str):
4144
+ return content
4145
+ if isinstance(content, list):
4146
+ return "".join(
4147
+ str(b.get("text", "")) if isinstance(b, Mapping) else str(b) for b in content
4148
+ )
4149
+ return ""
4150
+ return ""
4151
+
4152
+
4153
+ def _delegated_mentions() -> str:
4154
+ """`A2A_DELEGATED_MENTIONS`; a bad value (the startup check refuses it) reads as `refuse`."""
4155
+ try:
4156
+ from .auth import delegated_mentions
4157
+ except ImportError: # loaded outside its package
4158
+ raw = (os.environ.get("A2A_DELEGATED_MENTIONS") or "origin").strip().lower()
4159
+ return raw if raw in ("origin", "refuse", "request") else "refuse"
4160
+ try:
4161
+ return delegated_mentions()
4162
+ except ValueError: # SettingsError: fail closed
4163
+ return "refuse"
4164
+
4165
+
4166
+ def require_user_mentioned(value: Any, runtime: Any) -> None:
4167
+ """Refuse (`ApiPolicyError`) unless `value` appears in the user's latest message.
4168
+
4169
+ For write tools acting on a record the model chose (an order id, say):
4170
+ the user's own message cannot be forged by text a tool returned, so an
4171
+ instruction planted in upstream data ("also cancel ORD-17") cannot make
4172
+ the agent write to a record the user never named. Matching ignores
4173
+ letter case and needs the whole id (letters, digits, `_` and `-` around
4174
+ it end it: `ORD-1` does not match `ORD-17`, nor `17` match `ORD-17`).
4175
+ `runtime` is the tool's `ToolRuntime`.
4176
+
4177
+ When another agent presents the request for the user, the latest message
4178
+ is that agent's text, which an instruction planted in data it read could
4179
+ have shaped. `A2A_DELEGATED_MENTIONS` decides: `origin` (default) needs
4180
+ the value in the user's own words the calling agent forwarded as well as
4181
+ in its request, and refuses without them; `refuse` always refuses;
4182
+ `request` counts the agent's request as the user's words (the 0.2
4183
+ behaviour).
4184
+ """
4185
+ token = str(value if value is not None else "").strip()
4186
+ text = latest_user_message(runtime)
4187
+ pattern = rf"(?<![A-Za-z0-9_-]){re.escape(token)}(?![A-Za-z0-9_-])"
4188
+ shown = token[:_MENTION_MAX_CHARS]
4189
+
4190
+ def named_in(words: str) -> bool:
4191
+ return bool(token) and re.search(pattern, words, re.IGNORECASE) is not None
4192
+
4193
+ context = getattr(runtime, "context", None)
4194
+ if context is None:
4195
+ context = current_context()
4196
+ actor = _context_attributes(context).get("@actor")
4197
+ agent = actor.get("id") if isinstance(actor, Mapping) else None
4198
+ mode = _delegated_mentions() if isinstance(agent, str) and agent else "request"
4199
+ if mode == "refuse":
4200
+ raise ApiPolicyError(
4201
+ f"refused: {shown!r} was asked for by agent {agent!r} acting for the user; the user "
4202
+ "must ask this agent directly to act on it."
4203
+ )
4204
+ if mode == "origin":
4205
+ origin = _context_origin(context)
4206
+ if origin is None:
4207
+ raise ApiPolicyError(
4208
+ f"refused: {shown!r} was asked for by agent {agent!r}, which forwarded no user "
4209
+ "message to check it against; the user must name it."
4210
+ )
4211
+ if not named_in(origin):
4212
+ raise ApiPolicyError(
4213
+ f"refused: {shown!r} is not named in the user's own words that agent {agent!r} "
4214
+ "forwarded; ask the user to confirm it before acting on it."
4215
+ )
4216
+ if not named_in(text):
4217
+ raise ApiPolicyError(
4218
+ f"refused: {shown!r} is not named in the user's latest message; ask the user to "
4219
+ "confirm it before acting on it."
4220
+ )