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,1287 @@
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
+ """Static API-policy check run by ``graph-agents-cli lint``.
16
+
17
+ Every tool module under ``<agent_dir>/tools/`` (subpackages included) declares, at module level,
18
+ the external API calls it makes::
19
+
20
+ API_CALLS = [
21
+ {"api": "orders", "method": "POST", "operation_id": "createOrder", "path": "/orders"},
22
+ {"api": "orders", "method": "GET", "path": "/orders/{order_id}"},
23
+ ]
24
+
25
+ The list is read with :mod:`ast` (``ast.literal_eval`` on the assigned value),
26
+ so the check never imports a tool module and therefore never loads a model SDK
27
+ or the API client. Each declared call must name an API declared in
28
+ ``api-policy.yaml`` and be allowed by that API's rules. The file is validated
29
+ with the same strict schema, and calls are matched with the same rules, as the
30
+ runtime client of the scaffolded project (``graph_agents_cli._api_policy``
31
+ holds the shared copy). When an API sets ``openapi:``, every call must also
32
+ exist in that spec by ``operationId`` or by ``path`` + ``method``; a declared
33
+ ``operation_id`` must be the one the spec gives that method and path (the id is
34
+ a label the tool chooses, so a typo or a relabelled call must not pass as
35
+ another operation), and a call declared by ``operation_id`` alone is judged
36
+ with the path the spec gives it (the client always sends one), so path denials
37
+ apply to it.
38
+
39
+ Fail closed: without a policy file every declared call is refused, as the
40
+ runtime would refuse it. A module that still declares the retired
41
+ ``PRODUCT_CALLS`` is an error with a rename hint, and ``auth: forward`` is an
42
+ error under the ``langgraph-server`` runtime.
43
+
44
+ Every refused call carries a hint: the ``graph-agents-cli api`` command that
45
+ would allow it (a reviewed change to ``api-policy.yaml``), or what to change in
46
+ the tool. An allowed call that the API's ``approval`` block gates (``gated``,
47
+ the runtime's rule) carries its gate: the report says who must approve it
48
+ before it is sent. Approval never widens access: a refused call stays refused.
49
+
50
+ ``example_call`` uses the same judgement to pick the call that ``create``
51
+ renders into the example tool, so a fresh project passes this check.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import ast
57
+ import json
58
+ import keyword
59
+ import os
60
+ import re
61
+ from collections.abc import Mapping
62
+ from dataclasses import dataclass, field
63
+ from pathlib import Path
64
+ from typing import Any
65
+
66
+ import click
67
+ import yaml
68
+ from rich.markup import escape
69
+ from rich.table import Table
70
+
71
+ from graph_agents_cli._api_policy import (
72
+ _RPC_METHOD_RE,
73
+ A2A_APPROVE,
74
+ A2A_MESSAGE_METHODS,
75
+ A2A_OPERATIONS,
76
+ ANY_METHOD,
77
+ CALLS_NAME,
78
+ DESCRIPTION_KEY,
79
+ HTTP_METHODS,
80
+ LEGACY_CALLS_NAME,
81
+ POLICY_FILENAME,
82
+ PROTOCOL_A2A,
83
+ RPC_PROTOCOLS,
84
+ ApiPolicyFileError,
85
+ ApprovalGate,
86
+ ApprovalRuleConflict,
87
+ ExampleCall,
88
+ api_protocol,
89
+ approval_notes,
90
+ auth_policy_findings,
91
+ canonical_rpc_method,
92
+ denial_match,
93
+ describe_gate,
94
+ forward_runtime_problem,
95
+ gated,
96
+ label_problem,
97
+ load_policy_document,
98
+ operation_matches,
99
+ path_matches,
100
+ path_template_problem,
101
+ refusal_reason,
102
+ summarize,
103
+ )
104
+ from graph_agents_cli._output import Console, print_table
105
+
106
+ TOOLS_SUBDIR = "tools"
107
+ _CALL_KEYS = ("api", "method", "operation_id", "path", "rpc_method", "a2a_operation")
108
+ # More peers than this make the model's roster of agents long (lint warns).
109
+ MAX_PEERS = 40
110
+
111
+ STATUS_ALLOWED = "allowed"
112
+ STATUS_DENIED = "denied"
113
+ STATUS_UNKNOWN = "unknown"
114
+ STATUS_INVALID = "invalid"
115
+ VIOLATION_STATUSES = frozenset({STATUS_DENIED, STATUS_UNKNOWN, STATUS_INVALID})
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class DeclaredCall:
120
+ """One entry of a tool's ``API_CALLS`` list (or a problem's location)."""
121
+
122
+ tool: str
123
+ method: str
124
+ api: str = ""
125
+ operation_id: str | None = None
126
+ path: str | None = None
127
+ # A call to a JSON-RPC API (protocol: jsonrpc|a2a): the JSON-RPC method it sends and,
128
+ # for an A2A message that decides a pending approval, approve or reject.
129
+ rpc_method: str | None = None
130
+ a2a_operation: str | None = None
131
+
132
+ @property
133
+ def operation(self) -> str:
134
+ if self.operation_id and self.path:
135
+ text = f"{self.operation_id} {self.path}"
136
+ else:
137
+ text = self.operation_id or self.path or "-"
138
+ rpc = " ".join(part for part in (self.rpc_method, self.a2a_operation) if part)
139
+ return f"{text} ({rpc})" if rpc else text
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class CheckResult:
144
+ call: DeclaredCall
145
+ status: str
146
+ reason: str = ""
147
+ # For a refused call: the `graph-agents-cli api` command that would allow it,
148
+ # or what to change in the tool.
149
+ hint: str = ""
150
+ # For a call the policy allows: the human approval it needs before it is
151
+ # sent (the API's `approval` block), or None.
152
+ gate: ApprovalGate | None = None
153
+
154
+ @property
155
+ def is_violation(self) -> bool:
156
+ return self.status in VIOLATION_STATUSES
157
+
158
+
159
+ @dataclass
160
+ class PolicyReport:
161
+ """Everything the check found, ready to print."""
162
+
163
+ results: list[CheckResult] = field(default_factory=list)
164
+ notes: list[str] = field(default_factory=list)
165
+ policy_path: Path | None = None
166
+ openapi_paths: dict[str, Path] = field(default_factory=dict)
167
+ # The policy file itself is unusable (schema, a spec it names, a declared
168
+ # file that is missing): a configuration error, not a refused call.
169
+ policy_invalid: bool = False
170
+
171
+ @property
172
+ def violations(self) -> int:
173
+ return sum(1 for r in self.results if r.is_violation)
174
+
175
+ @property
176
+ def gated(self) -> int:
177
+ """Declared calls the policy allows only after a human approves them."""
178
+ return sum(1 for r in self.results if r.gate is not None)
179
+
180
+ def invalid(self, where: str, reason: str) -> None:
181
+ self.results.append(
182
+ CheckResult(DeclaredCall(tool=where, method="-"), STATUS_INVALID, reason)
183
+ )
184
+
185
+ def invalid_policy(self, where: str, reason: str) -> None:
186
+ self.invalid(where, reason)
187
+ self.policy_invalid = True
188
+
189
+
190
+ class InvalidPolicyFile(click.ClickException):
191
+ """``lint`` / ``api check``: the policy file is invalid (a configuration error, exit 3)."""
192
+
193
+ exit_code = 3
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Loading
198
+ # ---------------------------------------------------------------------------
199
+
200
+
201
+ def load_openapi(path: Path) -> dict[str, Any]:
202
+ """Load an OpenAPI document from YAML or JSON."""
203
+ text = path.read_text(encoding="utf-8")
204
+ if path.suffix.lower() == ".json":
205
+ data = json.loads(text)
206
+ else:
207
+ data = yaml.safe_load(text)
208
+ if not isinstance(data, dict):
209
+ raise ValueError(f"{path}: expected an OpenAPI mapping")
210
+ return data
211
+
212
+
213
+ def _literal(node: ast.AST) -> Any:
214
+ try:
215
+ return ast.literal_eval(node)
216
+ except (ValueError, SyntaxError, TypeError):
217
+ return None
218
+
219
+
220
+ def _assigned_names(node: ast.stmt) -> tuple[list[str], ast.expr | None]:
221
+ if isinstance(node, ast.Assign):
222
+ return [t.id for t in node.targets if isinstance(t, ast.Name)], node.value
223
+ if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
224
+ return [node.target.id], node.value
225
+ return [], None
226
+
227
+
228
+ def _entry_problem(entry: Any) -> str | None:
229
+ """Why an ``API_CALLS`` entry is malformed, or None."""
230
+ if not isinstance(entry, dict):
231
+ return "is not a dict"
232
+ unknown = sorted(set(entry) - set(_CALL_KEYS), key=str)
233
+ if unknown:
234
+ return (
235
+ f"has unknown key(s) {', '.join(map(repr, unknown))} (allowed: {', '.join(_CALL_KEYS)})"
236
+ )
237
+ if not isinstance(entry.get("api"), str) or not entry.get("api"):
238
+ return 'has no "api" (the name of an API in api-policy.yaml)'
239
+ method = entry.get("method")
240
+ if not isinstance(method, str) or method.upper() not in HTTP_METHODS:
241
+ return f"has no valid method (one of {', '.join(HTTP_METHODS)})"
242
+ operation_id, path = entry.get("operation_id"), entry.get("path")
243
+ if not operation_id and not path:
244
+ return "has neither operation_id nor path"
245
+ if operation_id is not None and not isinstance(operation_id, str):
246
+ return "has a non-string operation_id"
247
+ if path is not None:
248
+ problem = path_template_problem(path)
249
+ if problem:
250
+ return f"path {problem}"
251
+ rpc_method, a2a_operation = entry.get("rpc_method"), entry.get("a2a_operation")
252
+ if rpc_method is not None:
253
+ if not (isinstance(rpc_method, str) and _RPC_METHOD_RE.fullmatch(rpc_method)):
254
+ return (
255
+ "has an rpc_method that is not a JSON-RPC method name (a letter, then up to 63 "
256
+ "letters, digits, '_', '/' or '.')"
257
+ )
258
+ if method.upper() != "POST":
259
+ return f"has an rpc_method on a {method.upper()} (a JSON-RPC request is a POST)"
260
+ if a2a_operation is not None:
261
+ if a2a_operation not in A2A_OPERATIONS:
262
+ return "has an a2a_operation other than approve or reject"
263
+ if rpc_method is None:
264
+ return "has an a2a_operation without rpc_method (SendMessage or SendStreamingMessage)"
265
+ return None
266
+
267
+
268
+ # Methods that change a list or a dict in place.
269
+ _MUTATING_METHODS = frozenset(
270
+ {
271
+ "append",
272
+ "clear",
273
+ "extend",
274
+ "insert",
275
+ "pop",
276
+ "popitem",
277
+ "remove",
278
+ "reverse",
279
+ "setdefault",
280
+ "sort",
281
+ "update",
282
+ "__delitem__",
283
+ "__iadd__",
284
+ "__setitem__",
285
+ }
286
+ )
287
+
288
+
289
+ def _root_name(node: ast.AST) -> str | None:
290
+ """``API_CALLS`` for ``API_CALLS``, ``API_CALLS[0]["path"]`` or ``API_CALLS.append``."""
291
+ while isinstance(node, ast.Attribute | ast.Subscript):
292
+ node = node.value
293
+ return node.id if isinstance(node, ast.Name) else None
294
+
295
+
296
+ def _is_declaration(node: ast.stmt) -> bool:
297
+ """A plain ``API_CALLS = ...`` or ``API_CALLS: ... = ...`` (one target, a value)."""
298
+ if isinstance(node, ast.Assign):
299
+ target = node.targets[0] if len(node.targets) == 1 else None
300
+ elif isinstance(node, ast.AnnAssign) and node.value is not None:
301
+ target = node.target
302
+ else:
303
+ return False
304
+ return isinstance(target, ast.Name) and target.id == CALLS_NAME
305
+
306
+
307
+ def _unread_changes(tree: ast.Module, declaration: ast.stmt | None) -> list[int]:
308
+ """Lines outside ``declaration`` that bind or change ``API_CALLS``, in order.
309
+
310
+ The check reads one literal. ``+=``, ``.append()``, an item assignment, a
311
+ second or conditional assignment, an import or a ``def`` of the name could
312
+ change the calls the tool makes without the check seeing them, so each is
313
+ reported instead of trusted. (Changes through another name, such as an
314
+ alias or ``globals()``, are out of reach of a static check; the runtime
315
+ client still refuses every call outside the policy.)
316
+ """
317
+ skip: set[int] = set() # Name nodes that are not a change: the declaration's target
318
+ if isinstance(declaration, ast.Assign):
319
+ skip.add(id(declaration.targets[0]))
320
+ elif isinstance(declaration, ast.AnnAssign):
321
+ skip.add(id(declaration.target))
322
+ lines: set[int] = set()
323
+ for node in ast.walk(tree):
324
+ if isinstance(node, ast.AnnAssign) and node.value is None:
325
+ skip.add(id(node.target)) # an annotation alone binds nothing
326
+ for node in ast.walk(tree):
327
+ changed = False
328
+ if isinstance(node, ast.Name):
329
+ changed = (
330
+ node.id == CALLS_NAME
331
+ and isinstance(node.ctx, ast.Store | ast.Del)
332
+ and id(node) not in skip
333
+ )
334
+ elif isinstance(node, ast.Attribute | ast.Subscript):
335
+ changed = isinstance(node.ctx, ast.Store | ast.Del) and _root_name(node) == CALLS_NAME
336
+ elif isinstance(node, ast.Call):
337
+ func = node.func
338
+ changed = (
339
+ isinstance(func, ast.Attribute)
340
+ and func.attr in _MUTATING_METHODS
341
+ and _root_name(func.value) == CALLS_NAME
342
+ )
343
+ elif isinstance(node, ast.alias):
344
+ changed = (node.asname or node.name.split(".")[0]) == CALLS_NAME
345
+ elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
346
+ changed = node.name == CALLS_NAME
347
+ elif isinstance(node, ast.ExceptHandler | ast.MatchAs | ast.MatchStar):
348
+ changed = node.name == CALLS_NAME
349
+ elif isinstance(node, ast.MatchMapping):
350
+ changed = node.rest == CALLS_NAME
351
+ if changed:
352
+ lines.add(getattr(node, "lineno", 0))
353
+ return sorted(lines)
354
+
355
+
356
+ def read_api_calls(
357
+ tool_path: Path, label: str | None = None
358
+ ) -> tuple[list[DeclaredCall], list[str]]:
359
+ """Return the ``API_CALLS`` declared in ``tool_path`` plus any problems.
360
+
361
+ ``label`` names the module in the report (default: the file name); the
362
+ tools directory walk passes the path relative to ``tools/``.
363
+
364
+ A tool without the name declares no calls. The declaration must be one
365
+ module-level assignment of a literal list of dicts: anything the check
366
+ cannot read (a non-literal value, ``+=``, ``.append()``, a second or
367
+ conditional assignment) is reported as a problem rather than trusted, as
368
+ are a malformed entry and a leftover ``PRODUCT_CALLS``.
369
+ """
370
+ name = label or tool_path.name
371
+ problems: list[str] = []
372
+ try:
373
+ tree = ast.parse(tool_path.read_text(encoding="utf-8"), filename=str(tool_path))
374
+ except SyntaxError as exc:
375
+ return [], [f"{name}: syntax error: {exc}"]
376
+ except (OSError, UnicodeDecodeError, ValueError) as exc:
377
+ # Unreadable is not "declares no calls": the check cannot vouch for it.
378
+ return [], [f"{name}: cannot be read as UTF-8 Python source: {exc}"]
379
+
380
+ calls: list[DeclaredCall] = []
381
+ declaration: ast.stmt | None = None
382
+ for node in tree.body:
383
+ names, value = _assigned_names(node)
384
+ if LEGACY_CALLS_NAME in names:
385
+ problems.append(
386
+ f"{name}: {LEGACY_CALLS_NAME} was renamed to {CALLS_NAME}; rename it "
387
+ 'and add "api": "<name of an API in api-policy.yaml>" to every entry'
388
+ )
389
+ continue
390
+ if declaration is not None or not _is_declaration(node) or value is None:
391
+ continue
392
+ declaration = node
393
+ literal = _literal(value)
394
+ if not isinstance(literal, list | tuple):
395
+ problems.append(
396
+ f"{name}: {CALLS_NAME} is not a literal list; "
397
+ "declare calls as plain dict literals so the check can read them"
398
+ )
399
+ continue
400
+ for index, entry in enumerate(literal):
401
+ problem = _entry_problem(entry)
402
+ if problem:
403
+ problems.append(f"{name}: {CALLS_NAME}[{index}] {problem}")
404
+ continue
405
+ calls.append(
406
+ DeclaredCall(
407
+ tool=name,
408
+ api=str(entry["api"]),
409
+ method=str(entry["method"]).upper(),
410
+ operation_id=entry.get("operation_id") or None,
411
+ path=entry.get("path") or None,
412
+ rpc_method=entry.get("rpc_method") or None,
413
+ a2a_operation=entry.get("a2a_operation") or None,
414
+ )
415
+ )
416
+ for line in _unread_changes(tree, declaration):
417
+ problems.append(
418
+ f"{name}: line {line} binds or changes {CALLS_NAME} outside its "
419
+ "module-level literal (for example +=, .append() or an assignment inside a "
420
+ "block), so the check cannot read those calls; declare every call in the "
421
+ "one literal list"
422
+ )
423
+ return calls, problems
424
+
425
+
426
+ def collect_declared_calls(tools_dir: Path) -> tuple[list[DeclaredCall], list[str]]:
427
+ """Read every ``*.py`` under ``tools_dir``, subpackages included, except its ``__init__.py``.
428
+
429
+ The walk covers at least what ``tools.get_tools()`` imports (every module and
430
+ subpackage of the package, ``_``-prefixed ones too) plus the modules those
431
+ subpackages hold, so no module that can make calls escapes the check. A
432
+ subpackage's own ``__init__.py`` is read; the top-level one is the registry.
433
+ Cache and hidden directories are skipped.
434
+ """
435
+ calls: list[DeclaredCall] = []
436
+ problems: list[str] = []
437
+ if not tools_dir.is_dir():
438
+ return calls, problems
439
+ for relative in _tool_module_paths(tools_dir):
440
+ found, found_problems = read_api_calls(tools_dir / relative, relative)
441
+ calls.extend(found)
442
+ problems.extend(found_problems)
443
+ return calls, problems
444
+
445
+
446
+ def _tool_module_paths(tools_dir: Path) -> list[str]:
447
+ """``*.py`` paths under ``tools_dir`` relative to it, sorted, top ``__init__.py`` excluded.
448
+
449
+ Symlinked directories are followed (the import system follows them too),
450
+ each real directory once, so a link loop cannot hang the walk.
451
+ """
452
+ found: list[str] = []
453
+ seen: set[str] = set()
454
+ for root, dirs, files in os.walk(tools_dir, followlinks=True):
455
+ real = os.path.realpath(root)
456
+ if real in seen:
457
+ dirs[:] = []
458
+ continue
459
+ seen.add(real)
460
+ dirs[:] = sorted(d for d in dirs if not d.startswith(".") and d != "__pycache__")
461
+ base = Path(root).relative_to(tools_dir)
462
+ for filename in files:
463
+ if not filename.endswith(".py"):
464
+ continue
465
+ relative = (base / filename).as_posix()
466
+ if relative != "__init__.py" and (Path(root) / filename).is_file():
467
+ found.append(relative)
468
+ return sorted(found)
469
+
470
+
471
+ # ---------------------------------------------------------------------------
472
+ # Matching
473
+ # ---------------------------------------------------------------------------
474
+
475
+
476
+ def _spec_operations(spec: dict[str, Any]) -> list[tuple[str, str, str | None]]:
477
+ """Every operation of an OpenAPI spec as ``(path, METHOD, operationId or None)``, in order."""
478
+ operations: list[tuple[str, str, str | None]] = []
479
+ paths = spec.get("paths") or {}
480
+ if not isinstance(paths, dict):
481
+ return operations
482
+ http_methods = {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
483
+ for path, item in paths.items():
484
+ if not isinstance(item, dict):
485
+ continue
486
+ for method, op in item.items():
487
+ if not isinstance(method, str) or method.lower() not in http_methods:
488
+ continue
489
+ if not isinstance(op, dict):
490
+ continue
491
+ op_id = op.get("operationId")
492
+ operations.append((str(path), method.upper(), str(op_id) if op_id else None))
493
+ return operations
494
+
495
+
496
+ def _index_openapi(spec: dict[str, Any]) -> tuple[dict[str, tuple[str, str]], set[tuple[str, str]]]:
497
+ """Map operationId -> (path, METHOD) and the set of (path, METHOD) pairs."""
498
+ by_id: dict[str, tuple[str, str]] = {}
499
+ pairs: set[tuple[str, str]] = set()
500
+ for path, method, op_id in _spec_operations(spec):
501
+ pairs.add((path, method))
502
+ if op_id:
503
+ by_id[op_id] = (path, method)
504
+ return by_id, pairs
505
+
506
+
507
+ def _spec_ids(call: DeclaredCall, by_id: Mapping[str, tuple[str, str]]) -> list[str]:
508
+ """The operation ids the spec gives the call's method and path (none without a path)."""
509
+ if not call.path:
510
+ return []
511
+ return sorted(
512
+ op_id
513
+ for op_id, (spec_path, spec_method) in by_id.items()
514
+ if spec_method == call.method and path_matches(spec_path, call.path)
515
+ )
516
+
517
+
518
+ def _spec_operation_hint(call: DeclaredCall, by_id: Mapping[str, tuple[str, str]]) -> str:
519
+ """For a refused call declared without an operation id: the spec's id(s) for its path.
520
+
521
+ The operation id is not filled in for the policy decision: the client
522
+ judges a call by the ``operation_id`` the tool passes, so a check that
523
+ assumed the spec's id would pass calls the client refuses.
524
+ """
525
+ ids = [] if call.operation_id else _spec_ids(call, by_id)
526
+ if not ids:
527
+ return ""
528
+ return f" (the OpenAPI spec names this operation {' or '.join(ids)})"
529
+
530
+
531
+ def _spec_mismatch(
532
+ call: DeclaredCall,
533
+ by_id: Mapping[str, tuple[str, str]],
534
+ pairs: set[tuple[str, str]],
535
+ ) -> str | None:
536
+ """Why the API's OpenAPI spec does not define the call as declared, or None.
537
+
538
+ A declared ``operation_id`` must be one the spec defines, for the call's
539
+ method and path. It is a label the tool chooses, and the allow-list can
540
+ match on it: a typo, or a relabelled call, must not reach an endpoint
541
+ under a name the spec gives another operation.
542
+ """
543
+ if call.operation_id:
544
+ if call.operation_id not in by_id:
545
+ ids = _spec_ids(call, by_id)
546
+ named = f"; the spec names {call.method} {call.path} {' or '.join(ids)}" if ids else ""
547
+ return f"operationId {call.operation_id} is not in the OpenAPI spec{named}"
548
+ spec_path, spec_method = by_id[call.operation_id]
549
+ if spec_method != call.method:
550
+ return (
551
+ f"operationId {call.operation_id} is {spec_method} {spec_path} in the spec, "
552
+ f"not {call.method}"
553
+ )
554
+ if call.path and not path_matches(spec_path, call.path):
555
+ return (
556
+ f"operationId {call.operation_id} is {spec_method} {spec_path} in the spec, "
557
+ f"not {call.path}"
558
+ )
559
+ return None
560
+ if call.path and any(
561
+ spec_method == call.method and path_matches(spec_path, call.path)
562
+ for spec_path, spec_method in pairs
563
+ ):
564
+ return None
565
+ return "not found in the OpenAPI spec"
566
+
567
+
568
+ def _spec_fix_hint(call: DeclaredCall, by_id: Mapping[str, tuple[str, str]]) -> str:
569
+ """What to change in the tool when its declared operation id disagrees with the spec."""
570
+ ids = _spec_ids(call, by_id)
571
+ if len(ids) == 1:
572
+ return (
573
+ f'declare the call as the OpenAPI spec does: "operation_id": "{ids[0]}" for '
574
+ f"{call.method} {call.path}, in {CALLS_NAME} and on the call"
575
+ )
576
+ return (
577
+ f"declare an operation_id and path the OpenAPI spec defines, in {CALLS_NAME} and on "
578
+ "the call"
579
+ )
580
+
581
+
582
+ def check_call(
583
+ call: DeclaredCall,
584
+ document: Mapping[str, Any] | None,
585
+ specs: Mapping[str, dict[str, Any]] | None = None,
586
+ *,
587
+ policy_file: str = POLICY_FILENAME,
588
+ ) -> CheckResult:
589
+ """Evaluate one declared call against the policy and (optionally) the API's spec.
590
+
591
+ A call the policy refuses is ``denied`` (with the ``graph-agents-cli api``
592
+ command that would allow it). With a spec, a call it does not define as
593
+ declared is ``unknown`` (a declared ``operation_id`` must be the spec's
594
+ one for that method and path); a call that is both says so, and its hint
595
+ fixes the declaration first. A call the policy allows carries the
596
+ approval its API requires before sending it (``gate``), if any.
597
+ """
598
+ if document is None:
599
+ return CheckResult(
600
+ call,
601
+ STATUS_DENIED,
602
+ f"no {policy_file}: outbound API calls are refused (fail closed)",
603
+ add_hint(call.api),
604
+ )
605
+ api = document["apis"].get(call.api)
606
+ if api is None:
607
+ declared = ", ".join(sorted(document["apis"])) or "none"
608
+ return CheckResult(
609
+ call,
610
+ STATUS_DENIED,
611
+ f"API {call.api!r} is not declared in {policy_file} (declared: {declared})",
612
+ add_hint(call.api),
613
+ )
614
+ rpc_refusal = _rpc_refusal(call, api)
615
+ if rpc_refusal is not None:
616
+ return rpc_refusal
617
+ rpc = _declared_rpc(call, api)
618
+ spec = (specs or {}).get(call.api)
619
+ by_id, pairs = _index_openapi(spec) if spec is not None else ({}, set())
620
+ path = call.path
621
+ if path is None and call.operation_id in by_id and by_id[call.operation_id][1] == call.method:
622
+ # The client always sends a path: judge the one the spec gives the operation,
623
+ # so path denials apply to a call declared by operation_id alone.
624
+ path = by_id[call.operation_id][0]
625
+ reason = refusal_reason(api, call.method, call.operation_id, path, **rpc)
626
+ mismatch = _spec_mismatch(call, by_id, pairs) if spec is not None else None
627
+ # A declared operation_id the spec does not give this call: fix the declaration first.
628
+ id_mismatch = mismatch is not None and call.operation_id is not None
629
+ if reason:
630
+ return CheckResult(
631
+ call,
632
+ STATUS_DENIED,
633
+ reason
634
+ + _spec_operation_hint(call, by_id)
635
+ + (f"; also, {mismatch}" if id_mismatch else ""),
636
+ _spec_fix_hint(call, by_id) if id_mismatch else refusal_hint(call, api, path),
637
+ )
638
+ # Only now, with the call allowed: approval never widens access.
639
+ try:
640
+ gate = gated(api, call.method, call.operation_id, path, **rpc)
641
+ except ApprovalRuleConflict as exc:
642
+ # The runtime refuses it too: it could be either rule's call (fail closed).
643
+ return CheckResult(call, STATUS_DENIED, str(exc), conflict_hint(call, exc))
644
+ if rpc["a2a_operation"] == A2A_APPROVE and gate is None:
645
+ # The validator refuses such a policy, and the client such a message.
646
+ return CheckResult(
647
+ call,
648
+ STATUS_DENIED,
649
+ "a message that approves must wait for an approval or be denied",
650
+ f"{API_COMMAND} approval {call.api} --a2a-operations approve --approvers requester",
651
+ )
652
+ if mismatch is not None:
653
+ return CheckResult(
654
+ call,
655
+ STATUS_UNKNOWN,
656
+ mismatch,
657
+ _spec_fix_hint(call, by_id) if id_mismatch else "",
658
+ gate=gate,
659
+ )
660
+ if spec is not None:
661
+ if call.operation_id:
662
+ spec_path, spec_method = by_id[call.operation_id]
663
+ return CheckResult(call, STATUS_ALLOWED, f"spec: {spec_method} {spec_path}", gate=gate)
664
+ spec_path = next(
665
+ p for p, m in sorted(pairs) if m == call.method and path_matches(p, str(call.path))
666
+ )
667
+ return CheckResult(call, STATUS_ALLOWED, f"spec: {call.method} {spec_path}", gate=gate)
668
+ return CheckResult(call, STATUS_ALLOWED, "", gate=gate)
669
+
670
+
671
+ def _declared_rpc(call: DeclaredCall, api: Mapping[str, Any]) -> dict[str, str | None]:
672
+ """The declared JSON-RPC method (an A2A 0.3 name read as its 1.0 name) and decision."""
673
+ method = call.rpc_method
674
+ if method is not None:
675
+ method = canonical_rpc_method(api_protocol(api), method)
676
+ return {"rpc_method": method, "a2a_operation": call.a2a_operation}
677
+
678
+
679
+ def _rpc_refusal(call: DeclaredCall, api: Mapping[str, Any]) -> CheckResult | None:
680
+ """A declared call whose JSON-RPC keys do not fit its API's `protocol`, or None.
681
+
682
+ `rpc_method` and `a2a_operation` go with JSON-RPC APIs only; every POST to one
683
+ declares its `rpc_method` (the client reads it from the body, so lint must know it);
684
+ `a2a_operation` goes with an A2A message; and an `operation_id` naming an entry for
685
+ another request is refused, as the client refuses it (`label_problem`).
686
+ """
687
+ protocol = api_protocol(api)
688
+ rpc = _declared_rpc(call, api)
689
+ fix = f"in the call's {CALLS_NAME} entry"
690
+ if protocol not in RPC_PROTOCOLS:
691
+ if call.rpc_method is not None or call.a2a_operation is not None:
692
+ return CheckResult(
693
+ call,
694
+ STATUS_DENIED,
695
+ f"rpc_method and a2a_operation are for JSON-RPC APIs; {call.api} is protocol "
696
+ f"{protocol}",
697
+ f"remove rpc_method and a2a_operation {fix}, or set the API's protocol",
698
+ )
699
+ return None
700
+ if call.method == "POST" and call.rpc_method is None:
701
+ return CheckResult(
702
+ call,
703
+ STATUS_DENIED,
704
+ f"a POST to a protocol {protocol} API sends a JSON-RPC request: declare its "
705
+ "rpc_method (the client reads it from the body and judges the call by it)",
706
+ f'add "rpc_method": "<the JSON-RPC method>" {fix}',
707
+ )
708
+ if call.a2a_operation is not None and (
709
+ protocol != PROTOCOL_A2A or rpc["rpc_method"] not in A2A_MESSAGE_METHODS
710
+ ):
711
+ return CheckResult(
712
+ call,
713
+ STATUS_DENIED,
714
+ "a2a_operation is for an A2A message (protocol a2a, rpc_method SendMessage or "
715
+ "SendStreamingMessage)",
716
+ f"remove a2a_operation {fix}",
717
+ )
718
+ mislabelled = label_problem(api, call.operation_id, rpc["rpc_method"], rpc["a2a_operation"])
719
+ if mislabelled:
720
+ return CheckResult(
721
+ call,
722
+ STATUS_DENIED,
723
+ mislabelled,
724
+ f"name the operation_id of the entry for this request {fix}, or none",
725
+ )
726
+ return None
727
+
728
+
729
+ # ---------------------------------------------------------------------------
730
+ # Hints: the `graph-agents-cli api` command that would allow a refused call
731
+ # ---------------------------------------------------------------------------
732
+
733
+ API_COMMAND = "graph-agents-cli api"
734
+
735
+
736
+ def add_hint(api_name: str) -> str:
737
+ """How to declare an API the policy does not know (every choice is explicit)."""
738
+ return (
739
+ f"{API_COMMAND} add {api_name} --base-url-env <NAME>_API_BASE_URL "
740
+ "--auth <none|bearer|forward> --access <read-only|read-write|custom>"
741
+ )
742
+
743
+
744
+ def _allow_args(call: DeclaredCall) -> str:
745
+ """``api allow`` arguments for an entry that covers exactly the declared call.
746
+
747
+ The method is always pinned, and the path whenever the call names one, so
748
+ the entry never allows the label on another method or path. A JSON-RPC call
749
+ pins its ``rpc_method`` too.
750
+ """
751
+ if call.rpc_method and call.path:
752
+ label = f"{call.operation_id} " if call.operation_id else ""
753
+ return f"{label}--method {call.method} --path {call.path} --rpc-method {call.rpc_method}"
754
+ if call.operation_id and call.path:
755
+ return f"{call.operation_id} --method {call.method} --path {call.path}"
756
+ if call.operation_id:
757
+ return f"{call.operation_id} --methods {call.method}"
758
+ return f"--method {call.method} --path {call.path}"
759
+
760
+
761
+ def _allowed_methods(api: Mapping[str, Any]) -> list[str]:
762
+ methods = [str(m).upper() for m in api.get("allowed_methods") or []]
763
+ return list(HTTP_METHODS) if ANY_METHOD in methods else list(dict.fromkeys(methods))
764
+
765
+
766
+ def conflict_hint(call: DeclaredCall, conflict: ApprovalRuleConflict) -> str:
767
+ """What makes a call that two approval rules with other approvers may cover gateable."""
768
+ rule = f"approval[{conflict.index}]"
769
+ if conflict.unnamed == "path":
770
+ return (
771
+ f'name the path: add "path" to the call\'s {CALLS_NAME} entry ({rule} knows the '
772
+ "operation by its path; the client always sends one)"
773
+ )
774
+ return (
775
+ f"name the operation: add operation_id to the call and to {CALLS_NAME}, or pin path "
776
+ f"and methods in {rule} ({API_COMMAND} approval {call.api} --rule {conflict.index} "
777
+ "--operations ... pins them from the API's openapi: spec)"
778
+ )
779
+
780
+
781
+ def refusal_hint(call: DeclaredCall, api: Mapping[str, Any], path: str | None) -> str:
782
+ """What would make ``api`` accept ``call``: the exact ``graph-agents-cli api`` commands.
783
+
784
+ Each is a reviewed change to api-policy.yaml (CODEOWNERS covers it), and
785
+ together they are every change the call needs: the method, the denial and
786
+ the allow-list (an entry pinning the call's method, and its path when it
787
+ names one). A call refused only because it leaves out what a denial knows
788
+ the operation by is fixed in the tool instead.
789
+ """
790
+ steps: list[str] = []
791
+ allowed = _allowed_methods(api)
792
+ if call.method not in allowed:
793
+ methods = ",".join(m for m in HTTP_METHODS if m in {*allowed, call.method})
794
+ steps.append(f"{API_COMMAND} access {call.api} custom --methods {methods}")
795
+ rpc = _declared_rpc(call, api)
796
+ for entry in api.get("denied_operations") or []:
797
+ unnamed = denial_match(entry, call.method, call.operation_id, path, **rpc)
798
+ if unnamed == "operation_id":
799
+ return (
800
+ f"name the operation: add operation_id to the call and to {CALLS_NAME} "
801
+ "(a denial by operationId alone refuses calls that name none)"
802
+ )
803
+ if unnamed == "path":
804
+ return (
805
+ f'name the path: add "path" to the call\'s {CALLS_NAME} entry (a denial by path '
806
+ "refuses declared calls that name none; the client always sends one)"
807
+ )
808
+ if unnamed is not None:
809
+ if entry.get("rpc_method") is not None or entry.get("a2a_operation") is not None:
810
+ revoke = " ".join(
811
+ f"--{key.replace('_', '-')} {entry[key]}"
812
+ for key in ("rpc_method", "a2a_operation")
813
+ if entry.get(key) is not None
814
+ )
815
+ elif entry.get("operationId") is None and entry.get("path") is not None:
816
+ revoke = f"--method {call.method} --path {entry['path']}"
817
+ else:
818
+ revoke = str(entry.get("operationId"))
819
+ step = (
820
+ f"{API_COMMAND} revoke {call.api} {revoke} --from denied (lifts a deliberate "
821
+ "denial: make sure it should go)"
822
+ )
823
+ if step not in steps:
824
+ steps.append(step)
825
+ allowed_operations = api.get("allowed_operations")
826
+ if allowed_operations is not None and not any(
827
+ operation_matches(entry, call.method, call.operation_id, path, **rpc)
828
+ for entry in allowed_operations
829
+ ):
830
+ if call.operation_id or call.path:
831
+ steps.append(f"{API_COMMAND} allow {call.api} {_allow_args(call)}")
832
+ return "; then ".join(steps)
833
+
834
+
835
+ # ---------------------------------------------------------------------------
836
+ # The example tool's call
837
+ # ---------------------------------------------------------------------------
838
+
839
+ EXAMPLE_TOOL = "example_api.py"
840
+ # The last resort when the policy leaves every operation open: one per method,
841
+ # tried in the order of the API's allowed_methods.
842
+ DEFAULT_EXAMPLE_OPERATIONS = {
843
+ "GET": ("getItem", "/items/{item_id}"),
844
+ "HEAD": ("checkItem", "/items/{item_id}"),
845
+ "POST": ("createItem", "/items"),
846
+ "PUT": ("replaceItem", "/items/{item_id}"),
847
+ "PATCH": ("updateItem", "/items/{item_id}"),
848
+ "DELETE": ("deleteItem", "/items/{item_id}"),
849
+ "OPTIONS": ("describeItems", "/items"),
850
+ }
851
+ # The example is rendered into Python source: operation ids and paths outside
852
+ # these character sets are skipped rather than escaped.
853
+ _EXAMPLE_OPERATION_ID_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]{0,99}$")
854
+ _EXAMPLE_PATH_RE = re.compile(r"^/[A-Za-z0-9_.~{}/-]{0,199}$")
855
+ # Each path placeholder becomes a parameter of the example tool, so it must not
856
+ # shadow a name the tool's body uses, or one LangChain or pydantic treat specially.
857
+ _EXAMPLE_RESERVED_PARAMS = frozenset(
858
+ {
859
+ "Any",
860
+ "ToolRuntime",
861
+ "body",
862
+ "callbacks",
863
+ "client",
864
+ "config",
865
+ "construct",
866
+ "context",
867
+ "copy",
868
+ "data",
869
+ "dict",
870
+ "fields",
871
+ "get_client",
872
+ "getattr",
873
+ "isinstance",
874
+ "json",
875
+ "run_manager",
876
+ "runtime",
877
+ "schema",
878
+ "str",
879
+ "tool",
880
+ "validate",
881
+ }
882
+ )
883
+
884
+
885
+ def _example_param_ok(name: str) -> bool:
886
+ return (
887
+ name.isidentifier()
888
+ and not keyword.iskeyword(name)
889
+ and not name.startswith(("_", "model_"))
890
+ and name not in _EXAMPLE_RESERVED_PARAMS
891
+ )
892
+
893
+
894
+ def _example_renderable(operation_id: str | None, path: str) -> bool:
895
+ if operation_id is not None and not _EXAMPLE_OPERATION_ID_RE.match(operation_id):
896
+ return False
897
+ if not _EXAMPLE_PATH_RE.match(path) or path_template_problem(path) is not None:
898
+ return False
899
+ params = ExampleCall(api="x", method="GET", path=path).params
900
+ return all(_example_param_ok(name) for name in params)
901
+
902
+
903
+ def _load_example_spec(api: Mapping[str, Any], base_dir: Path | None) -> dict[str, Any] | None:
904
+ reference = api.get("openapi")
905
+ path = Path(str(reference))
906
+ if not path.is_absolute():
907
+ if base_dir is None:
908
+ return None
909
+ path = base_dir / path
910
+ try:
911
+ return load_openapi(path)
912
+ except (OSError, ValueError, yaml.YAMLError):
913
+ return None
914
+
915
+
916
+ def _example_candidates(
917
+ api: Mapping[str, Any], spec: dict[str, Any] | None
918
+ ) -> list[tuple[str, str | None, str]]:
919
+ """``(METHOD, operation_id, path)`` candidates for the example, best first.
920
+
921
+ Every method counts: the policy's own order decides (its entries first,
922
+ each with its pinned methods or else ``allowed_methods`` in the order the
923
+ file lists them).
924
+ """
925
+ operations = _spec_operations(spec) if spec is not None else []
926
+ allowed = _allowed_methods(api)
927
+ candidates: list[tuple[str, str | None, str]] = []
928
+ for entry in api.get("allowed_operations") or []:
929
+ pinned = [str(m).upper() for m in entry.get("methods") or []]
930
+ for method in [m for m in (pinned or allowed) if m in allowed]:
931
+ operation_id = entry.get("operationId")
932
+ path = entry.get("path")
933
+ if path is None:
934
+ # An entry by operationId alone: only the spec knows its path.
935
+ path = next(
936
+ (p for p, m, op_id in operations if op_id == operation_id and m == method),
937
+ None,
938
+ )
939
+ elif operation_id is None:
940
+ # Name the operation when the spec does, so denials by operationId pass.
941
+ operation_id = next(
942
+ (
943
+ op_id
944
+ for p, m, op_id in operations
945
+ if op_id and m == method and path_matches(p, path)
946
+ ),
947
+ None,
948
+ )
949
+ if path is not None:
950
+ candidates.append((method, operation_id, path))
951
+ candidates.extend((m, op_id, p) for p, m, op_id in operations if m in allowed)
952
+ if spec is not None or not api.get("openapi"):
953
+ # When the API names a spec that cannot be read here, lint would judge the
954
+ # default against a spec this check never saw, so it is not offered.
955
+ for method in allowed:
956
+ operation_id, path = DEFAULT_EXAMPLE_OPERATIONS[method]
957
+ candidates.append((method, operation_id, path))
958
+ return list(dict.fromkeys(candidates))
959
+
960
+
961
+ def example_call(
962
+ document: Mapping[str, Any], *, base_dir: Path | None = None
963
+ ) -> ExampleCall | None:
964
+ """The call the example tool makes on the policy's first API; None when it allows none.
965
+
966
+ The first operation that API allows, whatever its method. Candidates, in
967
+ order: each ``allowed_operations`` entry (with its pinned methods, else
968
+ each of ``allowed_methods``; a missing path or operationId taken from the
969
+ API's OpenAPI spec), each operation of that spec, then one generic
970
+ operation per allowed method (``GET getItem /items/{item_id}``,
971
+ ``POST createItem /items``, ...). The first one this check accepts wins
972
+ (``check_call``: the runtime client's rules plus the spec), so the rendered
973
+ example passes ``lint`` and the project's policy test. ``base_dir``
974
+ resolves a relative ``openapi:`` path (the directory holding the policy
975
+ file).
976
+ """
977
+ apis = document.get("apis") or {}
978
+ if not apis:
979
+ return None
980
+ name, api = next(iter(apis.items()))
981
+ spec = _load_example_spec(api, base_dir) if api.get("openapi") else None
982
+ specs = {name: spec} if spec is not None else {}
983
+ for method, operation_id, path in _example_candidates(api, spec):
984
+ if not _example_renderable(operation_id, path):
985
+ continue
986
+ call = DeclaredCall(
987
+ tool=EXAMPLE_TOOL, api=name, method=method, operation_id=operation_id, path=path
988
+ )
989
+ if check_call(call, document, specs).status == STATUS_ALLOWED:
990
+ return ExampleCall(api=name, method=method, path=path, operation_id=operation_id)
991
+ return None
992
+
993
+
994
+ # ---------------------------------------------------------------------------
995
+ # Driver
996
+ # ---------------------------------------------------------------------------
997
+
998
+
999
+ def build_report(
1000
+ project_root: Path,
1001
+ agent_dir: str,
1002
+ *,
1003
+ policy_file: str = POLICY_FILENAME,
1004
+ runtime: str = "fastapi",
1005
+ policy_declared: bool = False,
1006
+ auth_policy: str | None = None,
1007
+ ) -> PolicyReport:
1008
+ """Run the check for a project and return the report (nothing printed).
1009
+
1010
+ ``policy_declared`` is True when the manifest names the policy file: a
1011
+ missing file is then an error rather than a note. ``auth_policy`` (the
1012
+ project's, from the manifest) checks the APIs that act with the caller's
1013
+ identity against it (``auth_policy_findings``); None skips that check.
1014
+ """
1015
+ report = PolicyReport()
1016
+ document: dict[str, Any] | None = None
1017
+ specs: dict[str, dict[str, Any]] = {}
1018
+
1019
+ policy_path = project_root / policy_file
1020
+ if policy_path.is_file():
1021
+ report.policy_path = policy_path
1022
+ try:
1023
+ document = load_policy_document(policy_path)
1024
+ except ApiPolicyFileError as exc:
1025
+ for error in exc.errors:
1026
+ report.invalid_policy(policy_file, error)
1027
+ return report
1028
+ problem = forward_runtime_problem(summarize(document), runtime)
1029
+ if problem:
1030
+ report.invalid_policy(policy_file, problem)
1031
+ if auth_policy is not None:
1032
+ errors, notes = auth_policy_findings(document, auth_policy)
1033
+ for error in errors:
1034
+ report.invalid_policy(policy_file, error)
1035
+ report.notes.extend(notes)
1036
+ report.notes.extend(peer_notes(document))
1037
+ for name, api in document["apis"].items():
1038
+ report.notes.extend(approval_notes(name, api))
1039
+ openapi_ref = api.get("openapi")
1040
+ if not openapi_ref:
1041
+ continue
1042
+ openapi_path = Path(openapi_ref)
1043
+ if not openapi_path.is_absolute():
1044
+ openapi_path = project_root / openapi_path
1045
+ report.openapi_paths[name] = openapi_path
1046
+ try:
1047
+ specs[name] = load_openapi(openapi_path)
1048
+ except (OSError, ValueError, yaml.YAMLError) as exc:
1049
+ report.invalid_policy(
1050
+ policy_file, f"apis.{name}.openapi: cannot load {openapi_ref}: {exc}"
1051
+ )
1052
+ elif policy_declared:
1053
+ report.invalid_policy(
1054
+ policy_file,
1055
+ f"the manifest declares {policy_file} but the file does not exist; every API call "
1056
+ "would be refused at runtime",
1057
+ )
1058
+ else:
1059
+ report.notes.append(f"no {policy_file} at the project root: outbound API calls are refused")
1060
+
1061
+ report.notes.extend(delegated_mentions_notes(project_root))
1062
+ if not report.policy_invalid:
1063
+ drift = peers_module_problem(project_root, agent_dir, document)
1064
+ if drift:
1065
+ report.invalid(drift.split(":", 1)[0], drift)
1066
+ tools_dir = project_root / agent_dir / TOOLS_SUBDIR
1067
+ calls, problems = collect_declared_calls(tools_dir)
1068
+ for problem in problems:
1069
+ report.invalid(problem.split(":", 1)[0], problem)
1070
+ report.notes.extend(direct_peer_call_notes(calls, document))
1071
+ for call in calls:
1072
+ report.results.append(check_call(call, document, specs, policy_file=policy_file))
1073
+ if not calls and not problems:
1074
+ report.notes.append(f"no {CALLS_NAME} declarations under {agent_dir}/{TOOLS_SUBDIR}/")
1075
+ return report
1076
+
1077
+
1078
+ def peers_module_problem(
1079
+ project_root: Path, agent_dir: str, document: Mapping[str, Any] | None
1080
+ ) -> str | None:
1081
+ """Why the generated `tools/a2a_peers.py` is out of step with the policy, or None.
1082
+
1083
+ Only the module `graph-agents-cli peer` wrote (its marker line) is compared,
1084
+ with the text the policy generates now: a module of your own is yours.
1085
+ """
1086
+ from graph_agents_cli.peer import _generate as generated
1087
+
1088
+ path = generated.module_path(project_root, agent_dir)
1089
+ try:
1090
+ text = path.read_text(encoding="utf-8")
1091
+ except OSError:
1092
+ return None
1093
+ if not generated.is_generated(text):
1094
+ return None
1095
+ peers = generated.peers_of(document, generated.names_in(text))
1096
+ expected = generated.module_text(peers, agent_dir)
1097
+ if text == expected:
1098
+ return None
1099
+ rel = path.relative_to(project_root).as_posix()
1100
+ return (
1101
+ f"{rel}: out of sync with {POLICY_FILENAME} (its peers, calls or descriptions differ); "
1102
+ "fix: graph-agents-cli peer sync"
1103
+ )
1104
+
1105
+
1106
+ def direct_peer_call_notes(
1107
+ calls: list[DeclaredCall], document: Mapping[str, Any] | None
1108
+ ) -> list[str]:
1109
+ """A note for each tool module (other than the generated one) that calls an A2A peer."""
1110
+ from graph_agents_cli.peer._generate import MODULE_NAME
1111
+
1112
+ apis = (document or {}).get("apis") or {}
1113
+ modules = sorted(
1114
+ {
1115
+ call.tool
1116
+ for call in calls
1117
+ if call.api in apis
1118
+ and api_protocol(apis[call.api]) == PROTOCOL_A2A
1119
+ and Path(call.tool).name != MODULE_NAME
1120
+ }
1121
+ )
1122
+ return [
1123
+ f"{module} calls an A2A peer (protocol: a2a) directly: prefer app_utils.a2a_client "
1124
+ "(A2APeerClient, peer_tools), which checks the peer's card, keys the conversation and "
1125
+ "relays approvals"
1126
+ for module in modules
1127
+ ]
1128
+
1129
+
1130
+ def peer_notes(document: Mapping[str, Any]) -> list[str]:
1131
+ """Warnings about the policy's A2A peers (`protocol: a2a` APIs).
1132
+
1133
+ A peer without a `description`: the model picks an agent by what it is told the
1134
+ agent does. More than `MAX_PEERS` peers: the roster of agents the model reads grows
1135
+ long (about 1.5k tokens at 20 peers of 300 characters).
1136
+ """
1137
+ peers = [
1138
+ str(name)
1139
+ for name, api in (document.get("apis") or {}).items()
1140
+ if api_protocol(api) == PROTOCOL_A2A
1141
+ ]
1142
+ notes = [
1143
+ f"warning: A2A peer {name} has no {DESCRIPTION_KEY}: the model chooses an agent by "
1144
+ f"what it does (set apis.{name}.{DESCRIPTION_KEY}, 1-300 characters)"
1145
+ for name in peers
1146
+ if not document["apis"][name].get(DESCRIPTION_KEY)
1147
+ ]
1148
+ if len(peers) > MAX_PEERS:
1149
+ notes.append(
1150
+ f"warning: {len(peers)} A2A peers (more than {MAX_PEERS}): the roster of agents the "
1151
+ "model reads grows long; split the work across fewer agents"
1152
+ )
1153
+ return notes
1154
+
1155
+
1156
+ # `A2A_DELEGATED_MENTIONS=request` turns off the template's check of a record id against the
1157
+ # user's own words when another agent asks for the user (an explicit opt-out: lint says so).
1158
+ MENTIONS_SETTING = "A2A_DELEGATED_MENTIONS"
1159
+ MENTIONS_OPT_OUT = "request"
1160
+
1161
+
1162
+ def _settings_files(project_root: Path) -> list[tuple[str, dict[str, Any]]]:
1163
+ """The project's `.env` and chart values `env:` maps, by their path from the root."""
1164
+ found: list[tuple[str, dict[str, Any]]] = []
1165
+ env_file = project_root / ".env"
1166
+ if env_file.is_file():
1167
+ from dotenv import dotenv_values
1168
+
1169
+ try:
1170
+ found.append((".env", dict(dotenv_values(env_file))))
1171
+ except Exception: # an unreadable file: the app reports it when it loads it
1172
+ pass
1173
+ for values in sorted(project_root.glob("deployment/helm/*/values*.yaml")):
1174
+ try:
1175
+ data = yaml.safe_load(values.read_text(encoding="utf-8"))
1176
+ except (OSError, yaml.YAMLError):
1177
+ continue
1178
+ env = data.get("env") if isinstance(data, Mapping) else None
1179
+ if isinstance(env, Mapping):
1180
+ found.append((values.relative_to(project_root).as_posix(), dict(env)))
1181
+ return found
1182
+
1183
+
1184
+ def delegated_mentions_notes(project_root: Path) -> list[str]:
1185
+ """A note for each settings file that sets `A2A_DELEGATED_MENTIONS=request`."""
1186
+ return [
1187
+ f"{where} sets {MENTIONS_SETTING}={MENTIONS_OPT_OUT}: when another agent asks for a user, "
1188
+ "require_user_mentioned counts that agent's request as the user's words (the 0.2 "
1189
+ "behaviour), so an instruction planted in data it read can name the record a write "
1190
+ "tool acts on. Keep the default (origin) unless every calling agent is trusted"
1191
+ for where, settings in _settings_files(project_root)
1192
+ if str(settings.get(MENTIONS_SETTING) or "").strip().lower() == MENTIONS_OPT_OUT
1193
+ ]
1194
+
1195
+
1196
+ def print_report(report: PolicyReport, console: Console | None = None) -> None:
1197
+ console = console or Console()
1198
+ for note in report.notes:
1199
+ console.print(f"[dim]policy check: {escape(note)}[/]")
1200
+ if not report.results:
1201
+ console.print("[green]API policy check: nothing to check.[/]")
1202
+ return
1203
+ table = Table(title="API policy check", show_lines=False)
1204
+ # The Approval column (who approves a gated call) only when some call is gated.
1205
+ headers = ["Tool", "API", "Method", "Operation", "Status", "Reason"]
1206
+ if report.gated:
1207
+ headers.append("Approval")
1208
+ # fold: a narrow terminal wraps a long tool or path onto more lines, never cuts it.
1209
+ for header in headers:
1210
+ table.add_column(header, overflow="fold")
1211
+ styles = {
1212
+ STATUS_ALLOWED: "green",
1213
+ STATUS_DENIED: "red",
1214
+ STATUS_UNKNOWN: "yellow",
1215
+ STATUS_INVALID: "red",
1216
+ }
1217
+ for result in report.results:
1218
+ style = styles.get(result.status, "")
1219
+ row = [
1220
+ escape(result.call.tool),
1221
+ escape(result.call.api or "-"),
1222
+ escape(result.call.method),
1223
+ escape(result.call.operation),
1224
+ f"[{style}]{result.status}[/]" if style else result.status,
1225
+ escape(result.reason),
1226
+ ]
1227
+ if report.gated:
1228
+ row.append(escape(describe_gate(result.gate)) if result.gate else "-")
1229
+ table.add_row(*row)
1230
+ print_table(console, table)
1231
+ hints = list(dict.fromkeys(r.hint for r in report.results if r.is_violation and r.hint))
1232
+ if hints:
1233
+ console.print(
1234
+ "To fix a refused call, change the tool as shown, or change api-policy.yaml in a "
1235
+ "reviewed pull request (CODEOWNERS covers it), for example:"
1236
+ )
1237
+ for hint in hints:
1238
+ console.print(f" {escape(hint)}", style="cyan", highlight=False)
1239
+ if report.gated:
1240
+ console.print(
1241
+ f"[yellow]{report.gated} declared call(s) wait for a human approval before they are "
1242
+ "sent (the Approval column: who approves, and why).[/]"
1243
+ )
1244
+ overlapping = sum(1 for r in report.results if r.gate is not None and r.gate.also)
1245
+ if overlapping:
1246
+ console.print(
1247
+ f"[yellow]{overlapping} gated call(s) are covered by more than one approval rule: "
1248
+ "the first rule in file order gates each one, with its approvers (the Approval "
1249
+ "column names it, and the later rules that do not apply).[/]"
1250
+ )
1251
+ if report.violations:
1252
+ console.print(f"[red]{report.violations} violation(s).[/]")
1253
+ else:
1254
+ console.print("[green]All declared API calls are allowed.[/]")
1255
+
1256
+
1257
+ def run_policy_check(
1258
+ project_root: Path,
1259
+ agent_dir: str,
1260
+ *,
1261
+ policy_file: str = POLICY_FILENAME,
1262
+ runtime: str = "fastapi",
1263
+ policy_declared: bool = False,
1264
+ console: Console | None = None,
1265
+ auth_policy: str | None = None,
1266
+ ) -> int:
1267
+ """Run the check, print the table, and return the number of violations.
1268
+
1269
+ An invalid policy file raises :class:`InvalidPolicyFile` (exit 3) after the
1270
+ table: like an invalid manifest, it is a configuration error, and the
1271
+ ``api`` commands refuse the same file with the same code.
1272
+ """
1273
+ report = build_report(
1274
+ project_root,
1275
+ agent_dir,
1276
+ policy_file=policy_file,
1277
+ runtime=runtime,
1278
+ policy_declared=policy_declared,
1279
+ auth_policy=auth_policy,
1280
+ )
1281
+ print_report(report, console)
1282
+ if report.policy_invalid:
1283
+ raise InvalidPolicyFile(
1284
+ f"API policy check failed: {policy_file} is invalid (see above); fix it first "
1285
+ "(a configuration error)."
1286
+ )
1287
+ return report.violations