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,1986 @@
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
+ """Authentication and authorization adapter.
16
+
17
+ One `AuthPolicy` is selected by `AUTH_POLICY` and applied to every surface:
18
+ the chat API and thread routes through the `require(action)` dependency, the
19
+ A2A card and JSON-RPC endpoints through the middleware in `fast_api_app.py`,
20
+ and, under `langgraph-server`, the server's native API (threads, runs,
21
+ assistants, crons, store) through `auth`, a `langgraph_sdk.Auth` object built
22
+ from the same policy and referenced by `langgraph.json`. `langgraph_sdk` and
23
+ `jwt` (PyJWT) are imported lazily, so a runtime or policy that does not use
24
+ them never needs them.
25
+
26
+ Policies:
27
+ * `SharedBearerPolicy` (`shared-bearer`, default): `Authorization: Bearer <API_KEY>`,
28
+ constant-time compare, one principal `shared`, every action allowed.
29
+ * `JwtPolicy` (`jwt`): per-user principals from a verified OIDC/JWT bearer
30
+ token (`AUTH_JWT_*` settings, see `.env.example`): signature from a JWKS
31
+ URL (cached, refetched on an unknown key id) or one PEM public key,
32
+ issuer, audience, `exp`/`nbf`/`iat` with leeway, an algorithm allow-list.
33
+ * `CustomPolicy` (`custom`): interface plus a fail-closed stub in
34
+ `policies/custom.py`, implemented by the project (for example to validate
35
+ an existing application's session cookie).
36
+
37
+ Startup fails closed: `check_startup()` builds the selected policy when the
38
+ app is assembled (`a2a.add_a2a_routes`) and when LangGraph Server loads
39
+ `auth`. An unknown `AUTH_POLICY` stops the process in every environment; a
40
+ policy whose `startup_problems()` reports a misconfiguration stops it outside
41
+ `APP_ENV=dev` (under dev the problem is logged and every request gets 503).
42
+
43
+ LangGraph Server's own meta routes (`/docs`, `/openapi.json`, `/info`,
44
+ `/metrics`) are outside this auth. `langgraph dev` keeps them (LangGraph
45
+ Studio reads `/info`). The server image built from the project's Dockerfile
46
+ sets `"disable_meta": true` in its `LANGGRAPH_HTTP`, which removes them all
47
+ but `/ok`; this app's own `/metrics` (optionally behind `METRICS_TOKEN`) is
48
+ served in their place. A custom image must set the same flag.
49
+
50
+ The retired name `product-session` is still read as `custom`, with a warning.
51
+
52
+ Agents calling agents (delegation). A request may come from another agent
53
+ acting for a user: a `jwt` token carrying the RFC 8693 `act` claim, or a
54
+ principal a `custom` policy marks with `Principal.actor`. The principal's `id`
55
+ stays the subject (the user); `actor` names the agent presenting the request.
56
+ `finalize_principal` runs right after every policy's `authenticate` and applies
57
+ one rule set to all of them: it validates the ids, refuses a chain deeper than
58
+ `AUTH_MAX_DELEGATION_DEPTH` (401) and an agent `AUTH_ALLOWED_ACTORS` does not
59
+ list (403; none is listed by default), keeps only the roles
60
+ `AUTH_DELEGATED_ROLES` lends to agents, and publishes the actor in
61
+ `attributes["@actor"]` (a policy's own `@actor` is never trusted). Ownership
62
+ is by owner key (`Principal.owner_key`): a direct principal owns everything
63
+ done for its subject, a delegated one only what was created under its exact
64
+ subject and actor. Privileged role checks (read-across, admin, role approvers)
65
+ ignore delegated principals.
66
+ """
67
+
68
+ from __future__ import annotations
69
+
70
+ import asyncio
71
+ import copy
72
+ import hashlib
73
+ import hmac
74
+ import json
75
+ import logging
76
+ import os
77
+ import re
78
+ import time
79
+ from collections.abc import Awaitable, Callable, Mapping
80
+ from contextvars import ContextVar
81
+ from dataclasses import dataclass, field
82
+ from typing import Any, Protocol, runtime_checkable
83
+ from urllib.parse import urlsplit
84
+
85
+ from fastapi import HTTPException, Request
86
+
87
+ from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
88
+
89
+ logger = logging.getLogger(__name__)
90
+
91
+ # Every action a policy may be asked to authorize. `approval.read` lists the
92
+ # approvals of gated API calls and `approval.decide` approves or rejects one;
93
+ # who may decide a given approval is then the policy's `approvers`
94
+ # (`app_utils.approvals.may_decide`).
95
+ ACTIONS: frozenset[str] = frozenset(
96
+ {
97
+ "chat.send",
98
+ "thread.read",
99
+ "thread.list",
100
+ "thread.delete",
101
+ "run.read",
102
+ "a2a.invoke",
103
+ "card.read",
104
+ "approval.read",
105
+ "approval.decide",
106
+ }
107
+ )
108
+
109
+ SHARED_BEARER = "shared-bearer"
110
+ JWT = "jwt"
111
+ CUSTOM = "custom"
112
+ DEFAULT_POLICY = SHARED_BEARER
113
+ # Retired policy names still accepted from AUTH_POLICY.
114
+ LEGACY_ALIASES = {"product-session": CUSTOM}
115
+
116
+ # The one attribute key that may hold secrets: api name -> credential string,
117
+ # forwarded by `app_utils.api_client` for `auth: forward` APIs.
118
+ CREDENTIALS_KEY = "credentials"
119
+
120
+ # Optional secret key of `Principal.hashed_id()` (HMAC-SHA256); unset = plain sha256.
121
+ PRINCIPAL_HASH_SALT_ENV = "PRINCIPAL_HASH_SALT"
122
+
123
+ # Attribute keys starting with `@` belong to the framework (API names, the keys of
124
+ # `credentials`, never start with one). `@actor` is public (persisted with an
125
+ # approval's requester, passed in LangGraph Server run context); the `@` keys under
126
+ # `credentials` are private, like every credential.
127
+ ACTOR_ATTRIBUTE = "@actor"
128
+ SUBJECT_TOKEN_CREDENTIAL = "@subject_token"
129
+ SUBJECT_AUD_CREDENTIAL = "@subject_aud"
130
+ SUBJECT_EXP_CREDENTIAL = "@subject_exp"
131
+ ORIGIN_CREDENTIAL = "@origin"
132
+ # Between the subject and the actor in an owner key: the unit separator, which no
133
+ # valid id holds (ids have no control characters).
134
+ OWNER_KEY_SEPARATOR = "\x1f"
135
+ # `AUTH_MAX_DELEGATION_DEPTH`: how many agents may stand between the user and this one.
136
+ DEFAULT_MAX_DELEGATION_DEPTH = 3
137
+ MIN_DELEGATION_DEPTH = 1
138
+ MAX_DELEGATION_DEPTH = 8
139
+ # `AUTH_ALLOWED_ACTORS=*` allows any agent (the issuer's audience policy alone decides).
140
+ ANY_ACTOR = "*"
141
+ # The actor of a jwt token with no `act` whose client `AUTH_JWT_DIRECT_CLIENTS` does not
142
+ # list: `client:<azp>` (`client:?` when the token names no client).
143
+ CLIENT_ACTOR_PREFIX = "client:"
144
+ UNKNOWN_CLIENT = "?"
145
+ # The RFC 8693 claim that nests earlier actors inside an actor.
146
+ NESTED_ACTOR_CLAIM = "act"
147
+ DEFAULT_JWT_CLIENT_CLAIM = "azp"
148
+ # Read when the client claim is absent (RFC 9068 access tokens).
149
+ FALLBACK_JWT_CLIENT_CLAIM = "client_id"
150
+
151
+ _TRUE = ("1", "true", "yes", "on")
152
+
153
+
154
+ def dev_mode() -> bool:
155
+ """`APP_ENV` is exactly `dev`. Unset or any other value (`DEV`, ` dev`, `development`)
156
+ is a deployed environment: dev relaxes checks, so only the exact value turns it on."""
157
+ return os.environ.get("APP_ENV") == "dev"
158
+
159
+
160
+ def _csv(raw: str | None) -> list[str]:
161
+ return [part.strip() for part in (raw or "").split(",") if part.strip()]
162
+
163
+
164
+ def admin_roles() -> set[str]:
165
+ """Roles allowed to manage assistants, crons and the store (`AUTH_ADMIN_ROLES`; empty = nobody)."""
166
+ return set(_csv(os.environ.get("AUTH_ADMIN_ROLES")))
167
+
168
+
169
+ def read_across_roles() -> set[str]:
170
+ """Roles allowed to read other principals' threads (`AUTH_READ_ACROSS_ROLES`)."""
171
+ return set(_csv(os.environ.get("AUTH_READ_ACROSS_ROLES")))
172
+
173
+
174
+ @dataclass(frozen=True)
175
+ class Actor:
176
+ """The agent presenting a delegated request. Public: persisted and shown, never secret.
177
+
178
+ `id` is the current actor (the outermost `act.sub` of a jwt token,
179
+ `client:<azp>`, or what a custom policy names); `chain` is every actor,
180
+ current first (earlier agents follow); `client` the token's authorized
181
+ party (`azp`/`client_id`) when it names one.
182
+ """
183
+
184
+ id: str
185
+ chain: tuple[str, ...] = ()
186
+ client: str | None = None
187
+
188
+ def public(self) -> dict[str, Any]:
189
+ """The actor as `attributes["@actor"]` holds it."""
190
+ return {"id": self.id, "chain": list(self.chain or (self.id,)), "client": self.client}
191
+
192
+ @classmethod
193
+ def from_public(cls, value: Any) -> Actor | None:
194
+ """An actor from its `public()` form (a persisted requester's), or None when it is not one."""
195
+ if not isinstance(value, Mapping):
196
+ return None
197
+ actor_id = value.get("id")
198
+ if not _valid_id(actor_id):
199
+ return None
200
+ raw = value.get("chain")
201
+ chain = (
202
+ tuple(str(a) for a in raw if _valid_id(a))
203
+ if isinstance(raw, list | tuple)
204
+ else (actor_id,)
205
+ )
206
+ client = value.get("client")
207
+ return cls(
208
+ id=actor_id,
209
+ chain=chain or (actor_id,),
210
+ client=client if isinstance(client, str) else None,
211
+ )
212
+
213
+
214
+ def owner_key_of(subject: str, actor: str | None) -> str:
215
+ """The owner key of a record kept as two columns: the subject, or subject and actor.
216
+
217
+ Byte-identical to the subject for a direct owner (no actor, or an empty one),
218
+ so every row written before 0.3 keeps its owner.
219
+ """
220
+ return subject if not actor else f"{subject}{OWNER_KEY_SEPARATOR}{actor}"
221
+
222
+
223
+ @dataclass
224
+ class Principal:
225
+ """Who is calling. `id` is what traces and run records use, hashed.
226
+
227
+ `id` is always the subject: the user a request acts for (or a service
228
+ acting for itself). `actor` is the agent presenting a delegated request on
229
+ the subject's behalf, None for a direct one (the subject itself calls).
230
+
231
+ `attributes` may hold secrets only under `credentials` (api name ->
232
+ credential string). Anything persisted, logged, traced or passed into
233
+ LangGraph Server run context/metadata must use `public_attributes()`.
234
+ """
235
+
236
+ id: str
237
+ roles: list[str] = field(default_factory=list)
238
+ permissions: set[str] = field(default_factory=set)
239
+ # Out of repr: `credentials` holds secrets, and a repr ends up in logs and tracebacks.
240
+ attributes: dict[str, Any] = field(default_factory=dict, repr=False)
241
+ actor: Actor | None = None
242
+
243
+ @property
244
+ def delegated(self) -> bool:
245
+ """Whether an agent presents this request for the subject (see `actor`)."""
246
+ return self.actor is not None
247
+
248
+ def owner_key(self) -> str:
249
+ """What owns the threads and tasks this principal creates: the subject, or the
250
+ subject and the actor (`owner_key_of`). Equal to `id` for a direct principal."""
251
+ return owner_key_of(self.id, self.actor.id if self.actor is not None else None)
252
+
253
+ def hashed_id(self) -> str:
254
+ """The id hashed, first 16 hex characters: what logs, traces and run records carry.
255
+
256
+ HMAC-SHA256 keyed with `PRINCIPAL_HASH_SALT` when that is set, else
257
+ plain sha256 (the default, kept for compatibility). A secret salt stops
258
+ anyone holding logs or traces from confirming a guessed id (an email
259
+ address, say) by hashing it; changing the salt changes every hash, so
260
+ new hashes no longer match older logs and run records.
261
+ """
262
+ data = self.id.encode("utf-8")
263
+ salt = (os.environ.get(PRINCIPAL_HASH_SALT_ENV) or "").strip()
264
+ if salt:
265
+ return hmac.new(salt.encode("utf-8"), data, hashlib.sha256).hexdigest()[:16]
266
+ return hashlib.sha256(data).hexdigest()[:16]
267
+
268
+ def public_attributes(self) -> dict[str, Any]:
269
+ """`attributes` without `credentials`: safe to persist, log or trace."""
270
+ return {k: v for k, v in self.attributes.items() if k != CREDENTIALS_KEY}
271
+
272
+
273
+ def _valid_id(value: Any) -> bool:
274
+ """A principal or actor id: 1-`PRINCIPAL_ID_MAX_CHARS` characters, no control characters."""
275
+ return (
276
+ isinstance(value, str)
277
+ and 0 < len(value) <= PRINCIPAL_ID_MAX_CHARS
278
+ and not _CONTROL_CHARS.search(value)
279
+ )
280
+
281
+
282
+ def keep_subject_token(
283
+ principal: Principal, token: str, aud: Any = None, exp: Any = None
284
+ ) -> Principal:
285
+ """Keep the verified inbound bearer, its audience and expiry in the principal's credentials.
286
+
287
+ Under `credentials["@subject_token"]`, `credentials["@subject_aud"]` (a
288
+ tuple) and `credentials["@subject_exp"]` (the `exp` claim, epoch seconds,
289
+ when given): the token an API that acts with the user's own identity is
290
+ called with (`auth: exchange` exchanges it, `auth: forward` with
291
+ `forward_audience` forwards it). `jwt` keeps it only when the loaded
292
+ api-policy has such an API (`subject_token_needed`); a custom policy calls
293
+ this itself. An exchanged token is never kept past the subject token's
294
+ expiry, and a subject token with 10 s or less left is not exchanged.
295
+ Credentials are never persisted, logged or traced (`public_attributes()`
296
+ drops them).
297
+ """
298
+ if isinstance(aud, str):
299
+ audience: tuple[str, ...] = (aud,)
300
+ elif isinstance(aud, list | tuple):
301
+ audience = tuple(str(a) for a in aud if isinstance(a, str))
302
+ else:
303
+ audience = ()
304
+ credentials = principal.attributes.get(CREDENTIALS_KEY)
305
+ kept = dict(credentials) if isinstance(credentials, Mapping) else {}
306
+ kept[SUBJECT_TOKEN_CREDENTIAL] = token
307
+ kept[SUBJECT_AUD_CREDENTIAL] = audience
308
+ if isinstance(exp, int | float) and not isinstance(exp, bool):
309
+ kept[SUBJECT_EXP_CREDENTIAL] = exp
310
+ else:
311
+ kept.pop(SUBJECT_EXP_CREDENTIAL, None)
312
+ principal.attributes[CREDENTIALS_KEY] = kept
313
+ return principal
314
+
315
+
316
+ def origin_of(principal: Principal) -> dict[str, Any] | None:
317
+ """The user's own words a calling agent forwarded (`credentials["@origin"]`), or None.
318
+
319
+ A copy holding `text`, `truncated` and `hops` (the origin extension's
320
+ fields, as `a2a.read_origin` keeps them); None without a text.
321
+ """
322
+ credentials = principal.attributes.get(CREDENTIALS_KEY)
323
+ origin = credentials.get(ORIGIN_CREDENTIAL) if isinstance(credentials, Mapping) else None
324
+ if not isinstance(origin, Mapping) or not isinstance(origin.get("text"), str):
325
+ return None
326
+ return {key: origin[key] for key in ("text", "truncated", "hops") if key in origin}
327
+
328
+
329
+ def with_origin(principal: Principal, origin: Mapping[str, Any] | None) -> Principal:
330
+ """`principal` with `origin` as the user's forwarded words (private: never persisted).
331
+
332
+ A new principal, its credentials otherwise the same; `origin` None removes
333
+ any it had.
334
+ """
335
+ attributes = dict(principal.attributes)
336
+ credentials = attributes.get(CREDENTIALS_KEY)
337
+ kept = dict(credentials) if isinstance(credentials, Mapping) else {}
338
+ if origin is None:
339
+ kept.pop(ORIGIN_CREDENTIAL, None)
340
+ else:
341
+ kept[ORIGIN_CREDENTIAL] = dict(origin)
342
+ if kept:
343
+ attributes[CREDENTIALS_KEY] = kept
344
+ else:
345
+ attributes.pop(CREDENTIALS_KEY, None)
346
+ return Principal(
347
+ id=principal.id,
348
+ roles=list(principal.roles),
349
+ permissions=set(principal.permissions),
350
+ attributes=attributes,
351
+ actor=principal.actor,
352
+ )
353
+
354
+
355
+ def subject_token_needed() -> bool:
356
+ """Whether the loaded api-policy has an API that acts with the caller's own token.
357
+
358
+ That is an `auth: exchange` API, or an `auth: forward` one with
359
+ `forward_audience`. Without one (and without a readable policy) no
360
+ subject token is kept.
361
+ """
362
+ try:
363
+ from {{cookiecutter.agent_directory}}.app_utils.api_client import load_policy
364
+
365
+ apis = load_policy().apis
366
+ except Exception:
367
+ return False
368
+ return any(api.get("auth") == "exchange" or "forward_audience" in api for api in apis.values())
369
+
370
+
371
+ # ---------------------------------------------------------------------------
372
+ # Delegation: one rule set for every policy (`finalize_principal`)
373
+ # ---------------------------------------------------------------------------
374
+
375
+
376
+ @dataclass(frozen=True)
377
+ class DelegationSettings:
378
+ """`AUTH_MAX_DELEGATION_DEPTH`, `AUTH_ALLOWED_ACTORS` and `AUTH_DELEGATED_ROLES`."""
379
+
380
+ max_depth: int = DEFAULT_MAX_DELEGATION_DEPTH
381
+ # The agents that may present a request for a user; `*` in it allows any.
382
+ allowed_actors: frozenset[str] = field(default_factory=lambda: DEFAULT_ALLOWED_ACTORS)
383
+ # The roles a delegated principal keeps (the rest are dropped).
384
+ delegated_roles: frozenset[str] = frozenset()
385
+
386
+ def allows(self, actor_id: str) -> bool:
387
+ return ANY_ACTOR in self.allowed_actors or actor_id in self.allowed_actors
388
+
389
+
390
+ def delegation_settings(env: Mapping[str, str] | None = None) -> DelegationSettings:
391
+ """The delegation settings, validated; `SettingsError` names a bad one (a startup error)."""
392
+ env = os.environ if env is None else env
393
+ raw_depth = (env.get("AUTH_MAX_DELEGATION_DEPTH") or "").strip()
394
+ depth = DEFAULT_MAX_DELEGATION_DEPTH
395
+ if raw_depth:
396
+ try:
397
+ depth = int(raw_depth)
398
+ except ValueError:
399
+ raise SettingsError(
400
+ f"AUTH_MAX_DELEGATION_DEPTH={raw_depth!r} is not a whole number."
401
+ ) from None
402
+ if not MIN_DELEGATION_DEPTH <= depth <= MAX_DELEGATION_DEPTH:
403
+ raise SettingsError(
404
+ f"AUTH_MAX_DELEGATION_DEPTH={depth} must be from {MIN_DELEGATION_DEPTH} to "
405
+ f"{MAX_DELEGATION_DEPTH}."
406
+ )
407
+ actors = _csv(env.get("AUTH_ALLOWED_ACTORS"))
408
+ for actor in actors:
409
+ if actor != ANY_ACTOR and (not _valid_id(actor) or any(c.isspace() for c in actor)):
410
+ raise SettingsError(
411
+ f"AUTH_ALLOWED_ACTORS: {actor[:40]!r} is not an agent id (1-256 characters, no "
412
+ "whitespace or control characters), or * for any agent."
413
+ )
414
+ roles = _csv(env.get("AUTH_DELEGATED_ROLES"))
415
+ for role in roles:
416
+ if not _valid_id(role):
417
+ raise SettingsError(f"AUTH_DELEGATED_ROLES: {role[:40]!r} is not a role name.")
418
+ return DelegationSettings(
419
+ max_depth=depth,
420
+ allowed_actors=frozenset(actors) or DEFAULT_ALLOWED_ACTORS,
421
+ delegated_roles=frozenset(roles),
422
+ )
423
+
424
+
425
+ # How `api_client.require_user_mentioned` treats a request an agent presents for the user
426
+ # (`A2A_DELEGATED_MENTIONS`): `origin` (default) needs the id in the user's own words the
427
+ # calling agent forwarded and in its request; `refuse` always refuses; `request` counts the
428
+ # agent's request as the user's words (the 0.2 behaviour, an explicit opt-out).
429
+ MENTIONS_ORIGIN = "origin"
430
+ MENTIONS_REFUSE = "refuse"
431
+ MENTIONS_REQUEST = "request"
432
+ DELEGATED_MENTIONS_MODES = (MENTIONS_ORIGIN, MENTIONS_REFUSE, MENTIONS_REQUEST)
433
+ DEFAULT_DELEGATED_MENTIONS = MENTIONS_ORIGIN
434
+ # `A2A_CALLER_NOTE`: whether the model is told, in one system note, that an agent wrote
435
+ # the request (`on`, default) or not (`off`; the request stays fenced either way).
436
+ CALLER_NOTE_VALUES = ("on", "off")
437
+
438
+
439
+ def delegated_mentions(env: Mapping[str, str] | None = None) -> str:
440
+ """`A2A_DELEGATED_MENTIONS` (`origin`, `refuse` or `request`); `SettingsError` otherwise."""
441
+ env = os.environ if env is None else env
442
+ value = (env.get("A2A_DELEGATED_MENTIONS") or DEFAULT_DELEGATED_MENTIONS).strip().lower()
443
+ if value not in DELEGATED_MENTIONS_MODES:
444
+ raise SettingsError(
445
+ f"A2A_DELEGATED_MENTIONS={value!r} must be one of {', '.join(DELEGATED_MENTIONS_MODES)}."
446
+ )
447
+ return value
448
+
449
+
450
+ def caller_note_enabled(env: Mapping[str, str] | None = None) -> bool:
451
+ """`A2A_CALLER_NOTE` (`on`, the default, or `off`); `SettingsError` otherwise."""
452
+ env = os.environ if env is None else env
453
+ value = (env.get("A2A_CALLER_NOTE") or "on").strip().lower()
454
+ if value not in CALLER_NOTE_VALUES:
455
+ raise SettingsError(f"A2A_CALLER_NOTE={value!r} must be 'on' or 'off'.")
456
+ return value == "on"
457
+
458
+
459
+ def _principal_problem(principal: Principal) -> str | None:
460
+ """Which id of a principal is invalid (`principal` or `actor`), or None."""
461
+ if not _valid_id(principal.id):
462
+ return "principal"
463
+ actor = principal.actor
464
+ if actor is None:
465
+ return None
466
+ if not _valid_id(actor.id) or not isinstance(actor.chain, tuple):
467
+ return "actor"
468
+ if actor.chain and (actor.chain[0] != actor.id or not all(_valid_id(a) for a in actor.chain)):
469
+ return "actor"
470
+ if actor.client is not None and not _valid_id(actor.client):
471
+ return "actor"
472
+ return None
473
+
474
+
475
+ def finalize_principal(principal: Principal) -> Principal:
476
+ """Apply the delegation rules to what a policy's `authenticate` returned (every policy).
477
+
478
+ In this order: the ids are valid (1-256 characters, no control
479
+ characters; a `jwt` token that breaks this is refused with 401, a custom
480
+ policy's principal with 500, logged as a bug in the policy); a delegated
481
+ principal's actor chain is at most `AUTH_MAX_DELEGATION_DEPTH` long (401)
482
+ and its actor is listed in `AUTH_ALLOWED_ACTORS` (403); it keeps only the
483
+ roles `AUTH_DELEGATED_ROLES` lends to agents; and its actor is published
484
+ in `attributes["@actor"]`. A direct principal is returned as it came,
485
+ without any `@actor` a policy put there (only this function sets it).
486
+ """
487
+ try:
488
+ settings = delegation_settings()
489
+ except SettingsError as exc:
490
+ # The startup check refuses to start with this; fail closed if it is reached anyway.
491
+ logger.error("delegation settings are invalid: %s", exc)
492
+ raise HTTPException(
493
+ status_code=503, detail="The server's delegation settings are invalid."
494
+ ) from None
495
+ problem = _principal_problem(principal)
496
+ if problem is not None:
497
+ name = policy_name()
498
+ if name == JWT:
499
+ raise _invalid_token(f"invalid {problem} claim")
500
+ logger.error(
501
+ "AUTH_POLICY=%s returned a principal with an invalid %s id (1-%d characters, no "
502
+ "control characters): a bug in the policy; the request is refused",
503
+ name,
504
+ problem,
505
+ PRINCIPAL_ID_MAX_CHARS,
506
+ )
507
+ raise HTTPException(status_code=500, detail="The auth policy returned an invalid caller.")
508
+ attributes = {k: v for k, v in principal.attributes.items() if k != ACTOR_ATTRIBUTE}
509
+ actor = principal.actor
510
+ if actor is None:
511
+ if ACTOR_ATTRIBUTE not in principal.attributes:
512
+ return principal
513
+ return Principal(
514
+ id=principal.id,
515
+ roles=list(principal.roles),
516
+ permissions=set(principal.permissions),
517
+ attributes=attributes,
518
+ )
519
+ chain = actor.chain or (actor.id,)
520
+ if len(chain) > settings.max_depth:
521
+ raise _invalid_token("delegation too deep")
522
+ if not settings.allows(actor.id):
523
+ raise HTTPException(
524
+ status_code=403,
525
+ detail=f"Delegated caller {actor.id} is not allowed here (AUTH_ALLOWED_ACTORS).",
526
+ )
527
+ actor = Actor(id=actor.id, chain=chain, client=actor.client)
528
+ attributes[ACTOR_ATTRIBUTE] = actor.public()
529
+ return Principal(
530
+ id=principal.id,
531
+ roles=[r for r in principal.roles if r in settings.delegated_roles],
532
+ permissions=set(principal.permissions),
533
+ attributes=attributes,
534
+ actor=actor,
535
+ )
536
+
537
+
538
+ def actor_of_attributes(attributes: Any) -> Actor | None:
539
+ """The actor a principal's (public) attributes carry under `@actor`, or None (direct)."""
540
+ if not isinstance(attributes, Mapping):
541
+ return None
542
+ return Actor.from_public(attributes.get(ACTOR_ATTRIBUTE))
543
+
544
+
545
+ # The keys of a graph run's context that say who is calling (`agent.AgentContext`).
546
+ RUN_CONTEXT_KEYS = ("principal_id", "roles", "attributes")
547
+
548
+
549
+ def run_context_of(principal: Principal) -> dict[str, Any]:
550
+ """The run context LangGraph Server runs the graph with for `principal`: who tools act for.
551
+
552
+ Its id, roles and public attributes (`@actor` included; never `credentials`,
553
+ since the server persists run context). `/chat` and A2A send it with each
554
+ run under `langgraph-server`, and the server auth handler puts it on every
555
+ run the native API starts, whatever that request sent.
556
+ """
557
+ return {
558
+ "principal_id": principal.id,
559
+ "roles": list(principal.roles),
560
+ "attributes": principal.public_attributes(),
561
+ }
562
+
563
+
564
+ @runtime_checkable
565
+ class AuthPolicy(Protocol):
566
+ async def authenticate(self, request: Request) -> Principal:
567
+ """Return the caller or raise `HTTPException(401)`."""
568
+ ...
569
+
570
+ async def authorize(self, principal: Principal, action: str, resource: str | None) -> None:
571
+ """Allow `action` on `resource` (a thread id or None) or raise `HTTPException(403)`."""
572
+ ...
573
+
574
+
575
+ class SharedBearerPolicy:
576
+ """`Authorization: Bearer <API_KEY>`; one anonymous principal; every action allowed.
577
+
578
+ Suitable for internal tools and development. Conversation ownership is not
579
+ enforced because every caller is the same principal.
580
+ """
581
+
582
+ principal_id = "shared"
583
+
584
+ def __init__(self, api_key: str | None = None) -> None:
585
+ self._api_key = api_key
586
+
587
+ def expected_key(self) -> str:
588
+ return self._api_key if self._api_key is not None else os.environ.get("API_KEY", "")
589
+
590
+ async def authenticate(self, request: Request) -> Principal:
591
+ expected = self.expected_key()
592
+ if not expected:
593
+ # Fail closed: an unset key must never mean "no auth".
594
+ raise HTTPException(
595
+ status_code=503,
596
+ detail="API_KEY is not configured on the server (AUTH_POLICY=shared-bearer).",
597
+ )
598
+ header = request.headers.get("authorization", "")
599
+ scheme, _, token = header.partition(" ")
600
+ token = token.strip()
601
+ if (
602
+ scheme.lower() != "bearer"
603
+ or not token
604
+ or not hmac.compare_digest(token.encode("utf-8"), expected.encode("utf-8"))
605
+ ):
606
+ raise HTTPException(
607
+ status_code=401,
608
+ detail="Missing or invalid bearer token.",
609
+ headers={"WWW-Authenticate": "Bearer"},
610
+ )
611
+ return Principal(id=self.principal_id, roles=["shared"], permissions=set(ACTIONS))
612
+
613
+ async def authorize(self, principal: Principal, action: str, resource: str | None) -> None:
614
+ if action not in ACTIONS:
615
+ raise HTTPException(status_code=403, detail=f"Unknown action {action!r}.")
616
+
617
+
618
+ # ---------------------------------------------------------------------------
619
+ # JwtPolicy: per-user principals from a verified OIDC/JWT bearer token
620
+ # ---------------------------------------------------------------------------
621
+
622
+ # Asymmetric algorithms and the key they need: (JWK kty, curve or None).
623
+ ASYMMETRIC_ALGORITHMS: dict[str, tuple[str, str | None]] = {
624
+ "RS256": ("RSA", None),
625
+ "RS384": ("RSA", None),
626
+ "RS512": ("RSA", None),
627
+ "PS256": ("RSA", None),
628
+ "PS384": ("RSA", None),
629
+ "PS512": ("RSA", None),
630
+ "ES256": ("EC", "P-256"),
631
+ "ES384": ("EC", "P-384"),
632
+ "ES512": ("EC", "P-521"),
633
+ "EdDSA": ("OKP", None), # Ed25519 or Ed448
634
+ }
635
+ # Shared-secret algorithms: only with AUTH_JWT_ALLOW_HS=true and AUTH_JWT_SECRET.
636
+ HMAC_ALGORITHMS = ("HS256", "HS384", "HS512")
637
+ DEFAULT_JWT_ALGORITHMS = "RS256,ES256"
638
+ DEFAULT_LEEWAY_S = 60
639
+ DEFAULT_JWKS_CACHE_S = 300
640
+ # A token longer than this is refused before it is parsed.
641
+ JWT_MAX_TOKEN_CHARS = 16_384
642
+ # JWKS fetch: time limit, size limit, and how often an unknown key id (or a
643
+ # failed fetch) may trigger another request to the issuer.
644
+ JWKS_TIMEOUT_S = 5.0
645
+ JWKS_MAX_BYTES = 1_048_576
646
+ JWKS_REFETCH_INTERVAL_S = 30.0
647
+ # When a refresh fails, the last good key set stays usable this much longer
648
+ # than AUTH_JWT_JWKS_CACHE_S; after that requests get 503 until the issuer answers.
649
+ JWKS_STALE_GRACE_S = 3600.0
650
+ HMAC_MIN_SECRET_BYTES = 32
651
+ # Delegated callers (0.3). The jwt claim that names the agent acting for the user
652
+ # (`AUTH_JWT_ACTOR_CLAIM`, RFC 8693 `act`; set it empty to read every token as the
653
+ # user's own, as 0.2 does), and the agents that may act (`AUTH_ALLOWED_ACTORS`, a comma
654
+ # list): none by default, so a delegated caller is refused until it is listed.
655
+ DEFAULT_JWT_ACTOR_CLAIM = "act"
656
+ DEFAULT_ALLOWED_ACTORS: frozenset[str] = frozenset()
657
+ PRINCIPAL_ID_MAX_CHARS = 256
658
+ MAX_ROLES = 256
659
+ _CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
660
+ _LOOPBACK_HOSTS = ("localhost", "127.0.0.1", "::1")
661
+
662
+
663
+ def _canonical_algorithm(name: str) -> str | None:
664
+ """`rs256` -> `RS256`, `eddsa` -> `EdDSA`; None for anything not supported."""
665
+ for known in (*ASYMMETRIC_ALGORITHMS, *HMAC_ALGORITHMS):
666
+ if name.lower() == known.lower():
667
+ return known
668
+ return None
669
+
670
+
671
+ def _key_matches_algorithm(algorithm: str, kty: Any, crv: Any = None, key_alg: Any = None) -> bool:
672
+ """True when a key of type `kty`/`crv` (pinned to `key_alg`, if any) may verify `algorithm`.
673
+
674
+ Keeps the algorithm families apart (an RSA key never verifies ES256, an
675
+ EC P-384 key never verifies ES256) and honours a JWK's own `alg`.
676
+ """
677
+ spec = ASYMMETRIC_ALGORITHMS.get(algorithm)
678
+ if spec is None:
679
+ return False
680
+ if key_alg is not None and key_alg != algorithm:
681
+ return False
682
+ want_kty, want_crv = spec
683
+ if kty != want_kty:
684
+ return False
685
+ if want_kty == "EC":
686
+ return crv == want_crv
687
+ if want_kty == "OKP":
688
+ return crv in ("Ed25519", "Ed448")
689
+ return True
690
+
691
+
692
+ def _describe_public_key(key: Any) -> tuple[str, str | None]:
693
+ """(kty, crv) of a `cryptography` public key, in JWK terms."""
694
+ from cryptography.hazmat.primitives.asymmetric import ec, ed448, ed25519, rsa
695
+
696
+ if isinstance(key, rsa.RSAPublicKey):
697
+ return "RSA", None
698
+ if isinstance(key, ec.EllipticCurvePublicKey):
699
+ curves = {"secp256r1": "P-256", "secp384r1": "P-384", "secp521r1": "P-521"}
700
+ return "EC", curves.get(key.curve.name, key.curve.name)
701
+ if isinstance(key, ed25519.Ed25519PublicKey):
702
+ return "OKP", "Ed25519"
703
+ if isinstance(key, ed448.Ed448PublicKey):
704
+ return "OKP", "Ed448"
705
+ return type(key).__name__, None
706
+
707
+
708
+ def _load_public_key(pem: str) -> Any:
709
+ """A PEM public key or certificate -> a `cryptography` public key. Private keys are refused."""
710
+ from cryptography import x509
711
+ from cryptography.hazmat.primitives import serialization
712
+
713
+ data = pem.strip().replace("\\n", "\n").encode("utf-8")
714
+ if b"PRIVATE KEY" in data:
715
+ raise ValueError("a private key was given; set the issuer's public key")
716
+ try:
717
+ return serialization.load_pem_public_key(data)
718
+ except ValueError:
719
+ return x509.load_pem_x509_certificate(data).public_key()
720
+
721
+
722
+ def _int_setting(
723
+ env: Mapping[str, str], name: str, default: int, low: int, high: int, problems: list[str]
724
+ ) -> int:
725
+ raw = (env.get(name) or "").strip()
726
+ if not raw:
727
+ return default
728
+ try:
729
+ value = int(raw)
730
+ except ValueError:
731
+ problems.append(f"{name} must be a whole number of seconds, got {raw!r}")
732
+ return default
733
+ if not low <= value <= high:
734
+ problems.append(f"{name} must be between {low} and {high}, got {value}")
735
+ return default
736
+ return value
737
+
738
+
739
+ @dataclass
740
+ class JwtSettings:
741
+ """The `AUTH_JWT_*` settings, validated. `problems` lists what makes them unusable."""
742
+
743
+ algorithms: tuple[str, ...] = ()
744
+ jwks_url: str | None = None
745
+ public_key: Any = None
746
+ public_key_type: tuple[str, str | None] = ("", None)
747
+ secret: bytes | None = None
748
+ issuer: str | None = None
749
+ audience: tuple[str, ...] = ()
750
+ principal_claim: str = "sub"
751
+ roles_claim: str = "roles"
752
+ # Delegation (RFC 8693): the actor claim ("" = not read, the 0.2 behaviour), the
753
+ # client claim, and the clients of human sign-in (empty = every token without an
754
+ # actor claim is direct).
755
+ actor_claim: str = DEFAULT_JWT_ACTOR_CLAIM
756
+ client_claim: str = DEFAULT_JWT_CLIENT_CLAIM
757
+ direct_clients: frozenset[str] = frozenset()
758
+ leeway_s: int = DEFAULT_LEEWAY_S
759
+ jwks_cache_s: int = DEFAULT_JWKS_CACHE_S
760
+ problems: list[str] = field(default_factory=list)
761
+ warnings: list[str] = field(default_factory=list)
762
+
763
+ @classmethod
764
+ def from_env(cls, env: Mapping[str, str] | None = None) -> JwtSettings:
765
+ env = os.environ if env is None else env
766
+ dev = env.get("APP_ENV") == "dev" # exactly, as dev_mode()
767
+ s = cls()
768
+ problems, warnings = s.problems, s.warnings
769
+
770
+ # Algorithms: an explicit allow-list; `none` never, HS* only when opted in.
771
+ algorithms: list[str] = []
772
+ for name in _csv(env.get("AUTH_JWT_ALGORITHMS") or DEFAULT_JWT_ALGORITHMS):
773
+ canonical = _canonical_algorithm(name)
774
+ if name.lower() == "none":
775
+ problems.append("AUTH_JWT_ALGORITHMS must never include 'none'")
776
+ elif canonical is None:
777
+ problems.append(f"AUTH_JWT_ALGORITHMS: unsupported algorithm {name!r}")
778
+ elif canonical not in algorithms:
779
+ algorithms.append(canonical)
780
+ if not algorithms and not problems:
781
+ problems.append("AUTH_JWT_ALGORITHMS lists no algorithm")
782
+ s.algorithms = tuple(algorithms)
783
+
784
+ hmac_algs = [a for a in algorithms if a in HMAC_ALGORITHMS]
785
+ if hmac_algs:
786
+ secret = env.get("AUTH_JWT_SECRET") or ""
787
+ if (env.get("AUTH_JWT_ALLOW_HS") or "").strip().lower() not in _TRUE:
788
+ problems.append(
789
+ f"AUTH_JWT_ALGORITHMS lists {', '.join(hmac_algs)}: shared-secret "
790
+ "algorithms need AUTH_JWT_ALLOW_HS=true and AUTH_JWT_SECRET"
791
+ )
792
+ elif not secret:
793
+ problems.append("AUTH_JWT_ALLOW_HS=true needs AUTH_JWT_SECRET")
794
+ elif "-----BEGIN" in secret:
795
+ problems.append("AUTH_JWT_SECRET must be a shared secret, not a PEM key")
796
+ elif len(secret.encode("utf-8")) < HMAC_MIN_SECRET_BYTES:
797
+ problems.append(
798
+ f"AUTH_JWT_SECRET must be at least {HMAC_MIN_SECRET_BYTES} bytes long"
799
+ )
800
+ else:
801
+ s.secret = secret.encode("utf-8")
802
+
803
+ # Keys for the asymmetric algorithms: a JWKS URL or one PEM public key.
804
+ asymmetric = [a for a in algorithms if a in ASYMMETRIC_ALGORITHMS]
805
+ jwks_url = (env.get("AUTH_JWT_JWKS_URL") or "").strip()
806
+ pem = (env.get("AUTH_JWT_PUBLIC_KEY") or "").strip()
807
+ if asymmetric:
808
+ if jwks_url and pem:
809
+ problems.append("set AUTH_JWT_JWKS_URL or AUTH_JWT_PUBLIC_KEY, not both")
810
+ elif not jwks_url and not pem:
811
+ problems.append(
812
+ "no verification key: set AUTH_JWT_JWKS_URL (the issuer's JWKS) "
813
+ "or AUTH_JWT_PUBLIC_KEY (a PEM public key)"
814
+ )
815
+ elif jwks_url:
816
+ parts = urlsplit(jwks_url)
817
+ host = (parts.hostname or "").lower()
818
+ insecure_ok = (
819
+ dev
820
+ or host in _LOOPBACK_HOSTS
821
+ or (env.get("AUTH_JWT_JWKS_ALLOW_HTTP") or "").strip().lower() in _TRUE
822
+ )
823
+ if parts.scheme not in ("https", "http") or not host:
824
+ problems.append("AUTH_JWT_JWKS_URL must be an https:// URL")
825
+ elif parts.username or parts.password:
826
+ problems.append("AUTH_JWT_JWKS_URL must not carry credentials")
827
+ elif parts.scheme == "http" and not insecure_ok:
828
+ problems.append(
829
+ "AUTH_JWT_JWKS_URL must use https outside APP_ENV=dev (keys fetched "
830
+ "over plain http can be replaced in transit); set "
831
+ "AUTH_JWT_JWKS_ALLOW_HTTP=true only for a trusted in-cluster issuer"
832
+ )
833
+ else:
834
+ s.jwks_url = jwks_url
835
+ else:
836
+ try:
837
+ key = _load_public_key(pem)
838
+ except Exception as exc: # never echo the key material
839
+ reason = (
840
+ str(exc) if isinstance(exc, ValueError) and "private" in str(exc) else ""
841
+ )
842
+ problems.append(
843
+ "AUTH_JWT_PUBLIC_KEY is not a PEM public key or certificate"
844
+ + (f" ({reason})" if reason else "")
845
+ )
846
+ else:
847
+ kty, crv = _describe_public_key(key)
848
+ if not any(_key_matches_algorithm(a, kty, crv) for a in asymmetric):
849
+ problems.append(
850
+ f"AUTH_JWT_PUBLIC_KEY is a {kty}{' ' + crv if crv else ''} key, "
851
+ f"which verifies none of AUTH_JWT_ALGORITHMS ({', '.join(asymmetric)})"
852
+ )
853
+ else:
854
+ s.public_key, s.public_key_type = key, (kty, crv)
855
+
856
+ # Issuer and audience: required outside dev.
857
+ s.issuer = (env.get("AUTH_JWT_ISSUER") or "").strip() or None
858
+ s.audience = tuple(_csv(env.get("AUTH_JWT_AUDIENCE")))
859
+ for name, value in (("AUTH_JWT_ISSUER", s.issuer), ("AUTH_JWT_AUDIENCE", s.audience)):
860
+ if value:
861
+ continue
862
+ if dev:
863
+ warnings.append(
864
+ f"{name} is not set: it is not checked (allowed under APP_ENV=dev only)"
865
+ )
866
+ else:
867
+ problems.append(f"{name} is required outside APP_ENV=dev")
868
+
869
+ s.principal_claim = (env.get("AUTH_JWT_PRINCIPAL_CLAIM") or "sub").strip()
870
+ s.roles_claim = (env.get("AUTH_JWT_ROLES_CLAIM") or "roles").strip()
871
+ for name, claim in (
872
+ ("AUTH_JWT_PRINCIPAL_CLAIM", s.principal_claim),
873
+ ("AUTH_JWT_ROLES_CLAIM", s.roles_claim),
874
+ ):
875
+ if not claim or any(not part for part in claim.split(".")):
876
+ problems.append(f"{name} must be a claim name or a dotted path, got {claim!r}")
877
+
878
+ # AUTH_JWT_ACTOR_CLAIM set to nothing turns delegation off (0.2); unset is `act`.
879
+ raw_actor = env.get("AUTH_JWT_ACTOR_CLAIM")
880
+ s.actor_claim = DEFAULT_JWT_ACTOR_CLAIM if raw_actor is None else raw_actor.strip()
881
+ s.client_claim = (env.get("AUTH_JWT_CLIENT_CLAIM") or DEFAULT_JWT_CLIENT_CLAIM).strip()
882
+ for name, claim in (
883
+ ("AUTH_JWT_ACTOR_CLAIM", s.actor_claim),
884
+ ("AUTH_JWT_CLIENT_CLAIM", s.client_claim),
885
+ ):
886
+ if claim and any(not part for part in claim.split(".")):
887
+ problems.append(f"{name} must be a claim name or a dotted path, got {claim!r}")
888
+ if not s.client_claim:
889
+ problems.append("AUTH_JWT_CLIENT_CLAIM must be a claim name or a dotted path")
890
+ s.direct_clients = frozenset(_csv(env.get("AUTH_JWT_DIRECT_CLIENTS")))
891
+
892
+ s.leeway_s = _int_setting(env, "AUTH_JWT_LEEWAY_S", DEFAULT_LEEWAY_S, 0, 600, problems)
893
+ s.jwks_cache_s = _int_setting(
894
+ env, "AUTH_JWT_JWKS_CACHE_S", DEFAULT_JWKS_CACHE_S, 1, 86_400, problems
895
+ )
896
+ return s
897
+
898
+
899
+ class JwksUnavailable(Exception):
900
+ """No usable key set: the JWKS URL did not answer and no recent keys are cached."""
901
+
902
+
903
+ def _usable_signing_jwk(jwk: Any) -> bool:
904
+ if not isinstance(jwk, dict) or jwk.get("kty") not in ("RSA", "EC", "OKP"):
905
+ return False # symmetric (`oct`) keys from a JWKS are never trusted
906
+ if jwk.get("use") not in (None, "sig"):
907
+ return False
908
+ ops = jwk.get("key_ops")
909
+ return ops is None or (isinstance(ops, list) and "verify" in ops)
910
+
911
+
912
+ class JwksCache:
913
+ """The issuer's signing keys, fetched from a JWKS URL and cached.
914
+
915
+ Keys are refetched when the cache (`ttl_s`) expires and when a token
916
+ names a key id the cache does not know (key rotation).
917
+
918
+ * Single flight: at most one fetch runs at a time; every request that
919
+ needs it waits for that fetch and shares its result (a rotated key is
920
+ fetched once however many requests carry it).
921
+ * Stale while revalidate: once the cache has expired, requests keep
922
+ verifying with the cached keys while a background fetch refreshes them,
923
+ so a slow or hanging issuer never stalls a request the cached keys can
924
+ verify. A failed refresh keeps the last good keys for `stale_grace_s`
925
+ more; after that requests wait for a fetch and get 503 when it fails.
926
+ * Bounded: fetch attempts, failed ones included, are at most one per
927
+ `refetch_interval_s` (a flood of tokens with random key ids cannot flood
928
+ the issuer), and each has a total deadline of twice `timeout_s`.
929
+ """
930
+
931
+ def __init__(
932
+ self,
933
+ url: str,
934
+ ttl_s: float,
935
+ *,
936
+ timeout_s: float = JWKS_TIMEOUT_S,
937
+ refetch_interval_s: float = JWKS_REFETCH_INTERVAL_S,
938
+ stale_grace_s: float = JWKS_STALE_GRACE_S,
939
+ clock: Callable[[], float] = time.monotonic,
940
+ ) -> None:
941
+ self.url = url
942
+ self.ttl_s = ttl_s
943
+ self.timeout_s = timeout_s
944
+ self.refetch_interval_s = min(refetch_interval_s, ttl_s)
945
+ self.stale_grace_s = stale_grace_s
946
+ self._clock = clock
947
+ self._keys: list[dict[str, Any]] = []
948
+ self._fetched_at: float | None = None
949
+ self._last_attempt: float | None = None
950
+ self._task: asyncio.Task[bool] | None = None
951
+
952
+ def _age(self, now: float) -> float | None:
953
+ return None if self._fetched_at is None else now - self._fetched_at
954
+
955
+ def _fresh(self, now: float) -> bool:
956
+ age = self._age(now)
957
+ return age is not None and age < self.ttl_s
958
+
959
+ def _usable(self, now: float) -> bool:
960
+ age = self._age(now)
961
+ return age is not None and age < self.ttl_s + self.stale_grace_s
962
+
963
+ def _may_attempt(self, now: float) -> bool:
964
+ return self._last_attempt is None or now - self._last_attempt >= self.refetch_interval_s
965
+
966
+ def _in_flight(self) -> asyncio.Task[bool] | None:
967
+ """The fetch running on this event loop, if any."""
968
+ task = self._task
969
+ if task is None or task.done() or task.get_loop() is not asyncio.get_running_loop():
970
+ return None
971
+ return task
972
+
973
+ def _start_fetch(self) -> asyncio.Task[bool]:
974
+ # No await between the callers' checks and this: one fetch per loop.
975
+ previous_attempt, self._last_attempt = self._last_attempt, self._clock()
976
+ self._task = asyncio.get_running_loop().create_task(self._refresh(previous_attempt))
977
+ return self._task
978
+
979
+ @staticmethod
980
+ async def _join(task: asyncio.Task[bool]) -> bool:
981
+ # Shielded: a request that goes away (client disconnect) does not
982
+ # cancel the fetch other requests are waiting for.
983
+ return await asyncio.shield(task)
984
+
985
+ async def keys(self) -> list[dict[str, Any]]:
986
+ """The current key set; raises `JwksUnavailable` when there is none to use."""
987
+ now = self._clock()
988
+ if self._fresh(now):
989
+ return self._keys
990
+ task = self._in_flight()
991
+ if task is None and self._may_attempt(now):
992
+ task = self._start_fetch()
993
+ if self._usable(now):
994
+ return self._keys # the refresh (if any) completes in the background
995
+ if task is not None:
996
+ await self._join(task)
997
+ if self._usable(self._clock()):
998
+ return self._keys
999
+ raise JwksUnavailable(self.url)
1000
+
1001
+ async def refresh_for_unknown_kid(self, kid: str | None = None) -> bool:
1002
+ """Refetch because a token named an unknown key id `kid`.
1003
+
1004
+ True when the key set is worth looking at again: another request's
1005
+ fetch already brought `kid`, or the fetch this call started or joined
1006
+ succeeded. False when rate-limited or the fetch failed.
1007
+ """
1008
+ if kid is not None and any(k.get("kid") == kid for k in self._keys):
1009
+ return True
1010
+ task = self._in_flight()
1011
+ if task is None:
1012
+ if not self._may_attempt(self._clock()):
1013
+ return False
1014
+ task = self._start_fetch()
1015
+ return await self._join(task)
1016
+
1017
+ async def idle(self) -> None:
1018
+ """Wait until no fetch is running (for tests and orderly shutdown)."""
1019
+ task = self._in_flight()
1020
+ if task is not None:
1021
+ await asyncio.gather(task, return_exceptions=True)
1022
+
1023
+ async def _refresh(self, previous_attempt: float | None) -> bool:
1024
+ try:
1025
+ # A total deadline too: the per-read timeout alone lets a server that
1026
+ # trickles bytes keep a fetch (and the requests waiting on it) going.
1027
+ keys = await asyncio.wait_for(self._fetch(), timeout=2 * self.timeout_s)
1028
+ except asyncio.CancelledError:
1029
+ # Stopped from outside (the event loop is shutting down), not the
1030
+ # issuer's fault: the next request may try again at once.
1031
+ self._last_attempt = previous_attempt
1032
+ raise
1033
+ except Exception as exc:
1034
+ logger.warning(
1035
+ "jwt: could not fetch the JWKS from %s (%s); %s",
1036
+ self.url,
1037
+ type(exc).__name__,
1038
+ "keeping the previous keys for now"
1039
+ if self._usable(self._clock())
1040
+ else "no signing keys are available",
1041
+ )
1042
+ return False
1043
+ self._keys, self._fetched_at = keys, self._clock()
1044
+ return True
1045
+
1046
+ async def _fetch(self) -> list[dict[str, Any]]:
1047
+ import httpx
1048
+
1049
+ body = bytearray()
1050
+ async with httpx.AsyncClient(timeout=self.timeout_s, follow_redirects=False) as client:
1051
+ async with client.stream(
1052
+ "GET", self.url, headers={"Accept": "application/json"}
1053
+ ) as response:
1054
+ if response.status_code != 200:
1055
+ raise ValueError(f"HTTP {response.status_code}")
1056
+ async for chunk in response.aiter_bytes():
1057
+ body += chunk
1058
+ if len(body) > JWKS_MAX_BYTES:
1059
+ raise ValueError("JWKS response too large")
1060
+ data = json.loads(bytes(body))
1061
+ if not isinstance(data, dict) or not isinstance(data.get("keys"), list):
1062
+ raise ValueError("not a JWK set")
1063
+ keys = [k for k in data["keys"] if _usable_signing_jwk(k)]
1064
+ if not keys:
1065
+ raise ValueError("the JWK set has no usable signing keys")
1066
+ return keys
1067
+
1068
+
1069
+ def _claim(claims: Mapping[str, Any], path: str) -> Any:
1070
+ """A claim by name, or by dotted path (`realm_access.roles`); None when absent.
1071
+
1072
+ A top-level claim whose name itself contains dots (for example a
1073
+ namespaced `https://example.com/roles`) wins over the dotted reading.
1074
+ """
1075
+ if path in claims:
1076
+ return claims[path]
1077
+ current: Any = claims
1078
+ for part in path.split("."):
1079
+ if not isinstance(current, Mapping) or part not in current:
1080
+ return None
1081
+ current = current[part]
1082
+ return current
1083
+
1084
+
1085
+ _ABSENT = object()
1086
+
1087
+
1088
+ def _claim_or_absent(claims: Mapping[str, Any], path: str) -> Any:
1089
+ """As `_claim`, but `_ABSENT` for a claim the token does not have (a null one is present)."""
1090
+ if path in claims:
1091
+ return claims[path]
1092
+ current: Any = claims
1093
+ for part in path.split("."):
1094
+ if not isinstance(current, Mapping) or part not in current:
1095
+ return _ABSENT
1096
+ current = current[part]
1097
+ return current
1098
+
1099
+
1100
+ def _actor_chain(value: Any) -> tuple[str, ...]:
1101
+ """The actors of an RFC 8693 `act` claim, current first; 401 for a malformed one.
1102
+
1103
+ Each level is a mapping with a string `sub`, and may nest the earlier actor
1104
+ under `act`. A chain longer than `MAX_DELEGATION_DEPTH` is refused while it
1105
+ is walked.
1106
+ """
1107
+ chain: list[str] = []
1108
+ node = value
1109
+ while True:
1110
+ if not isinstance(node, Mapping):
1111
+ raise _invalid_token("invalid actor claim")
1112
+ sub = node.get("sub")
1113
+ if not _valid_id(sub):
1114
+ raise _invalid_token("invalid actor claim")
1115
+ chain.append(sub)
1116
+ if len(chain) > MAX_DELEGATION_DEPTH:
1117
+ raise _invalid_token("invalid actor claim")
1118
+ if NESTED_ACTOR_CLAIM not in node:
1119
+ return tuple(chain)
1120
+ node = node[NESTED_ACTOR_CLAIM]
1121
+
1122
+
1123
+ def actor_from_claims(
1124
+ claims: Mapping[str, Any],
1125
+ *,
1126
+ actor_claim: str = DEFAULT_JWT_ACTOR_CLAIM,
1127
+ client_claim: str = DEFAULT_JWT_CLIENT_CLAIM,
1128
+ direct_clients: frozenset[str] | set[str] = frozenset(),
1129
+ subject: str | None = None,
1130
+ ) -> Actor | None:
1131
+ """The actor of a verified token's claims (None: a direct request), as `jwt` reads it.
1132
+
1133
+ * `actor_claim` present (RFC 8693 `act`, default): delegated. It must be a
1134
+ mapping with a string `sub`, each nested `act` likewise; anything else
1135
+ is refused (401 `invalid actor claim`), never read as a direct request.
1136
+ * absent: direct, unless `direct_clients` is set and the token's client
1137
+ (`client_claim`, default `azp`, else `client_id`) is not in it: then an
1138
+ agent presents it, as `client:<client>` (`client:?` without one). A
1139
+ token whose `subject` is its own client (a service's own token) is direct.
1140
+ * `actor_claim` empty: direct (the 0.2 reading).
1141
+
1142
+ `may_act`, `scope` and other claims are not read. For custom policies that
1143
+ verify tokens themselves; `finalize_principal` checks the rest.
1144
+ """
1145
+ raw_client = _claim(claims, client_claim) if client_claim else None
1146
+ if raw_client is None:
1147
+ raw_client = claims.get(FALLBACK_JWT_CLIENT_CLAIM)
1148
+ client = raw_client if _valid_id(raw_client) else None
1149
+ if actor_claim:
1150
+ value = _claim_or_absent(claims, actor_claim)
1151
+ if value is not _ABSENT:
1152
+ chain = _actor_chain(value)
1153
+ return Actor(id=chain[0], chain=chain, client=client)
1154
+ if not direct_clients or client in direct_clients:
1155
+ return None
1156
+ if subject is not None and client is not None and subject == client:
1157
+ return None
1158
+ actor_id = f"{CLIENT_ACTOR_PREFIX}{client or UNKNOWN_CLIENT}"
1159
+ return Actor(id=actor_id, chain=(actor_id,), client=client)
1160
+
1161
+
1162
+ def _roles_from_claim(value: Any) -> list[str]:
1163
+ """A list of strings, or one space/comma separated string -> distinct role names."""
1164
+ if isinstance(value, str):
1165
+ candidates: list[Any] = re.split(r"[\s,]+", value)
1166
+ elif isinstance(value, list | tuple):
1167
+ candidates = list(value)
1168
+ else:
1169
+ return []
1170
+ roles: list[str] = []
1171
+ for item in candidates:
1172
+ if not isinstance(item, str):
1173
+ continue
1174
+ role = item.strip()
1175
+ if (
1176
+ role
1177
+ and role not in roles
1178
+ and len(role) <= PRINCIPAL_ID_MAX_CHARS
1179
+ and "," not in role
1180
+ and not _CONTROL_CHARS.search(role)
1181
+ ):
1182
+ roles.append(role)
1183
+ if len(roles) >= MAX_ROLES:
1184
+ break
1185
+ return roles
1186
+
1187
+
1188
+ def _invalid_token(reason: str) -> HTTPException:
1189
+ """401 with the RFC 6750 challenge. `reason` is a fixed phrase, never token content."""
1190
+ return HTTPException(
1191
+ status_code=401,
1192
+ detail=f"Invalid bearer token: {reason}.",
1193
+ headers={"WWW-Authenticate": f'Bearer error="invalid_token", error_description="{reason}"'},
1194
+ )
1195
+
1196
+
1197
+ def _bearer_token(request: Request) -> str:
1198
+ scheme, _, token = (request.headers.get("authorization") or "").partition(" ")
1199
+ token = token.strip()
1200
+ if scheme.lower() != "bearer" or not token:
1201
+ raise HTTPException(
1202
+ status_code=401,
1203
+ detail="Missing bearer token.",
1204
+ headers={"WWW-Authenticate": "Bearer"},
1205
+ )
1206
+ return token
1207
+
1208
+
1209
+ JWT_NOT_CONFIGURED = (
1210
+ "AUTH_POLICY=jwt is not configured on the server; every request is refused "
1211
+ "until it is (see the server log)."
1212
+ )
1213
+
1214
+
1215
+ class JwtPolicy:
1216
+ """Per-user principals from a verified OIDC/JWT bearer token.
1217
+
1218
+ The principal id is the `AUTH_JWT_PRINCIPAL_CLAIM` claim (default `sub`)
1219
+ and the roles come from `AUTH_JWT_ROLES_CLAIM` (default `roles`; a
1220
+ dotted path, a list or a space/comma separated string). The actor, for a
1221
+ token an agent presents for the user, is read from the RFC 8693 actor
1222
+ claim (`AUTH_JWT_ACTOR_CLAIM`, default `act`; see `actor_from_claims`). Every action is
1223
+ allowed to an authenticated principal; conversation ownership is enforced
1224
+ per thread (`app_utils.threads`, the LangGraph Server owner filters).
1225
+ Nothing from the token is logged or put in an error message.
1226
+ """
1227
+
1228
+ def __init__(self, settings: JwtSettings | None = None) -> None:
1229
+ self.settings = settings if settings is not None else JwtSettings.from_env()
1230
+ s = self.settings
1231
+ self.jwks = JwksCache(s.jwks_url, s.jwks_cache_s) if s.jwks_url and not s.problems else None
1232
+ for warning in s.warnings:
1233
+ logger.warning("AUTH_POLICY=jwt: %s", warning)
1234
+ if s.problems:
1235
+ logger.error(
1236
+ "AUTH_POLICY=jwt is misconfigured: %s. Every request is refused (503) until "
1237
+ "it is fixed.",
1238
+ "; ".join(s.problems),
1239
+ )
1240
+ elif s.actor_claim and _csv(os.environ.get("AUTH_ALLOWED_ACTORS")) and not s.direct_clients:
1241
+ logger.warning(
1242
+ "AUTH_POLICY=jwt: delegation is recognised only by the %s claim; if your "
1243
+ "issuer's exchanged tokens carry none, set AUTH_JWT_DIRECT_CLIENTS",
1244
+ s.actor_claim,
1245
+ )
1246
+
1247
+ def startup_problems(self) -> list[str]:
1248
+ return list(self.settings.problems)
1249
+
1250
+ async def authenticate(self, request: Request) -> Principal:
1251
+ if self.settings.problems:
1252
+ raise HTTPException(status_code=503, detail=JWT_NOT_CONFIGURED)
1253
+ token = _bearer_token(request)
1254
+ claims = await self.verify(token)
1255
+ principal = self.principal_from_claims(claims)
1256
+ if subject_token_needed():
1257
+ # An API acts with the user's own token (exchanged, or forwarded to its audience).
1258
+ keep_subject_token(principal, token, claims.get("aud"), claims.get("exp"))
1259
+ return principal
1260
+
1261
+ async def authorize(self, principal: Principal, action: str, resource: str | None) -> None:
1262
+ if action not in ACTIONS:
1263
+ raise HTTPException(status_code=403, detail=f"Unknown action {action!r}.")
1264
+
1265
+ async def verify(self, token: str) -> dict[str, Any]:
1266
+ """The token's claims after every check; raises 401 (or 503 when keys are unavailable)."""
1267
+ import jwt
1268
+
1269
+ s = self.settings
1270
+ if len(token) > JWT_MAX_TOKEN_CHARS:
1271
+ raise _invalid_token("token too large")
1272
+ try:
1273
+ header = jwt.get_unverified_header(token)
1274
+ except (jwt.PyJWTError, ValueError, TypeError):
1275
+ raise _invalid_token("malformed token") from None
1276
+ algorithm = header.get("alg")
1277
+ if not isinstance(algorithm, str) or algorithm not in s.algorithms:
1278
+ raise _invalid_token("signing algorithm not allowed")
1279
+ if "crit" in header:
1280
+ raise _invalid_token("unsupported critical header")
1281
+ kid = header.get("kid")
1282
+ if kid is not None and not isinstance(kid, str):
1283
+ raise _invalid_token("malformed token")
1284
+ key = await self._key_for(algorithm, kid)
1285
+ try:
1286
+ claims = jwt.decode(
1287
+ token,
1288
+ key=key,
1289
+ algorithms=[algorithm],
1290
+ audience=list(s.audience) or None,
1291
+ issuer=s.issuer,
1292
+ leeway=s.leeway_s,
1293
+ options={
1294
+ "require": ["exp"],
1295
+ "verify_aud": bool(s.audience),
1296
+ "verify_iss": bool(s.issuer),
1297
+ },
1298
+ )
1299
+ except jwt.ExpiredSignatureError:
1300
+ raise _invalid_token("token expired") from None
1301
+ except jwt.ImmatureSignatureError:
1302
+ raise _invalid_token("token not yet valid") from None
1303
+ except jwt.InvalidAudienceError:
1304
+ raise _invalid_token("wrong audience") from None
1305
+ except jwt.InvalidIssuerError:
1306
+ raise _invalid_token("wrong issuer") from None
1307
+ except jwt.MissingRequiredClaimError:
1308
+ raise _invalid_token("missing required claim") from None
1309
+ except jwt.InvalidSignatureError:
1310
+ raise _invalid_token("invalid signature") from None
1311
+ except (jwt.PyJWTError, ValueError, TypeError, OverflowError):
1312
+ raise _invalid_token("invalid token") from None
1313
+ if not isinstance(claims, dict):
1314
+ raise _invalid_token("invalid token")
1315
+ return claims
1316
+
1317
+ async def _key_for(self, algorithm: str, kid: str | None) -> Any:
1318
+ s = self.settings
1319
+ if algorithm in HMAC_ALGORITHMS:
1320
+ return s.secret
1321
+ if s.public_key is not None:
1322
+ if not _key_matches_algorithm(algorithm, *s.public_key_type):
1323
+ raise _invalid_token("signing algorithm does not match the key")
1324
+ return s.public_key
1325
+ if self.jwks is None: # unreachable with valid settings; fail closed anyway
1326
+ raise HTTPException(status_code=503, detail=JWT_NOT_CONFIGURED)
1327
+ keys = await self._jwks_keys()
1328
+ jwk = self._select(keys, algorithm, kid)
1329
+ if jwk is None and kid is not None and all(k.get("kid") != kid for k in keys):
1330
+ # Possibly a rotated key: refetch (one fetch shared by every request
1331
+ # carrying it, rate-limited) and look again.
1332
+ if await self.jwks.refresh_for_unknown_kid(kid):
1333
+ keys = await self._jwks_keys()
1334
+ jwk = self._select(keys, algorithm, kid)
1335
+ if jwk is None:
1336
+ raise _invalid_token("unknown signing key")
1337
+ from jwt.algorithms import get_default_algorithms
1338
+
1339
+ try:
1340
+ return get_default_algorithms()[algorithm].from_jwk(jwk)
1341
+ except Exception:
1342
+ raise _invalid_token("unusable signing key") from None
1343
+
1344
+ async def _jwks_keys(self) -> list[dict[str, Any]]:
1345
+ assert self.jwks is not None
1346
+ try:
1347
+ return await self.jwks.keys()
1348
+ except JwksUnavailable:
1349
+ raise HTTPException(
1350
+ status_code=503,
1351
+ detail="The token issuer's signing keys are unavailable; try again later.",
1352
+ ) from None
1353
+
1354
+ @staticmethod
1355
+ def _select(
1356
+ keys: list[dict[str, Any]], algorithm: str, kid: str | None
1357
+ ) -> dict[str, Any] | None:
1358
+ matching = [
1359
+ k
1360
+ for k in keys
1361
+ if _key_matches_algorithm(algorithm, k.get("kty"), k.get("crv"), k.get("alg"))
1362
+ ]
1363
+ if kid is not None:
1364
+ matching = [k for k in matching if k.get("kid") == kid]
1365
+ return matching[0] if matching else None
1366
+ # No key id: only unambiguous when exactly one key fits the algorithm.
1367
+ return matching[0] if len(matching) == 1 else None
1368
+
1369
+ def principal_from_claims(self, claims: Mapping[str, Any]) -> Principal:
1370
+ s = self.settings
1371
+ raw = _claim(claims, s.principal_claim)
1372
+ if isinstance(raw, bool) or not isinstance(raw, str | int):
1373
+ raise _invalid_token("missing principal claim")
1374
+ principal_id = str(raw).strip()
1375
+ if (
1376
+ not principal_id
1377
+ or len(principal_id) > PRINCIPAL_ID_MAX_CHARS
1378
+ or _CONTROL_CHARS.search(principal_id)
1379
+ ):
1380
+ raise _invalid_token("invalid principal claim")
1381
+ return Principal(
1382
+ id=principal_id,
1383
+ roles=_roles_from_claim(_claim(claims, s.roles_claim)),
1384
+ permissions=set(ACTIONS),
1385
+ actor=actor_from_claims(
1386
+ claims,
1387
+ actor_claim=s.actor_claim,
1388
+ client_claim=s.client_claim,
1389
+ direct_clients=s.direct_clients,
1390
+ subject=principal_id,
1391
+ ),
1392
+ )
1393
+
1394
+
1395
+ # ---------------------------------------------------------------------------
1396
+ # Policy selection
1397
+ # ---------------------------------------------------------------------------
1398
+
1399
+ _policies: dict[str, AuthPolicy] = {}
1400
+ _warned_aliases: set[str] = set()
1401
+
1402
+
1403
+ def policy_name() -> str:
1404
+ """The selected policy; a retired name is read as its replacement (warned once)."""
1405
+ name = (os.environ.get("AUTH_POLICY") or DEFAULT_POLICY).strip().lower()
1406
+ alias = LEGACY_ALIASES.get(name)
1407
+ if alias is None:
1408
+ return name
1409
+ if name not in _warned_aliases:
1410
+ _warned_aliases.add(name)
1411
+ logger.warning(
1412
+ "AUTH_POLICY=%s is deprecated and read as %s; set AUTH_POLICY=%s.", name, alias, alias
1413
+ )
1414
+ return alias
1415
+
1416
+
1417
+ def get_policy() -> AuthPolicy:
1418
+ """The policy instance for `AUTH_POLICY` (built once per name)."""
1419
+ name = policy_name()
1420
+ policy = _policies.get(name)
1421
+ if policy is None:
1422
+ from {{cookiecutter.agent_directory}}.policies import build_policy
1423
+
1424
+ policy = build_policy(name)
1425
+ _policies[name] = policy
1426
+ return policy
1427
+
1428
+
1429
+ def reset_policy_cache() -> None:
1430
+ """For tests that switch `AUTH_POLICY` (or its settings)."""
1431
+ _policies.clear()
1432
+
1433
+
1434
+ def check_startup() -> AuthPolicy:
1435
+ """Build the selected policy and refuse to start on a configuration that cannot work.
1436
+
1437
+ An unknown `AUTH_POLICY` raises in every environment. A policy may expose
1438
+ `startup_problems() -> list[str]`; any problem raises outside `APP_ENV=dev`
1439
+ (under dev it is logged and requests get 503 until it is fixed). So do the
1440
+ APIs of api-policy.yaml that act with the caller's identity where they
1441
+ cannot (`token_exchange.startup_problems`: an `auth: exchange` API under
1442
+ shared-bearer or langgraph-server, or without `TOKEN_EXCHANGE_URL`,
1443
+ `TOKEN_EXCHANGE_CLIENT_ID` and `TOKEN_EXCHANGE_CLIENT_SECRET`; an
1444
+ `auth: forward` API under shared-bearer, under jwt without
1445
+ `forward_audience`, or under langgraph-server; under dev they are logged,
1446
+ the app starts and the calls to those APIs fail).
1447
+ """
1448
+ policy = get_policy()
1449
+ probe = getattr(policy, "startup_problems", None)
1450
+ problems = list(probe()) if callable(probe) else []
1451
+ from {{cookiecutter.agent_directory}}.app_utils.token_exchange import (
1452
+ startup_problems as propagation_problems,
1453
+ )
1454
+
1455
+ propagation = propagation_problems(policy_name())
1456
+ if dev_mode():
1457
+ for problem in propagation:
1458
+ logger.error("api-policy: %s (APP_ENV=dev: starting anyway)", problem)
1459
+ return policy
1460
+ if problems:
1461
+ raise RuntimeError(
1462
+ f"AUTH_POLICY={policy_name()} is misconfigured, refusing to start: "
1463
+ + "; ".join(problems)
1464
+ )
1465
+ if propagation:
1466
+ raise RuntimeError(
1467
+ "api-policy.yaml cannot work with this configuration, refusing to start: "
1468
+ + "; ".join(propagation)
1469
+ )
1470
+ return policy
1471
+
1472
+
1473
+ def _as_http_exception(exc: Exception) -> HTTPException:
1474
+ if isinstance(exc, HTTPException):
1475
+ return exc
1476
+ if isinstance(exc, NotImplementedError):
1477
+ return HTTPException(
1478
+ status_code=503,
1479
+ detail=str(exc) or "The configured AUTH_POLICY is not implemented.",
1480
+ )
1481
+ raise exc
1482
+
1483
+
1484
+ def require(action: str) -> Callable[[Request], Awaitable[Principal]]:
1485
+ """FastAPI dependency: authenticate, then authorize `action` on the route's thread.
1486
+
1487
+ Usage: ``principal: Principal = Depends(require("chat.send"))``.
1488
+ """
1489
+ if action not in ACTIONS:
1490
+ raise ValueError(f"Unknown action {action!r}; expected one of {sorted(ACTIONS)}")
1491
+
1492
+ async def dependency(request: Request) -> Principal:
1493
+ policy = get_policy()
1494
+ try:
1495
+ principal = finalize_principal(await policy.authenticate(request))
1496
+ await policy.authorize(principal, action, request.path_params.get("thread_id"))
1497
+ except (HTTPException, NotImplementedError) as exc:
1498
+ raise _as_http_exception(exc) from exc
1499
+ request.state.principal = principal
1500
+ return principal
1501
+
1502
+ dependency.__name__ = f"require_{action.replace('.', '_')}"
1503
+ return dependency
1504
+
1505
+
1506
+ async def authorize_action(principal: Principal, action: str, resource: str | None = None) -> None:
1507
+ """Authorize `action` for an authenticated principal (as `require`: 403, or 503).
1508
+
1509
+ For decisions taken outside a route of their own, such as an approval
1510
+ decided over A2A: the endpoint authorized `a2a.invoke`, the decision
1511
+ needs `approval.decide` as well.
1512
+ """
1513
+ if action not in ACTIONS:
1514
+ raise ValueError(f"Unknown action {action!r}; expected one of {sorted(ACTIONS)}")
1515
+ try:
1516
+ await get_policy().authorize(principal, action, resource)
1517
+ except (HTTPException, NotImplementedError) as exc:
1518
+ raise _as_http_exception(exc) from exc
1519
+
1520
+
1521
+ async def authenticate_and_authorize(
1522
+ request: Request, action: str, resource: str | None = None
1523
+ ) -> Principal:
1524
+ """Same check as `require`, for code paths outside FastAPI's dependency system."""
1525
+ policy = get_policy()
1526
+ try:
1527
+ principal = finalize_principal(await policy.authenticate(request))
1528
+ await policy.authorize(principal, action, resource)
1529
+ except (HTTPException, NotImplementedError) as exc:
1530
+ raise _as_http_exception(exc) from exc
1531
+ return principal
1532
+
1533
+
1534
+ # ---------------------------------------------------------------------------
1535
+ # LangGraph Server auth handler (langgraph.json "auth": {"path": ".../auth.py:auth"})
1536
+ # ---------------------------------------------------------------------------
1537
+
1538
+ ROLE_PERMISSION_PREFIX = "role:"
1539
+ # The delegated caller's actor, carried beside the roles in the server's user
1540
+ # permissions (`actor:<id>`; none for a direct caller).
1541
+ ACTOR_PERMISSION_PREFIX = "actor:"
1542
+ # The key of the server's user dict holding the caller's run context (`run_context_of`),
1543
+ # which the handlers put on every native run.
1544
+ RUN_CONTEXT_USER_KEY = "run_context"
1545
+
1546
+ # Where `build_sdk_auth` leaves the policy's 401 challenge (`WWW-Authenticate`)
1547
+ # in the request's ASGI state: LangGraph Server drops the headers of an auth
1548
+ # error, and `middleware.AuthErrorMiddleware` puts them back.
1549
+ AUTH_CHALLENGE_STATE_KEY = "auth_challenge"
1550
+
1551
+ # The native API's thread copy (`POST /threads/{thread_id}/copy`).
1552
+ _THREAD_COPY_PATH = re.compile(r"(?:^|/)threads/[^/]+/copy/?$")
1553
+ # Whether the request being authorized is a thread copy. Set by the
1554
+ # authenticate handler for every request it sees (the resource handlers run
1555
+ # later in the same request, in the same context).
1556
+ _THREAD_COPY: ContextVar[bool] = ContextVar("thread_copy", default=False)
1557
+
1558
+
1559
+ # Run settings that start a run from a given checkpoint (a replay) instead of the latest.
1560
+ _CHECKPOINT_KEYS = ("checkpoint_id", "checkpoint", "checkpoint_ns", "checkpoint_map")
1561
+
1562
+
1563
+ def _replays(kwargs: Any) -> bool:
1564
+ """Whether a native run re-runs a thread's pending step: it has no input, or
1565
+ starts from a checkpoint (in `config.configurable` or `context`, where the
1566
+ server puts `checkpoint_id` and `checkpoint`)."""
1567
+ if not isinstance(kwargs, Mapping):
1568
+ return True
1569
+ if kwargs.get("input") is None:
1570
+ return True
1571
+ config = kwargs.get("config")
1572
+ configurable = config.get("configurable") if isinstance(config, Mapping) else None
1573
+ for settings in (configurable, kwargs.get("context")):
1574
+ if isinstance(settings, Mapping) and any(
1575
+ settings.get(key) not in (None, "") for key in _CHECKPOINT_KEYS
1576
+ ):
1577
+ return True
1578
+ return False
1579
+
1580
+
1581
+ def _is_thread_copy(request: Any) -> bool:
1582
+ scope = getattr(request, "scope", None) or {}
1583
+ path = scope.get("path") or ""
1584
+ return scope.get("method") == "POST" and bool(_THREAD_COPY_PATH.search(path))
1585
+
1586
+
1587
+ def _stash_challenge(request: Any, headers: Mapping[str, str] | None) -> None:
1588
+ """Keep a 401's `WWW-Authenticate` in the request state (see `AuthErrorMiddleware`)."""
1589
+ challenge = next(
1590
+ (v for k, v in (headers or {}).items() if k.lower() == "www-authenticate"), None
1591
+ )
1592
+ scope = getattr(request, "scope", None)
1593
+ if challenge and isinstance(scope, dict):
1594
+ state = scope.setdefault("state", {})
1595
+ if isinstance(state, dict):
1596
+ state[AUTH_CHALLENGE_STATE_KEY] = challenge
1597
+
1598
+
1599
+ def build_sdk_auth() -> Any:
1600
+ """A `langgraph_sdk.Auth` whose handlers delegate to the selected policy.
1601
+
1602
+ `@auth.authenticate` runs the policy's `authenticate`. Resource rules for
1603
+ the server's native API:
1604
+
1605
+ * threads (and their runs): `{principal_id, tenant}` is written into the
1606
+ thread metadata at creation (the caller's id; `tenant` None, since a
1607
+ native API caller cannot vouch for one) and every read, search, update,
1608
+ delete and run is filtered to the caller's own threads. A role listed in
1609
+ `AUTH_READ_ACROSS_ROLES` relaxes only the read and search filters
1610
+ (read-across roles may *read* others' threads, never change them), and
1611
+ an update can never change a thread's `principal_id` or `tenant`.
1612
+ A delegated caller (an agent acting for the user: `actor:<id>` in its
1613
+ permissions) also stamps `actor` and is filtered to the threads of its
1614
+ own subject and actor; the filters are containment filters, so the
1615
+ subject's own (direct) filter matches the threads its agents started
1616
+ for it. Nobody stamps, adopts or strips an actor by sending metadata,
1617
+ and a delegated caller's roles never read across or administer.
1618
+ A copy (`POST /threads/{id}/copy`) is a write: it creates a thread that
1619
+ keeps the source's metadata, owner included, so its source must be the
1620
+ caller's own thread whatever the caller's roles. A thread that recorded
1621
+ approvals of gated API calls is not copied (403): the copy would carry
1622
+ its tool calls without their approvals.
1623
+ * every run gets the caller's own run context (`run_context_of`: its id,
1624
+ roles and public attributes, `@actor` included), whatever the request
1625
+ sent in `context` or `config.configurable`: tools act only for the
1626
+ authenticated caller, with its roles, and see the agent presenting a
1627
+ delegated request. `authenticate` publishes it in the server's user
1628
+ (`run_context`); a user without it (or with another's) gets one built
1629
+ from its identity and permissions, never from the request.
1630
+ * a run that carries a `command` (a resume of a paused run) is refused:
1631
+ a run paused for the approval of a gated API call resumes only through
1632
+ the app's approval routes, which check who may decide (the app's
1633
+ approvals ledger refuses a forged resume anyway). A new run on a thread
1634
+ whose approval is still pending is refused with 409, as `/chat` does.
1635
+ A run without input, or from a checkpoint (`checkpoint_id`,
1636
+ `checkpoint`: a replay), is refused (403) on a thread that recorded
1637
+ approvals or waits on a gated call: it would run a paused step's tool
1638
+ calls again without a decision (the API client refuses a call an
1639
+ approval was asked for anyway).
1640
+ * the raw principal id is kept only in the thread metadata, where the
1641
+ owner filters need it. The server merges thread metadata into every
1642
+ run's metadata (and from there into traced config metadata and
1643
+ checkpoint metadata); a run on an existing thread therefore carries the
1644
+ hashed id (`Principal.hashed_id()`) under `principal_id` instead.
1645
+ * assistants, crons, store: any authenticated principal may read (read,
1646
+ search, get, list_namespaces); create, update, put and delete are
1647
+ allowed only to a role listed in `AUTH_ADMIN_ROLES` (empty = nobody).
1648
+ The assistant `/chat` runs on is therefore changeable only by admins.
1649
+ Store reads are not per-user: a graph that writes per-user data into
1650
+ the store must scope its namespaces by principal.
1651
+ * anything else the server dispatches (a resource or action added by a
1652
+ later server version): denied (default deny).
1653
+
1654
+ These handlers cover the native API called from outside the app; the
1655
+ custom routes' loopback SDK calls bypass the server's auth middleware, so
1656
+ `chat.py` enforces the thread rule in-app.
1657
+
1658
+ The server answers an auth error with a bare 401/403 (without the policy's
1659
+ `WWW-Authenticate` challenge) and turns any other status (a policy's 503)
1660
+ into a 500; `middleware.AuthErrorMiddleware`, installed by `fast_api_app.py`
1661
+ under langgraph-server, restores both. It relies on the server's default
1662
+ middleware order (no `"middleware_order": "auth_first"` in langgraph.json).
1663
+ """
1664
+ from langgraph_sdk import Auth
1665
+
1666
+ check_startup()
1667
+ auth = Auth()
1668
+
1669
+ def _permissions_of(ctx: Any) -> list[str]:
1670
+ perms = getattr(ctx.user, "permissions", None) or getattr(ctx, "permissions", None) or []
1671
+ return [str(p) for p in perms]
1672
+
1673
+ def _roles_of(ctx: Any) -> set[str]:
1674
+ return {
1675
+ p[len(ROLE_PERMISSION_PREFIX) :]
1676
+ for p in _permissions_of(ctx)
1677
+ if p.startswith(ROLE_PERMISSION_PREFIX)
1678
+ }
1679
+
1680
+ def _actor_of(ctx: Any) -> str | None:
1681
+ """The delegated caller's actor (its `actor:` permission), None for a direct caller."""
1682
+ for p in _permissions_of(ctx):
1683
+ if p.startswith(ACTOR_PERMISSION_PREFIX):
1684
+ return p[len(ACTOR_PERMISSION_PREFIX) :]
1685
+ return None
1686
+
1687
+ def _is_studio(ctx: Any) -> bool:
1688
+ # The Studio user exists only under `langgraph dev` (or LangSmith-hosted
1689
+ # auth); it is trusted as an admin under APP_ENV=dev only.
1690
+ return isinstance(ctx.user, Auth.types.StudioUser) and dev_mode()
1691
+
1692
+ def _reads_across(ctx: Any) -> bool:
1693
+ # A delegated caller's roles never read across (whatever AUTH_DELEGATED_ROLES lends).
1694
+ return _actor_of(ctx) is None and bool(_roles_of(ctx) & read_across_roles())
1695
+
1696
+ def _owner_filter(ctx: Any) -> dict[str, Any] | None:
1697
+ """Read filter: none for a read-across role, else the caller's own threads.
1698
+
1699
+ The server's copy reads its source with this filter; a copy is a
1700
+ write, so there the filter is the caller's own threads for every role.
1701
+ """
1702
+ if _reads_across(ctx) and not _THREAD_COPY.get():
1703
+ return None # no filter: may read across principals
1704
+ return _strict_owner_filter(ctx)
1705
+
1706
+ def _strict_owner_filter(ctx: Any) -> dict[str, Any]:
1707
+ """Write filter: always the caller's own threads (read-across is read-only).
1708
+
1709
+ A delegated caller's also name its actor: it reaches only the threads
1710
+ started under its own subject and actor. A thread without `actor`
1711
+ (a direct one, or one created before 0.3) never matches that filter.
1712
+ """
1713
+ actor = _actor_of(ctx)
1714
+ if actor is None:
1715
+ return {"principal_id": ctx.user.identity}
1716
+ return {"principal_id": ctx.user.identity, "actor": actor}
1717
+
1718
+ def _stamp_actor(ctx: Any, metadata: dict[str, Any]) -> None:
1719
+ """The caller's actor in metadata the app stamps: set for a delegated caller,
1720
+ removed for a direct one (no caller chooses it)."""
1721
+ actor = _actor_of(ctx)
1722
+ if actor is None:
1723
+ metadata.pop("actor", None)
1724
+ else:
1725
+ metadata["actor"] = actor
1726
+
1727
+ def _require_admin(ctx: Any) -> bool:
1728
+ if _is_studio(ctx) or (_actor_of(ctx) is None and _roles_of(ctx) & admin_roles()):
1729
+ return True
1730
+ raise Auth.exceptions.HTTPException(
1731
+ status_code=403,
1732
+ detail=f"{ctx.resource}.{ctx.action} is allowed only to AUTH_ADMIN_ROLES.",
1733
+ )
1734
+
1735
+ def _metadata_of(value: Any) -> dict[str, Any]:
1736
+ """The request's metadata dict, created when absent; refuses what cannot be stamped."""
1737
+ metadata = value.setdefault("metadata", {}) if isinstance(value, dict) else None
1738
+ if not isinstance(metadata, dict):
1739
+ raise Auth.exceptions.HTTPException(
1740
+ status_code=403, detail="Thread ownership metadata could not be set."
1741
+ )
1742
+ return metadata
1743
+
1744
+ def _caller_run_context(ctx: Any) -> dict[str, Any]:
1745
+ """The caller's run context (a copy): as `authenticate` published it in the
1746
+ user, else built from the user's identity and permissions. Never the request's."""
1747
+ user = ctx.user
1748
+ getter = getattr(user, "get", None)
1749
+ published = (
1750
+ getter(RUN_CONTEXT_USER_KEY)
1751
+ if callable(getter)
1752
+ else getattr(user, RUN_CONTEXT_USER_KEY, None)
1753
+ )
1754
+ actor = _actor_of(ctx)
1755
+ if isinstance(published, Mapping) and published.get("principal_id") == user.identity:
1756
+ published_actor = actor_of_attributes(published.get("attributes"))
1757
+ if (published_actor.id if published_actor is not None else None) == actor:
1758
+ return {key: copy.deepcopy(published.get(key)) for key in RUN_CONTEXT_KEYS}
1759
+ return {
1760
+ "principal_id": user.identity,
1761
+ "roles": [
1762
+ p[len(ROLE_PERMISSION_PREFIX) :]
1763
+ for p in _permissions_of(ctx)
1764
+ if p.startswith(ROLE_PERMISSION_PREFIX)
1765
+ ],
1766
+ "attributes": {} if actor is None else {ACTOR_ATTRIBUTE: Actor(id=actor).public()},
1767
+ }
1768
+
1769
+ def _stamp_run_context(ctx: Any, value: Any) -> None:
1770
+ """Put the caller's run context on a run, over whatever the request sent.
1771
+
1772
+ In `context` (what the graph runs with; all three keys, so an
1773
+ assistant's context cannot add any either) and in
1774
+ `config.configurable`, which the server fills from it.
1775
+ """
1776
+ kwargs = value.setdefault("kwargs", {}) if isinstance(value, dict) else None
1777
+ if not isinstance(kwargs, dict):
1778
+ raise Auth.exceptions.HTTPException(
1779
+ status_code=403, detail="The run's caller context could not be set."
1780
+ )
1781
+ caller = _caller_run_context(ctx)
1782
+ sent = kwargs.get("context")
1783
+ kwargs["context"] = {**(sent if isinstance(sent, dict) else {}), **caller}
1784
+ config = kwargs.get("config")
1785
+ configurable = config.get("configurable") if isinstance(config, dict) else None
1786
+ if isinstance(configurable, dict):
1787
+ for key in RUN_CONTEXT_KEYS:
1788
+ if key in configurable:
1789
+ configurable[key] = copy.deepcopy(caller[key])
1790
+
1791
+ @auth.authenticate
1792
+ async def authenticate(request: Any) -> dict[str, Any]:
1793
+ _THREAD_COPY.set(_is_thread_copy(request))
1794
+ policy = get_policy()
1795
+ try:
1796
+ principal = finalize_principal(await policy.authenticate(request))
1797
+ except HTTPException as exc:
1798
+ if exc.status_code == 401:
1799
+ _stash_challenge(request, exc.headers)
1800
+ raise Auth.exceptions.HTTPException(
1801
+ status_code=exc.status_code, detail=str(exc.detail), headers=exc.headers
1802
+ ) from exc
1803
+ except NotImplementedError as exc:
1804
+ raise Auth.exceptions.HTTPException(status_code=503, detail=str(exc)) from exc
1805
+ # A policy's own `actor:` permission is never trusted: only the actor it set does.
1806
+ permissions = sorted(
1807
+ p for p in principal.permissions if not str(p).startswith(ACTOR_PERMISSION_PREFIX)
1808
+ ) + [f"{ROLE_PERMISSION_PREFIX}{r}" for r in principal.roles]
1809
+ if principal.actor is not None:
1810
+ permissions.append(f"{ACTOR_PERMISSION_PREFIX}{principal.actor.id}")
1811
+ return {
1812
+ "identity": principal.id,
1813
+ "display_name": principal.id,
1814
+ "is_authenticated": True,
1815
+ "permissions": permissions,
1816
+ # What a native run's tools act for (public attributes only: no credentials).
1817
+ RUN_CONTEXT_USER_KEY: run_context_of(principal),
1818
+ }
1819
+
1820
+ # -- default deny: whatever has no rule below -----------------------------
1821
+
1822
+ @auth.on
1823
+ async def deny_by_default(ctx: Any, value: Any) -> bool:
1824
+ if _is_studio(ctx):
1825
+ return True
1826
+ raise Auth.exceptions.HTTPException(
1827
+ status_code=403, detail=f"{ctx.resource}.{ctx.action} is not allowed."
1828
+ )
1829
+
1830
+ # -- threads (and runs on them): owner-scoped ------------------------------
1831
+
1832
+ @auth.on.threads.create
1833
+ async def on_threads_create(ctx: Any, value: Any) -> dict[str, Any] | None:
1834
+ if isinstance(value, dict) and "metadata" not in value and not _THREAD_COPY.get():
1835
+ # The server creates a thread without metadata only for a copy,
1836
+ # which keeps its source's metadata (owner included) and ignores
1837
+ # what is stamped here. Not recognised as a copy, its source may
1838
+ # not have been limited to the caller's own threads: fail closed
1839
+ # for the roles whose reads are not.
1840
+ if _reads_across(ctx):
1841
+ raise Auth.exceptions.HTTPException(
1842
+ status_code=403,
1843
+ detail="Read-across roles may read other principals' threads, not copy them.",
1844
+ )
1845
+ metadata = _metadata_of(value)
1846
+ metadata["principal_id"] = ctx.user.identity
1847
+ metadata["tenant"] = None
1848
+ _stamp_actor(ctx, metadata)
1849
+ return _strict_owner_filter(ctx)
1850
+
1851
+ @auth.on.threads.read
1852
+ async def on_threads_read(ctx: Any, value: Any) -> dict[str, Any] | None:
1853
+ thread_id = value.get("thread_id") if isinstance(value, dict) else None
1854
+ if _THREAD_COPY.get() and thread_id is not None and not _is_studio(ctx):
1855
+ # The server's copy reads its source first. A copy keeps the
1856
+ # source's tool calls but not its approvals (they belong to the
1857
+ # source, and go when it is deleted): refused where calls were gated.
1858
+ from {{cookiecutter.agent_directory}}.app_utils.chat import RUNTIME
1859
+
1860
+ refusal = await RUNTIME.copy_refusal(str(thread_id))
1861
+ if refusal:
1862
+ raise Auth.exceptions.HTTPException(
1863
+ status_code=403,
1864
+ detail=f"This thread cannot be copied: {refusal}.",
1865
+ )
1866
+ return _owner_filter(ctx)
1867
+
1868
+ @auth.on.threads.search
1869
+ async def on_threads_search(ctx: Any, value: Any) -> dict[str, Any] | None:
1870
+ return _owner_filter(ctx)
1871
+
1872
+ @auth.on.threads.update
1873
+ async def on_threads_update(ctx: Any, value: Any) -> dict[str, Any] | None:
1874
+ metadata = value.get("metadata") if isinstance(value, dict) else None
1875
+ if isinstance(metadata, dict):
1876
+ # Ownership is not transferable: an owner cannot hand a thread
1877
+ # (and its content) to another principal, tenant or actor, and a
1878
+ # direct owner cannot strip the actor its agent's thread has.
1879
+ metadata["principal_id"] = ctx.user.identity
1880
+ metadata.pop("tenant", None)
1881
+ _stamp_actor(ctx, metadata)
1882
+ return _strict_owner_filter(ctx)
1883
+
1884
+ @auth.on.threads.delete
1885
+ async def on_threads_delete(ctx: Any, value: Any) -> dict[str, Any] | None:
1886
+ return _strict_owner_filter(ctx)
1887
+
1888
+ @auth.on.threads.create_run
1889
+ async def on_threads_create_run(ctx: Any, value: Any) -> dict[str, Any] | None:
1890
+ kwargs = value.get("kwargs") if isinstance(value, dict) else None
1891
+ if isinstance(kwargs, dict) and kwargs.get("command") and not _is_studio(ctx):
1892
+ # A command resumes a paused run: a gated API call waits there for a
1893
+ # human decision, which only the app's approval routes may deliver
1894
+ # (they check who may decide). The app's own loopback calls do not
1895
+ # pass through here.
1896
+ raise Auth.exceptions.HTTPException(
1897
+ status_code=403,
1898
+ detail="Resuming a run is done through the app's approval routes "
1899
+ "(POST /threads/{thread_id}/approvals/{approval_id}).",
1900
+ )
1901
+ thread_id = value.get("thread_id") if isinstance(value, dict) else None
1902
+ if thread_id is not None and not _is_studio(ctx):
1903
+ # Imported here: chat.py imports this module.
1904
+ from {{cookiecutter.agent_directory}}.app_utils.chat import RUNTIME
1905
+
1906
+ if await RUNTIME.pending_approvals(str(thread_id)):
1907
+ # A thread waiting for an approval takes no new run (as /chat's
1908
+ # 409 approval_pending): it would abandon the pending approval.
1909
+ raise Auth.exceptions.HTTPException(
1910
+ status_code=409,
1911
+ detail="approval_pending: a gated API call on this thread waits for a "
1912
+ "decision; decide it first (GET /threads/{thread_id}/approvals).",
1913
+ )
1914
+ if _replays(kwargs):
1915
+ refusal = await RUNTIME.replay_refusal(str(thread_id))
1916
+ if refusal:
1917
+ # Continued without input or replayed from a checkpoint, a
1918
+ # paused step runs its tool calls again with no decision.
1919
+ raise Auth.exceptions.HTTPException(
1920
+ status_code=403,
1921
+ detail=f"A run without input, or from a checkpoint, is refused here: "
1922
+ f"{refusal}. Send a new message instead (POST /chat), and decide "
1923
+ "approvals through POST /threads/{thread_id}/approvals/{approval_id}.",
1924
+ )
1925
+ metadata = _metadata_of(value)
1926
+ if value.get("thread_id") is None or value.get("if_not_exists") == "create":
1927
+ # The run may create its thread, whose metadata is then the run's
1928
+ # config metadata overlaid with this metadata: stamp both owner
1929
+ # keys. The raw id is the ownership stamp the filters compare, so
1930
+ # it is needed here (and this run's metadata carries it too).
1931
+ metadata["principal_id"] = ctx.user.identity
1932
+ else:
1933
+ # The thread exists and keeps its own stamp (the filter below
1934
+ # checks it). The server copies the run's metadata, thread
1935
+ # metadata merged in, into traces and checkpoints: override the
1936
+ # raw id there with the hashed one.
1937
+ metadata["principal_id"] = Principal(id=str(ctx.user.identity)).hashed_id()
1938
+ metadata["tenant"] = None
1939
+ _stamp_actor(ctx, metadata)
1940
+ if not _is_studio(ctx):
1941
+ # Who the run's tools act for is the caller: a request cannot name
1942
+ # another principal, other roles, or drop (or add) an actor.
1943
+ _stamp_run_context(ctx, value)
1944
+ return _strict_owner_filter(ctx)
1945
+
1946
+ # -- assistants, crons, store: read for everyone, change for admins --------
1947
+
1948
+ @auth.on.assistants.read
1949
+ @auth.on.assistants.search
1950
+ @auth.on.crons.read
1951
+ @auth.on.crons.search
1952
+ async def allow_read(ctx: Any, value: Any) -> bool:
1953
+ return True
1954
+
1955
+ @auth.on.assistants.create
1956
+ @auth.on.assistants.update
1957
+ @auth.on.assistants.delete
1958
+ @auth.on.crons.create
1959
+ @auth.on.crons.update
1960
+ @auth.on.crons.delete
1961
+ async def admin_only(ctx: Any, value: Any) -> bool:
1962
+ return _require_admin(ctx)
1963
+
1964
+ @auth.on.store(actions=["get", "search", "list_namespaces"])
1965
+ async def allow_store_read(ctx: Any, value: Any) -> bool:
1966
+ return True
1967
+
1968
+ @auth.on.store(actions=["put", "delete"])
1969
+ async def admin_only_store(ctx: Any, value: Any) -> bool:
1970
+ return _require_admin(ctx)
1971
+
1972
+ return auth
1973
+
1974
+
1975
+ _sdk_auth: Any = None
1976
+
1977
+
1978
+ def __getattr__(name: str) -> Any:
1979
+ # Reading `auth` from this module (langgraph.json `auth.path`) builds the
1980
+ # SDK object on first use, so importing the module never requires langgraph_sdk.
1981
+ if name == "auth":
1982
+ global _sdk_auth
1983
+ if _sdk_auth is None:
1984
+ _sdk_auth = build_sdk_auth()
1985
+ return _sdk_auth
1986
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")