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,991 @@
1
+ # Copyright 2026 graph-agents-cli contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """The `jwt` auth policy: verification, key handling, settings and both runtimes' wiring.
16
+
17
+ Keys are generated locally (RSA, EC P-256 and P-384, Ed25519); the JWKS is
18
+ served by a throwaway HTTP server on 127.0.0.1. No other network access.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import base64
25
+ import hashlib
26
+ import hmac
27
+ import json
28
+ import logging
29
+ import threading
30
+ import time
31
+ from collections.abc import Iterator
32
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
33
+ from typing import Any
34
+
35
+ import jwt
36
+ import pytest
37
+ from cryptography.hazmat.primitives import serialization
38
+ from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa
39
+ from fastapi import HTTPException
40
+ from starlette.requests import Request
41
+
42
+ from {{cookiecutter.agent_directory}}.app_utils import auth as auth_module
43
+ from {{cookiecutter.agent_directory}}.app_utils.auth import (
44
+ ACTIONS,
45
+ DEFAULT_ALLOWED_ACTORS,
46
+ DEFAULT_JWT_ACTOR_CLAIM,
47
+ JwksCache,
48
+ JwksUnavailable,
49
+ JwtPolicy,
50
+ JwtSettings,
51
+ Principal,
52
+ actor_from_claims,
53
+ build_sdk_auth,
54
+ check_startup,
55
+ finalize_principal,
56
+ get_policy,
57
+ require,
58
+ reset_policy_cache,
59
+ )
60
+
61
+ ISSUER = "https://issuer.test/"
62
+ AUDIENCE = "agent-api"
63
+
64
+
65
+ # --- keys, tokens and requests -------------------------------------------------
66
+
67
+
68
+ def _rsa() -> rsa.RSAPrivateKey:
69
+ return rsa.generate_private_key(public_exponent=65537, key_size=2048)
70
+
71
+
72
+ RSA_A = _rsa()
73
+ RSA_B = _rsa()
74
+ EC_P256 = ec.generate_private_key(ec.SECP256R1())
75
+ EC_P384 = ec.generate_private_key(ec.SECP384R1())
76
+ ED25519 = ed25519.Ed25519PrivateKey.generate()
77
+
78
+
79
+ def _pem(private_key: Any) -> str:
80
+ return (
81
+ private_key.public_key()
82
+ .public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
83
+ .decode()
84
+ )
85
+
86
+
87
+ def _jwk(private_key: Any, kid: str, **extra: Any) -> dict[str, Any]:
88
+ public = private_key.public_key()
89
+ if isinstance(public, rsa.RSAPublicKey):
90
+ data = jwt.algorithms.RSAAlgorithm.to_jwk(public, as_dict=True)
91
+ elif isinstance(public, ec.EllipticCurvePublicKey):
92
+ data = jwt.algorithms.ECAlgorithm.to_jwk(public, as_dict=True)
93
+ else:
94
+ data = jwt.algorithms.OKPAlgorithm.to_jwk(public, as_dict=True)
95
+ return {**data, "kid": kid, "use": "sig", **extra}
96
+
97
+
98
+ def _claims(**overrides: Any) -> dict[str, Any]:
99
+ now = int(time.time())
100
+ claims: dict[str, Any] = {
101
+ "sub": "user-1",
102
+ "iss": ISSUER,
103
+ "aud": AUDIENCE,
104
+ "iat": now,
105
+ "nbf": now,
106
+ "exp": now + 300,
107
+ "roles": ["viewer", "support"],
108
+ }
109
+ claims.update(overrides)
110
+ return {k: v for k, v in claims.items() if v is not None}
111
+
112
+
113
+ def _token(key: Any, alg: str = "RS256", kid: str | None = "a", **claims: Any) -> str:
114
+ headers = {"kid": kid} if kid is not None else None
115
+ return jwt.encode(_claims(**claims), key, algorithm=alg, headers=headers)
116
+
117
+
118
+ def _b64(data: bytes) -> str:
119
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
120
+
121
+
122
+ def _forge(header: dict[str, Any], claims: dict[str, Any], secret: bytes | None = None) -> str:
123
+ """A token signed with HMAC-SHA256 over `secret` (or unsigned when None)."""
124
+ signing_input = f"{_b64(json.dumps(header).encode())}.{_b64(json.dumps(claims).encode())}"
125
+ if secret is None:
126
+ return signing_input + "."
127
+ signature = hmac.new(secret, signing_input.encode(), hashlib.sha256).digest()
128
+ return f"{signing_input}.{_b64(signature)}"
129
+
130
+
131
+ def _request(token: str | None = None, scheme: str = "Bearer") -> Request:
132
+ headers = [(b"authorization", f"{scheme} {token}".encode())] if token is not None else []
133
+ return Request(
134
+ {"type": "http", "method": "POST", "path": "/chat", "headers": headers, "query_string": b""}
135
+ )
136
+
137
+
138
+ def _settings(**env: str) -> JwtSettings:
139
+ base = {
140
+ "APP_ENV": "prod",
141
+ "AUTH_JWT_ISSUER": ISSUER,
142
+ "AUTH_JWT_AUDIENCE": AUDIENCE,
143
+ "AUTH_JWT_PUBLIC_KEY": _pem(RSA_A),
144
+ }
145
+ base.update(env)
146
+ return JwtSettings.from_env({k: v for k, v in base.items() if v is not None})
147
+
148
+
149
+ async def _reject(policy: JwtPolicy, token: str | None, reason: str | None = None) -> HTTPException:
150
+ with pytest.raises(HTTPException) as exc:
151
+ await policy.authenticate(_request(token))
152
+ assert exc.value.status_code == 401, exc.value.detail
153
+ assert exc.value.headers and exc.value.headers["WWW-Authenticate"].startswith("Bearer")
154
+ if reason is not None:
155
+ assert reason in str(exc.value.detail), exc.value.detail
156
+ return exc.value
157
+
158
+
159
+ # --- a JWKS served from a local HTTP server ----------------------------------
160
+
161
+
162
+ class JwksServer:
163
+ def __init__(self) -> None:
164
+ self.keys: list[dict[str, Any]] = []
165
+ self.status = 200
166
+ self.body: bytes | None = None
167
+ self.hits = 0
168
+ server = self
169
+
170
+ class Handler(BaseHTTPRequestHandler):
171
+ def do_GET(self) -> None:
172
+ server.hits += 1
173
+ body = server.body or json.dumps({"keys": server.keys}).encode()
174
+ self.send_response(server.status)
175
+ self.send_header("Content-Type", "application/json")
176
+ self.send_header("Content-Length", str(len(body)))
177
+ self.end_headers()
178
+ self.wfile.write(body)
179
+
180
+ def log_message(self, *args: Any) -> None:
181
+ pass
182
+
183
+ class QuietServer(ThreadingHTTPServer):
184
+ def handle_error(self, request: Any, client_address: Any) -> None:
185
+ pass # a client that stops reading an oversized body is expected
186
+
187
+ self.httpd = QuietServer(("127.0.0.1", 0), Handler)
188
+ self.url = f"http://127.0.0.1:{self.httpd.server_address[1]}/.well-known/jwks.json"
189
+ self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
190
+ self.thread.start()
191
+
192
+ def close(self) -> None:
193
+ self.httpd.shutdown()
194
+ self.httpd.server_close()
195
+
196
+
197
+ @pytest.fixture
198
+ def jwks() -> Iterator[JwksServer]:
199
+ server = JwksServer()
200
+ server.keys = [_jwk(RSA_A, "a")]
201
+ try:
202
+ yield server
203
+ finally:
204
+ server.close()
205
+
206
+
207
+ def _jwks_policy(server: JwksServer, **env: str) -> JwtPolicy:
208
+ return JwtPolicy(
209
+ _settings(AUTH_JWT_PUBLIC_KEY=None, AUTH_JWT_JWKS_URL=server.url, **env) # type: ignore[arg-type]
210
+ )
211
+
212
+
213
+ class FakeClock:
214
+ def __init__(self) -> None:
215
+ self.now = 1000.0
216
+
217
+ def __call__(self) -> float:
218
+ return self.now
219
+
220
+
221
+ # --- verification ---------------------------------------------------------------
222
+
223
+
224
+ async def test_rs256_token_from_a_jwks_gives_the_principal_and_roles(jwks: JwksServer) -> None:
225
+ policy = _jwks_policy(jwks)
226
+ assert policy.startup_problems() == []
227
+ principal = await policy.authenticate(_request(_token(RSA_A)))
228
+ assert principal.id == "user-1"
229
+ assert principal.roles == ["viewer", "support"]
230
+ assert principal.permissions == set(ACTIONS)
231
+ assert principal.attributes == {}
232
+ for action in ACTIONS:
233
+ await policy.authorize(principal, action, None)
234
+ with pytest.raises(HTTPException) as exc:
235
+ await policy.authorize(principal, "thread.export", None)
236
+ assert exc.value.status_code == 403
237
+
238
+
239
+ async def test_ec_and_eddsa_tokens_verify_against_a_static_pem_key() -> None:
240
+ es = JwtPolicy(_settings(AUTH_JWT_PUBLIC_KEY=_pem(EC_P256), AUTH_JWT_ALGORITHMS="ES256"))
241
+ assert (await es.authenticate(_request(_token(EC_P256, "ES256")))).id == "user-1"
242
+ ed = JwtPolicy(_settings(AUTH_JWT_PUBLIC_KEY=_pem(ED25519), AUTH_JWT_ALGORITHMS="EdDSA"))
243
+ assert (await ed.authenticate(_request(_token(ED25519, "EdDSA")))).id == "user-1"
244
+ # The default allow-list is RS256,ES256; a PEM with escaped newlines (as
245
+ # some secret stores deliver it) is accepted too.
246
+ escaped = _pem(RSA_A).replace("\n", "\\n")
247
+ rs = JwtPolicy(_settings(AUTH_JWT_PUBLIC_KEY=escaped))
248
+ assert (await rs.authenticate(_request(_token(RSA_A)))).id == "user-1"
249
+
250
+
251
+ async def test_ec_token_from_a_jwks(jwks: JwksServer) -> None:
252
+ jwks.keys = [_jwk(RSA_A, "a"), _jwk(EC_P256, "e1")]
253
+ policy = _jwks_policy(jwks)
254
+ assert (await policy.authenticate(_request(_token(EC_P256, "ES256", kid="e1")))).id == "user-1"
255
+
256
+
257
+ @pytest.mark.parametrize(
258
+ ("claims", "reason"),
259
+ [
260
+ ({"exp": int(time.time()) - 3600}, "token expired"),
261
+ ({"nbf": int(time.time()) + 3600}, "token not yet valid"),
262
+ ({"iat": int(time.time()) + 3600}, "token not yet valid"),
263
+ ({"aud": "someone-else"}, "wrong audience"),
264
+ ({"aud": None}, "missing required claim"),
265
+ ({"iss": "https://evil.test/"}, "wrong issuer"),
266
+ ({"iss": None}, "missing required claim"),
267
+ ({"exp": None}, "missing required claim"),
268
+ ({"sub": None}, "missing principal claim"),
269
+ ({"sub": ""}, "invalid principal claim"),
270
+ ({"sub": "a\x00b"}, "invalid principal claim"),
271
+ ({"sub": "x" * 300}, "invalid principal claim"),
272
+ ({"sub": True}, "invalid token"), # PyJWT: `sub` must be a string
273
+ ],
274
+ )
275
+ async def test_claim_checks_reject_with_a_bearer_challenge(
276
+ claims: dict[str, Any], reason: str
277
+ ) -> None:
278
+ policy = JwtPolicy(_settings())
279
+ error = await _reject(policy, _token(RSA_A, **claims), reason)
280
+ assert 'error="invalid_token"' in error.headers["WWW-Authenticate"]
281
+
282
+
283
+ async def test_leeway_accepts_small_clock_skew() -> None:
284
+ policy = JwtPolicy(_settings(AUTH_JWT_LEEWAY_S="120"))
285
+ now = int(time.time())
286
+ token = _token(RSA_A, exp=now - 60, nbf=now + 60, iat=now + 60)
287
+ assert (await policy.authenticate(_request(token))).id == "user-1"
288
+ strict = JwtPolicy(_settings(AUTH_JWT_LEEWAY_S="0"))
289
+ await _reject(strict, token)
290
+
291
+
292
+ async def test_several_audiences_and_an_audience_list_in_the_token() -> None:
293
+ policy = JwtPolicy(_settings(AUTH_JWT_AUDIENCE="other, agent-api"))
294
+ token = _token(RSA_A, aud=["unrelated", AUDIENCE])
295
+ assert (await policy.authenticate(_request(token))).id == "user-1"
296
+
297
+
298
+ async def test_missing_or_non_bearer_credentials_get_a_plain_challenge() -> None:
299
+ policy = JwtPolicy(_settings())
300
+ for request in (_request(None), _request(_token(RSA_A), scheme="Basic"), _request("")):
301
+ with pytest.raises(HTTPException) as exc:
302
+ await policy.authenticate(request)
303
+ assert exc.value.status_code == 401
304
+ assert exc.value.headers == {"WWW-Authenticate": "Bearer"}
305
+
306
+
307
+ @pytest.mark.parametrize(
308
+ ("token", "reason"),
309
+ [
310
+ ("not-a-jwt", "malformed token"),
311
+ ("a.b.c", "malformed token"),
312
+ ("x" * 20_000, "token too large"),
313
+ ],
314
+ )
315
+ async def test_malformed_tokens(token: str, reason: str) -> None:
316
+ await _reject(JwtPolicy(_settings()), token, reason)
317
+
318
+
319
+ # --- algorithm confusion ------------------------------------------------------------
320
+
321
+
322
+ async def test_alg_none_is_never_accepted() -> None:
323
+ policy = JwtPolicy(_settings())
324
+ for alg in ("none", "None", "NONE"):
325
+ await _reject(policy, _forge({"alg": alg, "kid": "a"}, _claims()), "algorithm not allowed")
326
+ # ...not even when the allow-list tries to include it.
327
+ settings = _settings(AUTH_JWT_ALGORITHMS="RS256,none")
328
+ assert any("never include 'none'" in p for p in settings.problems)
329
+
330
+
331
+ async def test_hs256_signed_with_the_public_key_is_refused() -> None:
332
+ """The classic confusion: HMAC over the (public) PEM text the server trusts."""
333
+ pem = _pem(RSA_A).encode()
334
+ forged = _forge({"alg": "HS256", "typ": "JWT", "kid": "a"}, _claims(), secret=pem)
335
+ await _reject(JwtPolicy(_settings()), forged, "algorithm not allowed")
336
+ # Even with HS256 allowed, a token is checked with AUTH_JWT_SECRET only.
337
+ secret = "s" * 40
338
+ policy = JwtPolicy(
339
+ _settings(
340
+ AUTH_JWT_ALGORITHMS="RS256,HS256", AUTH_JWT_ALLOW_HS="true", AUTH_JWT_SECRET=secret
341
+ )
342
+ )
343
+ assert policy.startup_problems() == []
344
+ await _reject(policy, forged, "invalid signature")
345
+ assert (await policy.authenticate(_request(_token(secret, "HS256")))).id == "user-1"
346
+
347
+
348
+ async def test_a_token_is_only_checked_with_a_key_of_its_own_family(jwks: JwksServer) -> None:
349
+ # RS256 token, but only ES256 is allowed.
350
+ es_only = JwtPolicy(_settings(AUTH_JWT_PUBLIC_KEY=_pem(EC_P256), AUTH_JWT_ALGORITHMS="ES256"))
351
+ await _reject(es_only, _token(RSA_A), "algorithm not allowed")
352
+ # ES256 header over an EC key on the wrong curve.
353
+ both = JwtPolicy(
354
+ _settings(AUTH_JWT_PUBLIC_KEY=_pem(EC_P384), AUTH_JWT_ALGORITHMS="ES256,ES384")
355
+ )
356
+ await _reject(both, _token(EC_P256, "ES256"))
357
+ # A JWKS entry whose kid matches but whose type does not.
358
+ jwks.keys = [_jwk(RSA_A, "shared-kid")]
359
+ policy = _jwks_policy(jwks)
360
+ await _reject(policy, _token(EC_P256, "ES256", kid="shared-kid"), "unknown signing key")
361
+ # A JWK pinned to RS512 does not verify an RS256 token.
362
+ jwks.keys = [_jwk(RSA_A, "a", alg="RS512")]
363
+ await _reject(_jwks_policy(jwks), _token(RSA_A), "unknown signing key")
364
+
365
+
366
+ async def test_symmetric_and_encryption_keys_in_a_jwks_are_ignored(jwks: JwksServer) -> None:
367
+ secret = b"k" * 40
368
+ jwks.keys = [
369
+ {"kty": "oct", "kid": "sym", "k": _b64(secret), "alg": "HS256"},
370
+ _jwk(RSA_A, "enc", use="enc"),
371
+ _jwk(RSA_A, "a"),
372
+ ]
373
+ policy = _jwks_policy(jwks, AUTH_JWT_ALGORITHMS="RS256")
374
+ forged = _forge({"alg": "HS256", "kid": "sym"}, _claims(), secret=secret)
375
+ await _reject(policy, forged, "algorithm not allowed")
376
+ await _reject(policy, _token(RSA_A, kid="enc"), "unknown signing key")
377
+ assert (await policy.authenticate(_request(_token(RSA_A, kid="a")))).id == "user-1"
378
+
379
+
380
+ async def test_keys_named_or_embedded_in_the_token_are_never_used(jwks: JwksServer) -> None:
381
+ attacker = _rsa()
382
+ policy = _jwks_policy(jwks)
383
+ for header in (
384
+ {"kid": "a", "jwk": _jwk(attacker, "a")},
385
+ {"kid": "a", "jku": "https://evil.test/jwks.json"},
386
+ {"kid": "a", "x5u": "https://evil.test/cert.pem"},
387
+ ):
388
+ token = jwt.encode(_claims(), attacker, algorithm="RS256", headers=header)
389
+ await _reject(policy, token, "invalid signature")
390
+ await _reject(policy, _token(attacker, kid="../../etc/passwd"), "unknown signing key")
391
+ assert jwks.hits == 1 # the attacker's URLs were never fetched
392
+
393
+
394
+ async def test_a_redirecting_jwks_url_is_not_followed(jwks: JwksServer) -> None:
395
+ jwks.status = 302
396
+ with pytest.raises(HTTPException) as exc:
397
+ await _jwks_policy(jwks).authenticate(_request(_token(RSA_A)))
398
+ assert exc.value.status_code == 503
399
+
400
+
401
+ async def test_unsupported_critical_header_is_refused() -> None:
402
+ token = jwt.encode(_claims(), RSA_A, algorithm="RS256", headers={"kid": "a", "crit": ["exp"]})
403
+ # Refused whichever layer notices first (PyJWT's header check or the policy's).
404
+ await _reject(JwtPolicy(_settings()), token)
405
+
406
+
407
+ # --- JWKS caching and rotation --------------------------------------------------
408
+
409
+
410
+ async def test_the_jwks_is_fetched_once_and_cached(jwks: JwksServer) -> None:
411
+ policy = _jwks_policy(jwks)
412
+ for _ in range(5):
413
+ await policy.authenticate(_request(_token(RSA_A)))
414
+ assert jwks.hits == 1
415
+
416
+
417
+ async def test_an_unknown_kid_refetches_once_and_picks_up_a_rotated_key(jwks: JwksServer) -> None:
418
+ clock = FakeClock()
419
+ policy = _jwks_policy(jwks)
420
+ policy.jwks = JwksCache(jwks.url, 300, clock=clock)
421
+ await policy.authenticate(_request(_token(RSA_A)))
422
+ assert jwks.hits == 1
423
+ # The issuer rotates to key b; a token signed with b triggers one refetch.
424
+ jwks.keys = [_jwk(RSA_A, "a"), _jwk(RSA_B, "b")]
425
+ clock.now += 31
426
+ assert (await policy.authenticate(_request(_token(RSA_B, kid="b")))).id == "user-1"
427
+ assert jwks.hits == 2
428
+ # Unknown kids right after that do not reach the issuer (rate limit).
429
+ for kid in ("x1", "x2", "x3"):
430
+ await _reject(policy, _token(RSA_B, kid=kid), "unknown signing key")
431
+ assert jwks.hits == 2
432
+ # After the interval, one more refetch is allowed.
433
+ clock.now += 31
434
+ await _reject(policy, _token(RSA_B, kid="x4"), "unknown signing key")
435
+ assert jwks.hits == 3
436
+
437
+
438
+ async def test_concurrent_tokens_with_a_rotated_key_share_one_refetch(jwks: JwksServer) -> None:
439
+ """Every request carrying the new key id waits for the one refetch and uses its result."""
440
+ clock = FakeClock()
441
+ policy = _jwks_policy(jwks)
442
+ policy.jwks = JwksCache(jwks.url, 300, clock=clock)
443
+ await policy.authenticate(_request(_token(RSA_A)))
444
+ jwks.keys = [_jwk(RSA_A, "a"), _jwk(RSA_B, "b")]
445
+ clock.now += 31
446
+ token = _token(RSA_B, kid="b")
447
+ results = await asyncio.gather(
448
+ *(policy.authenticate(_request(token)) for _ in range(8)), return_exceptions=True
449
+ )
450
+ assert [getattr(r, "id", r) for r in results] == ["user-1"] * 8
451
+ assert jwks.hits == 2
452
+ # Random unknown key ids at once still cost the issuer at most one fetch.
453
+ clock.now += 31
454
+ results = await asyncio.gather(
455
+ *(policy.authenticate(_request(_token(RSA_B, kid=f"x{i}"))) for i in range(8)),
456
+ return_exceptions=True,
457
+ )
458
+ assert all(isinstance(r, HTTPException) and r.status_code == 401 for r in results)
459
+ assert jwks.hits == 3
460
+
461
+
462
+ async def test_a_hanging_issuer_never_stalls_requests_the_cached_keys_can_verify() -> None:
463
+ """Stale while revalidate: expired keys keep serving while one bounded refresh runs."""
464
+ clock = FakeClock()
465
+ cache = JwksCache(
466
+ "https://issuer.test/jwks", 300, timeout_s=0.1, stale_grace_s=600, clock=clock
467
+ )
468
+ fetches = 0
469
+
470
+ async def fetch() -> list[dict[str, Any]]:
471
+ nonlocal fetches
472
+ fetches += 1
473
+ if fetches == 1:
474
+ return [_jwk(RSA_A, "a")]
475
+ await asyncio.sleep(3600) # the issuer stops answering
476
+ return []
477
+
478
+ cache._fetch = fetch # type: ignore[method-assign]
479
+ assert [k["kid"] for k in await cache.keys()] == ["a"]
480
+ clock.now += 301 # expired, within the grace period
481
+ started = time.monotonic()
482
+ for _ in range(20):
483
+ assert [k["kid"] for k in await cache.keys()] == ["a"]
484
+ assert time.monotonic() - started < 0.05 # none of them waited for the issuer
485
+ await asyncio.sleep(0)
486
+ assert fetches == 2 # one background refresh, not one per request
487
+ await cache.idle() # it gives up at its deadline (2 x timeout_s)
488
+ assert [k["kid"] for k in await cache.keys()] == ["a"]
489
+ assert fetches == 2 # rate-limited: no new fetch yet, the cached keys still serve
490
+ clock.now += 600 # past the grace period: requests now wait for a (bounded) fetch
491
+ started = time.monotonic()
492
+ with pytest.raises(JwksUnavailable):
493
+ await cache.keys()
494
+ assert 0.15 < time.monotonic() - started < 2 and fetches == 3
495
+
496
+
497
+ async def test_a_token_without_kid_needs_an_unambiguous_key(jwks: JwksServer) -> None:
498
+ policy = _jwks_policy(jwks)
499
+ assert (await policy.authenticate(_request(_token(RSA_A, kid=None)))).id == "user-1"
500
+ jwks.keys = [_jwk(RSA_A, "a"), _jwk(RSA_B, "b")]
501
+ other = _jwks_policy(jwks)
502
+ await _reject(other, _token(RSA_A, kid=None), "unknown signing key")
503
+
504
+
505
+ async def test_an_unreachable_jwks_serves_stale_keys_then_fails_closed(jwks: JwksServer) -> None:
506
+ clock = FakeClock()
507
+ policy = _jwks_policy(jwks)
508
+ policy.jwks = JwksCache(jwks.url, 300, stale_grace_s=600, clock=clock)
509
+ await policy.authenticate(_request(_token(RSA_A)))
510
+ jwks.status = 500
511
+ clock.now += 400 # cache expired, refresh fails: the last good keys still verify
512
+ assert (await policy.authenticate(_request(_token(RSA_A)))).id == "user-1"
513
+ await policy.jwks.idle() # the refresh ran in the background
514
+ assert jwks.hits == 2
515
+ assert (await policy.authenticate(_request(_token(RSA_A)))).id == "user-1"
516
+ clock.now += 600 # beyond the grace period: 503, never "no auth"
517
+ with pytest.raises(HTTPException) as exc:
518
+ await policy.authenticate(_request(_token(RSA_A)))
519
+ assert exc.value.status_code == 503
520
+ jwks.status = 200
521
+ clock.now += 31
522
+ assert (await policy.authenticate(_request(_token(RSA_A)))).id == "user-1"
523
+
524
+
525
+ async def test_a_stalled_fetch_has_a_deadline_and_a_waiter_leaving_does_not_cancel_it() -> None:
526
+ async def stall() -> list[dict[str, Any]]:
527
+ await asyncio.sleep(3600)
528
+ return []
529
+
530
+ cache = JwksCache("https://issuer.test/jwks", 300, timeout_s=0.05)
531
+ cache._fetch = stall # type: ignore[method-assign]
532
+ with pytest.raises(JwksUnavailable):
533
+ await cache.keys() # gives up after 2 x timeout_s
534
+ shared = JwksCache("https://issuer.test/jwks", 300)
535
+ fetches = 0
536
+
537
+ async def slow() -> list[dict[str, Any]]:
538
+ nonlocal fetches
539
+ fetches += 1
540
+ await asyncio.sleep(0.1)
541
+ return [_jwk(RSA_A, "a")]
542
+
543
+ shared._fetch = slow # type: ignore[method-assign]
544
+ leaving = asyncio.ensure_future(shared.keys())
545
+ await asyncio.sleep(0.02)
546
+ leaving.cancel() # the client went away
547
+ with pytest.raises(asyncio.CancelledError):
548
+ await leaving
549
+ # The fetch carries on for the requests still waiting, which share it.
550
+ assert [k["kid"] for k in await shared.keys()] == ["a"] and fetches == 1
551
+
552
+
553
+ @pytest.mark.parametrize(
554
+ "body",
555
+ [
556
+ b"not json",
557
+ b'{"keys": "nope"}',
558
+ b'{"keys": []}',
559
+ b"[]",
560
+ b'{"keys": [' + b"1," * 600_000 + b"1]}",
561
+ ],
562
+ )
563
+ async def test_a_bad_jwks_response_is_not_used(jwks: JwksServer, body: bytes) -> None:
564
+ jwks.body = body
565
+ policy = _jwks_policy(jwks)
566
+ with pytest.raises(HTTPException) as exc:
567
+ await policy.authenticate(_request(_token(RSA_A)))
568
+ assert exc.value.status_code == 503
569
+
570
+
571
+ # --- settings ---------------------------------------------------------------------
572
+
573
+
574
+ @pytest.mark.parametrize(
575
+ ("env", "problem"),
576
+ [
577
+ ({"AUTH_JWT_ALGORITHMS": "none"}, "never include 'none'"),
578
+ ({"AUTH_JWT_ALGORITHMS": "RS256,XS999"}, "unsupported algorithm"),
579
+ ({"AUTH_JWT_ALGORITHMS": ","}, "lists no algorithm"),
580
+ ({"AUTH_JWT_ALGORITHMS": "HS256"}, "AUTH_JWT_ALLOW_HS=true"),
581
+ ({"AUTH_JWT_ALGORITHMS": "HS256", "AUTH_JWT_ALLOW_HS": "true"}, "needs AUTH_JWT_SECRET"),
582
+ (
583
+ {
584
+ "AUTH_JWT_ALGORITHMS": "HS256",
585
+ "AUTH_JWT_ALLOW_HS": "true",
586
+ "AUTH_JWT_SECRET": "short",
587
+ },
588
+ "at least 32 bytes",
589
+ ),
590
+ (
591
+ {
592
+ "AUTH_JWT_ALGORITHMS": "HS256",
593
+ "AUTH_JWT_ALLOW_HS": "true",
594
+ "AUTH_JWT_SECRET": _pem(RSA_A),
595
+ },
596
+ "not a PEM key",
597
+ ),
598
+ ({"AUTH_JWT_PUBLIC_KEY": ""}, "no verification key"),
599
+ ({"AUTH_JWT_JWKS_URL": "https://issuer.test/jwks"}, "not both"),
600
+ (
601
+ {"AUTH_JWT_PUBLIC_KEY": "-----BEGIN PUBLIC KEY-----\nnope\n-----END PUBLIC KEY-----"},
602
+ "not a PEM",
603
+ ),
604
+ (
605
+ {"AUTH_JWT_PUBLIC_KEY": "-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----"},
606
+ "private key",
607
+ ),
608
+ ({"AUTH_JWT_ALGORITHMS": "ES256"}, "verifies none of"),
609
+ ({"AUTH_JWT_ISSUER": ""}, "AUTH_JWT_ISSUER is required"),
610
+ ({"AUTH_JWT_AUDIENCE": " , "}, "AUTH_JWT_AUDIENCE is required"),
611
+ ({"AUTH_JWT_LEEWAY_S": "soon"}, "whole number"),
612
+ ({"AUTH_JWT_LEEWAY_S": "-5"}, "between 0 and 600"),
613
+ ({"AUTH_JWT_JWKS_CACHE_S": "0"}, "between 1 and 86400"),
614
+ ({"AUTH_JWT_ROLES_CLAIM": "realm_access..roles"}, "dotted path"),
615
+ ],
616
+ )
617
+ def test_settings_problems(env: dict[str, str], problem: str) -> None:
618
+ problems = _settings(**env).problems
619
+ assert any(problem in p for p in problems), problems
620
+ # A problem never quotes key material.
621
+ assert not any("BEGIN" in p for p in problems)
622
+
623
+
624
+ @pytest.mark.parametrize(
625
+ ("url", "extra", "ok"),
626
+ [
627
+ ("https://issuer.test/jwks", {}, True),
628
+ ("http://issuer.test/jwks", {}, False),
629
+ ("http://issuer.test/jwks", {"AUTH_JWT_JWKS_ALLOW_HTTP": "true"}, True),
630
+ ("http://issuer.test/jwks", {"APP_ENV": "dev"}, True),
631
+ ("http://127.0.0.1:9/jwks", {}, True),
632
+ ("http://localhost:9/jwks", {}, True),
633
+ ("ftp://issuer.test/jwks", {}, False),
634
+ ("https://user:pw@issuer.test/jwks", {}, False),
635
+ ("issuer.test/jwks", {}, False),
636
+ ],
637
+ )
638
+ def test_jwks_url_must_be_https_outside_dev(url: str, extra: dict[str, str], ok: bool) -> None:
639
+ problems = _settings(AUTH_JWT_PUBLIC_KEY=None, AUTH_JWT_JWKS_URL=url, **extra).problems # type: ignore[arg-type]
640
+ assert (problems == []) is ok, problems
641
+
642
+
643
+ def test_issuer_and_audience_are_optional_only_under_dev() -> None:
644
+ dev = _settings(APP_ENV="dev", AUTH_JWT_ISSUER="", AUTH_JWT_AUDIENCE="")
645
+ assert dev.problems == []
646
+ assert len(dev.warnings) == 2
647
+ for app_env in ("prod", "staging", "", "DEV", "Dev", " dev "): # only exactly `dev` relaxes
648
+ assert (
649
+ len(_settings(APP_ENV=app_env, AUTH_JWT_ISSUER="", AUTH_JWT_AUDIENCE="").problems) == 2
650
+ ), app_env
651
+
652
+
653
+ async def test_unchecked_issuer_and_audience_under_dev_accept_any() -> None:
654
+ policy = JwtPolicy(_settings(APP_ENV="dev", AUTH_JWT_ISSUER="", AUTH_JWT_AUDIENCE=""))
655
+ token = _token(RSA_A, iss="https://anyone.test/", aud="anything")
656
+ assert (await policy.authenticate(_request(token))).id == "user-1"
657
+
658
+
659
+ def test_algorithm_names_are_normalised() -> None:
660
+ settings = _settings(AUTH_JWT_ALGORITHMS="rs256, RS256 ,ps256")
661
+ assert settings.algorithms == ("RS256", "PS256") and settings.problems == []
662
+
663
+
664
+ # --- claims -----------------------------------------------------------------------
665
+
666
+
667
+ @pytest.mark.parametrize(
668
+ ("claims", "setting", "roles"),
669
+ [
670
+ ({"realm_access": {"roles": ["admin", "user"]}}, "realm_access.roles", ["admin", "user"]),
671
+ ({"scope": "read write,admin read"}, "scope", ["read", "write", "admin"]),
672
+ ({"https://example.com/roles": ["ops"]}, "https://example.com/roles", ["ops"]),
673
+ ({"roles": "solo"}, "roles", ["solo"]),
674
+ (
675
+ {"roles": ["ok", 7, None, {"x": 1}, " spaced ", "a,b", "bad\nrole"]},
676
+ "roles",
677
+ ["ok", "spaced"],
678
+ ),
679
+ ({"roles": {"not": "a list"}}, "roles", []),
680
+ ({}, "roles", []),
681
+ ({"realm_access": "flat"}, "realm_access.roles", []),
682
+ ],
683
+ )
684
+ def test_roles_claim_forms(claims: dict[str, Any], setting: str, roles: list[str]) -> None:
685
+ policy = JwtPolicy(_settings(AUTH_JWT_ROLES_CLAIM=setting))
686
+ principal = policy.principal_from_claims({"sub": "u", **claims})
687
+ assert principal.roles == roles
688
+
689
+
690
+ async def test_principal_claim_setting() -> None:
691
+ policy = JwtPolicy(_settings(AUTH_JWT_PRINCIPAL_CLAIM="email"))
692
+ principal = await policy.authenticate(_request(_token(RSA_A, email="ada@example.com")))
693
+ assert principal.id == "ada@example.com"
694
+ await _reject(policy, _token(RSA_A), "missing principal claim")
695
+ nested = JwtPolicy(_settings(AUTH_JWT_PRINCIPAL_CLAIM="user.id"))
696
+ assert (await nested.authenticate(_request(_token(RSA_A, user={"id": 42})))).id == "42"
697
+
698
+
699
+ # --- no token contents in logs or errors --------------------------------------------
700
+
701
+
702
+ async def test_token_contents_never_reach_logs_or_errors(
703
+ jwks: JwksServer, caplog: pytest.LogCaptureFixture
704
+ ) -> None:
705
+ secret_sub = "sub-7f3a9c-secret"
706
+ tokens = [
707
+ _token(RSA_A, sub=secret_sub, exp=int(time.time()) - 999),
708
+ _token(RSA_A, sub=secret_sub, aud="elsewhere"),
709
+ _token(RSA_B, sub=secret_sub, kid="unknown-kid-9d2e"),
710
+ _token(RSA_B, sub=secret_sub, kid="a"),
711
+ _forge({"alg": "none", "kid": "a"}, _claims(sub=secret_sub)),
712
+ ]
713
+ policy = _jwks_policy(jwks)
714
+ details = []
715
+ with caplog.at_level(logging.DEBUG):
716
+ for token in tokens:
717
+ details.append(str((await _reject(policy, token)).detail))
718
+ jwks.status = 500
719
+ policy.jwks = JwksCache(jwks.url, 1, refetch_interval_s=0)
720
+ with pytest.raises(HTTPException):
721
+ await policy.authenticate(_request(tokens[1]))
722
+ text = caplog.text + " ".join(details)
723
+ for token in tokens:
724
+ for part in token.split("."):
725
+ if len(part) > 8:
726
+ assert part not in text
727
+ assert secret_sub not in text and "unknown-kid-9d2e" not in text
728
+
729
+
730
+ # --- wiring: selection, startup, fastapi dependency, LangGraph Server auth ---------
731
+
732
+
733
+ @pytest.fixture
734
+ def jwt_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[pytest.MonkeyPatch]:
735
+ monkeypatch.setenv("AUTH_POLICY", "jwt")
736
+ monkeypatch.setenv("APP_ENV", "prod")
737
+ monkeypatch.setenv("AUTH_JWT_PUBLIC_KEY", _pem(RSA_A))
738
+ monkeypatch.setenv("AUTH_JWT_ISSUER", ISSUER)
739
+ monkeypatch.setenv("AUTH_JWT_AUDIENCE", AUDIENCE)
740
+ monkeypatch.delenv("AUTH_JWT_JWKS_URL", raising=False)
741
+ monkeypatch.delenv("AUTH_JWT_ALGORITHMS", raising=False)
742
+ reset_policy_cache()
743
+ yield monkeypatch
744
+ reset_policy_cache()
745
+
746
+
747
+ async def test_selected_by_auth_policy_and_used_by_the_fastapi_dependency(jwt_env) -> None:
748
+ assert isinstance(get_policy(), JwtPolicy)
749
+ check_startup()
750
+ principal = await require("chat.send")(_request(_token(RSA_A)))
751
+ assert principal.id == "user-1"
752
+ with pytest.raises(HTTPException) as exc:
753
+ await require("thread.read")(_request(_token(RSA_B)))
754
+ assert exc.value.status_code == 401 and "WWW-Authenticate" in exc.value.headers
755
+
756
+
757
+ async def test_startup_fails_closed_outside_dev_and_requests_get_503(
758
+ jwt_env, caplog: pytest.LogCaptureFixture
759
+ ) -> None:
760
+ jwt_env.delenv("AUTH_JWT_AUDIENCE")
761
+ reset_policy_cache()
762
+ with pytest.raises(RuntimeError, match="AUTH_JWT_AUDIENCE is required"):
763
+ check_startup()
764
+ for action in ACTIONS:
765
+ with pytest.raises(HTTPException) as exc:
766
+ await require(action)(_request(_token(RSA_A)))
767
+ assert exc.value.status_code == 503 and "AUTH_POLICY=jwt" in exc.value.detail
768
+ # Under dev a key-less configuration is logged, not fatal; requests still get 503.
769
+ jwt_env.setenv("APP_ENV", "dev")
770
+ jwt_env.delenv("AUTH_JWT_PUBLIC_KEY")
771
+ reset_policy_cache()
772
+ with caplog.at_level(logging.ERROR):
773
+ check_startup()
774
+ assert "no verification key" in caplog.text
775
+ with pytest.raises(HTTPException) as exc:
776
+ await require("chat.send")(_request(_token(RSA_A)))
777
+ assert exc.value.status_code == 503
778
+
779
+
780
+ async def test_langgraph_server_auth_uses_the_same_policy(jwt_env) -> None:
781
+ from langgraph_sdk import Auth
782
+
783
+ auth = build_sdk_auth()
784
+ authenticate = auth._authenticate_handler
785
+ user = await authenticate(request=_request(_token(RSA_A, roles="admin ops")))
786
+ assert user["identity"] == "user-1"
787
+ assert "role:admin" in user["permissions"] and "role:ops" in user["permissions"]
788
+ with pytest.raises(Auth.exceptions.HTTPException) as exc:
789
+ await authenticate(request=_request(_token(RSA_A, iss="https://evil.test/")))
790
+ assert exc.value.status_code == 401
791
+ assert "invalid_token" in dict(exc.value.headers or {})["WWW-Authenticate"]
792
+ # A misconfigured jwt policy stops the server from loading its auth outside dev.
793
+ jwt_env.delenv("AUTH_JWT_ISSUER")
794
+ reset_policy_cache()
795
+ with pytest.raises(RuntimeError, match="AUTH_JWT_ISSUER"):
796
+ build_sdk_auth()
797
+
798
+
799
+ def test_the_delegation_defaults_on_record() -> None:
800
+ """Defaults decided for 0.3 (P1 reads them): the actor is the RFC 8693 `act` claim
801
+ (empty turns delegation off, as in 0.2), and no delegated caller is allowed until
802
+ `AUTH_ALLOWED_ACTORS` lists it."""
803
+ assert DEFAULT_JWT_ACTOR_CLAIM == "act"
804
+ assert DEFAULT_ALLOWED_ACTORS == frozenset()
805
+
806
+
807
+ # --- delegation (0.3): an agent presenting a user's token ---------------------------------
808
+
809
+
810
+ def _delegated(token: str) -> Any:
811
+ return JwtPolicy(_settings()).authenticate(_request(token))
812
+
813
+
814
+ async def test_act_chain_is_read_current_actor_first() -> None:
815
+ act = {"sub": "billing", "act": {"sub": "concierge"}}
816
+ principal = await _delegated(_token(RSA_A, act=act, azp="billing"))
817
+ assert principal.id == "user-1" # the subject, unchanged
818
+ assert principal.actor is not None and principal.delegated
819
+ assert (principal.actor.id, principal.actor.chain) == ("billing", ("billing", "concierge"))
820
+ assert principal.actor.client == "billing"
821
+ assert principal.owner_key() == "user-1\x1fbilling"
822
+ assert principal.hashed_id() == Principal(id="user-1").hashed_id() # subject-only
823
+
824
+
825
+ @pytest.mark.parametrize(
826
+ "act",
827
+ [
828
+ None, # present but null
829
+ "concierge",
830
+ {},
831
+ {"sub": 7},
832
+ {"sub": ""},
833
+ {"sub": "con\x00cierge"},
834
+ {"sub": "x" * 257},
835
+ {"sub": "a", "act": "b"},
836
+ {"sub": "a", "act": None},
837
+ ],
838
+ )
839
+ async def test_a_malformed_actor_claim_is_refused_never_read_as_direct(act: Any) -> None:
840
+ claims = _claims()
841
+ claims["act"] = act
842
+ token = jwt.encode(claims, RSA_A, algorithm="RS256", headers={"kid": "a"})
843
+ await _reject(JwtPolicy(_settings()), token, "invalid actor claim")
844
+
845
+
846
+ async def test_an_actor_chain_longer_than_eight_is_refused_while_walking() -> None:
847
+ act: dict[str, Any] = {"sub": "a0"}
848
+ for index in range(1, 9):
849
+ act = {"sub": f"a{index}", "act": act}
850
+ await _reject(JwtPolicy(_settings()), _token(RSA_A, act=act), "invalid actor claim")
851
+
852
+
853
+ def test_direct_clients_make_unlisted_clients_agents() -> None:
854
+ direct = frozenset({"web"})
855
+
856
+ def actor(**claims: Any) -> Any:
857
+ return actor_from_claims(claims, direct_clients=direct, subject=claims.get("sub"))
858
+
859
+ assert actor(sub="alice", azp="web") is None
860
+ assert actor(sub="alice", azp="concierge").id == "client:concierge"
861
+ assert actor(sub="alice", client_id="cli").id == "client:cli" # RFC 9068 client_id
862
+ assert actor(sub="alice").id == "client:?"
863
+ assert actor(sub="concierge", azp="concierge") is None # a service's own token
864
+ assert actor_from_claims({"sub": "alice", "azp": "concierge"}) is None # none listed
865
+ okta = actor_from_claims({"cid": "bot"}, client_claim="cid", direct_clients=direct)
866
+ assert okta is not None and okta.id == "client:bot" and okta.client == "bot"
867
+
868
+
869
+ async def test_direct_clients_from_the_environment() -> None:
870
+ policy = JwtPolicy(_settings(AUTH_JWT_DIRECT_CLIENTS="web, mobile"))
871
+ assert (await policy.authenticate(_request(_token(RSA_A, azp="web")))).actor is None
872
+ agent = await policy.authenticate(_request(_token(RSA_A, azp="concierge")))
873
+ assert agent.actor is not None and agent.actor.id == "client:concierge"
874
+
875
+
876
+ async def test_an_empty_actor_claim_setting_restores_the_02_reading() -> None:
877
+ policy = JwtPolicy(_settings(AUTH_JWT_ACTOR_CLAIM=""))
878
+ principal = await policy.authenticate(_request(_token(RSA_A, act={"sub": "concierge"})))
879
+ assert principal.actor is None and principal.roles == ["viewer", "support"]
880
+ other = JwtPolicy(_settings(AUTH_JWT_ACTOR_CLAIM="ext.actor"))
881
+ nested = await other.authenticate(_request(_token(RSA_A, ext={"actor": {"sub": "bot"}})))
882
+ assert nested.actor is not None and nested.actor.id == "bot"
883
+
884
+
885
+ def test_actor_settings_are_checked() -> None:
886
+ assert _settings().actor_claim == DEFAULT_JWT_ACTOR_CLAIM
887
+ assert "AUTH_JWT_ACTOR_CLAIM" in " ".join(_settings(AUTH_JWT_ACTOR_CLAIM="a..b").problems)
888
+ assert "AUTH_JWT_CLIENT_CLAIM" in " ".join(_settings(AUTH_JWT_CLIENT_CLAIM=" ").problems)
889
+
890
+
891
+ async def test_delegated_roles_filtered(jwt_env) -> None:
892
+ jwt_env.setenv("AUTH_ALLOWED_ACTORS", "concierge")
893
+ token = _token(RSA_A, act={"sub": "concierge"}, roles=["viewer", "support", "ops"])
894
+ principal = await require("chat.send")(_request(token))
895
+ assert principal.actor is not None and principal.roles == []
896
+ jwt_env.setenv("AUTH_DELEGATED_ROLES", "support")
897
+ principal = await require("chat.send")(_request(token))
898
+ assert principal.roles == ["support"]
899
+ direct = await require("chat.send")(_request(_token(RSA_A, roles=["viewer", "ops"])))
900
+ assert direct.roles == ["viewer", "ops"] and direct.actor is None
901
+
902
+
903
+ async def test_a_delegated_caller_is_refused_until_it_is_listed(jwt_env) -> None:
904
+ token = _token(RSA_A, act={"sub": "concierge"})
905
+ with pytest.raises(HTTPException) as exc:
906
+ await require("chat.send")(_request(token))
907
+ assert exc.value.status_code == 403
908
+ assert exc.value.detail == (
909
+ "Delegated caller concierge is not allowed here (AUTH_ALLOWED_ACTORS)."
910
+ )
911
+ jwt_env.setenv("AUTH_ALLOWED_ACTORS", "billing, concierge")
912
+ assert (await require("chat.send")(_request(token))).actor.id == "concierge"
913
+ jwt_env.setenv("AUTH_ALLOWED_ACTORS", "*")
914
+ other = _token(RSA_A, act={"sub": "anyone"})
915
+ assert (await require("chat.send")(_request(other))).actor.id == "anyone"
916
+
917
+
918
+ async def test_a_chain_deeper_than_the_setting_is_refused(jwt_env) -> None:
919
+ jwt_env.setenv("AUTH_ALLOWED_ACTORS", "*")
920
+ act = {"sub": "d", "act": {"sub": "c", "act": {"sub": "b", "act": {"sub": "a"}}}}
921
+ with pytest.raises(HTTPException) as exc:
922
+ await require("chat.send")(_request(_token(RSA_A, act=act)))
923
+ assert exc.value.status_code == 401
924
+ assert exc.value.detail == "Invalid bearer token: delegation too deep."
925
+ jwt_env.setenv("AUTH_MAX_DELEGATION_DEPTH", "4")
926
+ principal = await require("chat.send")(_request(_token(RSA_A, act=act)))
927
+ assert principal.actor is not None and principal.actor.chain == ("d", "c", "b", "a")
928
+
929
+
930
+ async def test_the_actor_is_published_and_a_policy_supplied_one_is_not_trusted(jwt_env) -> None:
931
+ jwt_env.setenv("AUTH_ALLOWED_ACTORS", "concierge")
932
+ principal = await require("chat.send")(_request(_token(RSA_A, act={"sub": "concierge"})))
933
+ assert principal.attributes["@actor"] == {
934
+ "id": "concierge",
935
+ "chain": ["concierge"],
936
+ "client": None,
937
+ }
938
+ assert principal.public_attributes()["@actor"]["id"] == "concierge"
939
+ forged = Principal(id="alice", attributes={"@actor": {"id": "billing"}, "tenant": "t"})
940
+ assert finalize_principal(forged).attributes == {"tenant": "t"}
941
+
942
+
943
+ async def test_the_subject_token_is_kept_only_when_an_api_acts_with_it(
944
+ jwt_env, monkeypatch: pytest.MonkeyPatch
945
+ ) -> None:
946
+ token = _token(RSA_A)
947
+ principal = await require("chat.send")(_request(token))
948
+ assert "credentials" not in principal.attributes
949
+ monkeypatch.setattr(auth_module, "subject_token_needed", lambda: True)
950
+ principal = await require("chat.send")(_request(token))
951
+ credentials = principal.attributes["credentials"]
952
+ exp = jwt.decode(token, options={"verify_signature": False})["exp"]
953
+ assert credentials == {
954
+ "@subject_token": token,
955
+ "@subject_aud": (AUDIENCE,),
956
+ "@subject_exp": exp,
957
+ }
958
+ assert "credentials" not in principal.public_attributes()
959
+ assert token not in repr(principal)
960
+
961
+
962
+ def test_without_a_policy_no_subject_token_is_needed(monkeypatch: pytest.MonkeyPatch) -> None:
963
+ monkeypatch.setenv("API_POLICY_PATH", "/nonexistent/api-policy.yaml")
964
+ assert auth_module.subject_token_needed() is False
965
+
966
+
967
+ def test_a_warning_when_only_the_act_claim_can_show_delegation(
968
+ monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
969
+ ) -> None:
970
+ monkeypatch.setenv("AUTH_ALLOWED_ACTORS", "concierge")
971
+ with caplog.at_level(logging.WARNING):
972
+ JwtPolicy(_settings())
973
+ assert "set AUTH_JWT_DIRECT_CLIENTS" in caplog.text
974
+ caplog.clear()
975
+ with caplog.at_level(logging.WARNING):
976
+ JwtPolicy(_settings(AUTH_JWT_DIRECT_CLIENTS="web"))
977
+ assert "AUTH_JWT_DIRECT_CLIENTS" not in caplog.text
978
+
979
+
980
+ async def test_langgraph_server_auth_carries_the_actor(jwt_env) -> None:
981
+ jwt_env.setenv("AUTH_ALLOWED_ACTORS", "concierge")
982
+ jwt_env.setenv("AUTH_DELEGATED_ROLES", "ops")
983
+ auth = build_sdk_auth()
984
+ authenticate = auth._authenticate_handler
985
+ token = _token(RSA_A, act={"sub": "concierge"}, roles="admin ops")
986
+ user = await authenticate(request=_request(token))
987
+ assert user["identity"] == "user-1"
988
+ assert "actor:concierge" in user["permissions"] and "role:ops" in user["permissions"]
989
+ assert "role:admin" not in user["permissions"]
990
+ direct = await authenticate(request=_request(_token(RSA_A)))
991
+ assert not [p for p in direct["permissions"] if p.startswith("actor:")]