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,1214 @@
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
+ """Unit tests for the policy-enforcing API client (`app_utils.api_client`).
16
+
17
+ No outside network: most requests go to an httpx MockTransport, the
18
+ every-method tests to an HTTP server on 127.0.0.1 started by the test, and
19
+ every refusal is checked to happen before anything is sent.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import importlib
25
+ import json
26
+ import os
27
+ import pkgutil
28
+ import threading
29
+ from collections.abc import Iterator
30
+ from dataclasses import dataclass, field
31
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ import httpx
36
+ import pytest
37
+ import yaml
38
+
39
+ from {{cookiecutter.agent_directory}}.app_utils import api_client
40
+ from {{cookiecutter.agent_directory}}.app_utils.api_client import (
41
+ ApiCallError,
42
+ ApiPolicy,
43
+ ApiPolicyError,
44
+ Caller,
45
+ current_caller,
46
+ current_context,
47
+ current_run_id,
48
+ end_run,
49
+ get_client,
50
+ render_path,
51
+ require_direct_caller,
52
+ require_owner,
53
+ require_user_mentioned,
54
+ reset_limits,
55
+ reset_policy_cache,
56
+ set_outbound_headers,
57
+ )
58
+ from {{cookiecutter.agent_directory}}.app_utils.auth import caller_note_enabled, delegated_mentions
59
+ from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
60
+ from {{cookiecutter.agent_directory}}.app_utils.telemetry import bind_log_context
61
+
62
+ POLICY = """
63
+ apis:
64
+ items:
65
+ base_url_env: ITEMS_API_BASE_URL
66
+ auth: bearer
67
+ token_env: ITEMS_API_TOKEN
68
+ allowed_methods: [GET]
69
+ allowed_operations:
70
+ - operationId: getItem
71
+ path: /items/{item_id}
72
+ - path: /sites/{site_id}/topology
73
+ methods: [GET]
74
+ - operationId: getSecret
75
+ - path: /listing
76
+ denied_operations:
77
+ - operationId: getSecret
78
+ path: /secret
79
+ - path: /items/admin
80
+ timeouts_ms: {connect: 1500, read: 2500}
81
+ pagination: {page_size_param: pageSize, max_page_size: 200}
82
+ directory:
83
+ base_url_env: DIRECTORY_API_BASE_URL
84
+ auth: forward
85
+ forward_header: X-User-Token
86
+ allowed_methods: ["*"]
87
+ public:
88
+ base_url_env: PUBLIC_API_BASE_URL
89
+ auth: none
90
+ allowed_methods: [GET, POST]
91
+ records:
92
+ base_url_env: RECORDS_API_BASE_URL
93
+ auth: none
94
+ allowed_methods: ["*"]
95
+ denied_operations:
96
+ - operationId: deleteRecord
97
+ methods: [DELETE]
98
+ - path: /admin/{section}
99
+ - operationId: dropTable
100
+ path: /tables/{table}
101
+ methods: [DELETE]
102
+ pagination: {page_size_param: limit, max_page_size: 50}
103
+ orders:
104
+ base_url_env: ORDERS_API_BASE_URL
105
+ auth: bearer
106
+ token_env: ORDERS_API_TOKEN
107
+ allowed_methods: [GET, HEAD, POST, PUT, PATCH, DELETE]
108
+ allowed_operations:
109
+ - {operationId: listOrders, path: /orders, methods: [GET, HEAD]}
110
+ - {operationId: createOrder, path: /orders, methods: [POST]}
111
+ - {operationId: replaceOrder, path: "/orders/{order_id}", methods: [PUT]}
112
+ - {operationId: updateOrder, path: "/orders/{order_id}", methods: [PATCH]}
113
+ - {operationId: cancelOrder, path: "/orders/{order_id}", methods: [DELETE]}
114
+ denied_operations:
115
+ - {operationId: purgeOrders, path: /orders, methods: [DELETE]}
116
+ limits: {max_calls_per_run: 3}
117
+ metered:
118
+ base_url_env: METERED_API_BASE_URL
119
+ auth: none
120
+ allowed_methods: [GET, POST]
121
+ limits: {rate_per_minute: 2}
122
+ """
123
+
124
+
125
+ @dataclass
126
+ class _Context:
127
+ principal_id: str = "u1"
128
+ attributes: dict[str, Any] = field(default_factory=dict)
129
+
130
+
131
+ @pytest.fixture
132
+ def policy_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
133
+ path = tmp_path / "api-policy.yaml"
134
+ path.write_text(POLICY, encoding="utf-8")
135
+ monkeypatch.setenv("API_POLICY_PATH", str(path))
136
+ monkeypatch.setenv("ITEMS_API_BASE_URL", "http://items.test")
137
+ monkeypatch.setenv("ITEMS_API_TOKEN", "tok")
138
+ monkeypatch.setenv("DIRECTORY_API_BASE_URL", "http://directory.test/api/v2/")
139
+ monkeypatch.setenv("PUBLIC_API_BASE_URL", "https://public.test")
140
+ monkeypatch.setenv("RECORDS_API_BASE_URL", "https://records.test")
141
+ monkeypatch.setenv("ORDERS_API_BASE_URL", "http://orders.test")
142
+ monkeypatch.setenv("ORDERS_API_TOKEN", "orders-token")
143
+ monkeypatch.setenv("METERED_API_BASE_URL", "http://metered.test")
144
+ reset_policy_cache()
145
+ reset_limits()
146
+ yield path
147
+ reset_policy_cache()
148
+ reset_limits()
149
+
150
+
151
+ def _transport(calls: list[httpx.Request], status: int = 200, **extra: Any) -> httpx.MockTransport:
152
+ def handler(request: httpx.Request) -> httpx.Response:
153
+ calls.append(request)
154
+ return httpx.Response(status, json={"ok": True, "path": request.url.path}, **extra)
155
+
156
+ return httpx.MockTransport(handler)
157
+
158
+
159
+ # --- fail closed: the policy file ----------------------------------------------
160
+
161
+
162
+ def test_no_policy_file_refuses_every_client(
163
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
164
+ ) -> None:
165
+ monkeypatch.setenv("API_POLICY_PATH", str(tmp_path / "missing.yaml"))
166
+ reset_policy_cache()
167
+ with pytest.raises(ApiPolicyError, match="not found"):
168
+ get_client("anything")
169
+
170
+
171
+ def test_undeclared_api_is_refused(policy_file: Path) -> None:
172
+ with pytest.raises(ApiPolicyError, match="not declared"):
173
+ get_client("billing")
174
+
175
+
176
+ @pytest.mark.parametrize(
177
+ ("document", "fragment"),
178
+ [
179
+ ("product_api:\n base_url_env: X\n", "retired single-API format"),
180
+ ("apis: {}\n", "non-empty mapping"),
181
+ ("apis:\n a:\n base_url_env: A\n auth: none\n", "allowed_methods: required"),
182
+ (
183
+ "apis:\n a:\n base_url_env: A\n auth: none\n allowed_methods: [PUT]\n"
184
+ " allowed_method: [POST]\n",
185
+ "unknown key 'allowed_method'",
186
+ ),
187
+ (
188
+ "apis:\n a:\n base_url_env: A\n auth: bearer\n allowed_methods: [POST]\n",
189
+ "token_env",
190
+ ),
191
+ (
192
+ "apis:\n A:\n base_url_env: A\n auth: none\n allowed_methods: [DELETE]\n",
193
+ "invalid API name",
194
+ ),
195
+ (
196
+ "apis:\n a:\n base_url_env: A\n auth: none\n allowed_methods: [PATCH]\n"
197
+ " allowed_methods: ['*']\n",
198
+ "found duplicate key 'allowed_methods'",
199
+ ),
200
+ (
201
+ "apis:\n a:\n base_url_env: A\n auth: none\n allowed_methods: [POST]\n"
202
+ " approval: required\n",
203
+ "approval: must be a mapping with required_for and approvers",
204
+ ),
205
+ (
206
+ "apis:\n a:\n base_url_env: A\n auth: none\n allowed_methods: [POST]\n"
207
+ " approval: {required_for: {methods: [POST]}, approvers: [anyone]}\n",
208
+ "approval.approvers[0]: 'anyone' is not an approver",
209
+ ),
210
+ (
211
+ "apis:\n a:\n base_url_env: A\n auth: none\n allowed_methods: [POST]\n"
212
+ " approval: []\n",
213
+ "approval: must not be empty",
214
+ ),
215
+ (
216
+ "apis:\n a:\n base_url_env: A\n auth: none\n allowed_methods: [POST]\n"
217
+ " approval:\n - {required_for: {methods: [POST]}, approvers: [requester]}\n"
218
+ " - {required_for: {operations: [{operationId: x}]}}\n",
219
+ 'approval[1].approvers: required (a list of "requester" and/or "role:<name>")',
220
+ ),
221
+ (
222
+ "apis:\n a:\n base_url_env: A\n auth: none\n allowed_methods: [POST]\n"
223
+ " approval: {required_for: {methods: [POST]}, approvers: [requester], "
224
+ "timeout_s: 5}\n",
225
+ "approval.timeout_s: must be an integer from 30 to 86400",
226
+ ),
227
+ (
228
+ "apis:\n a:\n base_url_env: A\n auth: none\n allowed_methods: [POST]\n"
229
+ " allowed_operations:\n - operationId: createOrder\n approval: true\n",
230
+ "not valid on an operation entry; gate the operation with "
231
+ "apis.a.approval.required_for.operations",
232
+ ),
233
+ (
234
+ "apis:\n a:\n base_url_env: A\n auth: none\n allowed_methods: [PUT]\n"
235
+ " limits: {max_calls_per_run: 0}\n",
236
+ "limits.max_calls_per_run: must be an integer >= 1",
237
+ ),
238
+ ],
239
+ )
240
+ def test_invalid_policy_is_refused_with_the_schema_error(
241
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, document: str, fragment: str
242
+ ) -> None:
243
+ path = tmp_path / "api-policy.yaml"
244
+ path.write_text(document, encoding="utf-8")
245
+ monkeypatch.setenv("API_POLICY_PATH", str(path))
246
+ reset_policy_cache()
247
+ with pytest.raises(ApiPolicyError) as exc:
248
+ get_client("a")
249
+ assert fragment in str(exc.value)
250
+ assert exc.value.errors
251
+
252
+
253
+ # --- the rules -------------------------------------------------------------------
254
+
255
+
256
+ async def test_refusals_happen_before_sending(policy_file: Path) -> None:
257
+ calls: list[httpx.Request] = []
258
+ client = get_client("items", transport=_transport(calls))
259
+ with pytest.raises(ApiPolicyError, match="allowed_methods"):
260
+ await client.request("POST", "/items/1", operation_id="getItem")
261
+ with pytest.raises(ApiPolicyError, match="not in allowed_operations"):
262
+ await client.get("/users", operation_id="listUsers")
263
+ with pytest.raises(ApiPolicyError, match="denied"):
264
+ await client.get("/secret", operation_id="getSecret")
265
+ # AND: the operationId matches but the pinned path does not.
266
+ with pytest.raises(ApiPolicyError, match="not in allowed_operations"):
267
+ await client.get("/admin", operation_id="getItem")
268
+ # A denial on the rendered path is enforced even when the template is allowed.
269
+ with pytest.raises(ApiPolicyError, match="denied"):
270
+ await client.get(
271
+ "/items/{item_id}", operation_id="getItem", path_params={"item_id": "admin"}
272
+ )
273
+ assert calls == []
274
+
275
+
276
+ async def test_allowed_call_is_sent_with_the_bearer_token(policy_file: Path) -> None:
277
+ calls: list[httpx.Request] = []
278
+ client = get_client("items", transport=_transport(calls))
279
+ data = await client.get(
280
+ "/items/{item_id}", operation_id="getItem", path_params={"item_id": "42"}
281
+ )
282
+ assert data == {"ok": True, "path": "/items/42"}
283
+ await client.get("/sites/7/topology")
284
+ # The policy's credential wins over a caller-supplied header.
285
+ await client.get("/listing", headers={"Authorization": "Bearer forged"})
286
+ assert [c.headers["authorization"] for c in calls] == ["Bearer tok"] * 3
287
+ assert str(calls[0].url) == "http://items.test/items/42"
288
+ assert calls[0].extensions["timeout"]["connect"] == 1.5
289
+
290
+
291
+ TRACEPARENT = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
292
+ CORRELATION_HEADERS = ("x-request-id", "traceparent", "tracestate")
293
+
294
+
295
+ def _forwarding_caller() -> _Context:
296
+ return _Context(attributes={"credentials": {"directory": "user-token-1"}})
297
+
298
+
299
+ async def test_correlation_headers_are_sent_but_not_bound_by_an_approval(
300
+ policy_file: Path,
301
+ ) -> None:
302
+ calls: list[httpx.Request] = []
303
+ set_outbound_headers(
304
+ lambda: {"X-Request-ID": "req-1", "traceparent": TRACEPARENT, "Host": "elsewhere.test"}
305
+ )
306
+ try:
307
+ client = get_client("directory", context=_forwarding_caller(), transport=_transport(calls))
308
+ await client.get("/listing")
309
+ await client.get("/listing", headers={"X-Request-ID": "tool-own"})
310
+ prepared = client._prepare("GET", "/listing", None, None, None, None, {"X-Tool": "t"})
311
+ finally:
312
+ set_outbound_headers(None)
313
+ assert calls[0].headers["x-request-id"] == "req-1"
314
+ assert calls[0].headers["traceparent"] == TRACEPARENT
315
+ # A header a tool may not set is never added.
316
+ assert calls[0].headers["host"] == "directory.test"
317
+ assert calls[1].headers["x-request-id"] == "tool-own" # the tool's own header wins
318
+ # They differ per request, so the request an approval binds leaves them out.
319
+ assert prepared.tool_headers == [("x-tool", "t")]
320
+ assert prepared.headers["traceparent"] == TRACEPARENT
321
+
322
+
323
+ async def test_correlation_headers_go_only_to_forward_apis(policy_file: Path) -> None:
324
+ """The request id and trace context reach the APIs that act for the caller (another
325
+ agent, reached with the caller's credential), never a third party's bearer or open API."""
326
+ calls: list[httpx.Request] = []
327
+ set_outbound_headers(
328
+ lambda: {"X-Request-ID": "req-1", "traceparent": TRACEPARENT, "tracestate": "k=v"}
329
+ )
330
+ try:
331
+ await get_client("items", transport=_transport(calls)).get("/listing")
332
+ await get_client("public", transport=_transport(calls)).post("/search", json_body={})
333
+ forward = get_client("directory", context=_forwarding_caller(), transport=_transport(calls))
334
+ await forward.get("/me")
335
+ # A tool may still send its own header to any API: that is the tool's choice.
336
+ await get_client("items", transport=_transport(calls)).get(
337
+ "/listing", headers={"X-Request-ID": "tool-own"}
338
+ )
339
+ finally:
340
+ set_outbound_headers(None)
341
+ bearer, open_api, peer, own = calls
342
+ for third_party in (bearer, open_api):
343
+ assert not [h for h in CORRELATION_HEADERS if h in third_party.headers]
344
+ assert bearer.headers["authorization"] == "Bearer tok"
345
+ assert peer.headers["x-user-token"] == "user-token-1"
346
+ assert (peer.headers["x-request-id"], peer.headers["traceparent"]) == ("req-1", TRACEPARENT)
347
+ assert peer.headers["tracestate"] == "k=v"
348
+ assert own.headers["x-request-id"] == "tool-own" and "traceparent" not in own.headers
349
+ modes = ("forward", "exchange", "bearer", "none")
350
+ assert [api_client.propagates({"auth": mode}) for mode in modes] == [True, True, False, False]
351
+
352
+
353
+ async def test_a_failing_correlation_provider_does_not_stop_the_call(policy_file: Path) -> None:
354
+ calls: list[httpx.Request] = []
355
+
356
+ def broken() -> dict[str, str]:
357
+ raise RuntimeError("no tracer")
358
+
359
+ set_outbound_headers(broken)
360
+ try:
361
+ client = get_client("directory", context=_forwarding_caller(), transport=_transport(calls))
362
+ await client.get("/me")
363
+ finally:
364
+ set_outbound_headers(None)
365
+ assert len(calls) == 1 and "x-request-id" not in calls[0].headers
366
+
367
+
368
+ async def test_bearer_without_a_token_sends_nothing(
369
+ policy_file: Path, monkeypatch: pytest.MonkeyPatch
370
+ ) -> None:
371
+ monkeypatch.delenv("ITEMS_API_TOKEN")
372
+ calls: list[httpx.Request] = []
373
+ client = get_client("items", transport=_transport(calls))
374
+ with pytest.raises(ApiCallError, match="ITEMS_API_TOKEN"):
375
+ await client.get("/listing")
376
+ assert calls == []
377
+
378
+
379
+ async def test_forward_sends_the_callers_credential_under_a_path_prefix(policy_file: Path) -> None:
380
+ calls: list[httpx.Request] = []
381
+ context = _Context(attributes={"credentials": {"directory": "user-token-1"}, "tenant": "t"})
382
+ client = get_client("directory", context=context, transport=_transport(calls))
383
+ await client.request("DELETE", "/people/{person_id}", path_params={"person_id": "p 1"})
384
+ assert calls[0].headers["x-user-token"] == "user-token-1"
385
+ assert "authorization" not in calls[0].headers
386
+ # The base URL's /api/v2 prefix is kept: the path is joined under it.
387
+ assert calls[0].url.raw_path == b"/api/v2/people/p%201"
388
+ # A mapping context (what LangGraph Server passes) works too.
389
+ other = get_client(
390
+ "directory",
391
+ context={"attributes": {"credentials": {"directory": "user-token-2"}}},
392
+ transport=_transport(calls),
393
+ )
394
+ await other.get("/me")
395
+ assert calls[1].headers["x-user-token"] == "user-token-2"
396
+
397
+
398
+ async def test_forward_without_a_credential_sends_nothing(policy_file: Path) -> None:
399
+ calls: list[httpx.Request] = []
400
+ for context in (None, _Context(), _Context(attributes={"credentials": {"items": "x"}})):
401
+ client = get_client("directory", context=context, transport=_transport(calls))
402
+ with pytest.raises(ApiCallError, match="no credential"):
403
+ await client.get("/me")
404
+ assert calls == []
405
+
406
+
407
+ async def test_auth_none_sends_no_credential(policy_file: Path) -> None:
408
+ calls: list[httpx.Request] = []
409
+ client = get_client("public", transport=_transport(calls))
410
+ await client.request("POST", "/search", json_body={"q": "x"})
411
+ assert "authorization" not in calls[0].headers
412
+ assert str(calls[0].url) == "https://public.test/search"
413
+
414
+
415
+ @pytest.mark.parametrize(
416
+ "item_id",
417
+ [
418
+ "1/../../admin",
419
+ "../admin",
420
+ "..",
421
+ ".",
422
+ "1/extra",
423
+ "a/b",
424
+ "a\\b",
425
+ "",
426
+ " 1",
427
+ "1;x",
428
+ "1\x00", # servers that end a path at a NUL route it to /items/1
429
+ "1\n2",
430
+ ],
431
+ )
432
+ async def test_path_params_refuse_traversal_before_sending(policy_file: Path, item_id: str) -> None:
433
+ calls: list[httpx.Request] = []
434
+ client = get_client("items", transport=_transport(calls))
435
+ with pytest.raises(ApiPolicyError):
436
+ await client.get(
437
+ "/items/{item_id}", operation_id="getItem", path_params={"item_id": item_id}
438
+ )
439
+ assert calls == []
440
+
441
+
442
+ @pytest.mark.parametrize(
443
+ "path",
444
+ [
445
+ "/items/1/../../admin",
446
+ "/items/..",
447
+ "/items/%2e%2e",
448
+ "/items/1%2F..%2F..%2Fadmin",
449
+ "/items//1",
450
+ "/admin",
451
+ # Path parameters: servers that strip them route these to /items/1.
452
+ "/items/1;jsessionid=x",
453
+ "/items/1%3B",
454
+ "/items/1%3bx",
455
+ # Servers that trim segments, or end a path at a NUL, route these to /items/1.
456
+ "/items/1%20",
457
+ "/items/%201",
458
+ "/items/1%09",
459
+ "/items/1 ",
460
+ "/items/1%00",
461
+ "/items/1%00/x",
462
+ "/items/1%7F",
463
+ # Servers that trim the name before a format suffix, or strip trailing dots and
464
+ # spaces, route these to /items/1 as well.
465
+ "/items/1%20.json",
466
+ "/items/1%20%2e",
467
+ "/items/1.%20json",
468
+ ],
469
+ )
470
+ async def test_concrete_paths_are_validated(policy_file: Path, path: str) -> None:
471
+ calls: list[httpx.Request] = []
472
+ client = get_client("items", transport=_transport(calls))
473
+ with pytest.raises(ApiPolicyError):
474
+ await client.get(path, operation_id="getItem")
475
+ assert calls == []
476
+
477
+
478
+ async def test_whitespace_inside_a_segment_is_sent(policy_file: Path) -> None:
479
+ calls: list[httpx.Request] = []
480
+ client = get_client("items", transport=_transport(calls))
481
+ await client.get("/items/red%20shirt", operation_id="getItem")
482
+ await client.get("/items/{item_id}", operation_id="getItem", path_params={"item_id": "a b"})
483
+ assert [c.url.raw_path for c in calls] == [b"/items/red%20shirt", b"/items/a%20b"]
484
+
485
+
486
+ async def test_an_operation_id_denial_refuses_calls_that_do_not_name_one(policy_file: Path) -> None:
487
+ calls: list[httpx.Request] = []
488
+ client = get_client("records", transport=_transport(calls))
489
+ # Without an operation id the call cannot be told apart from deleteRecord.
490
+ with pytest.raises(ApiPolicyError, match="names no operation_id"):
491
+ await client.request("DELETE", "/records/{record_id}", path_params={"record_id": "1"})
492
+ with pytest.raises(ApiPolicyError, match="denied"):
493
+ await client.request(
494
+ "DELETE",
495
+ "/records/{record_id}",
496
+ operation_id="deleteRecord",
497
+ path_params={"record_id": "1"},
498
+ )
499
+ assert calls == []
500
+ await client.request(
501
+ "DELETE",
502
+ "/records/{record_id}",
503
+ operation_id="archiveRecord",
504
+ path_params={"record_id": "1"},
505
+ )
506
+ await client.get("/records/1") # the denial pins DELETE
507
+ assert [(c.method, c.url.path) for c in calls] == [
508
+ ("DELETE", "/records/1"),
509
+ ("GET", "/records/1"),
510
+ ]
511
+
512
+
513
+ @pytest.mark.parametrize("label", ["dropTable", "archiveTable", "dropTabel", None])
514
+ async def test_a_denial_pinning_a_path_refuses_the_call_whatever_its_label(
515
+ policy_file: Path, label: str | None
516
+ ) -> None:
517
+ """The operation id is the tool's label: a relabelled call to a denied endpoint is refused."""
518
+ calls: list[httpx.Request] = []
519
+ client = get_client("records", transport=_transport(calls))
520
+ with pytest.raises(ApiPolicyError, match="denied"):
521
+ await client.request(
522
+ "DELETE", "/tables/{table}", operation_id=label, path_params={"table": "t1"}
523
+ )
524
+ with pytest.raises(ApiPolicyError, match="denied"):
525
+ await client.delete("/TABLES/t1/", operation_id=label)
526
+ assert calls == []
527
+ await client.get("/tables/t1", operation_id=label) # the denial pins DELETE
528
+ assert [(c.method, c.url.path) for c in calls] == [("GET", "/tables/t1")]
529
+
530
+
531
+ @pytest.mark.parametrize("path", ["/admin/1", "/admin/1/", "/ADMIN/1", "/%61dmin/1", "/Admin/%31"])
532
+ async def test_path_denials_cover_equivalent_spellings(policy_file: Path, path: str) -> None:
533
+ calls: list[httpx.Request] = []
534
+ client = get_client("records", transport=_transport(calls))
535
+ with pytest.raises(ApiPolicyError, match="denied"):
536
+ await client.get(path, operation_id="getAdmin")
537
+ assert calls == []
538
+
539
+
540
+ def test_path_rendering_encodes_each_value_as_one_segment() -> None:
541
+ assert render_path("/sites/{site_id}/topology", {"site_id": "x y"}) == "/sites/x%20y/topology"
542
+ assert render_path("/items/{item_id}", {"item_id": "a?b=1#f"}) == "/items/a%3Fb%3D1%23f"
543
+ with pytest.raises(ApiPolicyError, match="no parameter"):
544
+ render_path("/items/{item_id}", {"item_id": "1", "other": "2"})
545
+ with pytest.raises(ApiPolicyError, match="needs value"):
546
+ render_path("/items/{item_id}", {})
547
+
548
+
549
+ @pytest.mark.parametrize(
550
+ ("value", "allowed"), [(50, True), ("200", True), (201, False), (0, False), ("many", False)]
551
+ )
552
+ async def test_page_size_is_capped(policy_file: Path, value: Any, allowed: bool) -> None:
553
+ calls: list[httpx.Request] = []
554
+ client = get_client("items", transport=_transport(calls))
555
+ if allowed:
556
+ await client.get("/listing", params={"pageSize": value})
557
+ assert calls[0].url.params["pageSize"] == str(value)
558
+ else:
559
+ with pytest.raises(ApiPolicyError, match="max_page_size"):
560
+ await client.get("/listing", params={"pageSize": value})
561
+ assert calls == []
562
+
563
+
564
+ @pytest.mark.parametrize(
565
+ "params",
566
+ [
567
+ [("limit", "5000")],
568
+ {"Limit": 5000},
569
+ "limit=5000",
570
+ [("limit", "10"), ("limit", "5000")],
571
+ {"LIMIT": "10", "limit": "51"},
572
+ {"limit": " 10"},
573
+ {"limit": "1e3"},
574
+ {"limit": "9" * 5000},
575
+ ],
576
+ )
577
+ async def test_page_size_cap_covers_every_spelling(policy_file: Path, params: Any) -> None:
578
+ calls: list[httpx.Request] = []
579
+ client = get_client("records", transport=_transport(calls))
580
+ with pytest.raises(ApiPolicyError, match="max_page_size"):
581
+ await client.get("/records", params=params)
582
+ assert calls == []
583
+
584
+
585
+ async def test_the_checked_query_is_the_one_sent(policy_file: Path) -> None:
586
+ calls: list[httpx.Request] = []
587
+ client = get_client("records", transport=_transport(calls))
588
+ await client.get("/records", params=[("limit", "20"), ("tag", "a"), ("tag", "b")])
589
+ assert calls[0].url.params.multi_items() == [("limit", "20"), ("tag", "a"), ("tag", "b")]
590
+
591
+
592
+ async def test_redirects_and_errors_are_not_followed(policy_file: Path) -> None:
593
+ calls: list[httpx.Request] = []
594
+ moved = _transport(calls, status=302, headers={"location": "http://elsewhere.test/admin"})
595
+ with pytest.raises(ApiCallError, match="HTTP 302"):
596
+ await get_client("items", transport=moved).get("/listing")
597
+ with pytest.raises(ApiCallError, match="HTTP 500"):
598
+ await get_client("items", transport=_transport(calls, status=500)).get("/listing")
599
+ assert [c.url.host for c in calls] == ["items.test", "items.test"]
600
+
601
+
602
+ async def test_missing_base_url_is_a_call_error(
603
+ policy_file: Path, monkeypatch: pytest.MonkeyPatch
604
+ ) -> None:
605
+ monkeypatch.delenv("PUBLIC_API_BASE_URL")
606
+ with pytest.raises(ApiCallError, match="PUBLIC_API_BASE_URL"):
607
+ await get_client("public", transport=_transport([])).get("/x")
608
+
609
+
610
+ def test_current_context_outside_a_run_is_none() -> None:
611
+ assert current_context() is None
612
+
613
+
614
+ # --- approval gates -------------------------------------------------------------
615
+
616
+ GATED_POLICY = """
617
+ apis:
618
+ shop:
619
+ base_url_env: SHOP_API_BASE_URL
620
+ auth: none
621
+ allowed_methods: [GET, POST, PATCH]
622
+ denied_operations:
623
+ - {path: "/orders/{order_id}/purge", methods: [POST]}
624
+ approval:
625
+ required_for:
626
+ methods: [PATCH]
627
+ operations:
628
+ - {operationId: cancelOrder, path: "/orders/{order_id}/cancel", methods: [POST]}
629
+ - {path: "/orders/{order_id}/purge"}
630
+ approvers: [requester, "role:ops"]
631
+ timeout_s: 60
632
+ """
633
+
634
+
635
+ @pytest.fixture
636
+ def gated_policy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
637
+ path = tmp_path / "api-policy.yaml"
638
+ path.write_text(GATED_POLICY, encoding="utf-8")
639
+ monkeypatch.setenv("API_POLICY_PATH", str(path))
640
+ monkeypatch.setenv("SHOP_API_BASE_URL", "http://shop.test")
641
+ reset_policy_cache()
642
+ yield path
643
+ reset_policy_cache()
644
+
645
+
646
+ def test_the_policy_names_gated_calls_and_their_approvers(gated_policy: Path) -> None:
647
+ policy = ApiPolicy.load(gated_policy)
648
+ gate = policy.gate("shop", "post", "cancelOrder", "/orders/7/cancel")
649
+ assert gate is not None
650
+ assert gate.approvers == ("requester", "role:ops") and gate.timeout_s == 60
651
+ # A path gate holds whatever label the call gives it.
652
+ assert policy.gate("shop", "POST", "archiveOrder", "/ORDERS/7/cancel/") is not None
653
+ assert policy.gate("shop", "PATCH", "updateOrder", "/orders/7").rule == (
654
+ "approval.required_for.methods ['PATCH']"
655
+ )
656
+ assert policy.gate("shop", "GET", "getOrder", "/orders/7") is None
657
+ assert policy.gate("shop", "POST", "createOrder", "/orders") is None
658
+
659
+
660
+ def test_with_a_list_of_rules_the_first_that_covers_a_call_gates_it(
661
+ gated_policy: Path,
662
+ ) -> None:
663
+ """Other approvers for other calls of one API: the first covering rule in file order."""
664
+ gated_policy.write_text(
665
+ GATED_POLICY.split(" approval:")[0]
666
+ + " approval:\n"
667
+ + " - required_for:\n"
668
+ + " operations:\n"
669
+ + ' - {operationId: cancelOrder, path: "/orders/{order_id}/cancel"}\n'
670
+ + " approvers: [requester]\n"
671
+ + " - required_for: {operations: [{operationId: createOrder, path: /orders}]}\n"
672
+ + ' approvers: ["role:admin"]\n'
673
+ + " timeout_s: 3600\n"
674
+ + ' - {required_for: {methods: [POST, PATCH]}, approvers: ["role:ops"]}\n',
675
+ encoding="utf-8",
676
+ )
677
+ reset_policy_cache()
678
+ policy = ApiPolicy.load(gated_policy)
679
+ cancel = policy.gate("shop", "POST", "cancelOrder", "/orders/7/cancel")
680
+ assert (cancel.approvers, cancel.timeout_s, cancel.index, cancel.also) == (
681
+ ("requester",),
682
+ 900,
683
+ 0,
684
+ (2,),
685
+ )
686
+ create = policy.gate("shop", "POST", "placeOrder", "/orders") # the path, whatever label
687
+ assert (create.approvers, create.timeout_s, create.index) == (("role:admin",), 3600, 1)
688
+ assert create.rule.startswith("approval[1].required_for.operations")
689
+ update = policy.gate("shop", "PATCH", "updateOrder", "/orders/7")
690
+ assert (update.approvers, update.index) == (("role:ops",), 2)
691
+ # The template it was rendered from counts too: the first rule covering either wins.
692
+ templated = policy.gate(
693
+ "shop", "POST", None, "/orders/7/x", template="/orders/{order_id}/cancel"
694
+ )
695
+ assert templated.index == 0
696
+ assert policy.gate("shop", "GET", "getOrder", "/orders/7") is None
697
+
698
+
699
+ async def test_a_call_either_of_two_rules_with_other_approvers_may_gate_is_refused(
700
+ gated_policy: Path,
701
+ ) -> None:
702
+ """A rule by operationId alone cannot rule out a call that names no operation id.
703
+
704
+ When a later rule with other approvers also covers that call, it could be either
705
+ rule's: it is refused before anything is sent, instead of going to the first rule's
706
+ approvers (a requester approving what the later rule gives to role:admin).
707
+ """
708
+ gated_policy.write_text(
709
+ GATED_POLICY.split(" approval:")[0]
710
+ + " approval:\n"
711
+ + " - required_for: {operations: [{operationId: cancelOrder}]}\n"
712
+ + " approvers: [requester]\n"
713
+ + ' - {required_for: {methods: [POST]}, approvers: ["role:admin"]}\n',
714
+ encoding="utf-8",
715
+ )
716
+ reset_policy_cache()
717
+ calls: list[httpx.Request] = []
718
+ client = get_client("shop", transport=_transport(calls))
719
+ with pytest.raises(ApiPolicyError) as exc:
720
+ await client.post("/orders", json_body={"sku": "A-1"})
721
+ assert "refused by the API policy" in str(exc.value)
722
+ assert "it could be either rule's call" in str(exc.value)
723
+ assert "name the operation_id on the call" in str(exc.value)
724
+ # Named, each call goes to its own rule's approvers (outside a run: refused as gated).
725
+ with pytest.raises(ApiPolicyError, match=r"needs human approval \(role:admin\)"):
726
+ await client.post("/orders", operation_id="createOrder", json_body={"sku": "A-1"})
727
+ with pytest.raises(ApiPolicyError, match=r"needs human approval \(requester\)"):
728
+ await client.post("/orders/7/cancel", operation_id="cancelOrder")
729
+ # No later rule with other approvers covers an unnamed PATCH: rule 0 gates it.
730
+ with pytest.raises(ApiPolicyError, match=r"needs human approval \(requester\)"):
731
+ await client.patch("/orders/7", json_body={"note": "x"})
732
+ assert calls == []
733
+
734
+
735
+ async def test_a_gated_call_is_refused_before_sending(gated_policy: Path) -> None:
736
+ """Outside an agent run nothing can pause for a decision: a gated call fails closed.
737
+
738
+ (Inside a run it pauses for approval: see `test_approval_ledger.py`.)
739
+ """
740
+ calls: list[httpx.Request] = []
741
+ client = get_client("shop", transport=_transport(calls))
742
+ with pytest.raises(ApiPolicyError) as exc:
743
+ await client.post(
744
+ "/orders/{order_id}/cancel",
745
+ operation_id="cancelOrder",
746
+ path_params={"order_id": "7"},
747
+ json_body={"reason": "customer asked"},
748
+ )
749
+ assert "needs human approval (requester, role:ops)" in str(exc.value)
750
+ assert "nothing was sent" in str(exc.value)
751
+ with pytest.raises(ApiPolicyError, match="needs human approval"):
752
+ await client.patch("/orders/7", operation_id="updateOrder", json_body={"note": "x"})
753
+ # Relabelled: the gate on the path still holds.
754
+ with pytest.raises(ApiPolicyError, match="needs human approval"):
755
+ await client.post("/orders/7/cancel", operation_id="closeOrder")
756
+ assert calls == []
757
+ assert await client.get("/orders/7", operation_id="getOrder") == {
758
+ "ok": True,
759
+ "path": "/orders/7",
760
+ }
761
+ assert await client.post("/orders", operation_id="createOrder", json_body={}) == {
762
+ "ok": True,
763
+ "path": "/orders",
764
+ }
765
+ assert [(r.method, r.url.path) for r in calls] == [("GET", "/orders/7"), ("POST", "/orders")]
766
+
767
+
768
+ @pytest.mark.parametrize(
769
+ "path",
770
+ [
771
+ # Format suffixes and a trailing dot on the gated (or denied) segment: servers
772
+ # that route `.json` or drop a trailing dot send these to the same endpoint.
773
+ "/orders/7/cancel.json",
774
+ "/orders/7/cancel.",
775
+ "/orders/7/cancel%2e",
776
+ "/orders/7/Cancel.JSON",
777
+ ],
778
+ )
779
+ async def test_a_dot_suffixed_spelling_of_a_gated_or_denied_path_is_covered(
780
+ gated_policy: Path, path: str
781
+ ) -> None:
782
+ calls: list[httpx.Request] = []
783
+ client = get_client("shop", transport=_transport(calls))
784
+ with pytest.raises(ApiPolicyError, match="needs human approval"):
785
+ await client.post(path, operation_id="closeOrder")
786
+ denied = path.replace("cancel", "purge").replace("Cancel", "Purge")
787
+ with pytest.raises(ApiPolicyError, match="denied by denied_operations"):
788
+ await client.post(denied, operation_id="purgeOrder")
789
+ assert calls == []
790
+
791
+
792
+ @pytest.mark.parametrize(
793
+ "path",
794
+ [
795
+ # Whitespace next to a dot on the gated (or denied) segment: servers that trim the
796
+ # name before a format suffix, or strip trailing dots and spaces, send these to
797
+ # the bare segment, which the gate would not see in them: refused, not gated.
798
+ "/orders/7/cancel%20.json",
799
+ "/orders/7/cancel%20%2e",
800
+ "/orders/7/cancel%20%2E%2e",
801
+ "/orders/7/cancel.%20json",
802
+ "/orders/{order_id}/cancel%20.json",
803
+ ],
804
+ )
805
+ async def test_whitespace_next_to_a_dot_is_refused_before_sending(
806
+ gated_policy: Path, path: str
807
+ ) -> None:
808
+ calls: list[httpx.Request] = []
809
+ client = get_client("shop", transport=_transport(calls))
810
+ params = {"order_id": "7"} if "{" in path else None
811
+ for spelling in (path, path.replace("cancel", "purge")):
812
+ with pytest.raises(ApiPolicyError, match=r"whitespace next to a dot|not a valid"):
813
+ await client.post(spelling, operation_id="closeOrder", path_params=params)
814
+ # A path parameter value that would put it there is refused as well.
815
+ with pytest.raises(ApiPolicyError):
816
+ await client.post(
817
+ "/orders/{order_id}/cancel", operation_id="closeOrder", path_params={"order_id": "7 .x"}
818
+ )
819
+ assert calls == []
820
+
821
+
822
+ async def test_approval_never_widens_access(gated_policy: Path) -> None:
823
+ """A gated call outside the policy is refused by the policy, not held for approval."""
824
+ calls: list[httpx.Request] = []
825
+ client = get_client("shop", transport=_transport(calls))
826
+ # Gated (the purge path) but denied: the denial wins.
827
+ with pytest.raises(ApiPolicyError) as denied:
828
+ await client.post("/orders/7/purge", operation_id="purgeOrder")
829
+ assert "denied by denied_operations" in str(denied.value)
830
+ assert "approval" not in str(denied.value)
831
+ # Gated (the purge path, every method) but DELETE is not allowed: allowed_methods refuses.
832
+ with pytest.raises(ApiPolicyError, match="not in allowed_methods"):
833
+ await client.delete("/orders/{order_id}/purge", path_params={"order_id": "7"})
834
+ assert calls == []
835
+
836
+
837
+ # --- every method against a real server ------------------------------------------
838
+
839
+
840
+ class _EchoHandler(BaseHTTPRequestHandler):
841
+ """Answers every method with what it received (204 and no body for DELETE)."""
842
+
843
+ def _answer(self) -> None:
844
+ length = int(self.headers.get("content-length") or 0)
845
+ raw = self.rfile.read(length) if length else b""
846
+ if self.command == "DELETE":
847
+ self.send_response(204)
848
+ self.end_headers()
849
+ return
850
+ payload = {
851
+ "method": self.command,
852
+ "path": self.path,
853
+ "body": json.loads(raw) if raw else None,
854
+ "authorization": self.headers.get("authorization"),
855
+ "x_request_tag": self.headers.get("x-request-tag"),
856
+ "content_type": self.headers.get("content-type"),
857
+ }
858
+ data = json.dumps(payload).encode()
859
+ self.send_response(200)
860
+ self.send_header("content-type", "application/json")
861
+ self.send_header("content-length", str(len(data)))
862
+ self.end_headers()
863
+ if self.command != "HEAD":
864
+ self.wfile.write(data)
865
+
866
+ do_GET = do_HEAD = do_POST = do_PUT = do_PATCH = do_DELETE = do_OPTIONS = _answer
867
+
868
+ def log_message(self, format: str, *args: Any) -> None: # quiet test output
869
+ return
870
+
871
+
872
+ @pytest.fixture
873
+ def local_server(policy_file: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[str]:
874
+ server = ThreadingHTTPServer(("127.0.0.1", 0), _EchoHandler)
875
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
876
+ thread.start()
877
+ base = f"http://127.0.0.1:{server.server_address[1]}/v1"
878
+ monkeypatch.setenv("ORDERS_API_BASE_URL", base)
879
+ try:
880
+ yield base
881
+ finally:
882
+ server.shutdown()
883
+ server.server_close()
884
+ thread.join(timeout=5)
885
+
886
+
887
+ async def test_every_allowed_method_reaches_a_real_server(local_server: str) -> None:
888
+ client = get_client("orders", run_id="methods-1")
889
+ created = await client.post(
890
+ "/orders",
891
+ operation_id="createOrder",
892
+ json_body={"sku": "a-1", "quantity": 2},
893
+ params={"dry": "false"},
894
+ headers={"X-Request-Tag": "t1", "Authorization": "Bearer forged"},
895
+ )
896
+ assert created["method"] == "POST"
897
+ assert created["path"] == "/v1/orders?dry=false"
898
+ assert created["body"] == {"sku": "a-1", "quantity": 2}
899
+ assert created["content_type"] == "application/json"
900
+ assert created["x_request_tag"] == "t1"
901
+ assert created["authorization"] == "Bearer orders-token" # the policy's token wins
902
+ replaced = await client.put(
903
+ "/orders/{order_id}",
904
+ operation_id="replaceOrder",
905
+ path_params={"order_id": "7"},
906
+ json_body={"sku": "b-2"},
907
+ )
908
+ assert (replaced["method"], replaced["path"], replaced["body"]) == (
909
+ "PUT",
910
+ "/v1/orders/7",
911
+ {"sku": "b-2"},
912
+ )
913
+ client = get_client("orders", run_id="methods-2") # max_calls_per_run is 3
914
+ updated = await client.patch(
915
+ "/orders/{order_id}",
916
+ operation_id="updateOrder",
917
+ path_params={"order_id": "7"},
918
+ json_body={"quantity": 3},
919
+ )
920
+ assert (updated["method"], updated["body"]) == ("PATCH", {"quantity": 3})
921
+ cancelled = await client.delete(
922
+ "/orders/{order_id}", operation_id="cancelOrder", path_params={"order_id": "7"}
923
+ )
924
+ assert cancelled == "" # 204, no body
925
+ assert await client.head("/orders", operation_id="listOrders") == ""
926
+
927
+
928
+ async def test_a_denied_method_never_reaches_the_server(local_server: str) -> None:
929
+ client = get_client("orders", run_id="denied")
930
+ with pytest.raises(ApiPolicyError, match="denied"):
931
+ await client.delete("/orders", operation_id="purgeOrders")
932
+ with pytest.raises(ApiPolicyError, match="allowed_methods"):
933
+ await client.options("/orders", operation_id="listOrders")
934
+
935
+
936
+ # --- limits ----------------------------------------------------------------------
937
+
938
+
939
+ class _Clock:
940
+ def __init__(self) -> None:
941
+ self.now = 1000.0
942
+
943
+ def __call__(self) -> float:
944
+ return self.now
945
+
946
+
947
+ async def test_max_calls_per_run_is_counted_per_run(policy_file: Path) -> None:
948
+ calls: list[httpx.Request] = []
949
+ run_a = get_client("orders", transport=_transport(calls), run_id="run-a")
950
+ for _ in range(3):
951
+ await run_a.get("/orders", operation_id="listOrders")
952
+ with pytest.raises(ApiPolicyError, match="max_calls_per_run"):
953
+ await run_a.get("/orders", operation_id="listOrders")
954
+ assert len(calls) == 3 # refused before sending
955
+ # Another run has its own count; a finished run's count is dropped.
956
+ run_b = get_client("orders", transport=_transport(calls), run_id="run-b")
957
+ await run_b.get("/orders", operation_id="listOrders")
958
+ end_run("run-a")
959
+ await run_a.get("/orders", operation_id="listOrders")
960
+ assert len(calls) == 5
961
+ # A refused call (outside the policy) does not use up the budget.
962
+ run_c = get_client("orders", transport=_transport(calls), run_id="run-c")
963
+ with pytest.raises(ApiPolicyError, match="not in allowed_operations"):
964
+ await run_c.get("/elsewhere", operation_id="other")
965
+ for _ in range(3):
966
+ await run_c.get("/orders", operation_id="listOrders")
967
+
968
+
969
+ async def test_rate_per_minute_is_a_token_bucket(policy_file: Path) -> None:
970
+ clock = _Clock()
971
+ reset_limits(clock)
972
+ calls: list[httpx.Request] = []
973
+ client = get_client("metered", transport=_transport(calls))
974
+ await client.get("/a")
975
+ await client.post("/b")
976
+ with pytest.raises(ApiPolicyError, match="rate_per_minute"):
977
+ await client.get("/a")
978
+ assert len(calls) == 2
979
+ clock.now += 30 # half a minute refills one of the two tokens
980
+ await client.get("/a")
981
+ with pytest.raises(ApiPolicyError, match="rate_per_minute"):
982
+ await client.get("/a")
983
+ assert len(calls) == 3
984
+
985
+
986
+ def test_run_counters_are_evicted_by_ttl_and_size(monkeypatch: pytest.MonkeyPatch) -> None:
987
+ clock = _Clock()
988
+ limiter = reset_limits(clock)
989
+ limits = {"max_calls_per_run": 1}
990
+ assert limiter.acquire("a", limits, "old") is None
991
+ clock.now += api_client.RUN_COUNTER_TTL_S + 1
992
+ assert limiter.acquire("a", limits, "new") is None
993
+ assert limiter.tracked_runs() == 1 # "old" went after the TTL
994
+ assert limiter.acquire("a", limits, "old") is None # a fresh count
995
+ monkeypatch.setattr(api_client, "MAX_TRACKED_RUNS", 2)
996
+ for run in ("r1", "r2", "r3"):
997
+ assert limiter.acquire("a", limits, run) is None
998
+ assert limiter.tracked_runs() == 2
999
+
1000
+
1001
+ async def test_the_run_id_comes_from_the_graph_run_then_the_request() -> None:
1002
+ from typing import TypedDict
1003
+
1004
+ from langgraph.graph import END, START, StateGraph
1005
+
1006
+ class State(TypedDict):
1007
+ seen: str
1008
+
1009
+ def node(state: State) -> dict[str, str]:
1010
+ return {"seen": current_run_id() or ""}
1011
+
1012
+ builder = StateGraph(State)
1013
+ builder.add_node("n", node)
1014
+ builder.add_edge(START, "n")
1015
+ builder.add_edge("n", END)
1016
+ graph = builder.compile()
1017
+ result = await graph.ainvoke({"seen": ""}, config={"metadata": {"run_id": "run-42"}})
1018
+ assert result["seen"] == "run-42"
1019
+ assert current_run_id() is None
1020
+ bind_log_context(request_id="req-1")
1021
+ try:
1022
+ assert current_run_id() == "req-1"
1023
+ bind_log_context(run_id="run-7")
1024
+ assert current_run_id() == "run-7"
1025
+ finally:
1026
+ bind_log_context(request_id=None, run_id=None)
1027
+
1028
+
1029
+ # --- the project's own tools and policy ----------------------------------------
1030
+
1031
+
1032
+ def _project_policy() -> ApiPolicy | None:
1033
+ path = Path(api_client.__file__).resolve().parents[2] / "api-policy.yaml"
1034
+ return ApiPolicy.load(path) if path.is_file() else None
1035
+
1036
+
1037
+ def _tool_modules(package: Any) -> list[str]:
1038
+ """Every module under the tools package, subpackages included (what lint reads).
1039
+
1040
+ `*.py` files anywhere below the package (hidden and cache directories
1041
+ skipped, symlinked directories followed once), a subpackage's own
1042
+ `__init__.py` included; the package's top-level `__init__.py` is the registry.
1043
+ """
1044
+ root = Path(package.__file__).resolve().parent
1045
+ names: list[str] = []
1046
+ seen: set[str] = set()
1047
+ for directory, dirs, files in os.walk(root, followlinks=True):
1048
+ real = os.path.realpath(directory)
1049
+ if real in seen:
1050
+ dirs[:] = []
1051
+ continue
1052
+ seen.add(real)
1053
+ dirs[:] = sorted(d for d in dirs if not d.startswith(".") and d != "__pycache__")
1054
+ relative = Path(directory).relative_to(root)
1055
+ for filename in sorted(files):
1056
+ if not filename.endswith(".py") or (not relative.parts and filename == "__init__.py"):
1057
+ continue
1058
+ parts = [*relative.parts] + ([] if filename == "__init__.py" else [filename[:-3]])
1059
+ names.append(".".join([package.__name__, *parts]))
1060
+ return sorted(names)
1061
+
1062
+
1063
+ def test_every_tool_declares_calls_its_policy_allows() -> None:
1064
+ """Every `API_CALLS` entry names a declared API and passes its rules (what lint checks).
1065
+
1066
+ Tool subpackages count: lint reads every module below `tools/`, and so does this test.
1067
+ """
1068
+ import {{cookiecutter.agent_directory}}.tools as tools
1069
+
1070
+ policy = _project_policy()
1071
+ modules = _tool_modules(tools)
1072
+ # At least every module and subpackage `get_tools()` imports.
1073
+ depth = len(tools.__name__.split("."))
1074
+ walked = {name.split(".")[depth] for name in modules}
1075
+ assert {info.name for info in pkgutil.iter_modules(tools.__path__)} <= walked
1076
+ for name in modules:
1077
+ module = importlib.import_module(name)
1078
+ for call in getattr(module, "API_CALLS", []):
1079
+ assert policy is not None, f"{name} declares API calls but there is no api-policy.yaml"
1080
+ policy.check(call["api"], call["method"], call.get("operation_id"), call.get("path"))
1081
+
1082
+
1083
+ def test_the_tool_module_walk_covers_subpackages(
1084
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1085
+ ) -> None:
1086
+ """The walk above reaches nested modules and subpackage `__init__`s, like lint."""
1087
+ package = tmp_path / "walked_tools"
1088
+ (package / "nested" / "deeper").mkdir(parents=True)
1089
+ (package / "__pycache__").mkdir()
1090
+ (package / ".hidden").mkdir()
1091
+ for relative in (
1092
+ "__init__.py",
1093
+ "top.py",
1094
+ "nested/__init__.py",
1095
+ "nested/inner.py",
1096
+ "nested/deeper/leaf.py",
1097
+ "__pycache__/cached.py",
1098
+ ".hidden/secret.py",
1099
+ ):
1100
+ (package / relative).write_text("API_CALLS = []\n", encoding="utf-8")
1101
+ monkeypatch.syspath_prepend(str(tmp_path))
1102
+ walked = importlib.import_module("walked_tools")
1103
+ assert _tool_modules(walked) == [
1104
+ "walked_tools.nested",
1105
+ "walked_tools.nested.deeper.leaf",
1106
+ "walked_tools.nested.inner",
1107
+ "walked_tools.top",
1108
+ ]
1109
+
1110
+
1111
+ def test_the_project_policy_is_valid() -> None:
1112
+ policy = _project_policy()
1113
+ if policy is None:
1114
+ pytest.skip("this project declares no api-policy.yaml")
1115
+ assert policy.apis
1116
+ assert yaml.safe_load(policy.source.read_text(encoding="utf-8"))["apis"]
1117
+
1118
+
1119
+ # --- the caller when another agent asks for the user (0.3) ----------------------------------
1120
+
1121
+ _CONCIERGE = {"id": "concierge", "chain": ["concierge", "web-agent"], "client": "concierge"}
1122
+
1123
+
1124
+ @dataclass
1125
+ class _AgentRun:
1126
+ """A tool's runtime: the run context and the thread state."""
1127
+
1128
+ context: Any
1129
+ state: dict[str, Any]
1130
+
1131
+
1132
+ def _context(*, actor: dict[str, Any] | None = None, origin: str | None = None) -> dict[str, Any]:
1133
+ attributes: dict[str, Any] = {}
1134
+ if actor is not None:
1135
+ attributes["@actor"] = actor
1136
+ if origin is not None:
1137
+ attributes["credentials"] = {"@origin": {"text": origin, "truncated": False, "hops": 1}}
1138
+ return {"principal_id": "alice", "roles": [], "attributes": attributes}
1139
+
1140
+
1141
+ def _asked(text: str, **context: Any) -> _AgentRun:
1142
+ return _AgentRun(
1143
+ context=_context(**context), state={"messages": [{"type": "human", "content": text}]}
1144
+ )
1145
+
1146
+
1147
+ def test_caller_fields() -> None:
1148
+ direct = current_caller(_context())
1149
+ assert (direct.actor, direct.actor_chain, direct.delegated) == (None, (), False)
1150
+ agent = current_caller(_context(actor=_CONCIERGE))
1151
+ assert agent.principal_id == "alice" and agent.delegated
1152
+ assert (agent.actor, agent.actor_chain) == ("concierge", ("concierge", "web-agent"))
1153
+ assert current_caller(_context(actor={"id": "billing"})).actor_chain == ("billing",)
1154
+ assert Caller("bob", frozenset()).actor is None # the 0.2 constructor still works
1155
+
1156
+
1157
+ def test_require_direct_caller() -> None:
1158
+ assert require_direct_caller(_context()).principal_id == "alice"
1159
+ with pytest.raises(ApiPolicyError, match="not agent 'concierge' acting for them"):
1160
+ require_direct_caller(_context(actor=_CONCIERGE))
1161
+ # require_owner still compares the subject: the record is the user's.
1162
+ assert require_owner("alice", context=_context(actor=_CONCIERGE)).actor == "concierge"
1163
+
1164
+
1165
+ def test_require_user_mentioned_delegated_uses_origin(monkeypatch: pytest.MonkeyPatch) -> None:
1166
+ monkeypatch.delenv("A2A_DELEGATED_MENTIONS", raising=False)
1167
+ # The user named it, and the agent's request names it: allowed.
1168
+ require_user_mentioned(
1169
+ "ORD-1002", _asked("Cancel ORD-1002", actor=_CONCIERGE, origin="cancel order ORD-1002")
1170
+ )
1171
+ # Only the agent's request names it (an instruction planted in data it read): refused.
1172
+ with pytest.raises(ApiPolicyError, match="not named in the user's own words"):
1173
+ require_user_mentioned(
1174
+ "ORD-1017", _asked("Cancel ORD-1017", actor=_CONCIERGE, origin="cancel ORD-1002")
1175
+ )
1176
+ # The user named it, the agent's request does not: refused as before.
1177
+ with pytest.raises(ApiPolicyError, match="not named in the user's latest message"):
1178
+ require_user_mentioned(
1179
+ "ORD-1002", _asked("Cancel it", actor=_CONCIERGE, origin="cancel ORD-1002")
1180
+ )
1181
+ # No forwarded words: refused, naming the agent.
1182
+ with pytest.raises(ApiPolicyError) as refused:
1183
+ require_user_mentioned("ORD-1002", _asked("Cancel ORD-1002", actor=_CONCIERGE))
1184
+ assert str(refused.value) == (
1185
+ "refused: 'ORD-1002' was asked for by agent 'concierge', which forwarded no user "
1186
+ "message to check it against; the user must name it."
1187
+ )
1188
+ # A direct caller: the user's latest message, as in 0.2 (forwarded words are not read).
1189
+ require_user_mentioned("ORD-1002", _asked("Cancel ORD-1002", origin="nothing"))
1190
+
1191
+
1192
+ def test_require_user_mentioned_refuse_and_request_modes(monkeypatch: pytest.MonkeyPatch) -> None:
1193
+ run = _asked("Cancel ORD-1002", actor=_CONCIERGE, origin="cancel ORD-1002")
1194
+ monkeypatch.setenv("A2A_DELEGATED_MENTIONS", "refuse")
1195
+ with pytest.raises(ApiPolicyError, match="the user must ask this agent directly"):
1196
+ require_user_mentioned("ORD-1002", run)
1197
+ require_user_mentioned("ORD-1002", _asked("Cancel ORD-1002")) # a direct caller
1198
+ monkeypatch.setenv("A2A_DELEGATED_MENTIONS", "request")
1199
+ require_user_mentioned("ORD-1017", _asked("Cancel ORD-1017", actor=_CONCIERGE))
1200
+ # A bad value (the startup check refuses it) fails closed: refused.
1201
+ monkeypatch.setenv("A2A_DELEGATED_MENTIONS", "sometimes")
1202
+ with pytest.raises(ApiPolicyError, match="the user must ask this agent directly"):
1203
+ require_user_mentioned("ORD-1002", run)
1204
+
1205
+
1206
+ def test_the_delegated_prompt_settings_are_checked() -> None:
1207
+ assert delegated_mentions({}) == "origin"
1208
+ assert delegated_mentions({"A2A_DELEGATED_MENTIONS": "Request"}) == "request"
1209
+ assert caller_note_enabled({}) is True
1210
+ assert caller_note_enabled({"A2A_CALLER_NOTE": "off"}) is False
1211
+ with pytest.raises(SettingsError, match="A2A_DELEGATED_MENTIONS"):
1212
+ delegated_mentions({"A2A_DELEGATED_MENTIONS": "any"})
1213
+ with pytest.raises(SettingsError, match="A2A_CALLER_NOTE"):
1214
+ caller_note_enabled({"A2A_CALLER_NOTE": "false"})