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,2145 @@
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
+ """The outbound API access policy (``api-policy.yaml``) as the CLI sees it.
16
+
17
+ A project declares every external API its tools may call in ``api-policy.yaml``
18
+ at the project root::
19
+
20
+ apis:
21
+ orders:
22
+ base_url_env: ORDERS_API_BASE_URL
23
+ auth: bearer # none | bearer | forward | exchange
24
+ token_env: ORDERS_API_TOKEN # auth: bearer only
25
+ # auth: exchange takes exchange: {audience, scope, resource,
26
+ # allow_actorless} (RFC 8693); auth: forward may take forward_audience
27
+ allowed_methods: [GET, POST] # required, explicit; ["*"] allows every method
28
+ allowed_operations: # optional; omitted = every operation
29
+ - operationId: createOrder
30
+ path: /orders
31
+ limits: {max_calls_per_run: 20} # optional
32
+ approval: # optional: calls a human approves before sending
33
+ required_for: {methods: [POST]}
34
+ approvers: [requester] # and/or role:<name>
35
+ timeout_s: 900 # optional, 30-86400
36
+
37
+ ``approval`` may also be a list of rules of that shape (different approvers
38
+ for different calls); the first rule in file order that covers a call gates it.
39
+
40
+ The schema rules and the matching rules live in the block between the
41
+ ``SHARED API POLICY RULES`` markers. The scaffolded runtime
42
+ (``app_utils/api_client.py``) carries a byte-identical copy of that block, so
43
+ ``create --api-policy``, ``lint`` and the running agent accept the same files,
44
+ report the same errors and refuse the same calls. The rest of this module is
45
+ CLI-only: reading the file, summarising it for the templates, and detecting
46
+ the retired single-API ``product_api`` format. ``graph-agents-cli api`` edits
47
+ the file (``graph_agents_cli.api``).
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import json
53
+ import logging
54
+ import re
55
+ from collections.abc import Mapping
56
+ from dataclasses import dataclass
57
+ from pathlib import Path
58
+ from typing import Any
59
+ from urllib.parse import unquote
60
+
61
+ import click
62
+ import yaml
63
+
64
+ # --- BEGIN SHARED API POLICY RULES ---
65
+ # Identical in graph-agents-cli (graph_agents_cli/_api_policy.py) and in every
66
+ # scaffolded project (app_utils/api_client.py). A CLI test keeps the two copies
67
+ # byte-identical: change both or neither.
68
+
69
+ POLICY_FILENAME = "api-policy.yaml"
70
+ AUTH_MODES = ("none", "bearer", "forward", "exchange")
71
+ # The modes that send the caller's identity in `forward_header` (default Authorization):
72
+ # `forward` the caller's own credential, `exchange` a token the issuer mints for the API in
73
+ # exchange for the caller's (RFC 8693, configured by the API's `exchange` block).
74
+ HEADER_AUTH_MODES = ("forward", "exchange")
75
+ EXCHANGE_KEY = "exchange"
76
+ # `exchange.allow_actorless: true` lets an `auth: exchange` API be called with an exchanged
77
+ # token that names no actor (no `act` claim, or one that is not a readable JWT); the calling
78
+ # agent refuses such tokens otherwise, since the agent behind the API would read them as the
79
+ # user's own unless it sets AUTH_JWT_DIRECT_CLIENTS.
80
+ ALLOW_ACTORLESS_KEY = "allow_actorless"
81
+ HTTP_METHODS = ("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
82
+ ANY_METHOD = "*"
83
+ DEFAULT_FORWARD_HEADER = "Authorization"
84
+ DEFAULT_TIMEOUTS_MS = (("connect", 2000), ("read", 5000))
85
+
86
+ API_NAME_RE = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
87
+ API_NAME_RULE = "lowercase letters, digits and underscores, starting with a letter, 1-32 characters"
88
+ ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
89
+ HEADER_NAME_RE = re.compile(r"^[A-Za-z0-9-]+$")
90
+ _PATH_SEGMENT_RE = re.compile(r"^(?:[^/?#\s{}]|\{[A-Za-z_][A-Za-z0-9_]*\})+$")
91
+ _PLACEHOLDER_SPLIT_RE = re.compile(r"(\{[^/{}]+\})")
92
+ _SPACE_BY_DOT_RE = re.compile(r"\s\.|\.\s")
93
+
94
+ _ESCAPE_RE = re.compile(r"%[0-9A-Fa-f]{2}")
95
+ _UNRESERVED = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
96
+
97
+ # An API's `protocol` says how its calls are judged: `http` (the default) by method, path
98
+ # and the operation id the tool names; `jsonrpc` also by the JSON-RPC request every POST
99
+ # sends, whose method (`rpc_method`) the policy client reads from the body, never from the
100
+ # tool; `a2a` (another agent, over A2A 1.0 JSON-RPC) as `jsonrpc`, plus what a message
101
+ # decides (`a2a_operation`: `approve` or `reject` a pending approval of that agent). A
102
+ # JSON-RPC API allows GET, POST and HEAD only, and an `a2a` one names its endpoint
103
+ # (`a2a.path`) and must gate or deny `a2a_operation: approve` if it can send messages.
104
+ PROTOCOL_KEY = "protocol"
105
+ PROTOCOL_HTTP = "http"
106
+ PROTOCOL_JSONRPC = "jsonrpc"
107
+ PROTOCOL_A2A = "a2a"
108
+ PROTOCOLS = (PROTOCOL_HTTP, PROTOCOL_JSONRPC, PROTOCOL_A2A)
109
+ DEFAULT_PROTOCOL = PROTOCOL_HTTP
110
+ RPC_PROTOCOLS = (PROTOCOL_JSONRPC, PROTOCOL_A2A)
111
+ RPC_HTTP_METHODS = ("GET", "POST", "HEAD")
112
+ A2A_KEY = "a2a"
113
+ _A2A_KEYS = ("path",)
114
+ DESCRIPTION_KEY = "description"
115
+ DESCRIPTION_MAX_CHARS = 300
116
+ RPC_METHOD_KEY = "rpc_method"
117
+ A2A_OPERATION_KEY = "a2a_operation"
118
+ A2A_APPROVE = "approve"
119
+ A2A_REJECT = "reject"
120
+ A2A_OPERATIONS = (A2A_APPROVE, A2A_REJECT)
121
+ # A JSON-RPC method name as an operation entry pins it.
122
+ _RPC_METHOD_RE = re.compile(r"[A-Za-z][A-Za-z0-9_/.]{0,63}")
123
+ # The A2A 0.3 method names and the A2A 1.0 names they are read as under `protocol: a2a`,
124
+ # so a 0.3 spelling of a call cannot slip past an entry that names it.
125
+ A2A_V03_METHODS = {
126
+ "message/send": "SendMessage",
127
+ "message/stream": "SendStreamingMessage",
128
+ "tasks/get": "GetTask",
129
+ "tasks/list": "ListTasks",
130
+ "tasks/cancel": "CancelTask",
131
+ "tasks/resubscribe": "SubscribeToTask",
132
+ "tasks/pushNotificationConfig/set": "CreateTaskPushNotificationConfig",
133
+ "tasks/pushNotificationConfig/get": "GetTaskPushNotificationConfig",
134
+ "tasks/pushNotificationConfig/list": "ListTaskPushNotificationConfigs",
135
+ "tasks/pushNotificationConfig/delete": "DeleteTaskPushNotificationConfig",
136
+ "agent/getAuthenticatedExtendedCard": "GetExtendedAgentCard",
137
+ }
138
+ # The A2A methods that send a message, which may carry a decision on an approval.
139
+ A2A_MESSAGE_METHODS = ("SendMessage", "SendStreamingMessage")
140
+ # Their names in any letter case, 0.3 spellings included: a message sent under any of them
141
+ # is read for a decision (failing closed toward a server that matched names loosely).
142
+ _A2A_MESSAGE_NAMES = frozenset(
143
+ name.casefold() for name in (*A2A_MESSAGE_METHODS, "message/send", "message/stream")
144
+ )
145
+ # The members of one JSON-RPC 2.0 request object.
146
+ _JSONRPC_KEYS = ("jsonrpc", "method", "params", "id")
147
+
148
+ _POLICY_KEYS = ("apis",)
149
+ _API_KEYS = (
150
+ DESCRIPTION_KEY,
151
+ PROTOCOL_KEY,
152
+ A2A_KEY,
153
+ "base_url_env",
154
+ "auth",
155
+ "token_env",
156
+ "forward_header",
157
+ "forward_audience",
158
+ EXCHANGE_KEY,
159
+ "allowed_methods",
160
+ "allowed_operations",
161
+ "denied_operations",
162
+ "openapi",
163
+ "timeouts_ms",
164
+ "pagination",
165
+ "limits",
166
+ "approval",
167
+ )
168
+ _OPERATION_KEYS = ("operationId", "path", "methods", RPC_METHOD_KEY, A2A_OPERATION_KEY)
169
+ _TIMEOUT_KEYS = ("connect", "read")
170
+ _PAGINATION_KEYS = ("page_size_param", "max_page_size")
171
+ _LIMIT_KEYS = ("max_calls_per_run", "rate_per_minute", "max_response_bytes")
172
+ # `limits.max_response_bytes`: the most a response body may hold (decoded) before the
173
+ # client stops reading it and discards it. Unset: no cap (as in 0.2).
174
+ MAX_RESPONSE_BYTES_LIMIT = 67108864
175
+ _APPROVAL_KEYS = ("required_for", "approvers", "timeout_s", "decide_with", "relayers")
176
+ _REQUIRED_FOR_KEYS = ("methods", "operations")
177
+ _EXCHANGE_KEYS = ("audience", "scope", "resource", ALLOW_ACTORLESS_KEY)
178
+ # An RFC 6749 scope: space-separated scope tokens (printable ASCII but space, " " and "\").
179
+ _SCOPE_RE = re.compile(r"[\x21\x23-\x5b\x5d-\x7e]+(?: [\x21\x23-\x5b\x5d-\x7e]+)*")
180
+ # An absolute URI (RFC 8707 `resource`): a scheme, then no whitespace and no fragment.
181
+ _ABSOLUTE_URI_RE = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:[^\s#\x00-\x1f\x7f]+")
182
+
183
+ # An API's `approval` block names the calls a human must approve before they are
184
+ # sent (`required_for`), who may approve them (`approvers`) and how long a
185
+ # pending approval waits before it expires, which rejects the call
186
+ # (`timeout_s`). It is one such rule (a mapping), or a non-empty list of rules
187
+ # of that same shape when different calls need different approvers: a call is
188
+ # gated by the FIRST rule, in file order, whose `required_for` covers it, and a
189
+ # later rule that also covers it does not apply to it. A call that an earlier
190
+ # rule covers only because it leaves out what the rule knows the operation by
191
+ # (no operation id, no path), and that a later rule with other approvers also
192
+ # covers, is refused (`ApprovalRuleConflict`): it could be either rule's call.
193
+ # It never widens access: a gated call must still be allowed, and denials still
194
+ # win. It belongs to the API only: on an operation entry the key is refused,
195
+ # with a pointer to `approval.required_for.operations`. A rule may also say how
196
+ # the requester decides (`decide_with`): `direct` (the default: with their own
197
+ # credentials, at this agent), or `relayed`, where the agents `relayers` names
198
+ # (by their actor ids) may deliver the requester's decision from another agent.
199
+ APPROVAL_KEY = "approval"
200
+ REQUESTER_APPROVER = "requester" # the principal who started the run confirms
201
+ ROLE_APPROVER_PREFIX = "role:" # any principal holding the role decides
202
+ DEFAULT_APPROVAL_TIMEOUT_S = 900
203
+ MIN_APPROVAL_TIMEOUT_S = 30
204
+ MAX_APPROVAL_TIMEOUT_S = 86400
205
+ # How approvers decide once an approval rule takes `decide_with` (0.3): `direct` by
206
+ # default, each with their own credential. `relayed`, with the `relayers` it names, lets
207
+ # those agents deliver the requester's decision: an opt-in that each callee's gate reviews.
208
+ DEFAULT_DECIDE_WITH = "direct"
209
+ DECIDE_DIRECT = "direct"
210
+ DECIDE_RELAYED = "relayed"
211
+ DECIDE_WITH_VALUES = (DECIDE_DIRECT, DECIDE_RELAYED)
212
+ # A value kept for a later release: a decision signed by the identity provider.
213
+ DECIDE_STEP_UP = "step_up"
214
+ # Role names and actor ids (`relayers`): 1-256 characters, no whitespace, commas or
215
+ # control characters.
216
+ _ROLE_NAME_RE = re.compile(r"[^\s,\x00-\x1f\x7f]{1,256}")
217
+
218
+ LEGACY_POLICY_HINT = (
219
+ "product_api: is the retired single-API format: move its fields under "
220
+ "apis: <name>: (for example apis: example:), add the now required "
221
+ "allowed_methods (for example [GET]), write auth: forward instead of "
222
+ "forwarded-session, and name the file api-policy.yaml"
223
+ )
224
+
225
+
226
+ class PolicyLoader(yaml.SafeLoader):
227
+ """``yaml.SafeLoader`` that refuses a key repeated within one mapping, at any level.
228
+
229
+ Plain ``safe_load`` silently keeps the last duplicate, so a reviewer reading
230
+ ``allowed_methods: [GET, POST]`` would miss a later ``allowed_methods: ["*"]``
231
+ that is the one applied. Merge keys (``<<: *anchor``) still work.
232
+ """
233
+
234
+ def construct_mapping(self, node: Any, deep: bool = False) -> Any:
235
+ if isinstance(node, yaml.MappingNode):
236
+ seen: set[Any] = set()
237
+ for key_node, _value_node in node.value:
238
+ if key_node.tag == "tag:yaml.org,2002:merge":
239
+ continue
240
+ key = self.construct_object(key_node, deep=deep)
241
+ try:
242
+ duplicate = key in seen
243
+ except TypeError: # an unhashable key: the base loader reports it
244
+ continue
245
+ if duplicate:
246
+ raise yaml.constructor.ConstructorError(
247
+ "while constructing a mapping",
248
+ node.start_mark,
249
+ f"found duplicate key {key!r}",
250
+ key_node.start_mark,
251
+ )
252
+ seen.add(key)
253
+ return super().construct_mapping(node, deep=deep)
254
+
255
+
256
+ def parse_policy_yaml(text: str) -> tuple[Any, list[str]]:
257
+ """Parse api-policy.yaml text: ``(data, [])``, or ``(None, [error])`` when it is
258
+ not valid YAML (a duplicate key included)."""
259
+ try:
260
+ return yaml.load(text, Loader=PolicyLoader), []
261
+ except yaml.YAMLError as exc:
262
+ return None, [f"not valid YAML: {exc}"]
263
+
264
+
265
+ def policy_errors(data: Any) -> list[str]:
266
+ """Every schema error in a parsed api-policy.yaml document; empty when it is valid.
267
+
268
+ Strict: unknown keys at any level are errors, so a typo can never widen access.
269
+ """
270
+ if not isinstance(data, Mapping):
271
+ return ["the document must be a mapping with a top-level 'apis' key"]
272
+ errors: list[str] = []
273
+ if "product_api" in data:
274
+ errors.append(LEGACY_POLICY_HINT)
275
+ for key in sorted(set(data) - set(_POLICY_KEYS) - {"product_api"}, key=str):
276
+ errors.append(f"unknown top-level key {key!r} (allowed: apis)")
277
+ if "apis" not in data:
278
+ if "product_api" not in data:
279
+ errors.append("apis: required (a mapping of API name to its settings)")
280
+ return errors
281
+ apis = data["apis"]
282
+ if not isinstance(apis, Mapping) or not apis:
283
+ errors.append("apis: must be a non-empty mapping of API name to its settings")
284
+ return errors
285
+ for name, api in apis.items():
286
+ errors.extend(_api_errors(name, api))
287
+ return errors
288
+
289
+
290
+ def _is_env_name(value: Any) -> bool:
291
+ return isinstance(value, str) and ENV_NAME_RE.match(value) is not None
292
+
293
+
294
+ def _is_audience(value: Any) -> bool:
295
+ """An audience (a token's `aud`): 1-256 characters, no whitespace, commas or control
296
+ characters (the target's `AUTH_JWT_AUDIENCE` is a comma list of them)."""
297
+ return isinstance(value, str) and _ROLE_NAME_RE.fullmatch(value) is not None
298
+
299
+
300
+ def _exchange_errors(where: str, value: Any) -> list[str]:
301
+ """Errors of an API's `exchange` block (`auth: exchange`, RFC 8693)."""
302
+ if not isinstance(value, Mapping):
303
+ return [
304
+ f"{where}: must be a mapping with audience, and optionally scope, resource and "
305
+ f"{ALLOW_ACTORLESS_KEY}"
306
+ ]
307
+ errors = [
308
+ f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_EXCHANGE_KEYS), key=str)
309
+ ]
310
+ if "audience" not in value:
311
+ errors.append(
312
+ f"{where}.audience: required (the audience the issuer mints the token for: the "
313
+ "target's AUTH_JWT_AUDIENCE)"
314
+ )
315
+ elif not _is_audience(value["audience"]):
316
+ errors.append(
317
+ f"{where}.audience: must be an audience (1-256 characters without spaces, commas or "
318
+ "control characters)"
319
+ )
320
+ if "scope" in value:
321
+ scope = value["scope"]
322
+ if not (isinstance(scope, str) and _SCOPE_RE.fullmatch(scope)):
323
+ errors.append(
324
+ f"{where}.scope: must be scopes separated by single spaces (RFC 6749: printable "
325
+ "ASCII, no quotes or backslashes)"
326
+ )
327
+ if "resource" in value:
328
+ resource = value["resource"]
329
+ if not (isinstance(resource, str) and _ABSOLUTE_URI_RE.fullmatch(resource)):
330
+ errors.append(
331
+ f"{where}.resource: must be an absolute URI without a fragment (RFC 8707), "
332
+ "such as https://orders.example.com"
333
+ )
334
+ if ALLOW_ACTORLESS_KEY in value and not isinstance(value[ALLOW_ACTORLESS_KEY], bool):
335
+ errors.append(
336
+ f"{where}.{ALLOW_ACTORLESS_KEY}: must be true or false (true: accept exchanged "
337
+ "tokens that name no actor, once the agent behind the API sets "
338
+ "AUTH_JWT_DIRECT_CLIENTS)"
339
+ )
340
+ return errors
341
+
342
+
343
+ def _api_errors(name: Any, api: Any) -> list[str]:
344
+ where = f"apis.{name}"
345
+ errors: list[str] = []
346
+ if not isinstance(name, str) or not API_NAME_RE.match(name):
347
+ errors.append(f"{where}: invalid API name ({API_NAME_RULE})")
348
+ if not isinstance(api, Mapping):
349
+ errors.append(f"{where}: must be a mapping")
350
+ return errors
351
+ for key in sorted(set(api) - set(_API_KEYS), key=str):
352
+ errors.append(f"{where}: unknown key {key!r}")
353
+
354
+ if "base_url_env" not in api:
355
+ errors.append(f"{where}.base_url_env: required")
356
+ elif not _is_env_name(api["base_url_env"]):
357
+ errors.append(f"{where}.base_url_env: must be an environment variable name")
358
+
359
+ auth = api.get("auth")
360
+ modes = ", ".join(AUTH_MODES)
361
+ if "auth" not in api:
362
+ errors.append(f"{where}.auth: required (one of {modes})")
363
+ elif auth not in AUTH_MODES:
364
+ errors.append(f"{where}.auth: must be one of {modes} (got {auth!r})")
365
+
366
+ if auth == "bearer":
367
+ if "token_env" not in api:
368
+ errors.append(f"{where}.token_env: required when auth is bearer")
369
+ elif not _is_env_name(api["token_env"]):
370
+ errors.append(f"{where}.token_env: must be an environment variable name")
371
+ elif "token_env" in api:
372
+ errors.append(f"{where}.token_env: only valid with auth: bearer")
373
+
374
+ if "forward_header" in api:
375
+ header = api["forward_header"]
376
+ if auth not in HEADER_AUTH_MODES:
377
+ errors.append(f"{where}.forward_header: only valid with auth: forward or exchange")
378
+ elif not (isinstance(header, str) and HEADER_NAME_RE.match(header)):
379
+ errors.append(f"{where}.forward_header: must be an HTTP header name")
380
+
381
+ if "forward_audience" in api:
382
+ if auth != "forward":
383
+ errors.append(f"{where}.forward_audience: only valid with auth: forward")
384
+ elif not _is_audience(api["forward_audience"]):
385
+ errors.append(
386
+ f"{where}.forward_audience: must be an audience (1-256 characters without "
387
+ "spaces, commas or control characters)"
388
+ )
389
+
390
+ if auth == "exchange":
391
+ if EXCHANGE_KEY not in api:
392
+ errors.append(
393
+ f"{where}.{EXCHANGE_KEY}: required when auth is exchange (a mapping with the "
394
+ "audience the issuer mints the token for, and optionally scope, resource and "
395
+ f"{ALLOW_ACTORLESS_KEY})"
396
+ )
397
+ else:
398
+ errors.extend(_exchange_errors(f"{where}.{EXCHANGE_KEY}", api[EXCHANGE_KEY]))
399
+ elif EXCHANGE_KEY in api:
400
+ errors.append(f"{where}.{EXCHANGE_KEY}: only valid with auth: exchange")
401
+
402
+ protocol = api.get(PROTOCOL_KEY, DEFAULT_PROTOCOL)
403
+ errors.extend(_protocol_errors(where, api, protocol, auth))
404
+
405
+ if "allowed_methods" not in api:
406
+ errors.append(f'{where}.allowed_methods: required (a list of HTTP methods, or ["*"])')
407
+ else:
408
+ method_errors = _methods_errors(f"{where}.allowed_methods", api["allowed_methods"], True)
409
+ errors.extend(method_errors)
410
+ if protocol in RPC_PROTOCOLS and not method_errors:
411
+ outside = [
412
+ str(m).upper()
413
+ for m in api["allowed_methods"]
414
+ if str(m).upper() not in RPC_HTTP_METHODS
415
+ ]
416
+ if outside:
417
+ errors.append(
418
+ f"{where}.allowed_methods: protocol {protocol} allows GET, POST and HEAD "
419
+ f"only (a JSON-RPC request is a POST), not {', '.join(outside)}"
420
+ )
421
+
422
+ if "allowed_operations" in api:
423
+ errors.extend(
424
+ _operations_errors(
425
+ f"{where}.allowed_operations",
426
+ api["allowed_operations"],
427
+ where,
428
+ "must not be empty; omit the key to allow every operation within allowed_methods",
429
+ protocol=protocol,
430
+ )
431
+ )
432
+ if "denied_operations" in api:
433
+ errors.extend(
434
+ _operations_errors(
435
+ f"{where}.denied_operations", api["denied_operations"], where, protocol=protocol
436
+ )
437
+ )
438
+
439
+ if "openapi" in api:
440
+ openapi = api["openapi"]
441
+ if not (isinstance(openapi, str) and openapi.strip()):
442
+ errors.append(f"{where}.openapi: must be a file path")
443
+
444
+ if "timeouts_ms" in api:
445
+ errors.extend(_timeouts_errors(f"{where}.timeouts_ms", api["timeouts_ms"]))
446
+ if "pagination" in api:
447
+ errors.extend(_pagination_errors(f"{where}.pagination", api["pagination"]))
448
+ if "limits" in api:
449
+ errors.extend(_limits_errors(f"{where}.limits", api["limits"]))
450
+ if APPROVAL_KEY in api:
451
+ errors.extend(_approval_errors(where, api[APPROVAL_KEY], protocol=protocol))
452
+ if not errors:
453
+ errors.extend(_approve_errors(where, api))
454
+ return errors
455
+
456
+
457
+ def _protocol_errors(where: str, api: Mapping[str, Any], protocol: Any, auth: Any) -> list[str]:
458
+ """Errors of an API's `protocol`, `a2a` and `description`."""
459
+ errors: list[str] = []
460
+ if protocol not in PROTOCOLS:
461
+ errors.append(
462
+ f"{where}.{PROTOCOL_KEY}: must be one of {', '.join(PROTOCOLS)} (got {protocol!r})"
463
+ )
464
+ if protocol == PROTOCOL_A2A:
465
+ if A2A_KEY not in api:
466
+ errors.append(
467
+ f"{where}.{A2A_KEY}: required with protocol a2a (a mapping with path, the "
468
+ "agent's A2A endpoint, such as /a2a/orders)"
469
+ )
470
+ else:
471
+ errors.extend(_a2a_errors(f"{where}.{A2A_KEY}", api[A2A_KEY]))
472
+ if auth == "none":
473
+ errors.append(
474
+ f"{where}.auth: protocol a2a needs a credential (bearer, forward or exchange): "
475
+ "an agent's A2A endpoint authenticates its callers"
476
+ )
477
+ elif A2A_KEY in api:
478
+ errors.append(f"{where}.{A2A_KEY}: only valid with protocol a2a")
479
+ if DESCRIPTION_KEY in api:
480
+ description = api[DESCRIPTION_KEY]
481
+ if not (
482
+ isinstance(description, str)
483
+ and description.strip()
484
+ and len(description) <= DESCRIPTION_MAX_CHARS
485
+ and not any(ord(c) < 0x20 or 0x7F <= ord(c) < 0xA0 for c in description)
486
+ ):
487
+ errors.append(
488
+ f"{where}.{DESCRIPTION_KEY}: must be text of 1-{DESCRIPTION_MAX_CHARS} "
489
+ "characters without control characters"
490
+ )
491
+ return errors
492
+
493
+
494
+ def _a2a_errors(where: str, value: Any) -> list[str]:
495
+ """Errors of an API's `a2a` block: `path`, the literal path of the agent's A2A endpoint."""
496
+ if not isinstance(value, Mapping):
497
+ return [f"{where}: must be a mapping with path (the agent's A2A endpoint, /a2a/<name>)"]
498
+ errors = [
499
+ f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_A2A_KEYS), key=str)
500
+ ]
501
+ if "path" not in value:
502
+ errors.append(f"{where}.path: required (the agent's A2A endpoint, such as /a2a/orders)")
503
+ return errors
504
+ path = value["path"]
505
+ problem = path_template_problem(path)
506
+ if problem:
507
+ errors.append(f"{where}.path: {problem}")
508
+ elif "{" in path or path.rstrip("/") == "":
509
+ errors.append(
510
+ f"{where}.path: must be the literal path of one endpoint (no placeholders), such "
511
+ "as /a2a/orders"
512
+ )
513
+ return errors
514
+
515
+
516
+ def api_protocol(api: Mapping[str, Any]) -> str:
517
+ """An API's `protocol`: `http` when it sets none."""
518
+ return str(api.get(PROTOCOL_KEY) or DEFAULT_PROTOCOL)
519
+
520
+
521
+ def _rpc_pins(entry: Mapping[str, Any]) -> bool:
522
+ """Whether an operation entry pins what a JSON-RPC request is (`rpc_method`, `a2a_operation`)."""
523
+ return entry.get(RPC_METHOD_KEY) is not None or entry.get(A2A_OPERATION_KEY) is not None
524
+
525
+
526
+ def _may_send_approve(api: Mapping[str, Any]) -> bool:
527
+ """Whether an A2A API's allow-list may let through a message that approves (`approve`)."""
528
+ methods = {str(m).upper() for m in api.get("allowed_methods") or []}
529
+ if "POST" not in methods and ANY_METHOD not in methods:
530
+ return False
531
+ allowed = api.get("allowed_operations")
532
+ if allowed is None:
533
+ return True
534
+ return any(
535
+ _methods_match(entry, "POST")
536
+ and entry.get(RPC_METHOD_KEY) in (None, *A2A_MESSAGE_METHODS)
537
+ and entry.get(A2A_OPERATION_KEY) in (None, A2A_APPROVE)
538
+ for entry in allowed
539
+ )
540
+
541
+
542
+ def _covers_every_approve(entry: Mapping[str, Any]) -> bool:
543
+ """Whether a denial or gate entry covers every message that approves, on any path."""
544
+ return entry.get(A2A_OPERATION_KEY) == A2A_APPROVE and _methods_match(entry, "POST")
545
+
546
+
547
+ def approve_is_held(api: Mapping[str, Any]) -> bool:
548
+ """Whether every message that approves waits for a human approval, or is denied.
549
+
550
+ An approval rule gating POST (or `"*"`), or an entry `a2a_operation: approve` (with no
551
+ methods, or POST among them) in a rule's `required_for.operations` or in
552
+ `denied_operations`. An entry pinning `rpc_method: SendMessage` does not count: it
553
+ leaves `SendStreamingMessage` out.
554
+ """
555
+ for rule in approval_rules(api):
556
+ required_for = rule.get("required_for") or {}
557
+ methods = {str(m).upper() for m in required_for.get("methods") or []}
558
+ if "POST" in methods or ANY_METHOD in methods:
559
+ return True
560
+ if any(_covers_every_approve(entry) for entry in required_for.get("operations") or []):
561
+ return True
562
+ return any(_covers_every_approve(entry) for entry in api.get("denied_operations") or [])
563
+
564
+
565
+ def _approve_errors(where: str, api: Mapping[str, Any]) -> list[str]:
566
+ """A `protocol: a2a` API that may send a message must gate or deny `approve`: otherwise
567
+ this agent could decide, on its own, the approvals the agent behind it waits for."""
568
+ if api_protocol(api) != PROTOCOL_A2A or not _may_send_approve(api) or approve_is_held(api):
569
+ return []
570
+ name = where.split(".", 1)[1] if "." in where else where
571
+ agent = str((api.get(A2A_KEY) or {}).get("path") or "").rstrip("/").rsplit("/", 1)[-1]
572
+ return [
573
+ f"{where}: protocol a2a allows SendMessage, so this agent could decide approvals at "
574
+ f"{agent or name}: gate them (graph-agents-cli api approval {name} --a2a-operations "
575
+ f"approve --approvers requester) or deny them (graph-agents-cli api deny {name} "
576
+ "--a2a-operation approve)"
577
+ ]
578
+
579
+
580
+ def _methods_errors(where: str, value: Any, allow_any: bool) -> list[str]:
581
+ if not isinstance(value, list) or not value:
582
+ return [f"{where}: must be a non-empty list of HTTP methods"]
583
+ errors: list[str] = []
584
+ if allow_any and ANY_METHOD in value and len(value) != 1:
585
+ errors.append(f'{where}: "*" must be the only entry when present')
586
+ for method in value:
587
+ if allow_any and method == ANY_METHOD:
588
+ continue
589
+ if not isinstance(method, str) or method.upper() not in HTTP_METHODS:
590
+ errors.append(
591
+ f"{where}: unknown HTTP method {method!r} (allowed: {', '.join(HTTP_METHODS)})"
592
+ )
593
+ return errors
594
+
595
+
596
+ def _operations_errors(
597
+ where: str,
598
+ value: Any,
599
+ api_where: str,
600
+ empty_error: str | None = None,
601
+ *,
602
+ protocol: Any = DEFAULT_PROTOCOL,
603
+ ) -> list[str]:
604
+ """Errors of a list of operation entries; ``empty_error`` refuses an empty list.
605
+
606
+ ``rpc_method`` is valid only with `protocol` jsonrpc or a2a, and ``a2a_operation``
607
+ only with a2a.
608
+ """
609
+ if not isinstance(value, list):
610
+ return [f"{where}: must be a list of operations"]
611
+ if not value and empty_error:
612
+ return [f"{where}: {empty_error}"]
613
+ errors: list[str] = []
614
+ for index, entry in enumerate(value):
615
+ at = f"{where}[{index}]"
616
+ if not isinstance(entry, Mapping):
617
+ errors.append(f"{at}: must be a mapping with operationId and/or path")
618
+ continue
619
+ for key in sorted(set(entry) - set(_OPERATION_KEYS) - {APPROVAL_KEY}, key=str):
620
+ errors.append(f"{at}: unknown key {key!r}")
621
+ if APPROVAL_KEY in entry:
622
+ errors.append(
623
+ f"{at}.{APPROVAL_KEY}: not valid on an operation entry; gate the operation "
624
+ f"with {api_where}.{APPROVAL_KEY}.required_for.operations"
625
+ )
626
+ if protocol in RPC_PROTOCOLS:
627
+ if not any(key in entry for key in _OPERATION_KEYS if key != "methods"):
628
+ errors.append(f"{at}: needs operationId, path, rpc_method and/or a2a_operation")
629
+ elif "operationId" not in entry and "path" not in entry:
630
+ errors.append(f"{at}: needs operationId and/or path")
631
+ errors.extend(_rpc_entry_errors(at, entry, protocol))
632
+ if "operationId" in entry:
633
+ op_id = entry["operationId"]
634
+ if not isinstance(op_id, str) or not op_id or any(c.isspace() for c in op_id):
635
+ errors.append(f"{at}.operationId: must be a non-empty string without spaces")
636
+ if "path" in entry:
637
+ problem = path_template_problem(entry["path"])
638
+ if problem:
639
+ errors.append(f"{at}.path: {problem}")
640
+ if "methods" in entry:
641
+ errors.extend(_methods_errors(f"{at}.methods", entry["methods"], False))
642
+ return errors
643
+
644
+
645
+ def _rpc_entry_errors(at: str, entry: Mapping[str, Any], protocol: Any) -> list[str]:
646
+ """Errors of an operation entry's `rpc_method` and `a2a_operation`."""
647
+ errors: list[str] = []
648
+ rpc_method = entry.get(RPC_METHOD_KEY)
649
+ if RPC_METHOD_KEY in entry:
650
+ if protocol not in RPC_PROTOCOLS:
651
+ errors.append(f"{at}.{RPC_METHOD_KEY}: only valid with protocol jsonrpc or a2a")
652
+ elif not (isinstance(rpc_method, str) and _RPC_METHOD_RE.fullmatch(rpc_method)):
653
+ errors.append(
654
+ f"{at}.{RPC_METHOD_KEY}: must be a JSON-RPC method name (a letter, then up to 63 "
655
+ "letters, digits, '_', '/' or '.')"
656
+ )
657
+ elif protocol == PROTOCOL_A2A and rpc_method in A2A_V03_METHODS:
658
+ errors.append(
659
+ f"{at}.{RPC_METHOD_KEY}: {rpc_method} is the A2A 0.3 name; write "
660
+ f"{A2A_V03_METHODS[rpc_method]} (a 0.3 name in a request is read as its 1.0 name)"
661
+ )
662
+ if A2A_OPERATION_KEY in entry:
663
+ operation = entry[A2A_OPERATION_KEY]
664
+ if protocol != PROTOCOL_A2A:
665
+ errors.append(f"{at}.{A2A_OPERATION_KEY}: only valid with protocol a2a")
666
+ elif operation not in A2A_OPERATIONS:
667
+ errors.append(f"{at}.{A2A_OPERATION_KEY}: must be approve or reject")
668
+ elif rpc_method is not None and rpc_method not in A2A_MESSAGE_METHODS:
669
+ errors.append(
670
+ f"{at}.{A2A_OPERATION_KEY}: goes with rpc_method SendMessage or "
671
+ f"SendStreamingMessage (the messages that decide an approval), not {rpc_method}"
672
+ )
673
+ return errors
674
+
675
+
676
+ def _is_positive_int(value: Any) -> bool:
677
+ return isinstance(value, int) and not isinstance(value, bool) and value > 0
678
+
679
+
680
+ def _timeouts_errors(where: str, value: Any) -> list[str]:
681
+ if not isinstance(value, Mapping):
682
+ return [f"{where}: must be a mapping with connect and/or read (milliseconds)"]
683
+ errors = [
684
+ f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_TIMEOUT_KEYS), key=str)
685
+ ]
686
+ for key in _TIMEOUT_KEYS:
687
+ if key in value and not _is_positive_int(value[key]):
688
+ errors.append(f"{where}.{key}: must be a positive integer (milliseconds)")
689
+ return errors
690
+
691
+
692
+ def _pagination_errors(where: str, value: Any) -> list[str]:
693
+ if not isinstance(value, Mapping):
694
+ return [f"{where}: must be a mapping with page_size_param and max_page_size"]
695
+ errors = [
696
+ f"{where}: unknown key {key!r}"
697
+ for key in sorted(set(value) - set(_PAGINATION_KEYS), key=str)
698
+ ]
699
+ param = value.get("page_size_param")
700
+ if "page_size_param" not in value:
701
+ errors.append(f"{where}.page_size_param: required")
702
+ elif not (isinstance(param, str) and param.strip()):
703
+ errors.append(f"{where}.page_size_param: must be a non-empty string")
704
+ if "max_page_size" not in value:
705
+ errors.append(f"{where}.max_page_size: required")
706
+ elif not _is_positive_int(value["max_page_size"]):
707
+ errors.append(f"{where}.max_page_size: must be a positive integer")
708
+ return errors
709
+
710
+
711
+ def _limits_errors(where: str, value: Any) -> list[str]:
712
+ if not isinstance(value, Mapping) or not value:
713
+ return [
714
+ f"{where}: must be a mapping with max_calls_per_run, rate_per_minute and/or "
715
+ "max_response_bytes"
716
+ ]
717
+ errors = [
718
+ f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_LIMIT_KEYS), key=str)
719
+ ]
720
+ for key in _LIMIT_KEYS:
721
+ if key in value and not _is_positive_int(value[key]):
722
+ errors.append(f"{where}.{key}: must be an integer >= 1")
723
+ size = value.get("max_response_bytes")
724
+ if _is_positive_int(size) and size > MAX_RESPONSE_BYTES_LIMIT:
725
+ errors.append(
726
+ f"{where}.max_response_bytes: must be an integer from 1 to "
727
+ f"{MAX_RESPONSE_BYTES_LIMIT} (bytes; 64 MiB at most)"
728
+ )
729
+ return errors
730
+
731
+
732
+ def _approval_errors(api_where: str, value: Any, *, protocol: Any = DEFAULT_PROTOCOL) -> list[str]:
733
+ """Errors of an API's `approval`: one rule (a mapping), or a non-empty list of rules."""
734
+ where = f"{api_where}.{APPROVAL_KEY}"
735
+ if isinstance(value, list):
736
+ if not value:
737
+ return [f"{where}: must not be empty; omit the key when no call needs approval"]
738
+ errors: list[str] = []
739
+ for index, rule in enumerate(value):
740
+ errors.extend(
741
+ _approval_rule_errors(f"{where}[{index}]", rule, api_where, protocol=protocol)
742
+ )
743
+ return errors
744
+ if not isinstance(value, Mapping):
745
+ return [
746
+ f"{where}: must be a mapping with required_for and approvers, or a non-empty "
747
+ "list of such mappings (rules; the first that covers a call gates it)"
748
+ ]
749
+ return _approval_rule_errors(where, value, api_where, protocol=protocol)
750
+
751
+
752
+ def _approval_rule_errors(
753
+ where: str, value: Any, api_where: str, *, protocol: Any = DEFAULT_PROTOCOL
754
+ ) -> list[str]:
755
+ """Errors of one approval rule (``where``: ``apis.<name>.approval`` or ``...approval[i]``)."""
756
+ if not isinstance(value, Mapping):
757
+ return [f"{where}: must be a mapping with required_for and approvers"]
758
+ errors = [
759
+ f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_APPROVAL_KEYS), key=str)
760
+ ]
761
+ if "required_for" not in value:
762
+ errors.append(f"{where}.required_for: required (the methods and/or operations it gates)")
763
+ else:
764
+ errors.extend(
765
+ _required_for_errors(
766
+ f"{where}.required_for", value["required_for"], api_where, protocol=protocol
767
+ )
768
+ )
769
+ if "approvers" not in value:
770
+ errors.append(f'{where}.approvers: required (a list of "requester" and/or "role:<name>")')
771
+ else:
772
+ errors.extend(_approvers_errors(f"{where}.approvers", value["approvers"]))
773
+ if "timeout_s" in value:
774
+ timeout = value["timeout_s"]
775
+ if not (
776
+ isinstance(timeout, int)
777
+ and not isinstance(timeout, bool)
778
+ and MIN_APPROVAL_TIMEOUT_S <= timeout <= MAX_APPROVAL_TIMEOUT_S
779
+ ):
780
+ errors.append(
781
+ f"{where}.timeout_s: must be an integer from {MIN_APPROVAL_TIMEOUT_S} to "
782
+ f"{MAX_APPROVAL_TIMEOUT_S} (seconds)"
783
+ )
784
+ errors.extend(_decide_with_errors(where, value))
785
+ return errors
786
+
787
+
788
+ def _decide_with_errors(where: str, rule: Mapping[str, Any]) -> list[str]:
789
+ """Errors of a rule's `decide_with` and `relayers`."""
790
+ decide_with = rule.get("decide_with", DEFAULT_DECIDE_WITH)
791
+ errors: list[str] = []
792
+ if decide_with == DECIDE_STEP_UP:
793
+ errors.append(f"{where}.decide_with: step_up is not supported yet (direct or relayed)")
794
+ elif decide_with not in DECIDE_WITH_VALUES:
795
+ errors.append(f"{where}.decide_with: must be direct or relayed (got {decide_with!r})")
796
+ if decide_with != DECIDE_RELAYED:
797
+ if "relayers" in rule:
798
+ errors.append(f"{where}.relayers: only valid with decide_with: relayed")
799
+ return errors
800
+ approvers = rule.get("approvers")
801
+ if isinstance(approvers, list) and REQUESTER_APPROVER not in approvers:
802
+ errors.append(
803
+ f"{where}.decide_with: relayed needs requester in approvers (role approvers always "
804
+ "decide with their own direct credentials, never relayed)"
805
+ )
806
+ if "relayers" not in rule:
807
+ errors.append(
808
+ f"{where}.relayers: required with decide_with: relayed (the agents, by actor id, "
809
+ "that may deliver the requester's decision)"
810
+ )
811
+ return errors
812
+ relayers = rule["relayers"]
813
+ if not isinstance(relayers, list) or not relayers:
814
+ return [*errors, f"{where}.relayers: must be a non-empty list of agent (actor) ids"]
815
+ for index, relayer in enumerate(relayers):
816
+ if not (isinstance(relayer, str) and _ROLE_NAME_RE.fullmatch(relayer)):
817
+ errors.append(
818
+ f"{where}.relayers[{index}]: {relayer!r} is not an agent id (1-256 characters "
819
+ "without spaces, commas or control characters)"
820
+ )
821
+ return errors
822
+
823
+
824
+ def _required_for_errors(
825
+ where: str, value: Any, api_where: str, *, protocol: Any = DEFAULT_PROTOCOL
826
+ ) -> list[str]:
827
+ if not isinstance(value, Mapping) or not value:
828
+ return [f"{where}: must be a mapping with methods and/or operations"]
829
+ errors = [
830
+ f"{where}: unknown key {key!r}"
831
+ for key in sorted(set(value) - set(_REQUIRED_FOR_KEYS), key=str)
832
+ ]
833
+ if "methods" not in value and "operations" not in value:
834
+ errors.append(f"{where}: needs methods and/or operations")
835
+ if "methods" in value:
836
+ errors.extend(_methods_errors(f"{where}.methods", value["methods"], True))
837
+ if "operations" in value:
838
+ errors.extend(
839
+ _operations_errors(
840
+ f"{where}.operations",
841
+ value["operations"],
842
+ api_where,
843
+ "must not be empty; omit the key when no operation needs approval",
844
+ protocol=protocol,
845
+ )
846
+ )
847
+ return errors
848
+
849
+
850
+ def _approvers_errors(where: str, value: Any) -> list[str]:
851
+ if not isinstance(value, list) or not value:
852
+ return [f'{where}: must be a non-empty list of "requester" and/or "role:<name>"']
853
+ errors: list[str] = []
854
+ for index, approver in enumerate(value):
855
+ if approver == REQUESTER_APPROVER:
856
+ continue
857
+ if (
858
+ isinstance(approver, str)
859
+ and approver.startswith(ROLE_APPROVER_PREFIX)
860
+ and _ROLE_NAME_RE.fullmatch(approver[len(ROLE_APPROVER_PREFIX) :])
861
+ ):
862
+ continue
863
+ errors.append(
864
+ f'{where}[{index}]: {approver!r} is not an approver ("requester", or "role:<name>" '
865
+ "with a role name of 1-256 characters without spaces or commas)"
866
+ )
867
+ return errors
868
+
869
+
870
+ def segment_text_problem(text: str) -> str | None:
871
+ """Why a path segment's text (percent-decoded) is refused, or None.
872
+
873
+ Each of these would send a call to an endpoint other than the one the
874
+ policy judged: a control character anywhere (``%00``: servers that end a
875
+ path at a NUL route it to the part before); whitespace at either end
876
+ (``cancel%20``: servers that trim path segments route it to ``cancel``)
877
+ or next to a dot (``cancel%20.json``, ``cancel%20%2e``: servers that
878
+ trim the name before a format suffix, or strip trailing dots and spaces,
879
+ route it to ``cancel``); a backslash or a slash (``%5C``, ``%2F``:
880
+ servers that decode them before routing split the segment); a ``;``
881
+ (``%3B``: servers that strip path parameters route ``cancel;x`` to
882
+ ``cancel``). Whitespace elsewhere in a segment (``red%20shirt``) is kept:
883
+ trimming does not touch it.
884
+ """
885
+ if any(ord(char) < 0x20 or ord(char) == 0x7F for char in text):
886
+ return "holds a control character (also percent-encoded, such as %00)"
887
+ if text[:1].isspace() or text[-1:].isspace():
888
+ return "starts or ends with whitespace (also percent-encoded, such as %20)"
889
+ if _SPACE_BY_DOT_RE.search(text):
890
+ return "has whitespace next to a dot (also percent-encoded, such as cancel%20.json)"
891
+ if "/" in text or "\\" in text:
892
+ return "holds a backslash or a percent-encoded slash (%5C, %2F)"
893
+ if ";" in text:
894
+ return "holds ';' (also percent-encoded, %3B), which some servers strip with what follows"
895
+ return None
896
+
897
+
898
+ def path_template_problem(path: Any) -> str | None:
899
+ """Why ``path`` is not a valid path template, or None.
900
+
901
+ A template starts with ``/``; each segment holds literal characters and
902
+ ``{name}`` placeholders only (no query, fragment, spaces, empty, ``.`` or
903
+ ``..`` segments, also percent-encoded), and none of the characters a
904
+ sent path is refused for, percent-encoded or not (``segment_text_problem``:
905
+ control characters, whitespace at either end or next to a dot, a
906
+ backslash or an encoded slash, ``;``), so lint passes no declared call the
907
+ client would refuse to send. One trailing slash is allowed.
908
+ """
909
+ if not isinstance(path, str) or not path.startswith("/"):
910
+ return "must be a string starting with /"
911
+ body = path[1:]
912
+ if body.endswith("/"):
913
+ body = body[:-1]
914
+ if not body:
915
+ return None
916
+ for segment in body.split("/"):
917
+ if segment in ("", ".", ".."):
918
+ return "must not contain empty, '.' or '..' segments"
919
+ if not _PATH_SEGMENT_RE.match(segment):
920
+ return (
921
+ "segments may hold literal characters and {name} placeholders only "
922
+ "(no query, fragment or whitespace)"
923
+ )
924
+ text = unquote(_PLACEHOLDER_SPLIT_RE.sub("x", segment))
925
+ if text in (".", ".."):
926
+ return "must not contain '.' or '..' segments, also percent-encoded (%2E)"
927
+ problem = segment_text_problem(text)
928
+ if problem:
929
+ return f"has a segment that {problem}"
930
+ return None
931
+
932
+
933
+ def normalize_path(path: str) -> str:
934
+ """``path`` in the form policy paths are compared in.
935
+
936
+ Percent-encoded unreserved characters are decoded (``/%61dmin`` is
937
+ ``/admin``), other escapes are upper-cased (``%2f`` is ``%2F``), and one
938
+ trailing slash is dropped (``/items/1/`` is ``/items/1``), so equivalent
939
+ spellings of a path match the same entries.
940
+ """
941
+
942
+ def _escape(match: re.Match[str]) -> str:
943
+ char = chr(int(match.group(0)[1:], 16))
944
+ return char if char in _UNRESERVED else match.group(0).upper()
945
+
946
+ path = _ESCAPE_RE.sub(_escape, path)
947
+ return path[:-1] if len(path) > 1 and path.endswith("/") else path
948
+
949
+
950
+ def path_matches(
951
+ template: str, path: str, *, ignore_case: bool = False, suffixes: bool = False
952
+ ) -> bool:
953
+ """Whether ``path`` is covered by ``template``.
954
+
955
+ A ``{name}`` placeholder matches exactly one non-empty segment, so
956
+ ``/items/{item_id}`` covers ``/items/42``, ``/items/{id}`` and itself.
957
+ Both sides are compared normalised (``normalize_path``). Letter case
958
+ counts unless ``ignore_case``: denials ignore it, so ``/ADMIN/1`` cannot
959
+ slip past a denial of ``/admin/{x}`` on a case-insensitive server. With
960
+ ``suffixes`` (denials and approval gates, which fail closed), a segment
961
+ that ends in literal text also covers that segment with a dot suffix:
962
+ ``/orders/{id}/cancel`` covers ``/orders/7/cancel.json`` and
963
+ ``/orders/7/cancel.`` (also spelled ``cancel%2e``), which servers that
964
+ route format suffixes (``.json``) or drop a trailing dot send to the
965
+ same endpoint. An allow never matches that way: it must be shown.
966
+ """
967
+ template, path = normalize_path(template), normalize_path(path)
968
+ if template == path or (ignore_case and template.casefold() == path.casefold()):
969
+ return True
970
+ segments = []
971
+ for segment in template.split("/"):
972
+ parts = _PLACEHOLDER_SPLIT_RE.split(segment)
973
+ pattern = "".join(
974
+ "[^/]+" if part.startswith("{") and part.endswith("}") else re.escape(part)
975
+ for part in parts
976
+ )
977
+ if suffixes and parts[-1] and not parts[-1].endswith("}"):
978
+ pattern += r"(?:\.[^/]*)?"
979
+ segments.append(pattern)
980
+ flags = re.IGNORECASE if ignore_case else 0
981
+ return re.fullmatch("/".join(segments), path, flags) is not None
982
+
983
+
984
+ def _methods_match(entry: Mapping[str, Any], method: str) -> bool:
985
+ methods = entry.get("methods")
986
+ return not methods or method.upper() in {str(m).upper() for m in methods}
987
+
988
+
989
+ def operation_matches(
990
+ entry: Mapping[str, Any],
991
+ method: str,
992
+ operation_id: str | None,
993
+ path: str | None,
994
+ *,
995
+ rpc_method: str | None = None,
996
+ a2a_operation: str | None = None,
997
+ ) -> bool:
998
+ """Whether an ``allowed_operations`` entry covers the call.
999
+
1000
+ AND semantics: every field the entry pins (``operationId``, ``path``,
1001
+ ``methods``, and on a JSON-RPC API ``rpc_method`` and ``a2a_operation``,
1002
+ compared with the values derived from the request body) must match. A
1003
+ call that does not name a pinned field (no operation id, no path, no
1004
+ JSON-RPC method or no decision) does not match: an allow must be shown.
1005
+ """
1006
+ if not _methods_match(entry, method):
1007
+ return False
1008
+ pinned_id = entry.get("operationId")
1009
+ if pinned_id is not None and operation_id != pinned_id:
1010
+ return False
1011
+ pinned_path = entry.get("path")
1012
+ if pinned_path is not None and (path is None or not path_matches(pinned_path, path)):
1013
+ return False
1014
+ pinned_rpc = entry.get(RPC_METHOD_KEY)
1015
+ if pinned_rpc is not None and rpc_method != pinned_rpc:
1016
+ return False
1017
+ pinned_operation = entry.get(A2A_OPERATION_KEY)
1018
+ if pinned_operation is not None and a2a_operation != pinned_operation:
1019
+ return False
1020
+ return (
1021
+ pinned_id is not None
1022
+ or pinned_path is not None
1023
+ or pinned_rpc is not None
1024
+ or pinned_operation is not None
1025
+ )
1026
+
1027
+
1028
+ def denial_match(
1029
+ entry: Mapping[str, Any],
1030
+ method: str,
1031
+ operation_id: str | None,
1032
+ path: str | None,
1033
+ *,
1034
+ rpc_method: str | None = None,
1035
+ a2a_operation: str | None = None,
1036
+ ) -> str | None:
1037
+ """How a ``denied_operations`` entry covers the call: None when it does not.
1038
+
1039
+ A denial names an endpoint and must hold whatever label a call gives it,
1040
+ so, unlike an allow, the fields it pins are alternatives, not
1041
+ requirements. With its ``methods`` (when pinned) covering the call's
1042
+ method, it covers a call whose path its ``path`` covers, whatever
1043
+ operation id the call names, and a call that names its ``operationId``
1044
+ (both return ``""``). Failing closed, it also covers a call that leaves
1045
+ out what the denial knows the operation by: no path when it pins
1046
+ ``path`` (returns ``"path"``), no operation id when it pins
1047
+ ``operationId`` alone (returns ``"operation_id"``). A denial by
1048
+ ``operationId`` alone knows only that label: pin ``path`` too so it holds
1049
+ on the wire. Operation ids and paths are compared ignoring letter case,
1050
+ and a path's literal segments also cover their dot-suffixed spellings
1051
+ (``cancel.json``, ``cancel.``: ``path_matches`` with ``suffixes``).
1052
+
1053
+ An entry of a JSON-RPC API that pins ``rpc_method`` or ``a2a_operation``
1054
+ (the values derived from the request body) covers, with its ``methods``,
1055
+ a call whose JSON-RPC method is its ``rpc_method`` (ignoring letter case),
1056
+ a call that decides as its ``a2a_operation`` says, and a call that names
1057
+ its ``operationId``, whatever the path: its ``path``, if any, neither
1058
+ widens nor narrows it. Every POST to such an API names its method (a body
1059
+ that does not is refused first), so there is nothing left unnamed.
1060
+ """
1061
+ if not _methods_match(entry, method):
1062
+ return None
1063
+ pinned_id = entry.get("operationId")
1064
+ if _rpc_pins(entry):
1065
+ pinned_rpc = entry.get(RPC_METHOD_KEY)
1066
+ if (
1067
+ pinned_rpc is not None
1068
+ and rpc_method is not None
1069
+ and str(rpc_method).casefold() == str(pinned_rpc).casefold()
1070
+ ):
1071
+ return ""
1072
+ pinned_operation = entry.get(A2A_OPERATION_KEY)
1073
+ if pinned_operation is not None and a2a_operation == pinned_operation:
1074
+ return ""
1075
+ if (
1076
+ pinned_id is not None
1077
+ and operation_id is not None
1078
+ and str(operation_id).casefold() == str(pinned_id).casefold()
1079
+ ):
1080
+ return ""
1081
+ return None
1082
+ pinned_path = entry.get("path")
1083
+ if (
1084
+ pinned_path is not None
1085
+ and path is not None
1086
+ and path_matches(pinned_path, path, ignore_case=True, suffixes=True)
1087
+ ):
1088
+ return ""
1089
+ if (
1090
+ pinned_id is not None
1091
+ and operation_id is not None
1092
+ and str(operation_id).casefold() == str(pinned_id).casefold()
1093
+ ):
1094
+ return ""
1095
+ if pinned_path is not None and path is None:
1096
+ return "path"
1097
+ if pinned_id is not None and pinned_path is None and operation_id is None:
1098
+ return "operation_id"
1099
+ return None
1100
+
1101
+
1102
+ def denial_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 a ``denied_operations`` entry covers the call (see ``denial_match``)."""
1112
+ return (
1113
+ denial_match(
1114
+ entry, method, operation_id, path, rpc_method=rpc_method, a2a_operation=a2a_operation
1115
+ )
1116
+ is not None
1117
+ )
1118
+
1119
+
1120
+ def describe_operation(entry: Mapping[str, Any]) -> str:
1121
+ """``operationId=updateOrder path=/orders/{order_id} methods=['PATCH']`` for messages
1122
+ (and ``rpc_method=GetTask a2a_operation=approve`` for an entry that pins them)."""
1123
+ parts = []
1124
+ if entry.get("operationId") is not None:
1125
+ parts.append(f"operationId={entry['operationId']}")
1126
+ if entry.get("path") is not None:
1127
+ parts.append(f"path={entry['path']}")
1128
+ if entry.get("methods"):
1129
+ parts.append(f"methods={sorted(str(m).upper() for m in entry['methods'])}")
1130
+ for key in (RPC_METHOD_KEY, A2A_OPERATION_KEY):
1131
+ if entry.get(key) is not None:
1132
+ parts.append(f"{key}={entry[key]}")
1133
+ return " ".join(parts)
1134
+
1135
+
1136
+ def refusal_reason(
1137
+ api: Mapping[str, Any],
1138
+ method: str,
1139
+ operation_id: str | None = None,
1140
+ path: str | None = None,
1141
+ *,
1142
+ rpc_method: str | None = None,
1143
+ a2a_operation: str | None = None,
1144
+ ) -> str | None:
1145
+ """Why the API's policy refuses the call, or None when it is allowed.
1146
+
1147
+ Every rule must pass: the method is in ``allowed_methods`` (``["*"]``
1148
+ allows every method); no ``denied_operations`` entry may cover the call
1149
+ (denials win: a denial pinning a path refuses every call to that path,
1150
+ whatever operation id it names, and a call that leaves out what a denial
1151
+ knows the operation by is refused by it: ``denial_match``); and, when
1152
+ ``allowed_operations`` is present, one of its entries matches
1153
+ (``operation_matches``: every field it pins). On a JSON-RPC API
1154
+ (``protocol: jsonrpc|a2a``) ``rpc_method`` and ``a2a_operation`` are the
1155
+ values ``derive_rpc`` reads from the request body.
1156
+ """
1157
+ method = method.upper()
1158
+ operation_id = operation_id or None
1159
+ path = path or None
1160
+ rpc = {"rpc_method": rpc_method or None, "a2a_operation": a2a_operation or None}
1161
+ allowed = [str(m).upper() for m in api.get("allowed_methods") or []]
1162
+ if ANY_METHOD not in allowed and method not in allowed:
1163
+ return f"method {method} is not in allowed_methods {allowed}"
1164
+ for entry in api.get("denied_operations") or []:
1165
+ unnamed = denial_match(entry, method, operation_id, path, **rpc)
1166
+ if unnamed is not None:
1167
+ reason = f"denied by denied_operations ({describe_operation(entry)})"
1168
+ if unnamed:
1169
+ reason += (
1170
+ f": the call names no {unnamed}, so it cannot be ruled out; name it on "
1171
+ "the call and in API_CALLS"
1172
+ )
1173
+ return reason
1174
+ allowed_operations = api.get("allowed_operations")
1175
+ if allowed_operations is not None and not any(
1176
+ operation_matches(entry, method, operation_id, path, **rpc) for entry in allowed_operations
1177
+ ):
1178
+ return "not in allowed_operations"
1179
+ return None
1180
+
1181
+
1182
+ @dataclass(frozen=True)
1183
+ class ApprovalGate:
1184
+ """The human approval an API's policy requires before a call is sent (``gated``)."""
1185
+
1186
+ # "requester" and/or "role:<name>" entries of the rule that gates the call,
1187
+ # in the policy's order.
1188
+ approvers: tuple[str, ...]
1189
+ # Seconds a pending approval waits for a decision; then it expires (= rejected).
1190
+ timeout_s: int
1191
+ # The rule and the part of its required_for that gates the call, for messages
1192
+ # ("approval.required_for.methods ['POST']", "approval[1].required_for.operations (...)").
1193
+ rule: str
1194
+ # The gating rule's index when `approval` is a list of rules; None for one mapping.
1195
+ index: int | None = None
1196
+ # Later rules that also cover the call; they do not apply to it (the first one does).
1197
+ also: tuple[int, ...] = ()
1198
+ # How the requester decides: `direct`, or `relayed` by the agents `relayers` names.
1199
+ decide_with: str = DEFAULT_DECIDE_WITH
1200
+ relayers: tuple[str, ...] = ()
1201
+
1202
+ def deciders(self) -> tuple[frozenset[str], str, frozenset[str]]:
1203
+ """Who decides, and how: what an approval is bound to (`rule_deciders`)."""
1204
+ return frozenset(self.approvers), self.decide_with, frozenset(self.relayers)
1205
+
1206
+
1207
+ def approval_rules(api: Mapping[str, Any]) -> list[Mapping[str, Any]]:
1208
+ """An API's approval rules in file order: none, its one ``approval`` mapping, or its list."""
1209
+ approval = api.get(APPROVAL_KEY)
1210
+ if approval is None:
1211
+ return []
1212
+ return list(approval) if isinstance(approval, list) else [approval]
1213
+
1214
+
1215
+ def rule_deciders(rule: Mapping[str, Any]) -> tuple[frozenset[str], str, frozenset[str]]:
1216
+ """Who decides the calls a rule gates, and how: its approvers, `decide_with` and
1217
+ relayers. Two rules with other deciders are two gates, and an approval taken under one
1218
+ does not cover the other's calls."""
1219
+ return (
1220
+ frozenset(str(a) for a in rule.get("approvers") or ()),
1221
+ str(rule.get("decide_with", DEFAULT_DECIDE_WITH)),
1222
+ frozenset(str(r) for r in rule.get("relayers") or ()),
1223
+ )
1224
+
1225
+
1226
+ def describe_deciders(rule: Mapping[str, Any]) -> str:
1227
+ """``requester, role:ops``, or ``requester; relayed by concierge`` for a relayed rule."""
1228
+ approvers = ", ".join(str(a) for a in rule.get("approvers") or ())
1229
+ if rule.get("decide_with", DEFAULT_DECIDE_WITH) != DECIDE_RELAYED:
1230
+ return approvers
1231
+ return f"{approvers}; relayed by {', '.join(str(r) for r in rule.get('relayers') or ())}"
1232
+
1233
+
1234
+ def approval_rule_label(api: Mapping[str, Any], index: int) -> str:
1235
+ """``approval`` for an API's one approval mapping, ``approval[<index>]`` in a list of rules."""
1236
+ if isinstance(api.get(APPROVAL_KEY), list):
1237
+ return f"{APPROVAL_KEY}[{index}]"
1238
+ return APPROVAL_KEY
1239
+
1240
+
1241
+ class ApprovalRuleConflict(ValueError):
1242
+ """``gated``: the call cannot be given to one approval rule, so it is refused.
1243
+
1244
+ The message says why (the rule that cannot rule the call out, the later
1245
+ rule with other approvers that covers it) and what to name. ``unnamed``
1246
+ is what the call leaves out (``"operation_id"`` or ``"path"``), ``index``
1247
+ and ``later`` are the two rules' indexes.
1248
+ """
1249
+
1250
+ def __init__(self, message: str, *, unnamed: str, index: int, later: int) -> None:
1251
+ super().__init__(message)
1252
+ self.unnamed = unnamed
1253
+ self.index = index
1254
+ self.later = later
1255
+
1256
+
1257
+ def _rule_match(
1258
+ rule: Mapping[str, Any],
1259
+ method: str,
1260
+ operation_id: str | None,
1261
+ path: str | None,
1262
+ rpc: Mapping[str, str | None] | None = None,
1263
+ ) -> tuple[str, str] | None:
1264
+ """How one approval rule covers the call: ``(part, unnamed)``, or None.
1265
+
1266
+ ``part`` names what covers it, for messages. ``unnamed`` is ``""`` when
1267
+ the rule surely covers the call, else what the call leaves out
1268
+ (``"operation_id"``, ``"path"``) that the covering entry knows the
1269
+ operation by: the rule covers it only because it cannot be ruled out. A
1270
+ sure match wins over one that only cannot be ruled out.
1271
+ """
1272
+ required_for = rule.get("required_for") or {}
1273
+ methods = [str(m).upper() for m in required_for.get("methods") or []]
1274
+ if ANY_METHOD in methods or method in methods:
1275
+ return f"required_for.methods {methods}", ""
1276
+ unsure: tuple[str, str] | None = None
1277
+ for entry in required_for.get("operations") or []:
1278
+ unnamed = denial_match(entry, method, operation_id, path, **(rpc or {}))
1279
+ if unnamed is None:
1280
+ continue
1281
+ part = f"required_for.operations ({describe_operation(entry)})"
1282
+ if not unnamed:
1283
+ return part, ""
1284
+ if unsure is None:
1285
+ unsure = (part, unnamed)
1286
+ return unsure
1287
+
1288
+
1289
+ def _unsure(part: str, unnamed: str) -> str:
1290
+ """``part``, and why it covers the call when the call only leaves out what it names."""
1291
+ return f"{part}: the call names no {unnamed}, so it cannot be ruled out" if unnamed else part
1292
+
1293
+
1294
+ def rule_covers(
1295
+ rule: Mapping[str, Any],
1296
+ method: str,
1297
+ operation_id: str | None,
1298
+ path: str | None,
1299
+ *,
1300
+ rpc_method: str | None = None,
1301
+ a2a_operation: str | None = None,
1302
+ ) -> str | None:
1303
+ """Which part of one approval rule's ``required_for`` covers the call, or None.
1304
+
1305
+ ``required_for.methods`` covers a call with one of its methods (``["*"]``:
1306
+ every method). An entry of ``required_for.operations`` covers a call as a
1307
+ denial does (``denial_match``: fail closed), not as an allow; an entry
1308
+ that surely covers it is named before one that only cannot rule it out.
1309
+ """
1310
+ rpc = {"rpc_method": rpc_method or None, "a2a_operation": a2a_operation or None}
1311
+ match = _rule_match(rule, method.upper(), operation_id or None, path or None, rpc)
1312
+ return None if match is None else _unsure(*match)
1313
+
1314
+
1315
+ def gated(
1316
+ api: Mapping[str, Any],
1317
+ method: str,
1318
+ operation_id: str | None = None,
1319
+ path: str | None = None,
1320
+ *,
1321
+ template: str | None = None,
1322
+ rpc_method: str | None = None,
1323
+ a2a_operation: str | None = None,
1324
+ ) -> ApprovalGate | None:
1325
+ """The approval the API's policy (a validated one) requires before the call, or None.
1326
+
1327
+ Ask it only about a call ``refusal_reason`` allows: approval never widens
1328
+ access, so a refused call stays refused whatever its gate, and denials
1329
+ still win. A rule covers the call when its ``required_for.methods`` holds
1330
+ the call's method (``["*"]``: every method), or when an entry of its
1331
+ ``required_for.operations`` covers it (``rule_covers``). Such an entry
1332
+ fails closed, as a denial does (``denial_match``), not as an allow: with
1333
+ its ``methods`` (when pinned) covering the call's method, its ``path``
1334
+ gates every call to that path whatever operation id the call names, its
1335
+ ``operationId`` gates the calls that name it, and a call that leaves out
1336
+ what the entry knows the operation by is gated too. Paths are compared
1337
+ normalised and ignoring letter case, and a literal segment also covers its
1338
+ dot-suffixed spellings (``cancel.json``, ``cancel.``), as for a denial.
1339
+
1340
+ ``approval`` is one rule, or a list of rules: the FIRST rule in file order
1341
+ that covers the call gates it, with that rule's approvers and timeout, and
1342
+ the later rules that also cover it are listed in ``also`` (they do not
1343
+ apply to it). Failing closed across rules: when the first rule covers the
1344
+ call only because the call leaves out what the rule knows the operation by
1345
+ (no operation id, no path), and a later rule with other approvers also
1346
+ covers it, the call may be that later rule's, so neither rule's approvers
1347
+ get it: ``ApprovalRuleConflict`` is raised and the call is refused. At
1348
+ runtime, ask with the path that is sent and, when there is one, the
1349
+ ``template`` it was rendered from: a rule covers the call when it covers
1350
+ either (surely, when it surely covers either). On a JSON-RPC API an entry
1351
+ pinning ``rpc_method`` or ``a2a_operation`` covers the call as a denial
1352
+ does (``denial_match``), by the values derived from the request body.
1353
+ """
1354
+ rules = approval_rules(api)
1355
+ if not rules:
1356
+ return None
1357
+ method = method.upper()
1358
+ operation_id = operation_id or None
1359
+ path = path or None
1360
+ template = template or None
1361
+ rpc = {"rpc_method": rpc_method or None, "a2a_operation": a2a_operation or None}
1362
+ covering: list[tuple[int, str, str]] = []
1363
+ for index, rule in enumerate(rules):
1364
+ match = _rule_match(rule, method, operation_id, path, rpc)
1365
+ if template is not None and (match is None or match[1]):
1366
+ other = _rule_match(rule, method, operation_id, template, rpc)
1367
+ if other is not None and (match is None or not other[1]):
1368
+ match = other
1369
+ if match is not None:
1370
+ covering.append((index, *match))
1371
+ if not covering:
1372
+ return None
1373
+ index, part, unnamed = covering[0]
1374
+ rule = rules[index]
1375
+ approvers = tuple(str(a) for a in rule.get("approvers") or ())
1376
+ label = approval_rule_label(api, index)
1377
+ if unnamed:
1378
+ pin = f", or pin path and methods in {label}" if unnamed == "operation_id" else ""
1379
+ for later, _part, _unnamed in covering[1:]:
1380
+ if rule_deciders(rules[later]) != rule_deciders(rule):
1381
+ raise ApprovalRuleConflict(
1382
+ f"{label} (approved by {describe_deciders(rule)}) covers it only because the "
1383
+ f"call names no {unnamed} ({label}.{part}), and "
1384
+ f"{approval_rule_label(api, later)} (approved by "
1385
+ f"{describe_deciders(rules[later])}) also covers it: it could be either "
1386
+ "rule's call, so neither rule's approvers are asked; name the "
1387
+ f"{unnamed} on the call and in API_CALLS{pin}",
1388
+ unnamed=unnamed,
1389
+ index=index,
1390
+ later=later,
1391
+ )
1392
+ listed = isinstance(api.get(APPROVAL_KEY), list)
1393
+ return ApprovalGate(
1394
+ approvers=approvers,
1395
+ timeout_s=int(rule.get("timeout_s", DEFAULT_APPROVAL_TIMEOUT_S)),
1396
+ rule=f"{label}.{_unsure(part, unnamed)}",
1397
+ index=index if listed else None,
1398
+ also=tuple(i for i, _, _ in covering[1:]),
1399
+ decide_with=str(rule.get("decide_with", DEFAULT_DECIDE_WITH)),
1400
+ relayers=tuple(str(r) for r in rule.get("relayers") or ()),
1401
+ )
1402
+
1403
+
1404
+ class RpcRequestError(ValueError):
1405
+ """A request a JSON-RPC API (``protocol: jsonrpc|a2a``) refuses to send (``derive_rpc``)."""
1406
+
1407
+
1408
+ @dataclass(frozen=True)
1409
+ class RpcCall:
1410
+ """What a request to a JSON-RPC API is, read from its body (``derive_rpc``).
1411
+
1412
+ ``rpc_method``: the JSON-RPC method of a POST (an A2A 0.3 name read as its 1.0
1413
+ name under ``protocol: a2a``); None for GET and HEAD, and for any call to an
1414
+ ``http`` API. ``a2a_operation``: under ``protocol: a2a``, ``approve`` or
1415
+ ``reject`` for a message that decides a pending approval, else None.
1416
+ """
1417
+
1418
+ rpc_method: str | None = None
1419
+ a2a_operation: str | None = None
1420
+
1421
+
1422
+ def canonical_rpc_method(protocol: str, name: str) -> str:
1423
+ """``name`` as the policy compares it: an A2A 0.3 name as its 1.0 name under a2a."""
1424
+ return A2A_V03_METHODS.get(name, name) if protocol == PROTOCOL_A2A else name
1425
+
1426
+
1427
+ def _is_rpc_id(value: Any) -> bool:
1428
+ return isinstance(value, str) or (isinstance(value, int) and not isinstance(value, bool))
1429
+
1430
+
1431
+ def _names_approval(data: Any) -> bool:
1432
+ """Whether a message part's data names an approval (as the called agent reads it)."""
1433
+ return isinstance(data, Mapping) and ("approval_id" in data or "decision" in data)
1434
+
1435
+
1436
+ def _a2a_operation(protocol: str, params: Any) -> str | None:
1437
+ """What an A2A message decides: ``reject`` only when every part that names an approval
1438
+ says ``reject``, ``approve`` when any other does (approve wins), None when none does."""
1439
+ message = params.get("message") if isinstance(params, Mapping) else None
1440
+ parts = message.get("parts", []) if isinstance(message, Mapping) else None
1441
+ if not isinstance(parts, list):
1442
+ raise RpcRequestError(
1443
+ f"protocol {protocol}: a message request needs params.message with a list of parts"
1444
+ )
1445
+ decisions = [
1446
+ part["data"].get("decision")
1447
+ for part in parts
1448
+ if isinstance(part, Mapping) and _names_approval(part.get("data"))
1449
+ ]
1450
+ if not decisions:
1451
+ return None
1452
+ return A2A_REJECT if all(d == A2A_REJECT for d in decisions) else A2A_APPROVE
1453
+
1454
+
1455
+ def _rpc_body_problem(sent: Any) -> str | None:
1456
+ """Why a parsed JSON body is not one JSON-RPC 2.0 request object, or None."""
1457
+ if isinstance(sent, list):
1458
+ return "a batch"
1459
+ if not isinstance(sent, dict):
1460
+ return "not a JSON-RPC request object"
1461
+ if set(sent) - set(_JSONRPC_KEYS):
1462
+ return "members other than jsonrpc, method, params and id"
1463
+ if sent.get("jsonrpc") != "2.0":
1464
+ return 'jsonrpc is not "2.0"'
1465
+ if not isinstance(sent.get("method"), str) or not sent["method"]:
1466
+ return "no method name"
1467
+ if "id" not in sent:
1468
+ return "a notification (no id)"
1469
+ if not _is_rpc_id(sent["id"]):
1470
+ return "an id that is not a string or an integer"
1471
+ if "params" in sent and not isinstance(sent["params"], dict | list):
1472
+ return "params that are not an object or an array"
1473
+ return None
1474
+
1475
+
1476
+ def derive_rpc(api: Mapping[str, Any], method: str, body: Any) -> RpcCall:
1477
+ """What a request to ``api`` is, read from the body sent (never from the tool's labels).
1478
+
1479
+ Nothing for an ``http`` API. For ``protocol: jsonrpc|a2a``: a POST must send
1480
+ one JSON-RPC 2.0 request object (``jsonrpc: "2.0"``, a method name, an ``id``
1481
+ that is a string or an integer, optional ``params`` that are an object or an
1482
+ array, and no other member), read as the server reads the JSON sent; a
1483
+ batch, a notification (no ``id``), a body that is not plain JSON or any
1484
+ other body raises ``RpcRequestError``, as does a GET or HEAD with a body.
1485
+ Its ``rpc_method`` is the request's method (under ``a2a``, an A2A 0.3 name as
1486
+ its 1.0 name). Under ``a2a``, a ``SendMessage`` or ``SendStreamingMessage``
1487
+ whose parts name an approval (a data part with ``approval_id`` or
1488
+ ``decision``) is ``a2a_operation: reject`` only when every such part says
1489
+ ``reject``, and ``approve`` otherwise: failing closed, approve wins. A
1490
+ message method in another letter case (``sendmessage``) is read for a
1491
+ decision too, though an A2A server answers it as an unknown method.
1492
+ """
1493
+ protocol = api_protocol(api)
1494
+ if protocol not in RPC_PROTOCOLS:
1495
+ return RpcCall()
1496
+ method = method.upper()
1497
+ if method != "POST":
1498
+ if body is not None:
1499
+ raise RpcRequestError(
1500
+ f"protocol {protocol}: a {method} sends no body (a JSON-RPC request is a POST)"
1501
+ )
1502
+ return RpcCall()
1503
+ try:
1504
+ # The JSON the server reads: tuples become lists, keys strings (never trust a
1505
+ # Python object that serializes as something other than it looks).
1506
+ sent = json.loads(json.dumps(body, allow_nan=False))
1507
+ except (TypeError, ValueError):
1508
+ sent = None
1509
+ problem: str | None = "not plain JSON"
1510
+ else:
1511
+ problem = _rpc_body_problem(sent)
1512
+ if problem is not None:
1513
+ raise RpcRequestError(
1514
+ f"protocol {protocol} sends one JSON-RPC request per call (a batch or non-request "
1515
+ f"body refused: {problem})"
1516
+ )
1517
+ name = canonical_rpc_method(protocol, sent["method"])
1518
+ if protocol != PROTOCOL_A2A or name.casefold() not in _A2A_MESSAGE_NAMES:
1519
+ return RpcCall(rpc_method=name)
1520
+ return RpcCall(rpc_method=name, a2a_operation=_a2a_operation(protocol, sent.get("params")))
1521
+
1522
+
1523
+ def _operation_entries(api: Mapping[str, Any]) -> list[Mapping[str, Any]]:
1524
+ """Every operation entry of an API: allowed, denied and those its approval rules gate."""
1525
+ entries = [*(api.get("allowed_operations") or []), *(api.get("denied_operations") or [])]
1526
+ for rule in approval_rules(api):
1527
+ entries.extend((rule.get("required_for") or {}).get("operations") or [])
1528
+ return [entry for entry in entries if isinstance(entry, Mapping)]
1529
+
1530
+
1531
+ def label_problem(
1532
+ api: Mapping[str, Any],
1533
+ operation_id: str | None,
1534
+ rpc_method: str | None = None,
1535
+ a2a_operation: str | None = None,
1536
+ ) -> str | None:
1537
+ """Why a tool's ``operation_id`` does not name the request it labels, or None.
1538
+
1539
+ On a JSON-RPC API, an entry that pins ``operationId`` with ``rpc_method``
1540
+ or ``a2a_operation`` says what a call so labelled is. A call labelled so
1541
+ whose request (``derive_rpc``) is something else is refused, so a label
1542
+ never carries a decision past a rule written for another request.
1543
+ Operation ids and JSON-RPC methods are compared ignoring letter case.
1544
+ """
1545
+ if not operation_id or api_protocol(api) not in RPC_PROTOCOLS:
1546
+ return None
1547
+ label = str(operation_id).casefold()
1548
+ for entry in _operation_entries(api):
1549
+ pinned_id = entry.get("operationId")
1550
+ if pinned_id is None or str(pinned_id).casefold() != label:
1551
+ continue
1552
+ pinned_rpc = entry.get(RPC_METHOD_KEY)
1553
+ if pinned_rpc is not None and (
1554
+ rpc_method is None or str(pinned_rpc).casefold() != str(rpc_method).casefold()
1555
+ ):
1556
+ return (
1557
+ f"operation_id {operation_id!r} does not match the request (rpc_method "
1558
+ f"{rpc_method or 'none'}); refused"
1559
+ )
1560
+ pinned_operation = entry.get(A2A_OPERATION_KEY)
1561
+ if pinned_operation is not None and pinned_operation != a2a_operation:
1562
+ return (
1563
+ f"operation_id {operation_id!r} does not match the request (a2a_operation "
1564
+ f"{a2a_operation or 'none'}); refused"
1565
+ )
1566
+ return None
1567
+
1568
+
1569
+ # --- END SHARED API POLICY RULES ---
1570
+
1571
+ # ---------------------------------------------------------------------------
1572
+ # CLI-only helpers
1573
+ # ---------------------------------------------------------------------------
1574
+
1575
+ MANIFEST_KEY = "api_policy"
1576
+ LEGACY_POLICY_FILENAME = "product-policy.yaml"
1577
+ LEGACY_MANIFEST_KEY = "product_api"
1578
+ LEGACY_CALLS_NAME = "PRODUCT_CALLS"
1579
+ CALLS_NAME = "API_CALLS"
1580
+
1581
+
1582
+ class ApiPolicyFileError(click.ClickException):
1583
+ """An ``api-policy.yaml`` that cannot be read or breaks the schema (exit 3)."""
1584
+
1585
+ exit_code = 3
1586
+
1587
+ def __init__(self, path: str | Path, errors: list[str]) -> None:
1588
+ self.path = Path(path)
1589
+ self.errors = list(errors)
1590
+ lines = "\n".join(f" - {e}" for e in self.errors)
1591
+ super().__init__(f"Invalid API policy {self.path}:\n{lines}")
1592
+
1593
+
1594
+ class LegacyApiPolicyError(click.ClickException):
1595
+ """The project still uses the retired product API policy (exit 3)."""
1596
+
1597
+ exit_code = 3
1598
+
1599
+
1600
+ class ApiPolicyConfigError(click.ClickException):
1601
+ """The manifest names a policy file the agent would not load (exit 3)."""
1602
+
1603
+ exit_code = 3
1604
+
1605
+
1606
+ def load_policy_document(path: str | Path) -> dict[str, Any]:
1607
+ """Read and validate an api-policy file; raise ``ApiPolicyFileError`` listing every problem."""
1608
+ policy_path = Path(path)
1609
+ try:
1610
+ text = policy_path.read_text(encoding="utf-8")
1611
+ except OSError as exc:
1612
+ raise ApiPolicyFileError(policy_path, [f"cannot read the file: {exc}"]) from exc
1613
+ data, errors = parse_policy_yaml(text)
1614
+ errors = errors or policy_errors(data)
1615
+ if errors:
1616
+ raise ApiPolicyFileError(policy_path, errors)
1617
+ return dict(data)
1618
+
1619
+
1620
+ def manifest_policy_file_problem(value: Any, manifest_name: str) -> str | None:
1621
+ """Why the manifest's ``api_policy.policy_file`` cannot be used, or None.
1622
+
1623
+ The agent loads ``api-policy.yaml`` from the project root (or
1624
+ ``API_POLICY_PATH``) and the Dockerfiles copy only that file, so a manifest
1625
+ naming another file would make ``lint`` check a file the agent never
1626
+ enforces.
1627
+ """
1628
+ if not value or Path(str(value)) == Path(POLICY_FILENAME):
1629
+ return None
1630
+ return (
1631
+ f"api_policy.policy_file in {manifest_name} is {str(value)!r}, but the agent loads "
1632
+ f"{POLICY_FILENAME} from the project root (the Dockerfiles copy only that file), so "
1633
+ f"lint would check a file the agent never enforces. Rename the file to "
1634
+ f"{POLICY_FILENAME} and set api_policy: {{policy_file: {POLICY_FILENAME}}}."
1635
+ )
1636
+
1637
+
1638
+ @dataclass(frozen=True)
1639
+ class ApiSummary:
1640
+ """What the templates need to know about one declared API."""
1641
+
1642
+ name: str
1643
+ base_url_env: str
1644
+ auth: str
1645
+ token_env: str = ""
1646
+
1647
+ def as_context(self) -> dict[str, str]:
1648
+ return {
1649
+ "name": self.name,
1650
+ "base_url_env": self.base_url_env,
1651
+ "auth": self.auth,
1652
+ "token_env": self.token_env,
1653
+ }
1654
+
1655
+
1656
+ def summarize(document: Mapping[str, Any]) -> tuple[ApiSummary, ...]:
1657
+ """One summary per declared API, in file order (the document must be valid)."""
1658
+ return tuple(
1659
+ ApiSummary(
1660
+ name=str(name),
1661
+ base_url_env=str(api["base_url_env"]),
1662
+ auth=str(api["auth"]),
1663
+ token_env=str(api.get("token_env") or ""),
1664
+ )
1665
+ for name, api in document["apis"].items()
1666
+ )
1667
+
1668
+
1669
+ # Methods whose example call sends a JSON body (the example tool takes a `body` argument).
1670
+ BODY_METHODS = ("POST", "PUT", "PATCH")
1671
+
1672
+
1673
+ @dataclass(frozen=True)
1674
+ class ExampleCall:
1675
+ """The call that the rendered ``tools/example_api.py`` makes.
1676
+
1677
+ Chosen when the project is rendered (``dev.policy_check.example_call``):
1678
+ the first operation the policy's first API allows, whatever its method, so
1679
+ the example passes ``lint`` and the project's policy test from the first
1680
+ commit.
1681
+ """
1682
+
1683
+ api: str
1684
+ method: str
1685
+ path: str
1686
+ operation_id: str | None = None
1687
+
1688
+ @property
1689
+ def params(self) -> tuple[str, ...]:
1690
+ """The path's ``{name}`` placeholders in order, each once: the tool's parameters."""
1691
+ return tuple(dict.fromkeys(_PLACEHOLDER_NAME_RE.findall(self.path)))
1692
+
1693
+ @property
1694
+ def has_body(self) -> bool:
1695
+ """True when the call sends a JSON body (POST, PUT, PATCH)."""
1696
+ return self.method in BODY_METHODS
1697
+
1698
+ def as_context(self) -> dict[str, Any]:
1699
+ return {
1700
+ "api": self.api,
1701
+ "method": self.method,
1702
+ "operation_id": self.operation_id or "",
1703
+ "path": self.path,
1704
+ "params": list(self.params),
1705
+ "has_body": self.has_body,
1706
+ }
1707
+
1708
+
1709
+ _PLACEHOLDER_NAME_RE = re.compile(r"\{([^/{}]+)\}")
1710
+
1711
+
1712
+ def _effective_rule(rule: Mapping[str, Any]) -> dict[str, Any]:
1713
+ required_for = rule["required_for"]
1714
+ effective: dict[str, Any] = {}
1715
+ if "methods" in required_for:
1716
+ effective["methods"] = [str(m).upper() for m in required_for["methods"]]
1717
+ if "operations" in required_for:
1718
+ effective["operations"] = [dict(entry) for entry in required_for["operations"]]
1719
+ out: dict[str, Any] = {
1720
+ "required_for": effective,
1721
+ "approvers": [str(a) for a in rule["approvers"]],
1722
+ "timeout_s": int(rule.get("timeout_s", DEFAULT_APPROVAL_TIMEOUT_S)),
1723
+ }
1724
+ # How the requester decides, when the rule says (absent: `direct`).
1725
+ if "decide_with" in rule:
1726
+ out["decide_with"] = str(rule["decide_with"])
1727
+ if "relayers" in rule:
1728
+ out["relayers"] = [str(r) for r in rule["relayers"]]
1729
+ return out
1730
+
1731
+
1732
+ def effective_approval(api: Mapping[str, Any]) -> dict[str, Any] | list[dict[str, Any]] | None:
1733
+ """An API's ``approval`` (the API must be valid) with defaults filled in, or None.
1734
+
1735
+ Shaped as the file writes it: one rule ``{"required_for": {"methods": [...],
1736
+ "operations": [...]}, "approvers": [...], "timeout_s": N}``, or a list of
1737
+ such rules; ``required_for`` holds only the keys the policy sets, methods
1738
+ upper-cased. ``decide_with`` and ``relayers`` are there when the rule sets
1739
+ them (absent: ``direct``).
1740
+ """
1741
+ approval = api.get(APPROVAL_KEY)
1742
+ if approval is None:
1743
+ return None
1744
+ if isinstance(approval, list):
1745
+ return [_effective_rule(rule) for rule in approval]
1746
+ return _effective_rule(approval)
1747
+
1748
+
1749
+ def effective_approval_rules(api: Mapping[str, Any]) -> list[dict[str, Any]]:
1750
+ """Every approval rule of a valid API in file order, defaults filled in (empty: none).
1751
+
1752
+ Each is ``_effective_rule``'s mapping plus ``rule``, its name in messages:
1753
+ ``approval`` for the one mapping, ``approval[<index>]`` in a list.
1754
+ """
1755
+ return [
1756
+ {"rule": approval_rule_label(api, index), **_effective_rule(rule)}
1757
+ for index, rule in enumerate(approval_rules(api))
1758
+ ]
1759
+
1760
+
1761
+ def gate_payload(gate: ApprovalGate | None) -> dict[str, Any] | None:
1762
+ """A gate as JSON-ready data (``api show --json``), or None.
1763
+
1764
+ ``rule_index`` is the gating rule's index in a list of rules (None for one
1765
+ mapping); ``also_covered_by`` names the later rules that also cover the
1766
+ call, which do not apply to it. A relayed gate adds ``decide_with`` and
1767
+ ``relayers`` (a gate without them is ``direct``).
1768
+ """
1769
+ if gate is None:
1770
+ return None
1771
+ payload: dict[str, Any] = {
1772
+ "approvers": list(gate.approvers),
1773
+ "timeout_s": gate.timeout_s,
1774
+ "rule": gate.rule,
1775
+ "rule_index": gate.index,
1776
+ "also_covered_by": [f"{APPROVAL_KEY}[{i}]" for i in gate.also],
1777
+ }
1778
+ if gate.decide_with != DEFAULT_DECIDE_WITH:
1779
+ payload["decide_with"] = gate.decide_with
1780
+ payload["relayers"] = list(gate.relayers)
1781
+ return payload
1782
+
1783
+
1784
+ def describe_gate(gate: ApprovalGate) -> str:
1785
+ """``requester, role:ops (approval.required_for.methods ['POST']; expires after 900 s)``.
1786
+
1787
+ With overlapping rules it also names the later ones that cover the call
1788
+ and says they do not apply (the first rule in file order gates it).
1789
+ """
1790
+ also = ""
1791
+ if gate.also:
1792
+ later = ", ".join(f"{APPROVAL_KEY}[{i}]" for i in gate.also)
1793
+ also = f"; also covered by {later}, which does not apply: the first rule gates the call"
1794
+ deciders = describe_deciders(
1795
+ {"approvers": gate.approvers, "decide_with": gate.decide_with, "relayers": gate.relayers}
1796
+ )
1797
+ return f"{deciders} ({gate.rule}; expires after {gate.timeout_s} s{also})"
1798
+
1799
+
1800
+ def _rule_methods(rule: Mapping[str, Any]) -> set[str]:
1801
+ """The methods one rule's ``required_for.methods`` gates outright (``"*"``: every one)."""
1802
+ methods = {str(m).upper() for m in (rule.get("required_for") or {}).get("methods") or []}
1803
+ return set(HTTP_METHODS) if ANY_METHOD in methods else methods
1804
+
1805
+
1806
+ def _entry_covers(earlier: Mapping[str, Any], later: Mapping[str, Any]) -> bool:
1807
+ """Whether gate entry ``earlier`` covers every call gate entry ``later`` covers.
1808
+
1809
+ Entries cover calls as denials do (``denial_match``): with the same
1810
+ operationId and path, the one pinning at least the other's methods (none:
1811
+ every method) covers the same calls, and more.
1812
+ """
1813
+ if earlier.get("operationId") != later.get("operationId"):
1814
+ return False
1815
+ # JSON-RPC entries (0.3) cover by the request's method and decision: the same ones.
1816
+ if any(earlier.get(key) != later.get(key) for key in (RPC_METHOD_KEY, A2A_OPERATION_KEY)):
1817
+ return False
1818
+ paths = [earlier.get("path"), later.get("path")]
1819
+ if (paths[0] is None) != (paths[1] is None):
1820
+ return False
1821
+ if paths[0] is not None and normalize_path(str(paths[0])) != normalize_path(str(paths[1])):
1822
+ return False
1823
+ if not earlier.get("methods"):
1824
+ return True
1825
+ if not later.get("methods"):
1826
+ return False
1827
+ return {str(m).upper() for m in earlier["methods"]} >= {
1828
+ str(m).upper() for m in later["methods"]
1829
+ }
1830
+
1831
+
1832
+ def rule_never_applies(api: Mapping[str, Any], index: int) -> bool:
1833
+ """Whether every call approval rule ``index`` covers is covered by an earlier rule.
1834
+
1835
+ The first rule in file order that covers a call gates it, so such a rule
1836
+ never gates anything: its approvers never decide a call. Sound, not
1837
+ complete: True only when earlier methods, or equal or wider earlier
1838
+ entries, provably cover each part of it.
1839
+ """
1840
+ rules = approval_rules(api)
1841
+ if not 0 < index < len(rules):
1842
+ return False
1843
+ earlier = rules[:index]
1844
+ methods = set().union(*(_rule_methods(r) for r in earlier))
1845
+ rule = rules[index]
1846
+ if not _rule_methods(rule) <= methods:
1847
+ return False
1848
+ earlier_entries = [
1849
+ entry for r in earlier for entry in (r.get("required_for") or {}).get("operations") or []
1850
+ ]
1851
+ for entry in (rule.get("required_for") or {}).get("operations") or []:
1852
+ pinned = {str(m).upper() for m in entry.get("methods") or []} or set(HTTP_METHODS)
1853
+ if pinned <= methods or any(_entry_covers(e, entry) for e in earlier_entries):
1854
+ continue
1855
+ return False
1856
+ return True
1857
+
1858
+
1859
+ def _rule_cover_methods(rule: Mapping[str, Any]) -> set[str]:
1860
+ """Every method some call one rule covers may have (its methods and its entries')."""
1861
+ methods = _rule_methods(rule)
1862
+ for entry in (rule.get("required_for") or {}).get("operations") or []:
1863
+ methods |= {str(m).upper() for m in entry.get("methods") or []} or set(HTTP_METHODS)
1864
+ return methods
1865
+
1866
+
1867
+ def rule_conflicts(api: Mapping[str, Any]) -> list[tuple[int, list[Mapping[str, Any]], list[int]]]:
1868
+ """Rules that may leave a call no operation id names to either of two approver sets.
1869
+
1870
+ ``gated`` refuses a call that the first covering rule covers only because
1871
+ the call names no operation id (an entry pinning ``operationId`` without
1872
+ ``path``) when a later rule with other approvers (or approvers who decide
1873
+ otherwise: ``decide_with``, ``relayers``) also covers it. For each such
1874
+ rule: its index, those entries, and the later rules with other deciders
1875
+ that may cover the same calls (by method; conservative).
1876
+ """
1877
+ rules = approval_rules(api)
1878
+ allowed = {str(m).upper() for m in api.get("allowed_methods") or []}
1879
+ allowed = set(HTTP_METHODS) if ANY_METHOD in allowed else allowed
1880
+ found = []
1881
+ sure: set[str] = set() # methods an earlier (or this) rule gates outright
1882
+ for index, rule in enumerate(rules):
1883
+ sure |= _rule_methods(rule)
1884
+ deciders = rule_deciders(rule)
1885
+ entries = [
1886
+ entry
1887
+ for entry in (rule.get("required_for") or {}).get("operations") or []
1888
+ # A JSON-RPC entry never leaves a call unnamed (every POST names its method).
1889
+ if entry.get("operationId") is not None
1890
+ and entry.get("path") is None
1891
+ and not _rpc_pins(entry)
1892
+ ]
1893
+ methods = {
1894
+ m
1895
+ for entry in entries
1896
+ for m in ({str(x).upper() for x in entry.get("methods") or []} or set(HTTP_METHODS))
1897
+ } & (allowed - sure)
1898
+ later = [
1899
+ j
1900
+ for j in range(index + 1, len(rules))
1901
+ if rule_deciders(rules[j]) != deciders and methods & _rule_cover_methods(rules[j])
1902
+ ]
1903
+ if entries and methods and later:
1904
+ found.append((index, entries, later))
1905
+ return found
1906
+
1907
+
1908
+ def approval_notes(name: str, api: Mapping[str, Any]) -> list[str]:
1909
+ """What an API's valid ``approval`` names that can never take effect, or refuses.
1910
+
1911
+ Approval never widens access, so a gate on a method outside
1912
+ ``allowed_methods`` changes nothing: those calls stay refused. In a list
1913
+ of rules, a rule whose every call an earlier rule covers first never
1914
+ gates anything (rules apply in file order), and a rule that names
1915
+ operations by ``operationId`` alone, before a rule with other approvers,
1916
+ has the calls that name no operation id and that both may cover refused
1917
+ (``rule_conflicts``).
1918
+ """
1919
+ notes: list[str] = []
1920
+ for index, entries, later in rule_conflicts(api):
1921
+ label = approval_rule_label(api, index)
1922
+ others = ", ".join(
1923
+ f"{approval_rule_label(api, j)} (approved by "
1924
+ f"{describe_deciders(approval_rules(api)[j])})"
1925
+ for j in later
1926
+ )
1927
+ named = "; ".join(describe_operation(entry) for entry in entries)
1928
+ notes.append(
1929
+ f"apis.{name}.{label} names operations by operationId alone ({named}), so it "
1930
+ f"cannot rule out a call that names no operation_id, and {others} may also cover "
1931
+ "such a call: the agent refuses it (it could be either rule's call). Name the "
1932
+ f"operation_id on every call to {name} and in API_CALLS, or pin path and methods "
1933
+ f"in {label} (api approval --operations pins them from the API's openapi: spec)"
1934
+ )
1935
+ allowed = [str(m).upper() for m in api.get("allowed_methods") or []]
1936
+ for index, rule in enumerate(approval_rules(api)):
1937
+ label = approval_rule_label(api, index)
1938
+ if ANY_METHOD not in allowed:
1939
+ required_for = rule["required_for"]
1940
+ named = [str(m).upper() for m in required_for.get("methods") or []]
1941
+ for entry in required_for.get("operations") or []:
1942
+ named.extend(str(m).upper() for m in entry.get("methods") or [])
1943
+ outside = [m for m in dict.fromkeys(named) if m != ANY_METHOD and m not in allowed]
1944
+ if outside:
1945
+ notes.append(
1946
+ f"apis.{name}.{label} gates {', '.join(outside)}, which allowed_methods does "
1947
+ "not allow: approval never widens access, so those calls stay refused"
1948
+ )
1949
+ if rule_never_applies(api, index):
1950
+ notes.append(
1951
+ f"apis.{name}.{label} never gates a call: an earlier rule covers every call it "
1952
+ "covers, and the first rule in file order gates a call. Move it above that rule, "
1953
+ "narrow the earlier rule, or remove it"
1954
+ )
1955
+ return notes
1956
+
1957
+
1958
+ def read_policy_document(path: str | Path | None) -> dict[str, Any] | None:
1959
+ """An existing project policy, validated; None (with a warning) when absent or invalid.
1960
+
1961
+ Used when a project is re-rendered: the policy belongs to the project and
1962
+ ``lint`` reports its problems, so a broken file must not stop the re-render.
1963
+ """
1964
+ if not path or not Path(path).is_file():
1965
+ return None
1966
+ try:
1967
+ return load_policy_document(path)
1968
+ except ApiPolicyFileError as exc:
1969
+ logging.warning("%s", exc.format_message())
1970
+ return None
1971
+
1972
+
1973
+ def read_summaries(path: str | Path | None) -> tuple[ApiSummary, ...]:
1974
+ """Summaries of an existing project policy; empty (with a warning) when unreadable."""
1975
+ document = read_policy_document(path)
1976
+ return summarize(document) if document is not None else ()
1977
+
1978
+
1979
+ def bearer_token_envs(summaries: tuple[ApiSummary, ...] | list[ApiSummary]) -> list[str]:
1980
+ """The ``token_env`` of every ``auth: bearer`` API, first occurrence first."""
1981
+ envs: list[str] = []
1982
+ for summary in summaries:
1983
+ if summary.auth == "bearer" and summary.token_env and summary.token_env not in envs:
1984
+ envs.append(summary.token_env)
1985
+ return envs
1986
+
1987
+
1988
+ # `auth: exchange` (RFC 8693): the issuer's token endpoint and this agent's client there. The
1989
+ # URL and the client id are plain settings (.env, the chart's values); the secret joins
1990
+ # `secrets.keys`, so it reaches the Secret and never the values files.
1991
+ TOKEN_EXCHANGE_URL_ENV = "TOKEN_EXCHANGE_URL"
1992
+ TOKEN_EXCHANGE_CLIENT_ID_ENV = "TOKEN_EXCHANGE_CLIENT_ID"
1993
+ TOKEN_EXCHANGE_SECRET_ENV = "TOKEN_EXCHANGE_CLIENT_SECRET"
1994
+
1995
+
1996
+ def uses_exchange(summaries: tuple[ApiSummary, ...] | list[ApiSummary]) -> bool:
1997
+ """Whether an API of the policy uses ``auth: exchange``."""
1998
+ return any(summary.auth == "exchange" for summary in summaries)
1999
+
2000
+
2001
+ def secret_envs(summaries: tuple[ApiSummary, ...] | list[ApiSummary]) -> list[str]:
2002
+ """The secrets the policy's APIs need in ``secrets.keys``, first occurrence first.
2003
+
2004
+ Every ``auth: bearer`` API's ``token_env``, and ``TOKEN_EXCHANGE_CLIENT_SECRET``
2005
+ once when an API uses ``auth: exchange``.
2006
+ """
2007
+ envs = bearer_token_envs(summaries)
2008
+ if uses_exchange(summaries) and TOKEN_EXCHANGE_SECRET_ENV not in envs:
2009
+ envs.append(TOKEN_EXCHANGE_SECRET_ENV)
2010
+ return envs
2011
+
2012
+
2013
+ def forward_runtime_problem(
2014
+ summaries: tuple[ApiSummary, ...] | list[ApiSummary], runtime: str
2015
+ ) -> str | None:
2016
+ """Why ``auth: forward`` or ``auth: exchange`` cannot be used with ``runtime``, or None."""
2017
+ carrying = [s for s in summaries if s.auth in HEADER_AUTH_MODES]
2018
+ if runtime != "langgraph-server" or not carrying:
2019
+ return None
2020
+ modes = [mode for mode in HEADER_AUTH_MODES if any(s.auth == mode for s in carrying)]
2021
+ stored = (
2022
+ "forwarded credentials" if modes == ["forward"] else "the caller's credentials (tokens)"
2023
+ )
2024
+ return (
2025
+ f"{' and '.join(f'auth: {mode}' for mode in modes)} (apis: "
2026
+ f"{', '.join(s.name for s in carrying)}) is not supported with runtime "
2027
+ f"langgraph-server: LangGraph Server persists the run context, so {stored} would be "
2028
+ "stored. Use auth: bearer or none, or the fastapi runtime."
2029
+ )
2030
+
2031
+
2032
+ def auth_policy_findings(
2033
+ document: Mapping[str, Any], auth_policy: str
2034
+ ) -> tuple[list[str], list[str]]:
2035
+ """The APIs that act with the caller's identity against the project's auth policy.
2036
+
2037
+ Returns ``(errors, notes)``, the compatibility matrix of `lint` and `api add`:
2038
+
2039
+ * ``auth: exchange`` under ``shared-bearer``: an error (there is no user token
2040
+ to exchange).
2041
+ * ``auth: forward`` under ``shared-bearer``: an error (there is no user
2042
+ credential to forward: every caller is the one principal ``shared``).
2043
+ * ``auth: forward`` under ``jwt`` without ``forward_audience``: an error (jwt
2044
+ sets no per-API credential); with it, a note to prefer ``auth: exchange``.
2045
+ * ``custom``: every mode is the policy's to serve (``keep_subject_token`` for
2046
+ exchange, ``attributes["credentials"]`` for forward).
2047
+ * ``auth: exchange`` with ``exchange.allow_actorless: true`` (``jwt`` or
2048
+ ``custom``): a note naming what the agent behind the API must set, since
2049
+ the calling agent then sends tokens that name no actor.
2050
+ """
2051
+ apis = document.get("apis") or {}
2052
+ exchange = [str(n) for n, a in apis.items() if a.get("auth") == "exchange"]
2053
+ forward = [str(n) for n, a in apis.items() if a.get("auth") == "forward"]
2054
+ actorless = [
2055
+ str(n)
2056
+ for n, a in apis.items()
2057
+ if a.get("auth") == "exchange"
2058
+ and isinstance(a.get(EXCHANGE_KEY), Mapping)
2059
+ and a[EXCHANGE_KEY].get(ALLOW_ACTORLESS_KEY) is True
2060
+ ]
2061
+ errors: list[str] = []
2062
+ notes: list[str] = []
2063
+ if actorless and auth_policy != "shared-bearer":
2064
+ notes.append(
2065
+ f"auth: exchange (apis: {', '.join(actorless)}) sets exchange.allow_actorless: this "
2066
+ "agent sends exchanged tokens that name no actor, so the agent behind each must set "
2067
+ "AUTH_JWT_DIRECT_CLIENTS to the clients people sign in with and list this agent in "
2068
+ "AUTH_ALLOWED_ACTORS as client:<its client id>; without them it reads this agent's "
2069
+ "calls as the person's own, and this agent could decide the person's approvals there"
2070
+ )
2071
+ if auth_policy == "shared-bearer":
2072
+ if exchange:
2073
+ errors.append(
2074
+ f"auth: exchange (apis: {', '.join(exchange)}) is not supported with the "
2075
+ "shared-bearer auth policy: shared-bearer has no user token to exchange; use "
2076
+ "auth: bearer with the peer's agent key"
2077
+ )
2078
+ if forward:
2079
+ errors.append(
2080
+ f"auth: forward (apis: {', '.join(forward)}) is not supported with the "
2081
+ "shared-bearer auth policy: there is no user credential to forward (every "
2082
+ "caller is the one principal `shared`); use auth: bearer or none"
2083
+ )
2084
+ elif auth_policy == "jwt":
2085
+ unaimed = [n for n in forward if "forward_audience" not in apis[n]]
2086
+ aimed = [n for n in forward if "forward_audience" in apis[n]]
2087
+ if unaimed:
2088
+ errors.append(
2089
+ f"auth: forward (apis: {', '.join(unaimed)}) needs forward_audience with the jwt "
2090
+ "auth policy: jwt sets no per-API credential, and forwards the caller's own "
2091
+ "token only to an audience the issuer minted it for; prefer auth: exchange"
2092
+ )
2093
+ if aimed:
2094
+ notes.append(
2095
+ f"auth: forward (apis: {', '.join(aimed)}): prefer auth: exchange; forward sends "
2096
+ "the caller's own token and needs one minted for both audiences"
2097
+ )
2098
+ return errors, notes
2099
+
2100
+
2101
+ def legacy_findings(project_dir: str | Path) -> list[str]:
2102
+ """What still uses the retired product API policy in ``project_dir``."""
2103
+ root = Path(project_dir)
2104
+ findings: list[str] = []
2105
+ if (root / LEGACY_POLICY_FILENAME).is_file():
2106
+ findings.append(f"{LEGACY_POLICY_FILENAME} exists")
2107
+ manifest = root / "graph-agents-cli-manifest.yaml"
2108
+ if manifest.is_file():
2109
+ try:
2110
+ data = yaml.safe_load(manifest.read_text(encoding="utf-8"))
2111
+ except (OSError, yaml.YAMLError):
2112
+ data = None
2113
+ if isinstance(data, Mapping) and LEGACY_MANIFEST_KEY in data:
2114
+ findings.append(f"the manifest has a {LEGACY_MANIFEST_KEY}: block")
2115
+ return findings
2116
+
2117
+
2118
+ def legacy_migration_message(findings: list[str]) -> str:
2119
+ """How to move a project from the retired product API policy to api-policy.yaml."""
2120
+ return (
2121
+ "This project uses the retired product API policy ("
2122
+ + "; ".join(findings)
2123
+ + "). Migrate it to api-policy.yaml, then re-run the command:\n"
2124
+ f" 1. Rename {LEGACY_POLICY_FILENAME} to {POLICY_FILENAME}.\n"
2125
+ f" 2. Replace its top-level `{LEGACY_MANIFEST_KEY}:` with `apis:` and move the fields\n"
2126
+ " under an API name, adding the now required allowed_methods:\n"
2127
+ " apis:\n"
2128
+ " example:\n"
2129
+ " base_url_env: EXAMPLE_API_BASE_URL\n"
2130
+ " auth: bearer # none | bearer | forward (was forwarded-session)\n"
2131
+ " token_env: EXAMPLE_API_TOKEN\n"
2132
+ " allowed_methods: [GET, POST] # list every method it may use\n"
2133
+ f" 3. In graph-agents-cli-manifest.yaml replace `{LEGACY_MANIFEST_KEY}:` with\n"
2134
+ f" `{MANIFEST_KEY}: {{policy_file: {POLICY_FILENAME}}}`.\n"
2135
+ f" 4. In every tool module rename {LEGACY_CALLS_NAME} to {CALLS_NAME}, add\n"
2136
+ ' "api": "<name>" to each entry, and call the API through\n'
2137
+ ' app_utils.api_client.get_client("<name>").'
2138
+ )
2139
+
2140
+
2141
+ def ensure_no_legacy_api_policy(project_dir: str | Path) -> None:
2142
+ """Raise ``LegacyApiPolicyError`` (exit 3) when the project uses the retired format."""
2143
+ findings = legacy_findings(project_dir)
2144
+ if findings:
2145
+ raise LegacyApiPolicyError(legacy_migration_message(findings))