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.
- graph_agents_cli/__init__.py +26 -0
- graph_agents_cli/_api_policy.py +2145 -0
- graph_agents_cli/_approvals.py +400 -0
- graph_agents_cli/_build.py +186 -0
- graph_agents_cli/_build_info.json +7 -0
- graph_agents_cli/_chat_client.py +462 -0
- graph_agents_cli/_click.py +157 -0
- graph_agents_cli/_defaults.py +139 -0
- graph_agents_cli/_experiments.py +64 -0
- graph_agents_cli/_http.py +192 -0
- graph_agents_cli/_output.py +83 -0
- graph_agents_cli/_project.py +462 -0
- graph_agents_cli/_remote.py +220 -0
- graph_agents_cli/_response_schema.py +264 -0
- graph_agents_cli/_runner.py +319 -0
- graph_agents_cli/_skills_check.py +274 -0
- graph_agents_cli/_tools.py +189 -0
- graph_agents_cli/_trust.py +66 -0
- graph_agents_cli/api/__init__.py +15 -0
- graph_agents_cli/api/_changes.py +506 -0
- graph_agents_cli/api/_files.py +658 -0
- graph_agents_cli/api/cmd_api.py +2480 -0
- graph_agents_cli/deploy/__init__.py +15 -0
- graph_agents_cli/deploy/_config.py +171 -0
- graph_agents_cli/deploy/_image.py +128 -0
- graph_agents_cli/deploy/_kube.py +286 -0
- graph_agents_cli/deploy/_modes.py +234 -0
- graph_agents_cli/deploy/_preflight.py +370 -0
- graph_agents_cli/deploy/_values.py +168 -0
- graph_agents_cli/deploy/cmd_deploy.py +1866 -0
- graph_agents_cli/deploy/gitops.py +562 -0
- graph_agents_cli/deploy/local_load.py +273 -0
- graph_agents_cli/dev/__init__.py +13 -0
- graph_agents_cli/dev/cmd_build.py +131 -0
- graph_agents_cli/dev/cmd_install.py +78 -0
- graph_agents_cli/dev/cmd_lint.py +119 -0
- graph_agents_cli/dev/cmd_playground.py +297 -0
- graph_agents_cli/dev/policy_check.py +1287 -0
- graph_agents_cli/eval/__init__.py +22 -0
- graph_agents_cli/eval/_client.py +670 -0
- graph_agents_cli/eval/_common.py +177 -0
- graph_agents_cli/eval/_judge.py +168 -0
- graph_agents_cli/eval/_judge_runner.py +238 -0
- graph_agents_cli/eval/_paths.py +212 -0
- graph_agents_cli/eval/checks.py +581 -0
- graph_agents_cli/eval/cmd_analyze.py +278 -0
- graph_agents_cli/eval/cmd_compare.py +284 -0
- graph_agents_cli/eval/cmd_eval_group.py +80 -0
- graph_agents_cli/eval/cmd_generate.py +558 -0
- graph_agents_cli/eval/cmd_grade.py +466 -0
- graph_agents_cli/eval/cmd_metric.py +156 -0
- graph_agents_cli/eval/cmd_run.py +370 -0
- graph_agents_cli/eval/cmd_submit.py +400 -0
- graph_agents_cli/eval/config.py +435 -0
- graph_agents_cli/eval/dataset.py +350 -0
- graph_agents_cli/eval/gate.py +420 -0
- graph_agents_cli/eval/transcript.py +192 -0
- graph_agents_cli/extension/__init__.py +13 -0
- graph_agents_cli/extension/_compat.py +86 -0
- graph_agents_cli/extension/_loader.py +293 -0
- graph_agents_cli/extension/_manifest.py +135 -0
- graph_agents_cli/extension/_overrides.py +195 -0
- graph_agents_cli/extension/_paths.py +91 -0
- graph_agents_cli/extension/_refs.py +193 -0
- graph_agents_cli/extension/_resolver.py +453 -0
- graph_agents_cli/extension/_schema.py +106 -0
- graph_agents_cli/extension/_spec.py +253 -0
- graph_agents_cli/extension/_sync.py +102 -0
- graph_agents_cli/extension/_trust.py +58 -0
- graph_agents_cli/extension/cmd_extension_add.py +259 -0
- graph_agents_cli/extension/cmd_extension_group.py +57 -0
- graph_agents_cli/extension/cmd_extension_list.py +56 -0
- graph_agents_cli/extension/cmd_extension_remove.py +61 -0
- graph_agents_cli/extension/cmd_extension_update.py +195 -0
- graph_agents_cli/info/__init__.py +13 -0
- graph_agents_cli/info/cmd_info.py +222 -0
- graph_agents_cli/infra/__init__.py +15 -0
- graph_agents_cli/infra/checks.py +1169 -0
- graph_agents_cli/infra/cmd_infra.py +103 -0
- graph_agents_cli/main.py +591 -0
- graph_agents_cli/peer/__init__.py +15 -0
- graph_agents_cli/peer/_generate.py +254 -0
- graph_agents_cli/peer/cmd_peer.py +1151 -0
- graph_agents_cli/run/__init__.py +13 -0
- graph_agents_cli/run/_local_server.py +1157 -0
- graph_agents_cli/run/_signals.py +141 -0
- graph_agents_cli/run/cmd_approvals.py +530 -0
- graph_agents_cli/run/cmd_run.py +1421 -0
- graph_agents_cli/scaffold/__init__.py +19 -0
- graph_agents_cli/scaffold/agents/README.md +24 -0
- graph_agents_cli/scaffold/agents/empty_py/.template/templateconfig.yaml +22 -0
- graph_agents_cli/scaffold/agents/langgraph/.env.example +292 -0
- graph_agents_cli/scaffold/agents/langgraph/.template/templateconfig.yaml +28 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile +59 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile.langgraph-server +59 -0
- graph_agents_cli/scaffold/agents/langgraph/README.md +571 -0
- graph_agents_cli/scaffold/agents/langgraph/api-policy.yaml +60 -0
- graph_agents_cli/scaffold/agents/langgraph/app/__init__.py +20 -0
- graph_agents_cli/scaffold/agents/langgraph/app/agent.py +174 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/__init__.py +15 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a.py +2162 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a_client.py +1167 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/api_client.py +4220 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/approvals.py +1349 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/auth.py +1986 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/chat.py +2962 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/checkpointer.py +432 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/content.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/db.py +580 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/limits.py +203 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/metrics.py +231 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/middleware.py +361 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/model.py +611 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/playground.py +230 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/run_locks.py +459 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/structured.py +755 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/telemetry.py +681 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/threads.py +493 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/token_exchange.py +959 -0
- graph_agents_cli/scaffold/agents/langgraph/app/fast_api_app.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/__init__.py +55 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/custom.py +97 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/__init__.py +46 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/example_api.py +92 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/weather.py +33 -0
- graph_agents_cli/scaffold/agents/langgraph/langgraph.json +14 -0
- graph_agents_cli/scaffold/agents/langgraph/pyproject.toml +78 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/conftest.py +376 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/datasets/basic-dataset.json +53 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/eval_config.yaml +32 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/approval_graph.py +137 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_issuer.py +216 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_openai.py +357 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_outcomes.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_relay.py +479 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_api_surface.py +812 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals.py +1367 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals_server.py +794 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor.py +497 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor_server.py +247 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_history_repair.py +278 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_model_apis.py +242 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_postgres.py +637 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_resilience_postgres.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_runtime_guardrails.py +854 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_e2e.py +340 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_runtime.py +989 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_answers.py +584 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_server.py +222 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_token_exchange_issuer.py +650 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/.results/.placeholder +0 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/README.md +22 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/conftest.py +21 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/load_test.py +81 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_client.py +824 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_scoping.py +724 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client.py +1214 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client_hardening.py +716 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_policy_rpc.py +767 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_approval_ledger.py +1536 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_fake_model.py +115 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_jwt_policy.py +991 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_limits.py +310 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging.py +148 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging_hardening.py +271 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_policy.py +378 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_resilience.py +610 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_server_auth.py +702 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_structured.py +673 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_telemetry.py +404 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_thread_listing.py +255 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_threads.py +268 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_token_exchange.py +1320 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_untrusted_content.py +393 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-fastapi.lock +2084 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-langgraph-server.lock +2106 -0
- graph_agents_cli/scaffold/agents/langgraph/{{cookiecutter.agent_guidance_filename}} +129 -0
- graph_agents_cli/scaffold/base_templates/_shared/graph-agents-cli-manifest.yaml +36 -0
- graph_agents_cli/scaffold/base_templates/python/.dockerignore +32 -0
- graph_agents_cli/scaffold/base_templates/python/.github/CODEOWNERS +30 -0
- graph_agents_cli/scaffold/base_templates/python/.github/agent.env +7 -0
- graph_agents_cli/scaffold/base_templates/python/.github/workflows/pr_checks.yaml +214 -0
- graph_agents_cli/scaffold/base_templates/python/.gitignore +209 -0
- graph_agents_cli/scaffold/base_templates/python/tests/unit/test_dummy.py +23 -0
- graph_agents_cli/scaffold/base_templates/python/{{cookiecutter.agent_guidance_filename}} +35 -0
- graph_agents_cli/scaffold/cmd_scaffold_group.py +49 -0
- graph_agents_cli/scaffold/commands/__init__.py +13 -0
- graph_agents_cli/scaffold/commands/create.py +1424 -0
- graph_agents_cli/scaffold/commands/enhance.py +1652 -0
- graph_agents_cli/scaffold/commands/upgrade.py +570 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/agent.env +12 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/promote-to-prod.yaml +371 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/staging.yaml +450 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-dev.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-prod.yaml +41 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-staging.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/.helmignore +14 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/Chart.yaml +21 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/examples/networkpolicy.yaml +103 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/NOTES.txt +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/_helpers.tpl +189 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/certificate.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/configmap.yaml +10 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/deployment.yaml +199 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/hpa.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/httproute.yaml +30 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/ingress.yaml +39 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/networkpolicy.yaml +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/pdb.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/postgresql-secret.yaml +37 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/service.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/serviceaccount.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/servicemonitor.yaml +42 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-dev.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-prod.yaml +45 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-staging.yaml +29 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values.yaml +396 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/tests/integration/test_chart.py +269 -0
- graph_agents_cli/scaffold/deployment_targets/none/README.md +5 -0
- graph_agents_cli/scaffold/deployment_targets/none/python/README.md +6 -0
- graph_agents_cli/scaffold/utils/__init__.py +13 -0
- graph_agents_cli/scaffold/utils/backup.py +212 -0
- graph_agents_cli/scaffold/utils/build_record.py +257 -0
- graph_agents_cli/scaffold/utils/cli_options.py +184 -0
- graph_agents_cli/scaffold/utils/fs.py +83 -0
- graph_agents_cli/scaffold/utils/generate_locks.py +214 -0
- graph_agents_cli/scaffold/utils/generation_metadata.py +88 -0
- graph_agents_cli/scaffold/utils/keyedit.py +768 -0
- graph_agents_cli/scaffold/utils/keymerge.py +537 -0
- graph_agents_cli/scaffold/utils/language.py +138 -0
- graph_agents_cli/scaffold/utils/lock_utils.py +94 -0
- graph_agents_cli/scaffold/utils/logging.py +77 -0
- graph_agents_cli/scaffold/utils/manifest.py +292 -0
- graph_agents_cli/scaffold/utils/merge.py +970 -0
- graph_agents_cli/scaffold/utils/merge3.py +216 -0
- graph_agents_cli/scaffold/utils/openapi_seed.py +199 -0
- graph_agents_cli/scaffold/utils/remote_template.py +376 -0
- graph_agents_cli/scaffold/utils/template.py +1352 -0
- graph_agents_cli/scaffold/utils/upgrade.py +894 -0
- graph_agents_cli/scaffold/utils/version.py +438 -0
- graph_agents_cli/secrets/__init__.py +15 -0
- graph_agents_cli/secrets/_apply.py +954 -0
- graph_agents_cli/secrets/_required.py +188 -0
- graph_agents_cli/secrets/cmd_secrets.py +211 -0
- graph_agents_cli/setup/__init__.py +13 -0
- graph_agents_cli/setup/_antigravity.py +221 -0
- graph_agents_cli/setup/cmd_auth.py +1030 -0
- graph_agents_cli/setup/cmd_dev_token.py +513 -0
- graph_agents_cli/setup/cmd_setup.py +428 -0
- graph_agents_cli/setup/cmd_update.py +140 -0
- graph_agents_cli/skills/__init__.py +13 -0
- graph_agents_cli/skills/_bundle.py +65 -0
- graph_agents_cli/skills/data/README.md +19 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/SKILL.md +357 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/github-settings.md +113 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/gitops.md +137 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/kubernetes.md +315 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/secrets.md +160 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/SKILL.md +303 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/dataset_schema.md +282 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/metrics-guide.md +143 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/SKILL.md +659 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langchain-models.md +124 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langgraph.md +235 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/template-contract.md +477 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/SKILL.md +231 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/langsmith.md +46 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/otel.md +59 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/SKILL.md +414 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/references/flags.md +134 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/SKILL.md +478 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/brainstorming.md +118 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/commands.md +419 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/extension.md +156 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/internals.md +67 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/spec-template.md +56 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/terminology.md +119 -0
- graph_agents_cli/system/__init__.py +15 -0
- graph_agents_cli/system/_apply.py +519 -0
- graph_agents_cli/system/_checks.py +1023 -0
- graph_agents_cli/system/_deploy.py +215 -0
- graph_agents_cli/system/_model.py +363 -0
- graph_agents_cli/system/_system.py +664 -0
- graph_agents_cli/system/_views.py +208 -0
- graph_agents_cli/system/cmd_system.py +423 -0
- graph_agents_cli-0.3.1.dist-info/METADATA +162 -0
- graph_agents_cli-0.3.1.dist-info/RECORD +291 -0
- graph_agents_cli-0.3.1.dist-info/WHEEL +4 -0
- graph_agents_cli-0.3.1.dist-info/entry_points.txt +2 -0
- graph_agents_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
- graph_agents_cli-0.3.1.dist-info/licenses/NOTICE +19 -0
|
@@ -0,0 +1,4220 @@
|
|
|
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
|
+
"""Policy-enforcing HTTP clients for the external APIs the agent's tools call.
|
|
16
|
+
|
|
17
|
+
`api-policy.yaml` (path from `API_POLICY_PATH`, default `./api-policy.yaml`,
|
|
18
|
+
else the one next to the project's `pyproject.toml`) declares every API a tool
|
|
19
|
+
may call: where it lives (`base_url_env`), how requests authenticate
|
|
20
|
+
(`auth: none | bearer | forward | exchange`) and which methods and operations
|
|
21
|
+
are allowed. `get_client(name)` returns a client for one declared API; every
|
|
22
|
+
request outside the policy raises `ApiPolicyError` before anything is sent.
|
|
23
|
+
|
|
24
|
+
Fail closed: without a policy file, with an invalid one, or for an API the
|
|
25
|
+
file does not declare, `get_client` raises `ApiPolicyError`; there is no
|
|
26
|
+
unrestricted fallback. `auth: bearer` sends `Authorization: Bearer
|
|
27
|
+
$<token_env>`. `auth: forward` sends the calling principal's own credential for
|
|
28
|
+
that API, `principal.attributes["credentials"][<name>]`, in `forward_header`
|
|
29
|
+
(default `Authorization`), or, with `forward_audience`, the caller's own
|
|
30
|
+
verified bearer token when it was minted for that audience too; the principal
|
|
31
|
+
comes from the run context the server sets for the graph run, and nothing is
|
|
32
|
+
sent when the caller has no credential. `auth: exchange` sends `Bearer
|
|
33
|
+
<token>` in `forward_header`, a token the issuer mints for the API's
|
|
34
|
+
`exchange.audience` in exchange for the caller's own (RFC 8693,
|
|
35
|
+
`token_exchange.py`): asked for just before the call is sent, after every
|
|
36
|
+
check, the approval and the limits, and never for a refused or paused call.
|
|
37
|
+
An exchanged token that names no actor (no `act` claim, or one that is not a
|
|
38
|
+
readable JWT) is refused and nothing is sent, unless the API sets
|
|
39
|
+
`exchange.allow_actorless: true`.
|
|
40
|
+
A call to such an API that would loop back to this agent, or to an agent
|
|
41
|
+
already in the request's delegation chain, is refused before anything else.
|
|
42
|
+
|
|
43
|
+
An API's `protocol` (`http` by default) says how its calls are judged. For
|
|
44
|
+
`protocol: jsonrpc` (a JSON-RPC 2.0 API) and `protocol: a2a` (another agent,
|
|
45
|
+
over A2A 1.0 JSON-RPC), every POST must send one JSON-RPC request object, and
|
|
46
|
+
the client reads what it is from the body sent, never from the tool
|
|
47
|
+
(`derive_rpc`): its method (`rpc_method`; under `a2a` an A2A 0.3 name is read
|
|
48
|
+
as its 1.0 name) and, for an A2A message whose parts name an approval, what it
|
|
49
|
+
decides (`a2a_operation`: `reject` only when every such part rejects, else
|
|
50
|
+
`approve`). Operation entries may pin `rpc_method` and `a2a_operation`: an
|
|
51
|
+
allow must match them, and a denial or approval gate naming them covers every
|
|
52
|
+
call they describe, whatever its path or label. A tool's `operation_id` that
|
|
53
|
+
names an entry pinning another method or decision is refused
|
|
54
|
+
(`label_problem`), as is a message that approves without an approval gate or
|
|
55
|
+
a denial holding it: an agent never decides, on its own, an approval the agent
|
|
56
|
+
it calls waits for.
|
|
57
|
+
|
|
58
|
+
Every method the policy allows can be sent (`request()`, or `get`, `head`,
|
|
59
|
+
`post`, `put`, `patch`, `delete`, `options`), with a JSON body, query
|
|
60
|
+
parameters and extra headers. The app adds its correlation headers to every
|
|
61
|
+
call of another agent (`protocol: a2a`) or of an `auth: forward` or `auth:
|
|
62
|
+
exchange` API, and of no other (`set_outbound_headers`: the request id and,
|
|
63
|
+
under OTLP tracing, the W3C trace context; `propagates`), unless the tool sets
|
|
64
|
+
the same header; they differ per request, so an approval does not bind them. An API's optional `limits` cap the calls before
|
|
65
|
+
they are sent: `max_calls_per_run` counts the calls to that API within one
|
|
66
|
+
agent run (the run id of the LangGraph run, else the request's; calls made
|
|
67
|
+
outside any run share one count), and `rate_per_minute` is a token bucket per
|
|
68
|
+
process, so each replica allows that rate. Counters are dropped when a `/chat`
|
|
69
|
+
or A2A run ends (`end_run`), and otherwise (LangGraph Server runs included)
|
|
70
|
+
after `RUN_COUNTER_TTL_S` without a call or beyond `MAX_TRACKED_RUNS` runs, so
|
|
71
|
+
memory does not grow across runs. `limits.max_response_bytes` caps each answer:
|
|
72
|
+
the body is read (decoded) up to that many bytes and discarded past it, and the
|
|
73
|
+
call fails; unset, answers are not capped. Under a cap the client asks for gzip
|
|
74
|
+
or deflate at most and decodes them itself, never past the cap, so a small
|
|
75
|
+
compressed body cannot fill memory; an answer in another content encoding
|
|
76
|
+
(zstd, br) is refused unread.
|
|
77
|
+
|
|
78
|
+
An API's optional `approval` block names the calls a human must approve before
|
|
79
|
+
they are sent (`gated`, `ApiPolicy.gate`). It is one rule, or a list of rules
|
|
80
|
+
when different calls need different approvers: the first rule in file order
|
|
81
|
+
that covers a call gates it, and the approval is asked of that rule's approvers
|
|
82
|
+
(they are recorded with it and decide it). A call the first rule covers only
|
|
83
|
+
because it names no operation id, and that a later rule with other approvers
|
|
84
|
+
also covers, is refused: it could be either rule's call. Approval never widens
|
|
85
|
+
access: a gated call must pass the policy first, and denials still win. A gated
|
|
86
|
+
call pauses the agent run before anything is sent: the client describes the
|
|
87
|
+
exact request (`canonical_call`: API, method, URL with the rendered path,
|
|
88
|
+
query, JSON body, operation id and the tool's own headers), hashes it
|
|
89
|
+
(`call_hash`) and calls LangGraph's `interrupt()` with the approval payload
|
|
90
|
+
(the call, with the fields named in `redact=` masked, the tool and the model's
|
|
91
|
+
stated purpose, the approvers; for an A2A message that approves one of another
|
|
92
|
+
agent's approvals, that approval as the message carries it, `nested`, and the
|
|
93
|
+
call that will then happen, `effect`: `relayed_approval`, `approval_effect`).
|
|
94
|
+
The chat runtime records the approval and ends
|
|
95
|
+
the stream awaiting a decision (see `approvals.py`). When the run resumes, the
|
|
96
|
+
tool runs again from its start and this client rebuilds the request: it is sent
|
|
97
|
+
only when the decision approves exactly this request (the same hash) and the
|
|
98
|
+
approvals ledger marks that approval used (`set_approval_ledger`), so an
|
|
99
|
+
approval is sent once, never replayed. A rejected or expired approval, a
|
|
100
|
+
request that changed after it was approved, a used approval, or a gated call
|
|
101
|
+
made outside an agent run (nothing can pause it) raises `ApiPolicyError` and
|
|
102
|
+
sends nothing. After an approved call was sent, a second gated call in the same
|
|
103
|
+
tool call is refused (on its resume the tool would run again and meet the
|
|
104
|
+
first, already used, approval): make it in a new tool call. Code before a gated
|
|
105
|
+
call runs again on resume, so keep other side effects after it.
|
|
106
|
+
|
|
107
|
+
A decision is bound to the call it was taken for, not to the policy of the
|
|
108
|
+
moment: when the resumed tool rebuilds a request to the same API, method and
|
|
109
|
+
path as the paused call (and, on a JSON-RPC API, the same JSON-RPC method and
|
|
110
|
+
A2A decision, so a read sent first on resume does not take an approve
|
|
111
|
+
message's decision, and a message that rejects is not stopped by the
|
|
112
|
+
rejection it reports), the decision applies whatever the policy now says
|
|
113
|
+
about gating it (`_decision_waiting`, `call_identity`). A rejected or expired call is never
|
|
114
|
+
sent, even when the policy no longer gates it (a new image while the call
|
|
115
|
+
waited, or a typo that un-gates it); a call whose approval is still pending
|
|
116
|
+
pauses again for that same approval. An approved call is sent only when the
|
|
117
|
+
current policy still allows it (a later denial, or narrower
|
|
118
|
+
`allowed_methods`/`allowed_operations`, refuses it first), still gates it with
|
|
119
|
+
the same approvers (those of the rule that gates it now: a reordered or edited
|
|
120
|
+
list of rules that hands the call to other approvers does not keep the
|
|
121
|
+
approval), and the request is exactly the approved one; otherwise
|
|
122
|
+
nothing is sent and the model is told to ask again. A call a decision stopped
|
|
123
|
+
stays stopped for the rest of that tool call.
|
|
124
|
+
|
|
125
|
+
The ledger binds a decision to its call too, for a tool call that runs again
|
|
126
|
+
with no decision (LangGraph Server's own API can continue a paused run
|
|
127
|
+
without input, or replay it from a checkpoint, and a copied thread keeps its
|
|
128
|
+
tool calls): before a call is sent without a decision waiting for it, the
|
|
129
|
+
ledger is asked for the approvals recorded for this tool call (the model
|
|
130
|
+
message that made it and its call id, or the task's interrupt), and a call
|
|
131
|
+
one was asked for is refused, gated or not now: a rejected, expired or
|
|
132
|
+
pending one is not sent, and an approved one is sent only by the run its
|
|
133
|
+
decision resumed, once (`_bound_approvals`). Keep the agent's middleware
|
|
134
|
+
(`tool_call_scope`), which names the tool call.
|
|
135
|
+
|
|
136
|
+
Every tool module declares `API_CALLS`, a module-level list of
|
|
137
|
+
`{"api", "method", "operation_id", "path"}` dicts naming each call it makes
|
|
138
|
+
(plus `rpc_method`, and `a2a_operation` for a message that decides, on a
|
|
139
|
+
JSON-RPC API); `graph-agents-cli lint` checks those declarations against the
|
|
140
|
+
same rules.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
from __future__ import annotations
|
|
144
|
+
|
|
145
|
+
import contextlib
|
|
146
|
+
import hashlib
|
|
147
|
+
import ipaddress
|
|
148
|
+
import json
|
|
149
|
+
import logging
|
|
150
|
+
import os
|
|
151
|
+
import re
|
|
152
|
+
import threading
|
|
153
|
+
import time
|
|
154
|
+
import zlib
|
|
155
|
+
from collections import OrderedDict
|
|
156
|
+
from collections.abc import Callable, Iterable, Iterator, Mapping
|
|
157
|
+
from contextvars import ContextVar
|
|
158
|
+
from dataclasses import dataclass, field
|
|
159
|
+
from pathlib import Path
|
|
160
|
+
from typing import Any, NamedTuple, Protocol
|
|
161
|
+
from urllib.parse import quote, unquote
|
|
162
|
+
|
|
163
|
+
import httpx
|
|
164
|
+
import yaml
|
|
165
|
+
|
|
166
|
+
logger = logging.getLogger(__name__)
|
|
167
|
+
# This module logs each outbound call itself (API, method, operation, status);
|
|
168
|
+
# the HTTP client's own INFO lines would add the full URL with its values.
|
|
169
|
+
# Here as well as in the logging setup (`telemetry.QUIET_LOGGERS`, the same
|
|
170
|
+
# names): under langgraph-server the graph can run in a process that never
|
|
171
|
+
# loads the app.
|
|
172
|
+
for _name in ("httpx", "httpcore", "httpx2", "httpcore2"):
|
|
173
|
+
logging.getLogger(_name).setLevel(logging.WARNING)
|
|
174
|
+
|
|
175
|
+
POLICY_PATH_ENV = "API_POLICY_PATH"
|
|
176
|
+
|
|
177
|
+
# --- BEGIN SHARED API POLICY RULES ---
|
|
178
|
+
# Identical in graph-agents-cli (graph_agents_cli/_api_policy.py) and in every
|
|
179
|
+
# scaffolded project (app_utils/api_client.py). A CLI test keeps the two copies
|
|
180
|
+
# byte-identical: change both or neither.
|
|
181
|
+
|
|
182
|
+
POLICY_FILENAME = "api-policy.yaml"
|
|
183
|
+
AUTH_MODES = ("none", "bearer", "forward", "exchange")
|
|
184
|
+
# The modes that send the caller's identity in `forward_header` (default Authorization):
|
|
185
|
+
# `forward` the caller's own credential, `exchange` a token the issuer mints for the API in
|
|
186
|
+
# exchange for the caller's (RFC 8693, configured by the API's `exchange` block).
|
|
187
|
+
HEADER_AUTH_MODES = ("forward", "exchange")
|
|
188
|
+
EXCHANGE_KEY = "exchange"
|
|
189
|
+
# `exchange.allow_actorless: true` lets an `auth: exchange` API be called with an exchanged
|
|
190
|
+
# token that names no actor (no `act` claim, or one that is not a readable JWT); the calling
|
|
191
|
+
# agent refuses such tokens otherwise, since the agent behind the API would read them as the
|
|
192
|
+
# user's own unless it sets AUTH_JWT_DIRECT_CLIENTS.
|
|
193
|
+
ALLOW_ACTORLESS_KEY = "allow_actorless"
|
|
194
|
+
HTTP_METHODS = ("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
|
195
|
+
ANY_METHOD = "*"
|
|
196
|
+
DEFAULT_FORWARD_HEADER = "Authorization"
|
|
197
|
+
DEFAULT_TIMEOUTS_MS = (("connect", 2000), ("read", 5000))
|
|
198
|
+
|
|
199
|
+
API_NAME_RE = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
|
|
200
|
+
API_NAME_RULE = "lowercase letters, digits and underscores, starting with a letter, 1-32 characters"
|
|
201
|
+
ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
202
|
+
HEADER_NAME_RE = re.compile(r"^[A-Za-z0-9-]+$")
|
|
203
|
+
_PATH_SEGMENT_RE = re.compile(r"^(?:[^/?#\s{}]|\{[A-Za-z_][A-Za-z0-9_]*\})+$")
|
|
204
|
+
_PLACEHOLDER_SPLIT_RE = re.compile(r"(\{[^/{}]+\})")
|
|
205
|
+
_SPACE_BY_DOT_RE = re.compile(r"\s\.|\.\s")
|
|
206
|
+
|
|
207
|
+
_ESCAPE_RE = re.compile(r"%[0-9A-Fa-f]{2}")
|
|
208
|
+
_UNRESERVED = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
|
|
209
|
+
|
|
210
|
+
# An API's `protocol` says how its calls are judged: `http` (the default) by method, path
|
|
211
|
+
# and the operation id the tool names; `jsonrpc` also by the JSON-RPC request every POST
|
|
212
|
+
# sends, whose method (`rpc_method`) the policy client reads from the body, never from the
|
|
213
|
+
# tool; `a2a` (another agent, over A2A 1.0 JSON-RPC) as `jsonrpc`, plus what a message
|
|
214
|
+
# decides (`a2a_operation`: `approve` or `reject` a pending approval of that agent). A
|
|
215
|
+
# JSON-RPC API allows GET, POST and HEAD only, and an `a2a` one names its endpoint
|
|
216
|
+
# (`a2a.path`) and must gate or deny `a2a_operation: approve` if it can send messages.
|
|
217
|
+
PROTOCOL_KEY = "protocol"
|
|
218
|
+
PROTOCOL_HTTP = "http"
|
|
219
|
+
PROTOCOL_JSONRPC = "jsonrpc"
|
|
220
|
+
PROTOCOL_A2A = "a2a"
|
|
221
|
+
PROTOCOLS = (PROTOCOL_HTTP, PROTOCOL_JSONRPC, PROTOCOL_A2A)
|
|
222
|
+
DEFAULT_PROTOCOL = PROTOCOL_HTTP
|
|
223
|
+
RPC_PROTOCOLS = (PROTOCOL_JSONRPC, PROTOCOL_A2A)
|
|
224
|
+
RPC_HTTP_METHODS = ("GET", "POST", "HEAD")
|
|
225
|
+
A2A_KEY = "a2a"
|
|
226
|
+
_A2A_KEYS = ("path",)
|
|
227
|
+
DESCRIPTION_KEY = "description"
|
|
228
|
+
DESCRIPTION_MAX_CHARS = 300
|
|
229
|
+
RPC_METHOD_KEY = "rpc_method"
|
|
230
|
+
A2A_OPERATION_KEY = "a2a_operation"
|
|
231
|
+
A2A_APPROVE = "approve"
|
|
232
|
+
A2A_REJECT = "reject"
|
|
233
|
+
A2A_OPERATIONS = (A2A_APPROVE, A2A_REJECT)
|
|
234
|
+
# A JSON-RPC method name as an operation entry pins it.
|
|
235
|
+
_RPC_METHOD_RE = re.compile(r"[A-Za-z][A-Za-z0-9_/.]{0,63}")
|
|
236
|
+
# The A2A 0.3 method names and the A2A 1.0 names they are read as under `protocol: a2a`,
|
|
237
|
+
# so a 0.3 spelling of a call cannot slip past an entry that names it.
|
|
238
|
+
A2A_V03_METHODS = {
|
|
239
|
+
"message/send": "SendMessage",
|
|
240
|
+
"message/stream": "SendStreamingMessage",
|
|
241
|
+
"tasks/get": "GetTask",
|
|
242
|
+
"tasks/list": "ListTasks",
|
|
243
|
+
"tasks/cancel": "CancelTask",
|
|
244
|
+
"tasks/resubscribe": "SubscribeToTask",
|
|
245
|
+
"tasks/pushNotificationConfig/set": "CreateTaskPushNotificationConfig",
|
|
246
|
+
"tasks/pushNotificationConfig/get": "GetTaskPushNotificationConfig",
|
|
247
|
+
"tasks/pushNotificationConfig/list": "ListTaskPushNotificationConfigs",
|
|
248
|
+
"tasks/pushNotificationConfig/delete": "DeleteTaskPushNotificationConfig",
|
|
249
|
+
"agent/getAuthenticatedExtendedCard": "GetExtendedAgentCard",
|
|
250
|
+
}
|
|
251
|
+
# The A2A methods that send a message, which may carry a decision on an approval.
|
|
252
|
+
A2A_MESSAGE_METHODS = ("SendMessage", "SendStreamingMessage")
|
|
253
|
+
# Their names in any letter case, 0.3 spellings included: a message sent under any of them
|
|
254
|
+
# is read for a decision (failing closed toward a server that matched names loosely).
|
|
255
|
+
_A2A_MESSAGE_NAMES = frozenset(
|
|
256
|
+
name.casefold() for name in (*A2A_MESSAGE_METHODS, "message/send", "message/stream")
|
|
257
|
+
)
|
|
258
|
+
# The members of one JSON-RPC 2.0 request object.
|
|
259
|
+
_JSONRPC_KEYS = ("jsonrpc", "method", "params", "id")
|
|
260
|
+
|
|
261
|
+
_POLICY_KEYS = ("apis",)
|
|
262
|
+
_API_KEYS = (
|
|
263
|
+
DESCRIPTION_KEY,
|
|
264
|
+
PROTOCOL_KEY,
|
|
265
|
+
A2A_KEY,
|
|
266
|
+
"base_url_env",
|
|
267
|
+
"auth",
|
|
268
|
+
"token_env",
|
|
269
|
+
"forward_header",
|
|
270
|
+
"forward_audience",
|
|
271
|
+
EXCHANGE_KEY,
|
|
272
|
+
"allowed_methods",
|
|
273
|
+
"allowed_operations",
|
|
274
|
+
"denied_operations",
|
|
275
|
+
"openapi",
|
|
276
|
+
"timeouts_ms",
|
|
277
|
+
"pagination",
|
|
278
|
+
"limits",
|
|
279
|
+
"approval",
|
|
280
|
+
)
|
|
281
|
+
_OPERATION_KEYS = ("operationId", "path", "methods", RPC_METHOD_KEY, A2A_OPERATION_KEY)
|
|
282
|
+
_TIMEOUT_KEYS = ("connect", "read")
|
|
283
|
+
_PAGINATION_KEYS = ("page_size_param", "max_page_size")
|
|
284
|
+
_LIMIT_KEYS = ("max_calls_per_run", "rate_per_minute", "max_response_bytes")
|
|
285
|
+
# `limits.max_response_bytes`: the most a response body may hold (decoded) before the
|
|
286
|
+
# client stops reading it and discards it. Unset: no cap (as in 0.2).
|
|
287
|
+
MAX_RESPONSE_BYTES_LIMIT = 67108864
|
|
288
|
+
_APPROVAL_KEYS = ("required_for", "approvers", "timeout_s", "decide_with", "relayers")
|
|
289
|
+
_REQUIRED_FOR_KEYS = ("methods", "operations")
|
|
290
|
+
_EXCHANGE_KEYS = ("audience", "scope", "resource", ALLOW_ACTORLESS_KEY)
|
|
291
|
+
# An RFC 6749 scope: space-separated scope tokens (printable ASCII but space, " " and "\").
|
|
292
|
+
_SCOPE_RE = re.compile(r"[\x21\x23-\x5b\x5d-\x7e]+(?: [\x21\x23-\x5b\x5d-\x7e]+)*")
|
|
293
|
+
# An absolute URI (RFC 8707 `resource`): a scheme, then no whitespace and no fragment.
|
|
294
|
+
_ABSOLUTE_URI_RE = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:[^\s#\x00-\x1f\x7f]+")
|
|
295
|
+
|
|
296
|
+
# An API's `approval` block names the calls a human must approve before they are
|
|
297
|
+
# sent (`required_for`), who may approve them (`approvers`) and how long a
|
|
298
|
+
# pending approval waits before it expires, which rejects the call
|
|
299
|
+
# (`timeout_s`). It is one such rule (a mapping), or a non-empty list of rules
|
|
300
|
+
# of that same shape when different calls need different approvers: a call is
|
|
301
|
+
# gated by the FIRST rule, in file order, whose `required_for` covers it, and a
|
|
302
|
+
# later rule that also covers it does not apply to it. A call that an earlier
|
|
303
|
+
# rule covers only because it leaves out what the rule knows the operation by
|
|
304
|
+
# (no operation id, no path), and that a later rule with other approvers also
|
|
305
|
+
# covers, is refused (`ApprovalRuleConflict`): it could be either rule's call.
|
|
306
|
+
# It never widens access: a gated call must still be allowed, and denials still
|
|
307
|
+
# win. It belongs to the API only: on an operation entry the key is refused,
|
|
308
|
+
# with a pointer to `approval.required_for.operations`. A rule may also say how
|
|
309
|
+
# the requester decides (`decide_with`): `direct` (the default: with their own
|
|
310
|
+
# credentials, at this agent), or `relayed`, where the agents `relayers` names
|
|
311
|
+
# (by their actor ids) may deliver the requester's decision from another agent.
|
|
312
|
+
APPROVAL_KEY = "approval"
|
|
313
|
+
REQUESTER_APPROVER = "requester" # the principal who started the run confirms
|
|
314
|
+
ROLE_APPROVER_PREFIX = "role:" # any principal holding the role decides
|
|
315
|
+
DEFAULT_APPROVAL_TIMEOUT_S = 900
|
|
316
|
+
MIN_APPROVAL_TIMEOUT_S = 30
|
|
317
|
+
MAX_APPROVAL_TIMEOUT_S = 86400
|
|
318
|
+
# How approvers decide once an approval rule takes `decide_with` (0.3): `direct` by
|
|
319
|
+
# default, each with their own credential. `relayed`, with the `relayers` it names, lets
|
|
320
|
+
# those agents deliver the requester's decision: an opt-in that each callee's gate reviews.
|
|
321
|
+
DEFAULT_DECIDE_WITH = "direct"
|
|
322
|
+
DECIDE_DIRECT = "direct"
|
|
323
|
+
DECIDE_RELAYED = "relayed"
|
|
324
|
+
DECIDE_WITH_VALUES = (DECIDE_DIRECT, DECIDE_RELAYED)
|
|
325
|
+
# A value kept for a later release: a decision signed by the identity provider.
|
|
326
|
+
DECIDE_STEP_UP = "step_up"
|
|
327
|
+
# Role names and actor ids (`relayers`): 1-256 characters, no whitespace, commas or
|
|
328
|
+
# control characters.
|
|
329
|
+
_ROLE_NAME_RE = re.compile(r"[^\s,\x00-\x1f\x7f]{1,256}")
|
|
330
|
+
|
|
331
|
+
LEGACY_POLICY_HINT = (
|
|
332
|
+
"product_api: is the retired single-API format: move its fields under "
|
|
333
|
+
"apis: <name>: (for example apis: example:), add the now required "
|
|
334
|
+
"allowed_methods (for example [GET]), write auth: forward instead of "
|
|
335
|
+
"forwarded-session, and name the file api-policy.yaml"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class PolicyLoader(yaml.SafeLoader):
|
|
340
|
+
"""``yaml.SafeLoader`` that refuses a key repeated within one mapping, at any level.
|
|
341
|
+
|
|
342
|
+
Plain ``safe_load`` silently keeps the last duplicate, so a reviewer reading
|
|
343
|
+
``allowed_methods: [GET, POST]`` would miss a later ``allowed_methods: ["*"]``
|
|
344
|
+
that is the one applied. Merge keys (``<<: *anchor``) still work.
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
def construct_mapping(self, node: Any, deep: bool = False) -> Any:
|
|
348
|
+
if isinstance(node, yaml.MappingNode):
|
|
349
|
+
seen: set[Any] = set()
|
|
350
|
+
for key_node, _value_node in node.value:
|
|
351
|
+
if key_node.tag == "tag:yaml.org,2002:merge":
|
|
352
|
+
continue
|
|
353
|
+
key = self.construct_object(key_node, deep=deep)
|
|
354
|
+
try:
|
|
355
|
+
duplicate = key in seen
|
|
356
|
+
except TypeError: # an unhashable key: the base loader reports it
|
|
357
|
+
continue
|
|
358
|
+
if duplicate:
|
|
359
|
+
raise yaml.constructor.ConstructorError(
|
|
360
|
+
"while constructing a mapping",
|
|
361
|
+
node.start_mark,
|
|
362
|
+
f"found duplicate key {key!r}",
|
|
363
|
+
key_node.start_mark,
|
|
364
|
+
)
|
|
365
|
+
seen.add(key)
|
|
366
|
+
return super().construct_mapping(node, deep=deep)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def parse_policy_yaml(text: str) -> tuple[Any, list[str]]:
|
|
370
|
+
"""Parse api-policy.yaml text: ``(data, [])``, or ``(None, [error])`` when it is
|
|
371
|
+
not valid YAML (a duplicate key included)."""
|
|
372
|
+
try:
|
|
373
|
+
return yaml.load(text, Loader=PolicyLoader), []
|
|
374
|
+
except yaml.YAMLError as exc:
|
|
375
|
+
return None, [f"not valid YAML: {exc}"]
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def policy_errors(data: Any) -> list[str]:
|
|
379
|
+
"""Every schema error in a parsed api-policy.yaml document; empty when it is valid.
|
|
380
|
+
|
|
381
|
+
Strict: unknown keys at any level are errors, so a typo can never widen access.
|
|
382
|
+
"""
|
|
383
|
+
if not isinstance(data, Mapping):
|
|
384
|
+
return ["the document must be a mapping with a top-level 'apis' key"]
|
|
385
|
+
errors: list[str] = []
|
|
386
|
+
if "product_api" in data:
|
|
387
|
+
errors.append(LEGACY_POLICY_HINT)
|
|
388
|
+
for key in sorted(set(data) - set(_POLICY_KEYS) - {"product_api"}, key=str):
|
|
389
|
+
errors.append(f"unknown top-level key {key!r} (allowed: apis)")
|
|
390
|
+
if "apis" not in data:
|
|
391
|
+
if "product_api" not in data:
|
|
392
|
+
errors.append("apis: required (a mapping of API name to its settings)")
|
|
393
|
+
return errors
|
|
394
|
+
apis = data["apis"]
|
|
395
|
+
if not isinstance(apis, Mapping) or not apis:
|
|
396
|
+
errors.append("apis: must be a non-empty mapping of API name to its settings")
|
|
397
|
+
return errors
|
|
398
|
+
for name, api in apis.items():
|
|
399
|
+
errors.extend(_api_errors(name, api))
|
|
400
|
+
return errors
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _is_env_name(value: Any) -> bool:
|
|
404
|
+
return isinstance(value, str) and ENV_NAME_RE.match(value) is not None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _is_audience(value: Any) -> bool:
|
|
408
|
+
"""An audience (a token's `aud`): 1-256 characters, no whitespace, commas or control
|
|
409
|
+
characters (the target's `AUTH_JWT_AUDIENCE` is a comma list of them)."""
|
|
410
|
+
return isinstance(value, str) and _ROLE_NAME_RE.fullmatch(value) is not None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _exchange_errors(where: str, value: Any) -> list[str]:
|
|
414
|
+
"""Errors of an API's `exchange` block (`auth: exchange`, RFC 8693)."""
|
|
415
|
+
if not isinstance(value, Mapping):
|
|
416
|
+
return [
|
|
417
|
+
f"{where}: must be a mapping with audience, and optionally scope, resource and "
|
|
418
|
+
f"{ALLOW_ACTORLESS_KEY}"
|
|
419
|
+
]
|
|
420
|
+
errors = [
|
|
421
|
+
f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_EXCHANGE_KEYS), key=str)
|
|
422
|
+
]
|
|
423
|
+
if "audience" not in value:
|
|
424
|
+
errors.append(
|
|
425
|
+
f"{where}.audience: required (the audience the issuer mints the token for: the "
|
|
426
|
+
"target's AUTH_JWT_AUDIENCE)"
|
|
427
|
+
)
|
|
428
|
+
elif not _is_audience(value["audience"]):
|
|
429
|
+
errors.append(
|
|
430
|
+
f"{where}.audience: must be an audience (1-256 characters without spaces, commas or "
|
|
431
|
+
"control characters)"
|
|
432
|
+
)
|
|
433
|
+
if "scope" in value:
|
|
434
|
+
scope = value["scope"]
|
|
435
|
+
if not (isinstance(scope, str) and _SCOPE_RE.fullmatch(scope)):
|
|
436
|
+
errors.append(
|
|
437
|
+
f"{where}.scope: must be scopes separated by single spaces (RFC 6749: printable "
|
|
438
|
+
"ASCII, no quotes or backslashes)"
|
|
439
|
+
)
|
|
440
|
+
if "resource" in value:
|
|
441
|
+
resource = value["resource"]
|
|
442
|
+
if not (isinstance(resource, str) and _ABSOLUTE_URI_RE.fullmatch(resource)):
|
|
443
|
+
errors.append(
|
|
444
|
+
f"{where}.resource: must be an absolute URI without a fragment (RFC 8707), "
|
|
445
|
+
"such as https://orders.example.com"
|
|
446
|
+
)
|
|
447
|
+
if ALLOW_ACTORLESS_KEY in value and not isinstance(value[ALLOW_ACTORLESS_KEY], bool):
|
|
448
|
+
errors.append(
|
|
449
|
+
f"{where}.{ALLOW_ACTORLESS_KEY}: must be true or false (true: accept exchanged "
|
|
450
|
+
"tokens that name no actor, once the agent behind the API sets "
|
|
451
|
+
"AUTH_JWT_DIRECT_CLIENTS)"
|
|
452
|
+
)
|
|
453
|
+
return errors
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _api_errors(name: Any, api: Any) -> list[str]:
|
|
457
|
+
where = f"apis.{name}"
|
|
458
|
+
errors: list[str] = []
|
|
459
|
+
if not isinstance(name, str) or not API_NAME_RE.match(name):
|
|
460
|
+
errors.append(f"{where}: invalid API name ({API_NAME_RULE})")
|
|
461
|
+
if not isinstance(api, Mapping):
|
|
462
|
+
errors.append(f"{where}: must be a mapping")
|
|
463
|
+
return errors
|
|
464
|
+
for key in sorted(set(api) - set(_API_KEYS), key=str):
|
|
465
|
+
errors.append(f"{where}: unknown key {key!r}")
|
|
466
|
+
|
|
467
|
+
if "base_url_env" not in api:
|
|
468
|
+
errors.append(f"{where}.base_url_env: required")
|
|
469
|
+
elif not _is_env_name(api["base_url_env"]):
|
|
470
|
+
errors.append(f"{where}.base_url_env: must be an environment variable name")
|
|
471
|
+
|
|
472
|
+
auth = api.get("auth")
|
|
473
|
+
modes = ", ".join(AUTH_MODES)
|
|
474
|
+
if "auth" not in api:
|
|
475
|
+
errors.append(f"{where}.auth: required (one of {modes})")
|
|
476
|
+
elif auth not in AUTH_MODES:
|
|
477
|
+
errors.append(f"{where}.auth: must be one of {modes} (got {auth!r})")
|
|
478
|
+
|
|
479
|
+
if auth == "bearer":
|
|
480
|
+
if "token_env" not in api:
|
|
481
|
+
errors.append(f"{where}.token_env: required when auth is bearer")
|
|
482
|
+
elif not _is_env_name(api["token_env"]):
|
|
483
|
+
errors.append(f"{where}.token_env: must be an environment variable name")
|
|
484
|
+
elif "token_env" in api:
|
|
485
|
+
errors.append(f"{where}.token_env: only valid with auth: bearer")
|
|
486
|
+
|
|
487
|
+
if "forward_header" in api:
|
|
488
|
+
header = api["forward_header"]
|
|
489
|
+
if auth not in HEADER_AUTH_MODES:
|
|
490
|
+
errors.append(f"{where}.forward_header: only valid with auth: forward or exchange")
|
|
491
|
+
elif not (isinstance(header, str) and HEADER_NAME_RE.match(header)):
|
|
492
|
+
errors.append(f"{where}.forward_header: must be an HTTP header name")
|
|
493
|
+
|
|
494
|
+
if "forward_audience" in api:
|
|
495
|
+
if auth != "forward":
|
|
496
|
+
errors.append(f"{where}.forward_audience: only valid with auth: forward")
|
|
497
|
+
elif not _is_audience(api["forward_audience"]):
|
|
498
|
+
errors.append(
|
|
499
|
+
f"{where}.forward_audience: must be an audience (1-256 characters without "
|
|
500
|
+
"spaces, commas or control characters)"
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
if auth == "exchange":
|
|
504
|
+
if EXCHANGE_KEY not in api:
|
|
505
|
+
errors.append(
|
|
506
|
+
f"{where}.{EXCHANGE_KEY}: required when auth is exchange (a mapping with the "
|
|
507
|
+
"audience the issuer mints the token for, and optionally scope, resource and "
|
|
508
|
+
f"{ALLOW_ACTORLESS_KEY})"
|
|
509
|
+
)
|
|
510
|
+
else:
|
|
511
|
+
errors.extend(_exchange_errors(f"{where}.{EXCHANGE_KEY}", api[EXCHANGE_KEY]))
|
|
512
|
+
elif EXCHANGE_KEY in api:
|
|
513
|
+
errors.append(f"{where}.{EXCHANGE_KEY}: only valid with auth: exchange")
|
|
514
|
+
|
|
515
|
+
protocol = api.get(PROTOCOL_KEY, DEFAULT_PROTOCOL)
|
|
516
|
+
errors.extend(_protocol_errors(where, api, protocol, auth))
|
|
517
|
+
|
|
518
|
+
if "allowed_methods" not in api:
|
|
519
|
+
errors.append(f'{where}.allowed_methods: required (a list of HTTP methods, or ["*"])')
|
|
520
|
+
else:
|
|
521
|
+
method_errors = _methods_errors(f"{where}.allowed_methods", api["allowed_methods"], True)
|
|
522
|
+
errors.extend(method_errors)
|
|
523
|
+
if protocol in RPC_PROTOCOLS and not method_errors:
|
|
524
|
+
outside = [
|
|
525
|
+
str(m).upper()
|
|
526
|
+
for m in api["allowed_methods"]
|
|
527
|
+
if str(m).upper() not in RPC_HTTP_METHODS
|
|
528
|
+
]
|
|
529
|
+
if outside:
|
|
530
|
+
errors.append(
|
|
531
|
+
f"{where}.allowed_methods: protocol {protocol} allows GET, POST and HEAD "
|
|
532
|
+
f"only (a JSON-RPC request is a POST), not {', '.join(outside)}"
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
if "allowed_operations" in api:
|
|
536
|
+
errors.extend(
|
|
537
|
+
_operations_errors(
|
|
538
|
+
f"{where}.allowed_operations",
|
|
539
|
+
api["allowed_operations"],
|
|
540
|
+
where,
|
|
541
|
+
"must not be empty; omit the key to allow every operation within allowed_methods",
|
|
542
|
+
protocol=protocol,
|
|
543
|
+
)
|
|
544
|
+
)
|
|
545
|
+
if "denied_operations" in api:
|
|
546
|
+
errors.extend(
|
|
547
|
+
_operations_errors(
|
|
548
|
+
f"{where}.denied_operations", api["denied_operations"], where, protocol=protocol
|
|
549
|
+
)
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
if "openapi" in api:
|
|
553
|
+
openapi = api["openapi"]
|
|
554
|
+
if not (isinstance(openapi, str) and openapi.strip()):
|
|
555
|
+
errors.append(f"{where}.openapi: must be a file path")
|
|
556
|
+
|
|
557
|
+
if "timeouts_ms" in api:
|
|
558
|
+
errors.extend(_timeouts_errors(f"{where}.timeouts_ms", api["timeouts_ms"]))
|
|
559
|
+
if "pagination" in api:
|
|
560
|
+
errors.extend(_pagination_errors(f"{where}.pagination", api["pagination"]))
|
|
561
|
+
if "limits" in api:
|
|
562
|
+
errors.extend(_limits_errors(f"{where}.limits", api["limits"]))
|
|
563
|
+
if APPROVAL_KEY in api:
|
|
564
|
+
errors.extend(_approval_errors(where, api[APPROVAL_KEY], protocol=protocol))
|
|
565
|
+
if not errors:
|
|
566
|
+
errors.extend(_approve_errors(where, api))
|
|
567
|
+
return errors
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _protocol_errors(where: str, api: Mapping[str, Any], protocol: Any, auth: Any) -> list[str]:
|
|
571
|
+
"""Errors of an API's `protocol`, `a2a` and `description`."""
|
|
572
|
+
errors: list[str] = []
|
|
573
|
+
if protocol not in PROTOCOLS:
|
|
574
|
+
errors.append(
|
|
575
|
+
f"{where}.{PROTOCOL_KEY}: must be one of {', '.join(PROTOCOLS)} (got {protocol!r})"
|
|
576
|
+
)
|
|
577
|
+
if protocol == PROTOCOL_A2A:
|
|
578
|
+
if A2A_KEY not in api:
|
|
579
|
+
errors.append(
|
|
580
|
+
f"{where}.{A2A_KEY}: required with protocol a2a (a mapping with path, the "
|
|
581
|
+
"agent's A2A endpoint, such as /a2a/orders)"
|
|
582
|
+
)
|
|
583
|
+
else:
|
|
584
|
+
errors.extend(_a2a_errors(f"{where}.{A2A_KEY}", api[A2A_KEY]))
|
|
585
|
+
if auth == "none":
|
|
586
|
+
errors.append(
|
|
587
|
+
f"{where}.auth: protocol a2a needs a credential (bearer, forward or exchange): "
|
|
588
|
+
"an agent's A2A endpoint authenticates its callers"
|
|
589
|
+
)
|
|
590
|
+
elif A2A_KEY in api:
|
|
591
|
+
errors.append(f"{where}.{A2A_KEY}: only valid with protocol a2a")
|
|
592
|
+
if DESCRIPTION_KEY in api:
|
|
593
|
+
description = api[DESCRIPTION_KEY]
|
|
594
|
+
if not (
|
|
595
|
+
isinstance(description, str)
|
|
596
|
+
and description.strip()
|
|
597
|
+
and len(description) <= DESCRIPTION_MAX_CHARS
|
|
598
|
+
and not any(ord(c) < 0x20 or 0x7F <= ord(c) < 0xA0 for c in description)
|
|
599
|
+
):
|
|
600
|
+
errors.append(
|
|
601
|
+
f"{where}.{DESCRIPTION_KEY}: must be text of 1-{DESCRIPTION_MAX_CHARS} "
|
|
602
|
+
"characters without control characters"
|
|
603
|
+
)
|
|
604
|
+
return errors
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _a2a_errors(where: str, value: Any) -> list[str]:
|
|
608
|
+
"""Errors of an API's `a2a` block: `path`, the literal path of the agent's A2A endpoint."""
|
|
609
|
+
if not isinstance(value, Mapping):
|
|
610
|
+
return [f"{where}: must be a mapping with path (the agent's A2A endpoint, /a2a/<name>)"]
|
|
611
|
+
errors = [
|
|
612
|
+
f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_A2A_KEYS), key=str)
|
|
613
|
+
]
|
|
614
|
+
if "path" not in value:
|
|
615
|
+
errors.append(f"{where}.path: required (the agent's A2A endpoint, such as /a2a/orders)")
|
|
616
|
+
return errors
|
|
617
|
+
path = value["path"]
|
|
618
|
+
problem = path_template_problem(path)
|
|
619
|
+
if problem:
|
|
620
|
+
errors.append(f"{where}.path: {problem}")
|
|
621
|
+
elif "{" in path or path.rstrip("/") == "":
|
|
622
|
+
errors.append(
|
|
623
|
+
f"{where}.path: must be the literal path of one endpoint (no placeholders), such "
|
|
624
|
+
"as /a2a/orders"
|
|
625
|
+
)
|
|
626
|
+
return errors
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def api_protocol(api: Mapping[str, Any]) -> str:
|
|
630
|
+
"""An API's `protocol`: `http` when it sets none."""
|
|
631
|
+
return str(api.get(PROTOCOL_KEY) or DEFAULT_PROTOCOL)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _rpc_pins(entry: Mapping[str, Any]) -> bool:
|
|
635
|
+
"""Whether an operation entry pins what a JSON-RPC request is (`rpc_method`, `a2a_operation`)."""
|
|
636
|
+
return entry.get(RPC_METHOD_KEY) is not None or entry.get(A2A_OPERATION_KEY) is not None
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _may_send_approve(api: Mapping[str, Any]) -> bool:
|
|
640
|
+
"""Whether an A2A API's allow-list may let through a message that approves (`approve`)."""
|
|
641
|
+
methods = {str(m).upper() for m in api.get("allowed_methods") or []}
|
|
642
|
+
if "POST" not in methods and ANY_METHOD not in methods:
|
|
643
|
+
return False
|
|
644
|
+
allowed = api.get("allowed_operations")
|
|
645
|
+
if allowed is None:
|
|
646
|
+
return True
|
|
647
|
+
return any(
|
|
648
|
+
_methods_match(entry, "POST")
|
|
649
|
+
and entry.get(RPC_METHOD_KEY) in (None, *A2A_MESSAGE_METHODS)
|
|
650
|
+
and entry.get(A2A_OPERATION_KEY) in (None, A2A_APPROVE)
|
|
651
|
+
for entry in allowed
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _covers_every_approve(entry: Mapping[str, Any]) -> bool:
|
|
656
|
+
"""Whether a denial or gate entry covers every message that approves, on any path."""
|
|
657
|
+
return entry.get(A2A_OPERATION_KEY) == A2A_APPROVE and _methods_match(entry, "POST")
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def approve_is_held(api: Mapping[str, Any]) -> bool:
|
|
661
|
+
"""Whether every message that approves waits for a human approval, or is denied.
|
|
662
|
+
|
|
663
|
+
An approval rule gating POST (or `"*"`), or an entry `a2a_operation: approve` (with no
|
|
664
|
+
methods, or POST among them) in a rule's `required_for.operations` or in
|
|
665
|
+
`denied_operations`. An entry pinning `rpc_method: SendMessage` does not count: it
|
|
666
|
+
leaves `SendStreamingMessage` out.
|
|
667
|
+
"""
|
|
668
|
+
for rule in approval_rules(api):
|
|
669
|
+
required_for = rule.get("required_for") or {}
|
|
670
|
+
methods = {str(m).upper() for m in required_for.get("methods") or []}
|
|
671
|
+
if "POST" in methods or ANY_METHOD in methods:
|
|
672
|
+
return True
|
|
673
|
+
if any(_covers_every_approve(entry) for entry in required_for.get("operations") or []):
|
|
674
|
+
return True
|
|
675
|
+
return any(_covers_every_approve(entry) for entry in api.get("denied_operations") or [])
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _approve_errors(where: str, api: Mapping[str, Any]) -> list[str]:
|
|
679
|
+
"""A `protocol: a2a` API that may send a message must gate or deny `approve`: otherwise
|
|
680
|
+
this agent could decide, on its own, the approvals the agent behind it waits for."""
|
|
681
|
+
if api_protocol(api) != PROTOCOL_A2A or not _may_send_approve(api) or approve_is_held(api):
|
|
682
|
+
return []
|
|
683
|
+
name = where.split(".", 1)[1] if "." in where else where
|
|
684
|
+
agent = str((api.get(A2A_KEY) or {}).get("path") or "").rstrip("/").rsplit("/", 1)[-1]
|
|
685
|
+
return [
|
|
686
|
+
f"{where}: protocol a2a allows SendMessage, so this agent could decide approvals at "
|
|
687
|
+
f"{agent or name}: gate them (graph-agents-cli api approval {name} --a2a-operations "
|
|
688
|
+
f"approve --approvers requester) or deny them (graph-agents-cli api deny {name} "
|
|
689
|
+
"--a2a-operation approve)"
|
|
690
|
+
]
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _methods_errors(where: str, value: Any, allow_any: bool) -> list[str]:
|
|
694
|
+
if not isinstance(value, list) or not value:
|
|
695
|
+
return [f"{where}: must be a non-empty list of HTTP methods"]
|
|
696
|
+
errors: list[str] = []
|
|
697
|
+
if allow_any and ANY_METHOD in value and len(value) != 1:
|
|
698
|
+
errors.append(f'{where}: "*" must be the only entry when present')
|
|
699
|
+
for method in value:
|
|
700
|
+
if allow_any and method == ANY_METHOD:
|
|
701
|
+
continue
|
|
702
|
+
if not isinstance(method, str) or method.upper() not in HTTP_METHODS:
|
|
703
|
+
errors.append(
|
|
704
|
+
f"{where}: unknown HTTP method {method!r} (allowed: {', '.join(HTTP_METHODS)})"
|
|
705
|
+
)
|
|
706
|
+
return errors
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _operations_errors(
|
|
710
|
+
where: str,
|
|
711
|
+
value: Any,
|
|
712
|
+
api_where: str,
|
|
713
|
+
empty_error: str | None = None,
|
|
714
|
+
*,
|
|
715
|
+
protocol: Any = DEFAULT_PROTOCOL,
|
|
716
|
+
) -> list[str]:
|
|
717
|
+
"""Errors of a list of operation entries; ``empty_error`` refuses an empty list.
|
|
718
|
+
|
|
719
|
+
``rpc_method`` is valid only with `protocol` jsonrpc or a2a, and ``a2a_operation``
|
|
720
|
+
only with a2a.
|
|
721
|
+
"""
|
|
722
|
+
if not isinstance(value, list):
|
|
723
|
+
return [f"{where}: must be a list of operations"]
|
|
724
|
+
if not value and empty_error:
|
|
725
|
+
return [f"{where}: {empty_error}"]
|
|
726
|
+
errors: list[str] = []
|
|
727
|
+
for index, entry in enumerate(value):
|
|
728
|
+
at = f"{where}[{index}]"
|
|
729
|
+
if not isinstance(entry, Mapping):
|
|
730
|
+
errors.append(f"{at}: must be a mapping with operationId and/or path")
|
|
731
|
+
continue
|
|
732
|
+
for key in sorted(set(entry) - set(_OPERATION_KEYS) - {APPROVAL_KEY}, key=str):
|
|
733
|
+
errors.append(f"{at}: unknown key {key!r}")
|
|
734
|
+
if APPROVAL_KEY in entry:
|
|
735
|
+
errors.append(
|
|
736
|
+
f"{at}.{APPROVAL_KEY}: not valid on an operation entry; gate the operation "
|
|
737
|
+
f"with {api_where}.{APPROVAL_KEY}.required_for.operations"
|
|
738
|
+
)
|
|
739
|
+
if protocol in RPC_PROTOCOLS:
|
|
740
|
+
if not any(key in entry for key in _OPERATION_KEYS if key != "methods"):
|
|
741
|
+
errors.append(f"{at}: needs operationId, path, rpc_method and/or a2a_operation")
|
|
742
|
+
elif "operationId" not in entry and "path" not in entry:
|
|
743
|
+
errors.append(f"{at}: needs operationId and/or path")
|
|
744
|
+
errors.extend(_rpc_entry_errors(at, entry, protocol))
|
|
745
|
+
if "operationId" in entry:
|
|
746
|
+
op_id = entry["operationId"]
|
|
747
|
+
if not isinstance(op_id, str) or not op_id or any(c.isspace() for c in op_id):
|
|
748
|
+
errors.append(f"{at}.operationId: must be a non-empty string without spaces")
|
|
749
|
+
if "path" in entry:
|
|
750
|
+
problem = path_template_problem(entry["path"])
|
|
751
|
+
if problem:
|
|
752
|
+
errors.append(f"{at}.path: {problem}")
|
|
753
|
+
if "methods" in entry:
|
|
754
|
+
errors.extend(_methods_errors(f"{at}.methods", entry["methods"], False))
|
|
755
|
+
return errors
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _rpc_entry_errors(at: str, entry: Mapping[str, Any], protocol: Any) -> list[str]:
|
|
759
|
+
"""Errors of an operation entry's `rpc_method` and `a2a_operation`."""
|
|
760
|
+
errors: list[str] = []
|
|
761
|
+
rpc_method = entry.get(RPC_METHOD_KEY)
|
|
762
|
+
if RPC_METHOD_KEY in entry:
|
|
763
|
+
if protocol not in RPC_PROTOCOLS:
|
|
764
|
+
errors.append(f"{at}.{RPC_METHOD_KEY}: only valid with protocol jsonrpc or a2a")
|
|
765
|
+
elif not (isinstance(rpc_method, str) and _RPC_METHOD_RE.fullmatch(rpc_method)):
|
|
766
|
+
errors.append(
|
|
767
|
+
f"{at}.{RPC_METHOD_KEY}: must be a JSON-RPC method name (a letter, then up to 63 "
|
|
768
|
+
"letters, digits, '_', '/' or '.')"
|
|
769
|
+
)
|
|
770
|
+
elif protocol == PROTOCOL_A2A and rpc_method in A2A_V03_METHODS:
|
|
771
|
+
errors.append(
|
|
772
|
+
f"{at}.{RPC_METHOD_KEY}: {rpc_method} is the A2A 0.3 name; write "
|
|
773
|
+
f"{A2A_V03_METHODS[rpc_method]} (a 0.3 name in a request is read as its 1.0 name)"
|
|
774
|
+
)
|
|
775
|
+
if A2A_OPERATION_KEY in entry:
|
|
776
|
+
operation = entry[A2A_OPERATION_KEY]
|
|
777
|
+
if protocol != PROTOCOL_A2A:
|
|
778
|
+
errors.append(f"{at}.{A2A_OPERATION_KEY}: only valid with protocol a2a")
|
|
779
|
+
elif operation not in A2A_OPERATIONS:
|
|
780
|
+
errors.append(f"{at}.{A2A_OPERATION_KEY}: must be approve or reject")
|
|
781
|
+
elif rpc_method is not None and rpc_method not in A2A_MESSAGE_METHODS:
|
|
782
|
+
errors.append(
|
|
783
|
+
f"{at}.{A2A_OPERATION_KEY}: goes with rpc_method SendMessage or "
|
|
784
|
+
f"SendStreamingMessage (the messages that decide an approval), not {rpc_method}"
|
|
785
|
+
)
|
|
786
|
+
return errors
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _is_positive_int(value: Any) -> bool:
|
|
790
|
+
return isinstance(value, int) and not isinstance(value, bool) and value > 0
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _timeouts_errors(where: str, value: Any) -> list[str]:
|
|
794
|
+
if not isinstance(value, Mapping):
|
|
795
|
+
return [f"{where}: must be a mapping with connect and/or read (milliseconds)"]
|
|
796
|
+
errors = [
|
|
797
|
+
f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_TIMEOUT_KEYS), key=str)
|
|
798
|
+
]
|
|
799
|
+
for key in _TIMEOUT_KEYS:
|
|
800
|
+
if key in value and not _is_positive_int(value[key]):
|
|
801
|
+
errors.append(f"{where}.{key}: must be a positive integer (milliseconds)")
|
|
802
|
+
return errors
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _pagination_errors(where: str, value: Any) -> list[str]:
|
|
806
|
+
if not isinstance(value, Mapping):
|
|
807
|
+
return [f"{where}: must be a mapping with page_size_param and max_page_size"]
|
|
808
|
+
errors = [
|
|
809
|
+
f"{where}: unknown key {key!r}"
|
|
810
|
+
for key in sorted(set(value) - set(_PAGINATION_KEYS), key=str)
|
|
811
|
+
]
|
|
812
|
+
param = value.get("page_size_param")
|
|
813
|
+
if "page_size_param" not in value:
|
|
814
|
+
errors.append(f"{where}.page_size_param: required")
|
|
815
|
+
elif not (isinstance(param, str) and param.strip()):
|
|
816
|
+
errors.append(f"{where}.page_size_param: must be a non-empty string")
|
|
817
|
+
if "max_page_size" not in value:
|
|
818
|
+
errors.append(f"{where}.max_page_size: required")
|
|
819
|
+
elif not _is_positive_int(value["max_page_size"]):
|
|
820
|
+
errors.append(f"{where}.max_page_size: must be a positive integer")
|
|
821
|
+
return errors
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _limits_errors(where: str, value: Any) -> list[str]:
|
|
825
|
+
if not isinstance(value, Mapping) or not value:
|
|
826
|
+
return [
|
|
827
|
+
f"{where}: must be a mapping with max_calls_per_run, rate_per_minute and/or "
|
|
828
|
+
"max_response_bytes"
|
|
829
|
+
]
|
|
830
|
+
errors = [
|
|
831
|
+
f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_LIMIT_KEYS), key=str)
|
|
832
|
+
]
|
|
833
|
+
for key in _LIMIT_KEYS:
|
|
834
|
+
if key in value and not _is_positive_int(value[key]):
|
|
835
|
+
errors.append(f"{where}.{key}: must be an integer >= 1")
|
|
836
|
+
size = value.get("max_response_bytes")
|
|
837
|
+
if _is_positive_int(size) and size > MAX_RESPONSE_BYTES_LIMIT:
|
|
838
|
+
errors.append(
|
|
839
|
+
f"{where}.max_response_bytes: must be an integer from 1 to "
|
|
840
|
+
f"{MAX_RESPONSE_BYTES_LIMIT} (bytes; 64 MiB at most)"
|
|
841
|
+
)
|
|
842
|
+
return errors
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _approval_errors(api_where: str, value: Any, *, protocol: Any = DEFAULT_PROTOCOL) -> list[str]:
|
|
846
|
+
"""Errors of an API's `approval`: one rule (a mapping), or a non-empty list of rules."""
|
|
847
|
+
where = f"{api_where}.{APPROVAL_KEY}"
|
|
848
|
+
if isinstance(value, list):
|
|
849
|
+
if not value:
|
|
850
|
+
return [f"{where}: must not be empty; omit the key when no call needs approval"]
|
|
851
|
+
errors: list[str] = []
|
|
852
|
+
for index, rule in enumerate(value):
|
|
853
|
+
errors.extend(
|
|
854
|
+
_approval_rule_errors(f"{where}[{index}]", rule, api_where, protocol=protocol)
|
|
855
|
+
)
|
|
856
|
+
return errors
|
|
857
|
+
if not isinstance(value, Mapping):
|
|
858
|
+
return [
|
|
859
|
+
f"{where}: must be a mapping with required_for and approvers, or a non-empty "
|
|
860
|
+
"list of such mappings (rules; the first that covers a call gates it)"
|
|
861
|
+
]
|
|
862
|
+
return _approval_rule_errors(where, value, api_where, protocol=protocol)
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _approval_rule_errors(
|
|
866
|
+
where: str, value: Any, api_where: str, *, protocol: Any = DEFAULT_PROTOCOL
|
|
867
|
+
) -> list[str]:
|
|
868
|
+
"""Errors of one approval rule (``where``: ``apis.<name>.approval`` or ``...approval[i]``)."""
|
|
869
|
+
if not isinstance(value, Mapping):
|
|
870
|
+
return [f"{where}: must be a mapping with required_for and approvers"]
|
|
871
|
+
errors = [
|
|
872
|
+
f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_APPROVAL_KEYS), key=str)
|
|
873
|
+
]
|
|
874
|
+
if "required_for" not in value:
|
|
875
|
+
errors.append(f"{where}.required_for: required (the methods and/or operations it gates)")
|
|
876
|
+
else:
|
|
877
|
+
errors.extend(
|
|
878
|
+
_required_for_errors(
|
|
879
|
+
f"{where}.required_for", value["required_for"], api_where, protocol=protocol
|
|
880
|
+
)
|
|
881
|
+
)
|
|
882
|
+
if "approvers" not in value:
|
|
883
|
+
errors.append(f'{where}.approvers: required (a list of "requester" and/or "role:<name>")')
|
|
884
|
+
else:
|
|
885
|
+
errors.extend(_approvers_errors(f"{where}.approvers", value["approvers"]))
|
|
886
|
+
if "timeout_s" in value:
|
|
887
|
+
timeout = value["timeout_s"]
|
|
888
|
+
if not (
|
|
889
|
+
isinstance(timeout, int)
|
|
890
|
+
and not isinstance(timeout, bool)
|
|
891
|
+
and MIN_APPROVAL_TIMEOUT_S <= timeout <= MAX_APPROVAL_TIMEOUT_S
|
|
892
|
+
):
|
|
893
|
+
errors.append(
|
|
894
|
+
f"{where}.timeout_s: must be an integer from {MIN_APPROVAL_TIMEOUT_S} to "
|
|
895
|
+
f"{MAX_APPROVAL_TIMEOUT_S} (seconds)"
|
|
896
|
+
)
|
|
897
|
+
errors.extend(_decide_with_errors(where, value))
|
|
898
|
+
return errors
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _decide_with_errors(where: str, rule: Mapping[str, Any]) -> list[str]:
|
|
902
|
+
"""Errors of a rule's `decide_with` and `relayers`."""
|
|
903
|
+
decide_with = rule.get("decide_with", DEFAULT_DECIDE_WITH)
|
|
904
|
+
errors: list[str] = []
|
|
905
|
+
if decide_with == DECIDE_STEP_UP:
|
|
906
|
+
errors.append(f"{where}.decide_with: step_up is not supported yet (direct or relayed)")
|
|
907
|
+
elif decide_with not in DECIDE_WITH_VALUES:
|
|
908
|
+
errors.append(f"{where}.decide_with: must be direct or relayed (got {decide_with!r})")
|
|
909
|
+
if decide_with != DECIDE_RELAYED:
|
|
910
|
+
if "relayers" in rule:
|
|
911
|
+
errors.append(f"{where}.relayers: only valid with decide_with: relayed")
|
|
912
|
+
return errors
|
|
913
|
+
approvers = rule.get("approvers")
|
|
914
|
+
if isinstance(approvers, list) and REQUESTER_APPROVER not in approvers:
|
|
915
|
+
errors.append(
|
|
916
|
+
f"{where}.decide_with: relayed needs requester in approvers (role approvers always "
|
|
917
|
+
"decide with their own direct credentials, never relayed)"
|
|
918
|
+
)
|
|
919
|
+
if "relayers" not in rule:
|
|
920
|
+
errors.append(
|
|
921
|
+
f"{where}.relayers: required with decide_with: relayed (the agents, by actor id, "
|
|
922
|
+
"that may deliver the requester's decision)"
|
|
923
|
+
)
|
|
924
|
+
return errors
|
|
925
|
+
relayers = rule["relayers"]
|
|
926
|
+
if not isinstance(relayers, list) or not relayers:
|
|
927
|
+
return [*errors, f"{where}.relayers: must be a non-empty list of agent (actor) ids"]
|
|
928
|
+
for index, relayer in enumerate(relayers):
|
|
929
|
+
if not (isinstance(relayer, str) and _ROLE_NAME_RE.fullmatch(relayer)):
|
|
930
|
+
errors.append(
|
|
931
|
+
f"{where}.relayers[{index}]: {relayer!r} is not an agent id (1-256 characters "
|
|
932
|
+
"without spaces, commas or control characters)"
|
|
933
|
+
)
|
|
934
|
+
return errors
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _required_for_errors(
|
|
938
|
+
where: str, value: Any, api_where: str, *, protocol: Any = DEFAULT_PROTOCOL
|
|
939
|
+
) -> list[str]:
|
|
940
|
+
if not isinstance(value, Mapping) or not value:
|
|
941
|
+
return [f"{where}: must be a mapping with methods and/or operations"]
|
|
942
|
+
errors = [
|
|
943
|
+
f"{where}: unknown key {key!r}"
|
|
944
|
+
for key in sorted(set(value) - set(_REQUIRED_FOR_KEYS), key=str)
|
|
945
|
+
]
|
|
946
|
+
if "methods" not in value and "operations" not in value:
|
|
947
|
+
errors.append(f"{where}: needs methods and/or operations")
|
|
948
|
+
if "methods" in value:
|
|
949
|
+
errors.extend(_methods_errors(f"{where}.methods", value["methods"], True))
|
|
950
|
+
if "operations" in value:
|
|
951
|
+
errors.extend(
|
|
952
|
+
_operations_errors(
|
|
953
|
+
f"{where}.operations",
|
|
954
|
+
value["operations"],
|
|
955
|
+
api_where,
|
|
956
|
+
"must not be empty; omit the key when no operation needs approval",
|
|
957
|
+
protocol=protocol,
|
|
958
|
+
)
|
|
959
|
+
)
|
|
960
|
+
return errors
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _approvers_errors(where: str, value: Any) -> list[str]:
|
|
964
|
+
if not isinstance(value, list) or not value:
|
|
965
|
+
return [f'{where}: must be a non-empty list of "requester" and/or "role:<name>"']
|
|
966
|
+
errors: list[str] = []
|
|
967
|
+
for index, approver in enumerate(value):
|
|
968
|
+
if approver == REQUESTER_APPROVER:
|
|
969
|
+
continue
|
|
970
|
+
if (
|
|
971
|
+
isinstance(approver, str)
|
|
972
|
+
and approver.startswith(ROLE_APPROVER_PREFIX)
|
|
973
|
+
and _ROLE_NAME_RE.fullmatch(approver[len(ROLE_APPROVER_PREFIX) :])
|
|
974
|
+
):
|
|
975
|
+
continue
|
|
976
|
+
errors.append(
|
|
977
|
+
f'{where}[{index}]: {approver!r} is not an approver ("requester", or "role:<name>" '
|
|
978
|
+
"with a role name of 1-256 characters without spaces or commas)"
|
|
979
|
+
)
|
|
980
|
+
return errors
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def segment_text_problem(text: str) -> str | None:
|
|
984
|
+
"""Why a path segment's text (percent-decoded) is refused, or None.
|
|
985
|
+
|
|
986
|
+
Each of these would send a call to an endpoint other than the one the
|
|
987
|
+
policy judged: a control character anywhere (``%00``: servers that end a
|
|
988
|
+
path at a NUL route it to the part before); whitespace at either end
|
|
989
|
+
(``cancel%20``: servers that trim path segments route it to ``cancel``)
|
|
990
|
+
or next to a dot (``cancel%20.json``, ``cancel%20%2e``: servers that
|
|
991
|
+
trim the name before a format suffix, or strip trailing dots and spaces,
|
|
992
|
+
route it to ``cancel``); a backslash or a slash (``%5C``, ``%2F``:
|
|
993
|
+
servers that decode them before routing split the segment); a ``;``
|
|
994
|
+
(``%3B``: servers that strip path parameters route ``cancel;x`` to
|
|
995
|
+
``cancel``). Whitespace elsewhere in a segment (``red%20shirt``) is kept:
|
|
996
|
+
trimming does not touch it.
|
|
997
|
+
"""
|
|
998
|
+
if any(ord(char) < 0x20 or ord(char) == 0x7F for char in text):
|
|
999
|
+
return "holds a control character (also percent-encoded, such as %00)"
|
|
1000
|
+
if text[:1].isspace() or text[-1:].isspace():
|
|
1001
|
+
return "starts or ends with whitespace (also percent-encoded, such as %20)"
|
|
1002
|
+
if _SPACE_BY_DOT_RE.search(text):
|
|
1003
|
+
return "has whitespace next to a dot (also percent-encoded, such as cancel%20.json)"
|
|
1004
|
+
if "/" in text or "\\" in text:
|
|
1005
|
+
return "holds a backslash or a percent-encoded slash (%5C, %2F)"
|
|
1006
|
+
if ";" in text:
|
|
1007
|
+
return "holds ';' (also percent-encoded, %3B), which some servers strip with what follows"
|
|
1008
|
+
return None
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def path_template_problem(path: Any) -> str | None:
|
|
1012
|
+
"""Why ``path`` is not a valid path template, or None.
|
|
1013
|
+
|
|
1014
|
+
A template starts with ``/``; each segment holds literal characters and
|
|
1015
|
+
``{name}`` placeholders only (no query, fragment, spaces, empty, ``.`` or
|
|
1016
|
+
``..`` segments, also percent-encoded), and none of the characters a
|
|
1017
|
+
sent path is refused for, percent-encoded or not (``segment_text_problem``:
|
|
1018
|
+
control characters, whitespace at either end or next to a dot, a
|
|
1019
|
+
backslash or an encoded slash, ``;``), so lint passes no declared call the
|
|
1020
|
+
client would refuse to send. One trailing slash is allowed.
|
|
1021
|
+
"""
|
|
1022
|
+
if not isinstance(path, str) or not path.startswith("/"):
|
|
1023
|
+
return "must be a string starting with /"
|
|
1024
|
+
body = path[1:]
|
|
1025
|
+
if body.endswith("/"):
|
|
1026
|
+
body = body[:-1]
|
|
1027
|
+
if not body:
|
|
1028
|
+
return None
|
|
1029
|
+
for segment in body.split("/"):
|
|
1030
|
+
if segment in ("", ".", ".."):
|
|
1031
|
+
return "must not contain empty, '.' or '..' segments"
|
|
1032
|
+
if not _PATH_SEGMENT_RE.match(segment):
|
|
1033
|
+
return (
|
|
1034
|
+
"segments may hold literal characters and {name} placeholders only "
|
|
1035
|
+
"(no query, fragment or whitespace)"
|
|
1036
|
+
)
|
|
1037
|
+
text = unquote(_PLACEHOLDER_SPLIT_RE.sub("x", segment))
|
|
1038
|
+
if text in (".", ".."):
|
|
1039
|
+
return "must not contain '.' or '..' segments, also percent-encoded (%2E)"
|
|
1040
|
+
problem = segment_text_problem(text)
|
|
1041
|
+
if problem:
|
|
1042
|
+
return f"has a segment that {problem}"
|
|
1043
|
+
return None
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def normalize_path(path: str) -> str:
|
|
1047
|
+
"""``path`` in the form policy paths are compared in.
|
|
1048
|
+
|
|
1049
|
+
Percent-encoded unreserved characters are decoded (``/%61dmin`` is
|
|
1050
|
+
``/admin``), other escapes are upper-cased (``%2f`` is ``%2F``), and one
|
|
1051
|
+
trailing slash is dropped (``/items/1/`` is ``/items/1``), so equivalent
|
|
1052
|
+
spellings of a path match the same entries.
|
|
1053
|
+
"""
|
|
1054
|
+
|
|
1055
|
+
def _escape(match: re.Match[str]) -> str:
|
|
1056
|
+
char = chr(int(match.group(0)[1:], 16))
|
|
1057
|
+
return char if char in _UNRESERVED else match.group(0).upper()
|
|
1058
|
+
|
|
1059
|
+
path = _ESCAPE_RE.sub(_escape, path)
|
|
1060
|
+
return path[:-1] if len(path) > 1 and path.endswith("/") else path
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def path_matches(
|
|
1064
|
+
template: str, path: str, *, ignore_case: bool = False, suffixes: bool = False
|
|
1065
|
+
) -> bool:
|
|
1066
|
+
"""Whether ``path`` is covered by ``template``.
|
|
1067
|
+
|
|
1068
|
+
A ``{name}`` placeholder matches exactly one non-empty segment, so
|
|
1069
|
+
``/items/{item_id}`` covers ``/items/42``, ``/items/{id}`` and itself.
|
|
1070
|
+
Both sides are compared normalised (``normalize_path``). Letter case
|
|
1071
|
+
counts unless ``ignore_case``: denials ignore it, so ``/ADMIN/1`` cannot
|
|
1072
|
+
slip past a denial of ``/admin/{x}`` on a case-insensitive server. With
|
|
1073
|
+
``suffixes`` (denials and approval gates, which fail closed), a segment
|
|
1074
|
+
that ends in literal text also covers that segment with a dot suffix:
|
|
1075
|
+
``/orders/{id}/cancel`` covers ``/orders/7/cancel.json`` and
|
|
1076
|
+
``/orders/7/cancel.`` (also spelled ``cancel%2e``), which servers that
|
|
1077
|
+
route format suffixes (``.json``) or drop a trailing dot send to the
|
|
1078
|
+
same endpoint. An allow never matches that way: it must be shown.
|
|
1079
|
+
"""
|
|
1080
|
+
template, path = normalize_path(template), normalize_path(path)
|
|
1081
|
+
if template == path or (ignore_case and template.casefold() == path.casefold()):
|
|
1082
|
+
return True
|
|
1083
|
+
segments = []
|
|
1084
|
+
for segment in template.split("/"):
|
|
1085
|
+
parts = _PLACEHOLDER_SPLIT_RE.split(segment)
|
|
1086
|
+
pattern = "".join(
|
|
1087
|
+
"[^/]+" if part.startswith("{") and part.endswith("}") else re.escape(part)
|
|
1088
|
+
for part in parts
|
|
1089
|
+
)
|
|
1090
|
+
if suffixes and parts[-1] and not parts[-1].endswith("}"):
|
|
1091
|
+
pattern += r"(?:\.[^/]*)?"
|
|
1092
|
+
segments.append(pattern)
|
|
1093
|
+
flags = re.IGNORECASE if ignore_case else 0
|
|
1094
|
+
return re.fullmatch("/".join(segments), path, flags) is not None
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def _methods_match(entry: Mapping[str, Any], method: str) -> bool:
|
|
1098
|
+
methods = entry.get("methods")
|
|
1099
|
+
return not methods or method.upper() in {str(m).upper() for m in methods}
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def operation_matches(
|
|
1103
|
+
entry: Mapping[str, Any],
|
|
1104
|
+
method: str,
|
|
1105
|
+
operation_id: str | None,
|
|
1106
|
+
path: str | None,
|
|
1107
|
+
*,
|
|
1108
|
+
rpc_method: str | None = None,
|
|
1109
|
+
a2a_operation: str | None = None,
|
|
1110
|
+
) -> bool:
|
|
1111
|
+
"""Whether an ``allowed_operations`` entry covers the call.
|
|
1112
|
+
|
|
1113
|
+
AND semantics: every field the entry pins (``operationId``, ``path``,
|
|
1114
|
+
``methods``, and on a JSON-RPC API ``rpc_method`` and ``a2a_operation``,
|
|
1115
|
+
compared with the values derived from the request body) must match. A
|
|
1116
|
+
call that does not name a pinned field (no operation id, no path, no
|
|
1117
|
+
JSON-RPC method or no decision) does not match: an allow must be shown.
|
|
1118
|
+
"""
|
|
1119
|
+
if not _methods_match(entry, method):
|
|
1120
|
+
return False
|
|
1121
|
+
pinned_id = entry.get("operationId")
|
|
1122
|
+
if pinned_id is not None and operation_id != pinned_id:
|
|
1123
|
+
return False
|
|
1124
|
+
pinned_path = entry.get("path")
|
|
1125
|
+
if pinned_path is not None and (path is None or not path_matches(pinned_path, path)):
|
|
1126
|
+
return False
|
|
1127
|
+
pinned_rpc = entry.get(RPC_METHOD_KEY)
|
|
1128
|
+
if pinned_rpc is not None and rpc_method != pinned_rpc:
|
|
1129
|
+
return False
|
|
1130
|
+
pinned_operation = entry.get(A2A_OPERATION_KEY)
|
|
1131
|
+
if pinned_operation is not None and a2a_operation != pinned_operation:
|
|
1132
|
+
return False
|
|
1133
|
+
return (
|
|
1134
|
+
pinned_id is not None
|
|
1135
|
+
or pinned_path is not None
|
|
1136
|
+
or pinned_rpc is not None
|
|
1137
|
+
or pinned_operation is not None
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def denial_match(
|
|
1142
|
+
entry: Mapping[str, Any],
|
|
1143
|
+
method: str,
|
|
1144
|
+
operation_id: str | None,
|
|
1145
|
+
path: str | None,
|
|
1146
|
+
*,
|
|
1147
|
+
rpc_method: str | None = None,
|
|
1148
|
+
a2a_operation: str | None = None,
|
|
1149
|
+
) -> str | None:
|
|
1150
|
+
"""How a ``denied_operations`` entry covers the call: None when it does not.
|
|
1151
|
+
|
|
1152
|
+
A denial names an endpoint and must hold whatever label a call gives it,
|
|
1153
|
+
so, unlike an allow, the fields it pins are alternatives, not
|
|
1154
|
+
requirements. With its ``methods`` (when pinned) covering the call's
|
|
1155
|
+
method, it covers a call whose path its ``path`` covers, whatever
|
|
1156
|
+
operation id the call names, and a call that names its ``operationId``
|
|
1157
|
+
(both return ``""``). Failing closed, it also covers a call that leaves
|
|
1158
|
+
out what the denial knows the operation by: no path when it pins
|
|
1159
|
+
``path`` (returns ``"path"``), no operation id when it pins
|
|
1160
|
+
``operationId`` alone (returns ``"operation_id"``). A denial by
|
|
1161
|
+
``operationId`` alone knows only that label: pin ``path`` too so it holds
|
|
1162
|
+
on the wire. Operation ids and paths are compared ignoring letter case,
|
|
1163
|
+
and a path's literal segments also cover their dot-suffixed spellings
|
|
1164
|
+
(``cancel.json``, ``cancel.``: ``path_matches`` with ``suffixes``).
|
|
1165
|
+
|
|
1166
|
+
An entry of a JSON-RPC API that pins ``rpc_method`` or ``a2a_operation``
|
|
1167
|
+
(the values derived from the request body) covers, with its ``methods``,
|
|
1168
|
+
a call whose JSON-RPC method is its ``rpc_method`` (ignoring letter case),
|
|
1169
|
+
a call that decides as its ``a2a_operation`` says, and a call that names
|
|
1170
|
+
its ``operationId``, whatever the path: its ``path``, if any, neither
|
|
1171
|
+
widens nor narrows it. Every POST to such an API names its method (a body
|
|
1172
|
+
that does not is refused first), so there is nothing left unnamed.
|
|
1173
|
+
"""
|
|
1174
|
+
if not _methods_match(entry, method):
|
|
1175
|
+
return None
|
|
1176
|
+
pinned_id = entry.get("operationId")
|
|
1177
|
+
if _rpc_pins(entry):
|
|
1178
|
+
pinned_rpc = entry.get(RPC_METHOD_KEY)
|
|
1179
|
+
if (
|
|
1180
|
+
pinned_rpc is not None
|
|
1181
|
+
and rpc_method is not None
|
|
1182
|
+
and str(rpc_method).casefold() == str(pinned_rpc).casefold()
|
|
1183
|
+
):
|
|
1184
|
+
return ""
|
|
1185
|
+
pinned_operation = entry.get(A2A_OPERATION_KEY)
|
|
1186
|
+
if pinned_operation is not None and a2a_operation == pinned_operation:
|
|
1187
|
+
return ""
|
|
1188
|
+
if (
|
|
1189
|
+
pinned_id is not None
|
|
1190
|
+
and operation_id is not None
|
|
1191
|
+
and str(operation_id).casefold() == str(pinned_id).casefold()
|
|
1192
|
+
):
|
|
1193
|
+
return ""
|
|
1194
|
+
return None
|
|
1195
|
+
pinned_path = entry.get("path")
|
|
1196
|
+
if (
|
|
1197
|
+
pinned_path is not None
|
|
1198
|
+
and path is not None
|
|
1199
|
+
and path_matches(pinned_path, path, ignore_case=True, suffixes=True)
|
|
1200
|
+
):
|
|
1201
|
+
return ""
|
|
1202
|
+
if (
|
|
1203
|
+
pinned_id is not None
|
|
1204
|
+
and operation_id is not None
|
|
1205
|
+
and str(operation_id).casefold() == str(pinned_id).casefold()
|
|
1206
|
+
):
|
|
1207
|
+
return ""
|
|
1208
|
+
if pinned_path is not None and path is None:
|
|
1209
|
+
return "path"
|
|
1210
|
+
if pinned_id is not None and pinned_path is None and operation_id is None:
|
|
1211
|
+
return "operation_id"
|
|
1212
|
+
return None
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def denial_matches(
|
|
1216
|
+
entry: Mapping[str, Any],
|
|
1217
|
+
method: str,
|
|
1218
|
+
operation_id: str | None,
|
|
1219
|
+
path: str | None,
|
|
1220
|
+
*,
|
|
1221
|
+
rpc_method: str | None = None,
|
|
1222
|
+
a2a_operation: str | None = None,
|
|
1223
|
+
) -> bool:
|
|
1224
|
+
"""Whether a ``denied_operations`` entry covers the call (see ``denial_match``)."""
|
|
1225
|
+
return (
|
|
1226
|
+
denial_match(
|
|
1227
|
+
entry, method, operation_id, path, rpc_method=rpc_method, a2a_operation=a2a_operation
|
|
1228
|
+
)
|
|
1229
|
+
is not None
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def describe_operation(entry: Mapping[str, Any]) -> str:
|
|
1234
|
+
"""``operationId=updateOrder path=/orders/{order_id} methods=['PATCH']`` for messages
|
|
1235
|
+
(and ``rpc_method=GetTask a2a_operation=approve`` for an entry that pins them)."""
|
|
1236
|
+
parts = []
|
|
1237
|
+
if entry.get("operationId") is not None:
|
|
1238
|
+
parts.append(f"operationId={entry['operationId']}")
|
|
1239
|
+
if entry.get("path") is not None:
|
|
1240
|
+
parts.append(f"path={entry['path']}")
|
|
1241
|
+
if entry.get("methods"):
|
|
1242
|
+
parts.append(f"methods={sorted(str(m).upper() for m in entry['methods'])}")
|
|
1243
|
+
for key in (RPC_METHOD_KEY, A2A_OPERATION_KEY):
|
|
1244
|
+
if entry.get(key) is not None:
|
|
1245
|
+
parts.append(f"{key}={entry[key]}")
|
|
1246
|
+
return " ".join(parts)
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def refusal_reason(
|
|
1250
|
+
api: Mapping[str, Any],
|
|
1251
|
+
method: str,
|
|
1252
|
+
operation_id: str | None = None,
|
|
1253
|
+
path: str | None = None,
|
|
1254
|
+
*,
|
|
1255
|
+
rpc_method: str | None = None,
|
|
1256
|
+
a2a_operation: str | None = None,
|
|
1257
|
+
) -> str | None:
|
|
1258
|
+
"""Why the API's policy refuses the call, or None when it is allowed.
|
|
1259
|
+
|
|
1260
|
+
Every rule must pass: the method is in ``allowed_methods`` (``["*"]``
|
|
1261
|
+
allows every method); no ``denied_operations`` entry may cover the call
|
|
1262
|
+
(denials win: a denial pinning a path refuses every call to that path,
|
|
1263
|
+
whatever operation id it names, and a call that leaves out what a denial
|
|
1264
|
+
knows the operation by is refused by it: ``denial_match``); and, when
|
|
1265
|
+
``allowed_operations`` is present, one of its entries matches
|
|
1266
|
+
(``operation_matches``: every field it pins). On a JSON-RPC API
|
|
1267
|
+
(``protocol: jsonrpc|a2a``) ``rpc_method`` and ``a2a_operation`` are the
|
|
1268
|
+
values ``derive_rpc`` reads from the request body.
|
|
1269
|
+
"""
|
|
1270
|
+
method = method.upper()
|
|
1271
|
+
operation_id = operation_id or None
|
|
1272
|
+
path = path or None
|
|
1273
|
+
rpc = {"rpc_method": rpc_method or None, "a2a_operation": a2a_operation or None}
|
|
1274
|
+
allowed = [str(m).upper() for m in api.get("allowed_methods") or []]
|
|
1275
|
+
if ANY_METHOD not in allowed and method not in allowed:
|
|
1276
|
+
return f"method {method} is not in allowed_methods {allowed}"
|
|
1277
|
+
for entry in api.get("denied_operations") or []:
|
|
1278
|
+
unnamed = denial_match(entry, method, operation_id, path, **rpc)
|
|
1279
|
+
if unnamed is not None:
|
|
1280
|
+
reason = f"denied by denied_operations ({describe_operation(entry)})"
|
|
1281
|
+
if unnamed:
|
|
1282
|
+
reason += (
|
|
1283
|
+
f": the call names no {unnamed}, so it cannot be ruled out; name it on "
|
|
1284
|
+
"the call and in API_CALLS"
|
|
1285
|
+
)
|
|
1286
|
+
return reason
|
|
1287
|
+
allowed_operations = api.get("allowed_operations")
|
|
1288
|
+
if allowed_operations is not None and not any(
|
|
1289
|
+
operation_matches(entry, method, operation_id, path, **rpc) for entry in allowed_operations
|
|
1290
|
+
):
|
|
1291
|
+
return "not in allowed_operations"
|
|
1292
|
+
return None
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
@dataclass(frozen=True)
|
|
1296
|
+
class ApprovalGate:
|
|
1297
|
+
"""The human approval an API's policy requires before a call is sent (``gated``)."""
|
|
1298
|
+
|
|
1299
|
+
# "requester" and/or "role:<name>" entries of the rule that gates the call,
|
|
1300
|
+
# in the policy's order.
|
|
1301
|
+
approvers: tuple[str, ...]
|
|
1302
|
+
# Seconds a pending approval waits for a decision; then it expires (= rejected).
|
|
1303
|
+
timeout_s: int
|
|
1304
|
+
# The rule and the part of its required_for that gates the call, for messages
|
|
1305
|
+
# ("approval.required_for.methods ['POST']", "approval[1].required_for.operations (...)").
|
|
1306
|
+
rule: str
|
|
1307
|
+
# The gating rule's index when `approval` is a list of rules; None for one mapping.
|
|
1308
|
+
index: int | None = None
|
|
1309
|
+
# Later rules that also cover the call; they do not apply to it (the first one does).
|
|
1310
|
+
also: tuple[int, ...] = ()
|
|
1311
|
+
# How the requester decides: `direct`, or `relayed` by the agents `relayers` names.
|
|
1312
|
+
decide_with: str = DEFAULT_DECIDE_WITH
|
|
1313
|
+
relayers: tuple[str, ...] = ()
|
|
1314
|
+
|
|
1315
|
+
def deciders(self) -> tuple[frozenset[str], str, frozenset[str]]:
|
|
1316
|
+
"""Who decides, and how: what an approval is bound to (`rule_deciders`)."""
|
|
1317
|
+
return frozenset(self.approvers), self.decide_with, frozenset(self.relayers)
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def approval_rules(api: Mapping[str, Any]) -> list[Mapping[str, Any]]:
|
|
1321
|
+
"""An API's approval rules in file order: none, its one ``approval`` mapping, or its list."""
|
|
1322
|
+
approval = api.get(APPROVAL_KEY)
|
|
1323
|
+
if approval is None:
|
|
1324
|
+
return []
|
|
1325
|
+
return list(approval) if isinstance(approval, list) else [approval]
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def rule_deciders(rule: Mapping[str, Any]) -> tuple[frozenset[str], str, frozenset[str]]:
|
|
1329
|
+
"""Who decides the calls a rule gates, and how: its approvers, `decide_with` and
|
|
1330
|
+
relayers. Two rules with other deciders are two gates, and an approval taken under one
|
|
1331
|
+
does not cover the other's calls."""
|
|
1332
|
+
return (
|
|
1333
|
+
frozenset(str(a) for a in rule.get("approvers") or ()),
|
|
1334
|
+
str(rule.get("decide_with", DEFAULT_DECIDE_WITH)),
|
|
1335
|
+
frozenset(str(r) for r in rule.get("relayers") or ()),
|
|
1336
|
+
)
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def describe_deciders(rule: Mapping[str, Any]) -> str:
|
|
1340
|
+
"""``requester, role:ops``, or ``requester; relayed by concierge`` for a relayed rule."""
|
|
1341
|
+
approvers = ", ".join(str(a) for a in rule.get("approvers") or ())
|
|
1342
|
+
if rule.get("decide_with", DEFAULT_DECIDE_WITH) != DECIDE_RELAYED:
|
|
1343
|
+
return approvers
|
|
1344
|
+
return f"{approvers}; relayed by {', '.join(str(r) for r in rule.get('relayers') or ())}"
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
def approval_rule_label(api: Mapping[str, Any], index: int) -> str:
|
|
1348
|
+
"""``approval`` for an API's one approval mapping, ``approval[<index>]`` in a list of rules."""
|
|
1349
|
+
if isinstance(api.get(APPROVAL_KEY), list):
|
|
1350
|
+
return f"{APPROVAL_KEY}[{index}]"
|
|
1351
|
+
return APPROVAL_KEY
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
class ApprovalRuleConflict(ValueError):
|
|
1355
|
+
"""``gated``: the call cannot be given to one approval rule, so it is refused.
|
|
1356
|
+
|
|
1357
|
+
The message says why (the rule that cannot rule the call out, the later
|
|
1358
|
+
rule with other approvers that covers it) and what to name. ``unnamed``
|
|
1359
|
+
is what the call leaves out (``"operation_id"`` or ``"path"``), ``index``
|
|
1360
|
+
and ``later`` are the two rules' indexes.
|
|
1361
|
+
"""
|
|
1362
|
+
|
|
1363
|
+
def __init__(self, message: str, *, unnamed: str, index: int, later: int) -> None:
|
|
1364
|
+
super().__init__(message)
|
|
1365
|
+
self.unnamed = unnamed
|
|
1366
|
+
self.index = index
|
|
1367
|
+
self.later = later
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def _rule_match(
|
|
1371
|
+
rule: Mapping[str, Any],
|
|
1372
|
+
method: str,
|
|
1373
|
+
operation_id: str | None,
|
|
1374
|
+
path: str | None,
|
|
1375
|
+
rpc: Mapping[str, str | None] | None = None,
|
|
1376
|
+
) -> tuple[str, str] | None:
|
|
1377
|
+
"""How one approval rule covers the call: ``(part, unnamed)``, or None.
|
|
1378
|
+
|
|
1379
|
+
``part`` names what covers it, for messages. ``unnamed`` is ``""`` when
|
|
1380
|
+
the rule surely covers the call, else what the call leaves out
|
|
1381
|
+
(``"operation_id"``, ``"path"``) that the covering entry knows the
|
|
1382
|
+
operation by: the rule covers it only because it cannot be ruled out. A
|
|
1383
|
+
sure match wins over one that only cannot be ruled out.
|
|
1384
|
+
"""
|
|
1385
|
+
required_for = rule.get("required_for") or {}
|
|
1386
|
+
methods = [str(m).upper() for m in required_for.get("methods") or []]
|
|
1387
|
+
if ANY_METHOD in methods or method in methods:
|
|
1388
|
+
return f"required_for.methods {methods}", ""
|
|
1389
|
+
unsure: tuple[str, str] | None = None
|
|
1390
|
+
for entry in required_for.get("operations") or []:
|
|
1391
|
+
unnamed = denial_match(entry, method, operation_id, path, **(rpc or {}))
|
|
1392
|
+
if unnamed is None:
|
|
1393
|
+
continue
|
|
1394
|
+
part = f"required_for.operations ({describe_operation(entry)})"
|
|
1395
|
+
if not unnamed:
|
|
1396
|
+
return part, ""
|
|
1397
|
+
if unsure is None:
|
|
1398
|
+
unsure = (part, unnamed)
|
|
1399
|
+
return unsure
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
def _unsure(part: str, unnamed: str) -> str:
|
|
1403
|
+
"""``part``, and why it covers the call when the call only leaves out what it names."""
|
|
1404
|
+
return f"{part}: the call names no {unnamed}, so it cannot be ruled out" if unnamed else part
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
def rule_covers(
|
|
1408
|
+
rule: Mapping[str, Any],
|
|
1409
|
+
method: str,
|
|
1410
|
+
operation_id: str | None,
|
|
1411
|
+
path: str | None,
|
|
1412
|
+
*,
|
|
1413
|
+
rpc_method: str | None = None,
|
|
1414
|
+
a2a_operation: str | None = None,
|
|
1415
|
+
) -> str | None:
|
|
1416
|
+
"""Which part of one approval rule's ``required_for`` covers the call, or None.
|
|
1417
|
+
|
|
1418
|
+
``required_for.methods`` covers a call with one of its methods (``["*"]``:
|
|
1419
|
+
every method). An entry of ``required_for.operations`` covers a call as a
|
|
1420
|
+
denial does (``denial_match``: fail closed), not as an allow; an entry
|
|
1421
|
+
that surely covers it is named before one that only cannot rule it out.
|
|
1422
|
+
"""
|
|
1423
|
+
rpc = {"rpc_method": rpc_method or None, "a2a_operation": a2a_operation or None}
|
|
1424
|
+
match = _rule_match(rule, method.upper(), operation_id or None, path or None, rpc)
|
|
1425
|
+
return None if match is None else _unsure(*match)
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
def gated(
|
|
1429
|
+
api: Mapping[str, Any],
|
|
1430
|
+
method: str,
|
|
1431
|
+
operation_id: str | None = None,
|
|
1432
|
+
path: str | None = None,
|
|
1433
|
+
*,
|
|
1434
|
+
template: str | None = None,
|
|
1435
|
+
rpc_method: str | None = None,
|
|
1436
|
+
a2a_operation: str | None = None,
|
|
1437
|
+
) -> ApprovalGate | None:
|
|
1438
|
+
"""The approval the API's policy (a validated one) requires before the call, or None.
|
|
1439
|
+
|
|
1440
|
+
Ask it only about a call ``refusal_reason`` allows: approval never widens
|
|
1441
|
+
access, so a refused call stays refused whatever its gate, and denials
|
|
1442
|
+
still win. A rule covers the call when its ``required_for.methods`` holds
|
|
1443
|
+
the call's method (``["*"]``: every method), or when an entry of its
|
|
1444
|
+
``required_for.operations`` covers it (``rule_covers``). Such an entry
|
|
1445
|
+
fails closed, as a denial does (``denial_match``), not as an allow: with
|
|
1446
|
+
its ``methods`` (when pinned) covering the call's method, its ``path``
|
|
1447
|
+
gates every call to that path whatever operation id the call names, its
|
|
1448
|
+
``operationId`` gates the calls that name it, and a call that leaves out
|
|
1449
|
+
what the entry knows the operation by is gated too. Paths are compared
|
|
1450
|
+
normalised and ignoring letter case, and a literal segment also covers its
|
|
1451
|
+
dot-suffixed spellings (``cancel.json``, ``cancel.``), as for a denial.
|
|
1452
|
+
|
|
1453
|
+
``approval`` is one rule, or a list of rules: the FIRST rule in file order
|
|
1454
|
+
that covers the call gates it, with that rule's approvers and timeout, and
|
|
1455
|
+
the later rules that also cover it are listed in ``also`` (they do not
|
|
1456
|
+
apply to it). Failing closed across rules: when the first rule covers the
|
|
1457
|
+
call only because the call leaves out what the rule knows the operation by
|
|
1458
|
+
(no operation id, no path), and a later rule with other approvers also
|
|
1459
|
+
covers it, the call may be that later rule's, so neither rule's approvers
|
|
1460
|
+
get it: ``ApprovalRuleConflict`` is raised and the call is refused. At
|
|
1461
|
+
runtime, ask with the path that is sent and, when there is one, the
|
|
1462
|
+
``template`` it was rendered from: a rule covers the call when it covers
|
|
1463
|
+
either (surely, when it surely covers either). On a JSON-RPC API an entry
|
|
1464
|
+
pinning ``rpc_method`` or ``a2a_operation`` covers the call as a denial
|
|
1465
|
+
does (``denial_match``), by the values derived from the request body.
|
|
1466
|
+
"""
|
|
1467
|
+
rules = approval_rules(api)
|
|
1468
|
+
if not rules:
|
|
1469
|
+
return None
|
|
1470
|
+
method = method.upper()
|
|
1471
|
+
operation_id = operation_id or None
|
|
1472
|
+
path = path or None
|
|
1473
|
+
template = template or None
|
|
1474
|
+
rpc = {"rpc_method": rpc_method or None, "a2a_operation": a2a_operation or None}
|
|
1475
|
+
covering: list[tuple[int, str, str]] = []
|
|
1476
|
+
for index, rule in enumerate(rules):
|
|
1477
|
+
match = _rule_match(rule, method, operation_id, path, rpc)
|
|
1478
|
+
if template is not None and (match is None or match[1]):
|
|
1479
|
+
other = _rule_match(rule, method, operation_id, template, rpc)
|
|
1480
|
+
if other is not None and (match is None or not other[1]):
|
|
1481
|
+
match = other
|
|
1482
|
+
if match is not None:
|
|
1483
|
+
covering.append((index, *match))
|
|
1484
|
+
if not covering:
|
|
1485
|
+
return None
|
|
1486
|
+
index, part, unnamed = covering[0]
|
|
1487
|
+
rule = rules[index]
|
|
1488
|
+
approvers = tuple(str(a) for a in rule.get("approvers") or ())
|
|
1489
|
+
label = approval_rule_label(api, index)
|
|
1490
|
+
if unnamed:
|
|
1491
|
+
pin = f", or pin path and methods in {label}" if unnamed == "operation_id" else ""
|
|
1492
|
+
for later, _part, _unnamed in covering[1:]:
|
|
1493
|
+
if rule_deciders(rules[later]) != rule_deciders(rule):
|
|
1494
|
+
raise ApprovalRuleConflict(
|
|
1495
|
+
f"{label} (approved by {describe_deciders(rule)}) covers it only because the "
|
|
1496
|
+
f"call names no {unnamed} ({label}.{part}), and "
|
|
1497
|
+
f"{approval_rule_label(api, later)} (approved by "
|
|
1498
|
+
f"{describe_deciders(rules[later])}) also covers it: it could be either "
|
|
1499
|
+
"rule's call, so neither rule's approvers are asked; name the "
|
|
1500
|
+
f"{unnamed} on the call and in API_CALLS{pin}",
|
|
1501
|
+
unnamed=unnamed,
|
|
1502
|
+
index=index,
|
|
1503
|
+
later=later,
|
|
1504
|
+
)
|
|
1505
|
+
listed = isinstance(api.get(APPROVAL_KEY), list)
|
|
1506
|
+
return ApprovalGate(
|
|
1507
|
+
approvers=approvers,
|
|
1508
|
+
timeout_s=int(rule.get("timeout_s", DEFAULT_APPROVAL_TIMEOUT_S)),
|
|
1509
|
+
rule=f"{label}.{_unsure(part, unnamed)}",
|
|
1510
|
+
index=index if listed else None,
|
|
1511
|
+
also=tuple(i for i, _, _ in covering[1:]),
|
|
1512
|
+
decide_with=str(rule.get("decide_with", DEFAULT_DECIDE_WITH)),
|
|
1513
|
+
relayers=tuple(str(r) for r in rule.get("relayers") or ()),
|
|
1514
|
+
)
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
class RpcRequestError(ValueError):
|
|
1518
|
+
"""A request a JSON-RPC API (``protocol: jsonrpc|a2a``) refuses to send (``derive_rpc``)."""
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
@dataclass(frozen=True)
|
|
1522
|
+
class RpcCall:
|
|
1523
|
+
"""What a request to a JSON-RPC API is, read from its body (``derive_rpc``).
|
|
1524
|
+
|
|
1525
|
+
``rpc_method``: the JSON-RPC method of a POST (an A2A 0.3 name read as its 1.0
|
|
1526
|
+
name under ``protocol: a2a``); None for GET and HEAD, and for any call to an
|
|
1527
|
+
``http`` API. ``a2a_operation``: under ``protocol: a2a``, ``approve`` or
|
|
1528
|
+
``reject`` for a message that decides a pending approval, else None.
|
|
1529
|
+
"""
|
|
1530
|
+
|
|
1531
|
+
rpc_method: str | None = None
|
|
1532
|
+
a2a_operation: str | None = None
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def canonical_rpc_method(protocol: str, name: str) -> str:
|
|
1536
|
+
"""``name`` as the policy compares it: an A2A 0.3 name as its 1.0 name under a2a."""
|
|
1537
|
+
return A2A_V03_METHODS.get(name, name) if protocol == PROTOCOL_A2A else name
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
def _is_rpc_id(value: Any) -> bool:
|
|
1541
|
+
return isinstance(value, str) or (isinstance(value, int) and not isinstance(value, bool))
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def _names_approval(data: Any) -> bool:
|
|
1545
|
+
"""Whether a message part's data names an approval (as the called agent reads it)."""
|
|
1546
|
+
return isinstance(data, Mapping) and ("approval_id" in data or "decision" in data)
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def _a2a_operation(protocol: str, params: Any) -> str | None:
|
|
1550
|
+
"""What an A2A message decides: ``reject`` only when every part that names an approval
|
|
1551
|
+
says ``reject``, ``approve`` when any other does (approve wins), None when none does."""
|
|
1552
|
+
message = params.get("message") if isinstance(params, Mapping) else None
|
|
1553
|
+
parts = message.get("parts", []) if isinstance(message, Mapping) else None
|
|
1554
|
+
if not isinstance(parts, list):
|
|
1555
|
+
raise RpcRequestError(
|
|
1556
|
+
f"protocol {protocol}: a message request needs params.message with a list of parts"
|
|
1557
|
+
)
|
|
1558
|
+
decisions = [
|
|
1559
|
+
part["data"].get("decision")
|
|
1560
|
+
for part in parts
|
|
1561
|
+
if isinstance(part, Mapping) and _names_approval(part.get("data"))
|
|
1562
|
+
]
|
|
1563
|
+
if not decisions:
|
|
1564
|
+
return None
|
|
1565
|
+
return A2A_REJECT if all(d == A2A_REJECT for d in decisions) else A2A_APPROVE
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
def _rpc_body_problem(sent: Any) -> str | None:
|
|
1569
|
+
"""Why a parsed JSON body is not one JSON-RPC 2.0 request object, or None."""
|
|
1570
|
+
if isinstance(sent, list):
|
|
1571
|
+
return "a batch"
|
|
1572
|
+
if not isinstance(sent, dict):
|
|
1573
|
+
return "not a JSON-RPC request object"
|
|
1574
|
+
if set(sent) - set(_JSONRPC_KEYS):
|
|
1575
|
+
return "members other than jsonrpc, method, params and id"
|
|
1576
|
+
if sent.get("jsonrpc") != "2.0":
|
|
1577
|
+
return 'jsonrpc is not "2.0"'
|
|
1578
|
+
if not isinstance(sent.get("method"), str) or not sent["method"]:
|
|
1579
|
+
return "no method name"
|
|
1580
|
+
if "id" not in sent:
|
|
1581
|
+
return "a notification (no id)"
|
|
1582
|
+
if not _is_rpc_id(sent["id"]):
|
|
1583
|
+
return "an id that is not a string or an integer"
|
|
1584
|
+
if "params" in sent and not isinstance(sent["params"], dict | list):
|
|
1585
|
+
return "params that are not an object or an array"
|
|
1586
|
+
return None
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
def derive_rpc(api: Mapping[str, Any], method: str, body: Any) -> RpcCall:
|
|
1590
|
+
"""What a request to ``api`` is, read from the body sent (never from the tool's labels).
|
|
1591
|
+
|
|
1592
|
+
Nothing for an ``http`` API. For ``protocol: jsonrpc|a2a``: a POST must send
|
|
1593
|
+
one JSON-RPC 2.0 request object (``jsonrpc: "2.0"``, a method name, an ``id``
|
|
1594
|
+
that is a string or an integer, optional ``params`` that are an object or an
|
|
1595
|
+
array, and no other member), read as the server reads the JSON sent; a
|
|
1596
|
+
batch, a notification (no ``id``), a body that is not plain JSON or any
|
|
1597
|
+
other body raises ``RpcRequestError``, as does a GET or HEAD with a body.
|
|
1598
|
+
Its ``rpc_method`` is the request's method (under ``a2a``, an A2A 0.3 name as
|
|
1599
|
+
its 1.0 name). Under ``a2a``, a ``SendMessage`` or ``SendStreamingMessage``
|
|
1600
|
+
whose parts name an approval (a data part with ``approval_id`` or
|
|
1601
|
+
``decision``) is ``a2a_operation: reject`` only when every such part says
|
|
1602
|
+
``reject``, and ``approve`` otherwise: failing closed, approve wins. A
|
|
1603
|
+
message method in another letter case (``sendmessage``) is read for a
|
|
1604
|
+
decision too, though an A2A server answers it as an unknown method.
|
|
1605
|
+
"""
|
|
1606
|
+
protocol = api_protocol(api)
|
|
1607
|
+
if protocol not in RPC_PROTOCOLS:
|
|
1608
|
+
return RpcCall()
|
|
1609
|
+
method = method.upper()
|
|
1610
|
+
if method != "POST":
|
|
1611
|
+
if body is not None:
|
|
1612
|
+
raise RpcRequestError(
|
|
1613
|
+
f"protocol {protocol}: a {method} sends no body (a JSON-RPC request is a POST)"
|
|
1614
|
+
)
|
|
1615
|
+
return RpcCall()
|
|
1616
|
+
try:
|
|
1617
|
+
# The JSON the server reads: tuples become lists, keys strings (never trust a
|
|
1618
|
+
# Python object that serializes as something other than it looks).
|
|
1619
|
+
sent = json.loads(json.dumps(body, allow_nan=False))
|
|
1620
|
+
except (TypeError, ValueError):
|
|
1621
|
+
sent = None
|
|
1622
|
+
problem: str | None = "not plain JSON"
|
|
1623
|
+
else:
|
|
1624
|
+
problem = _rpc_body_problem(sent)
|
|
1625
|
+
if problem is not None:
|
|
1626
|
+
raise RpcRequestError(
|
|
1627
|
+
f"protocol {protocol} sends one JSON-RPC request per call (a batch or non-request "
|
|
1628
|
+
f"body refused: {problem})"
|
|
1629
|
+
)
|
|
1630
|
+
name = canonical_rpc_method(protocol, sent["method"])
|
|
1631
|
+
if protocol != PROTOCOL_A2A or name.casefold() not in _A2A_MESSAGE_NAMES:
|
|
1632
|
+
return RpcCall(rpc_method=name)
|
|
1633
|
+
return RpcCall(rpc_method=name, a2a_operation=_a2a_operation(protocol, sent.get("params")))
|
|
1634
|
+
|
|
1635
|
+
|
|
1636
|
+
def _operation_entries(api: Mapping[str, Any]) -> list[Mapping[str, Any]]:
|
|
1637
|
+
"""Every operation entry of an API: allowed, denied and those its approval rules gate."""
|
|
1638
|
+
entries = [*(api.get("allowed_operations") or []), *(api.get("denied_operations") or [])]
|
|
1639
|
+
for rule in approval_rules(api):
|
|
1640
|
+
entries.extend((rule.get("required_for") or {}).get("operations") or [])
|
|
1641
|
+
return [entry for entry in entries if isinstance(entry, Mapping)]
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def label_problem(
|
|
1645
|
+
api: Mapping[str, Any],
|
|
1646
|
+
operation_id: str | None,
|
|
1647
|
+
rpc_method: str | None = None,
|
|
1648
|
+
a2a_operation: str | None = None,
|
|
1649
|
+
) -> str | None:
|
|
1650
|
+
"""Why a tool's ``operation_id`` does not name the request it labels, or None.
|
|
1651
|
+
|
|
1652
|
+
On a JSON-RPC API, an entry that pins ``operationId`` with ``rpc_method``
|
|
1653
|
+
or ``a2a_operation`` says what a call so labelled is. A call labelled so
|
|
1654
|
+
whose request (``derive_rpc``) is something else is refused, so a label
|
|
1655
|
+
never carries a decision past a rule written for another request.
|
|
1656
|
+
Operation ids and JSON-RPC methods are compared ignoring letter case.
|
|
1657
|
+
"""
|
|
1658
|
+
if not operation_id or api_protocol(api) not in RPC_PROTOCOLS:
|
|
1659
|
+
return None
|
|
1660
|
+
label = str(operation_id).casefold()
|
|
1661
|
+
for entry in _operation_entries(api):
|
|
1662
|
+
pinned_id = entry.get("operationId")
|
|
1663
|
+
if pinned_id is None or str(pinned_id).casefold() != label:
|
|
1664
|
+
continue
|
|
1665
|
+
pinned_rpc = entry.get(RPC_METHOD_KEY)
|
|
1666
|
+
if pinned_rpc is not None and (
|
|
1667
|
+
rpc_method is None or str(pinned_rpc).casefold() != str(rpc_method).casefold()
|
|
1668
|
+
):
|
|
1669
|
+
return (
|
|
1670
|
+
f"operation_id {operation_id!r} does not match the request (rpc_method "
|
|
1671
|
+
f"{rpc_method or 'none'}); refused"
|
|
1672
|
+
)
|
|
1673
|
+
pinned_operation = entry.get(A2A_OPERATION_KEY)
|
|
1674
|
+
if pinned_operation is not None and pinned_operation != a2a_operation:
|
|
1675
|
+
return (
|
|
1676
|
+
f"operation_id {operation_id!r} does not match the request (a2a_operation "
|
|
1677
|
+
f"{a2a_operation or 'none'}); refused"
|
|
1678
|
+
)
|
|
1679
|
+
return None
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
# --- END SHARED API POLICY RULES ---
|
|
1683
|
+
|
|
1684
|
+
|
|
1685
|
+
class ApiPolicyError(Exception):
|
|
1686
|
+
"""Refused by the policy: no or invalid policy file, an undeclared API, or a
|
|
1687
|
+
request outside the policy. Always raised before anything is sent."""
|
|
1688
|
+
|
|
1689
|
+
def __init__(
|
|
1690
|
+
self, message: str, errors: list[str] | None = None, *, reason: str | None = None
|
|
1691
|
+
) -> None:
|
|
1692
|
+
super().__init__(message)
|
|
1693
|
+
self.errors = list(errors or [])
|
|
1694
|
+
# The policy rule that refused a call (policy-derived text only, never the
|
|
1695
|
+
# call's arguments), for the log line; None for other refusals.
|
|
1696
|
+
self.reason = reason
|
|
1697
|
+
|
|
1698
|
+
|
|
1699
|
+
# How much of an error response an `ApiCallError` keeps (`body`) and puts in
|
|
1700
|
+
# its message (the part the model reads).
|
|
1701
|
+
ERROR_BODY_MAX_CHARS = 2000
|
|
1702
|
+
ERROR_MESSAGE_BODY_CHARS = 300
|
|
1703
|
+
_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
|
1704
|
+
|
|
1705
|
+
|
|
1706
|
+
class ApiCallError(Exception):
|
|
1707
|
+
"""A declared call that could not be made or failed: missing configuration or
|
|
1708
|
+
credential, a transport error, or a non-2xx response.
|
|
1709
|
+
|
|
1710
|
+
For a non-2xx response, `status_code` is the HTTP status and `body` the
|
|
1711
|
+
start of the response body (at most `ERROR_BODY_MAX_CHARS` characters,
|
|
1712
|
+
with the credential the call sent replaced by `<redacted>`), so a tool can
|
|
1713
|
+
branch on the status (a 404 as "not found", a 409 as a conflict) and the
|
|
1714
|
+
model reads the upstream's reason from the message. Both are None when no
|
|
1715
|
+
response came back. The body is the upstream's text, as untrusted as any
|
|
1716
|
+
other API data.
|
|
1717
|
+
"""
|
|
1718
|
+
|
|
1719
|
+
def __init__(
|
|
1720
|
+
self,
|
|
1721
|
+
message: str,
|
|
1722
|
+
*,
|
|
1723
|
+
status_code: int | None = None,
|
|
1724
|
+
body: str | None = None,
|
|
1725
|
+
reason: str | None = None,
|
|
1726
|
+
) -> None:
|
|
1727
|
+
super().__init__(message)
|
|
1728
|
+
self.status_code = status_code
|
|
1729
|
+
self.body = body
|
|
1730
|
+
# Why nothing was sent (a fixed phrase, never a value), for the log line.
|
|
1731
|
+
self.reason = reason
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
|
+
class ResponseTooLarge(Exception):
|
|
1735
|
+
"""A response body over the API's `limits.max_response_bytes` (the client discards it)."""
|
|
1736
|
+
|
|
1737
|
+
|
|
1738
|
+
class ResponseEncodingRefused(Exception):
|
|
1739
|
+
"""A capped response in a content encoding the client cannot decode within the cap."""
|
|
1740
|
+
|
|
1741
|
+
|
|
1742
|
+
# Under `limits.max_response_bytes` the client asks for these content encodings only and
|
|
1743
|
+
# decodes them itself, never more than the cap at a time (zlib's `max_length`). httpx
|
|
1744
|
+
# decodes each network read (up to 64 KiB) whole before a cap could be checked: 32 KiB of
|
|
1745
|
+
# zstd decodes to 1 GiB, and 64 KiB of gzip to 64 MiB. An answer in any other encoding
|
|
1746
|
+
# (zstd, br, an unknown one, or two stacked) is refused unread, whatever was asked for.
|
|
1747
|
+
CAPPED_ACCEPT_ENCODING = "gzip, deflate"
|
|
1748
|
+
_CAPPED_CODINGS = ("gzip", "deflate")
|
|
1749
|
+
|
|
1750
|
+
|
|
1751
|
+
class CappedBody:
|
|
1752
|
+
"""A response body read, decoded, up to `cap` bytes.
|
|
1753
|
+
|
|
1754
|
+
`content_encoding` is the response's `Content-Encoding`: none or `identity`, `gzip`,
|
|
1755
|
+
or `deflate` (zlib-wrapped, or raw as some servers send it, as httpx reads it).
|
|
1756
|
+
Anything else raises `ResponseEncodingRefused` before a byte is read. `feed` each raw
|
|
1757
|
+
chunk as it arrives, then `finish`; `ResponseTooLarge` as soon as the decoded body
|
|
1758
|
+
passes the cap, so at most `cap + 1` decoded bytes are ever held. A corrupt compressed
|
|
1759
|
+
body raises `zlib.error`."""
|
|
1760
|
+
|
|
1761
|
+
def __init__(self, content_encoding: str, cap: int) -> None:
|
|
1762
|
+
codings = [
|
|
1763
|
+
coding
|
|
1764
|
+
for coding in (part.strip().lower() for part in content_encoding.split(","))
|
|
1765
|
+
if coding not in ("", "identity")
|
|
1766
|
+
]
|
|
1767
|
+
if len(codings) > 1 or (codings and codings[0] not in _CAPPED_CODINGS):
|
|
1768
|
+
raise ResponseEncodingRefused
|
|
1769
|
+
self.coding = codings[0] if codings else None
|
|
1770
|
+
self.cap = cap
|
|
1771
|
+
self.size = 0
|
|
1772
|
+
self._parts: list[bytes] = []
|
|
1773
|
+
self._first = True
|
|
1774
|
+
self._zlib: Any = None
|
|
1775
|
+
if self.coding == "gzip":
|
|
1776
|
+
self._zlib = zlib.decompressobj(zlib.MAX_WBITS | 16)
|
|
1777
|
+
elif self.coding == "deflate":
|
|
1778
|
+
self._zlib = zlib.decompressobj()
|
|
1779
|
+
|
|
1780
|
+
def feed(self, data: bytes) -> None:
|
|
1781
|
+
if self._zlib is None:
|
|
1782
|
+
self._take(data)
|
|
1783
|
+
return
|
|
1784
|
+
while data:
|
|
1785
|
+
# At most one byte past the cap per call (a `max_length` of 0 would mean no bound);
|
|
1786
|
+
# the input left over waits in `unconsumed_tail`.
|
|
1787
|
+
self._take(self._decompress(data, self.cap - self.size + 1))
|
|
1788
|
+
data = self._zlib.unconsumed_tail
|
|
1789
|
+
|
|
1790
|
+
def finish(self) -> bytes:
|
|
1791
|
+
if self._zlib is not None:
|
|
1792
|
+
self._take(self._zlib.flush()) # every input byte is consumed: nothing unbounded
|
|
1793
|
+
return b"".join(self._parts)
|
|
1794
|
+
|
|
1795
|
+
def _decompress(self, data: bytes, room: int) -> bytes:
|
|
1796
|
+
first, self._first = self._first, False
|
|
1797
|
+
try:
|
|
1798
|
+
return self._zlib.decompress(data, room)
|
|
1799
|
+
except zlib.error:
|
|
1800
|
+
if not (first and self.coding == "deflate"):
|
|
1801
|
+
raise
|
|
1802
|
+
self._zlib = zlib.decompressobj(-zlib.MAX_WBITS) # raw deflate, no zlib header
|
|
1803
|
+
return self._zlib.decompress(data, room)
|
|
1804
|
+
|
|
1805
|
+
def _take(self, piece: bytes) -> None:
|
|
1806
|
+
self.size += len(piece)
|
|
1807
|
+
if self.size > self.cap:
|
|
1808
|
+
raise ResponseTooLarge
|
|
1809
|
+
if piece:
|
|
1810
|
+
self._parts.append(piece)
|
|
1811
|
+
|
|
1812
|
+
|
|
1813
|
+
def error_body_excerpt(text: str, secrets: tuple[str, ...] = ()) -> str:
|
|
1814
|
+
"""`text` bounded to `ERROR_BODY_MAX_CHARS`, control characters dropped, `secrets` redacted."""
|
|
1815
|
+
for secret in secrets:
|
|
1816
|
+
if secret:
|
|
1817
|
+
text = text.replace(secret, "<redacted>")
|
|
1818
|
+
text = _CONTROL_CHARS.sub("", text)
|
|
1819
|
+
return text[:ERROR_BODY_MAX_CHARS]
|
|
1820
|
+
|
|
1821
|
+
|
|
1822
|
+
# Request headers a tool may not set: they would change the request's routing
|
|
1823
|
+
# or method after the policy check (`Host`, `X-HTTP-Method-Override` and the
|
|
1824
|
+
# like), or belong to the connection rather than the request (hop-by-hop).
|
|
1825
|
+
# They are dropped before sending, with a warning naming them.
|
|
1826
|
+
FORBIDDEN_HEADERS = frozenset(
|
|
1827
|
+
{
|
|
1828
|
+
"host",
|
|
1829
|
+
"x-http-method-override",
|
|
1830
|
+
"x-http-method",
|
|
1831
|
+
"x-method-override",
|
|
1832
|
+
"x-original-url",
|
|
1833
|
+
"x-rewrite-url",
|
|
1834
|
+
"x-original-method",
|
|
1835
|
+
"forwarded",
|
|
1836
|
+
"connection",
|
|
1837
|
+
"keep-alive",
|
|
1838
|
+
"proxy-authenticate",
|
|
1839
|
+
"proxy-authorization",
|
|
1840
|
+
"proxy-connection",
|
|
1841
|
+
"te",
|
|
1842
|
+
"trailer",
|
|
1843
|
+
"transfer-encoding",
|
|
1844
|
+
"upgrade",
|
|
1845
|
+
"content-length",
|
|
1846
|
+
}
|
|
1847
|
+
)
|
|
1848
|
+
FORBIDDEN_HEADER_PREFIXES = ("x-forwarded-",)
|
|
1849
|
+
# A `_method` query parameter or top-level JSON body key turns a POST into
|
|
1850
|
+
# another method on servers that honour it (Rails, Laravel, method-override
|
|
1851
|
+
# middleware); the policy checks the request's own method, so it is refused.
|
|
1852
|
+
METHOD_OVERRIDE_PARAM = "_method"
|
|
1853
|
+
|
|
1854
|
+
|
|
1855
|
+
def forbidden_header(name: str) -> bool:
|
|
1856
|
+
"""Whether a tool may not set header `name`.
|
|
1857
|
+
|
|
1858
|
+
Underscores count as hyphens: CGI and WSGI servers (and proxies that do
|
|
1859
|
+
not drop such headers) map `X_HTTP_METHOD_OVERRIDE` and
|
|
1860
|
+
`X-HTTP-Method-Override` to the same variable.
|
|
1861
|
+
"""
|
|
1862
|
+
lowered = name.strip().lower().replace("_", "-")
|
|
1863
|
+
return lowered in FORBIDDEN_HEADERS or lowered.startswith(FORBIDDEN_HEADER_PREFIXES)
|
|
1864
|
+
|
|
1865
|
+
|
|
1866
|
+
# ---------------------------------------------------------------------------
|
|
1867
|
+
# The policy
|
|
1868
|
+
# ---------------------------------------------------------------------------
|
|
1869
|
+
|
|
1870
|
+
|
|
1871
|
+
@dataclass
|
|
1872
|
+
class ApiPolicy:
|
|
1873
|
+
"""A validated `api-policy.yaml`: API name -> its settings."""
|
|
1874
|
+
|
|
1875
|
+
apis: dict[str, dict[str, Any]]
|
|
1876
|
+
source: Path | None = None
|
|
1877
|
+
|
|
1878
|
+
@property
|
|
1879
|
+
def file(self) -> str:
|
|
1880
|
+
return self.source.name if self.source else POLICY_FILENAME
|
|
1881
|
+
|
|
1882
|
+
@classmethod
|
|
1883
|
+
def from_dict(cls, data: Any, source: Path | None = None) -> ApiPolicy:
|
|
1884
|
+
"""Validate `data` with the strict schema; raise `ApiPolicyError` listing every error."""
|
|
1885
|
+
errors = policy_errors(data)
|
|
1886
|
+
if errors:
|
|
1887
|
+
name = source.name if source else POLICY_FILENAME
|
|
1888
|
+
raise ApiPolicyError(f"invalid {name}: " + "; ".join(errors), errors)
|
|
1889
|
+
return cls(apis={str(k): dict(v) for k, v in data["apis"].items()}, source=source)
|
|
1890
|
+
|
|
1891
|
+
@classmethod
|
|
1892
|
+
def load(cls, path: str | Path) -> ApiPolicy:
|
|
1893
|
+
policy_path = Path(path)
|
|
1894
|
+
if not policy_path.is_file():
|
|
1895
|
+
raise ApiPolicyError(
|
|
1896
|
+
f"{policy_path} not found: outbound API calls are refused until the project "
|
|
1897
|
+
f"declares them in {POLICY_FILENAME} (set {POLICY_PATH_ENV} to use another path)."
|
|
1898
|
+
)
|
|
1899
|
+
try:
|
|
1900
|
+
text = policy_path.read_text(encoding="utf-8")
|
|
1901
|
+
except OSError as exc:
|
|
1902
|
+
raise ApiPolicyError(f"cannot read {policy_path}: {exc}") from exc
|
|
1903
|
+
data, errors = parse_policy_yaml(text)
|
|
1904
|
+
if errors:
|
|
1905
|
+
raise ApiPolicyError(f"invalid {policy_path.name}: " + "; ".join(errors), errors)
|
|
1906
|
+
return cls.from_dict(data, source=policy_path)
|
|
1907
|
+
|
|
1908
|
+
def api(self, name: str) -> dict[str, Any]:
|
|
1909
|
+
"""The settings of API `name`; `ApiPolicyError` when it is not declared."""
|
|
1910
|
+
settings = self.apis.get(name)
|
|
1911
|
+
if settings is None:
|
|
1912
|
+
declared = ", ".join(sorted(self.apis)) or "none"
|
|
1913
|
+
raise ApiPolicyError(
|
|
1914
|
+
f"API {name!r} is not declared in {self.file} (declared: {declared})."
|
|
1915
|
+
)
|
|
1916
|
+
return settings
|
|
1917
|
+
|
|
1918
|
+
def check(
|
|
1919
|
+
self,
|
|
1920
|
+
api_name: str,
|
|
1921
|
+
method: str,
|
|
1922
|
+
operation_id: str | None = None,
|
|
1923
|
+
path: str | None = None,
|
|
1924
|
+
*,
|
|
1925
|
+
rpc: RpcCall | None = None,
|
|
1926
|
+
) -> None:
|
|
1927
|
+
"""Raise `ApiPolicyError` when the call is outside the API's policy.
|
|
1928
|
+
|
|
1929
|
+
The message is what the model reads (a tool error): the API, the
|
|
1930
|
+
call and the rule that refused it, without file names. `rpc` is what
|
|
1931
|
+
a request to a JSON-RPC API is (`derive_rpc`: its method and decision).
|
|
1932
|
+
"""
|
|
1933
|
+
rpc = rpc or RpcCall()
|
|
1934
|
+
reason = refusal_reason(
|
|
1935
|
+
self.api(api_name),
|
|
1936
|
+
method,
|
|
1937
|
+
operation_id,
|
|
1938
|
+
path,
|
|
1939
|
+
rpc_method=rpc.rpc_method,
|
|
1940
|
+
a2a_operation=rpc.a2a_operation,
|
|
1941
|
+
)
|
|
1942
|
+
if reason:
|
|
1943
|
+
what = (operation_id or path or "<unnamed operation>") + describe_rpc(rpc)
|
|
1944
|
+
raise ApiPolicyError(
|
|
1945
|
+
f"{api_name}: {method.upper()} {what} refused by the API policy: {reason}.",
|
|
1946
|
+
reason=reason,
|
|
1947
|
+
)
|
|
1948
|
+
|
|
1949
|
+
def gate(
|
|
1950
|
+
self,
|
|
1951
|
+
api_name: str,
|
|
1952
|
+
method: str,
|
|
1953
|
+
operation_id: str | None = None,
|
|
1954
|
+
path: str | None = None,
|
|
1955
|
+
*,
|
|
1956
|
+
template: str | None = None,
|
|
1957
|
+
rpc: RpcCall | None = None,
|
|
1958
|
+
) -> ApprovalGate | None:
|
|
1959
|
+
"""The human approval the API's policy requires before the call is sent, or None.
|
|
1960
|
+
|
|
1961
|
+
See `gated`: with a list of approval rules, the first rule in file order
|
|
1962
|
+
that covers the call (its sent `path`, or the `template` it was rendered
|
|
1963
|
+
from) gates it, with that rule's approvers. Ask only after `check`
|
|
1964
|
+
passed: approval never widens access.
|
|
1965
|
+
|
|
1966
|
+
Fails closed across rules: a call the first covering rule cannot rule
|
|
1967
|
+
out only because it names no operation id, and that a later rule with
|
|
1968
|
+
other approvers also covers, could be either rule's call. It raises
|
|
1969
|
+
`ApiPolicyError` (refused, nothing is sent) instead of asking either
|
|
1970
|
+
rule's approvers.
|
|
1971
|
+
"""
|
|
1972
|
+
rpc = rpc or RpcCall()
|
|
1973
|
+
try:
|
|
1974
|
+
return gated(
|
|
1975
|
+
self.api(api_name),
|
|
1976
|
+
method,
|
|
1977
|
+
operation_id,
|
|
1978
|
+
path,
|
|
1979
|
+
template=template,
|
|
1980
|
+
rpc_method=rpc.rpc_method,
|
|
1981
|
+
a2a_operation=rpc.a2a_operation,
|
|
1982
|
+
)
|
|
1983
|
+
except ApprovalRuleConflict as exc:
|
|
1984
|
+
reason = str(exc)
|
|
1985
|
+
what = (operation_id or path or "<unnamed operation>") + describe_rpc(rpc)
|
|
1986
|
+
raise ApiPolicyError(
|
|
1987
|
+
f"{api_name}: {method.upper()} {what} refused by the API policy: {reason}.",
|
|
1988
|
+
reason=reason,
|
|
1989
|
+
) from None
|
|
1990
|
+
|
|
1991
|
+
|
|
1992
|
+
def describe_rpc(rpc: RpcCall) -> str:
|
|
1993
|
+
"""` (rpc_method GetTask)`, ` (rpc_method SendMessage, a2a_operation approve)`, or ""."""
|
|
1994
|
+
parts = [
|
|
1995
|
+
f"{key} {value}"
|
|
1996
|
+
for key, value in ((RPC_METHOD_KEY, rpc.rpc_method), (A2A_OPERATION_KEY, rpc.a2a_operation))
|
|
1997
|
+
if value
|
|
1998
|
+
]
|
|
1999
|
+
return f" ({', '.join(parts)})" if parts else ""
|
|
2000
|
+
|
|
2001
|
+
|
|
2002
|
+
def _beside_pyproject() -> Path | None:
|
|
2003
|
+
"""The policy path beside the project's `pyproject.toml` (found once, at import)."""
|
|
2004
|
+
for parent in Path(__file__).resolve().parents:
|
|
2005
|
+
if (parent / "pyproject.toml").is_file():
|
|
2006
|
+
return parent / POLICY_FILENAME
|
|
2007
|
+
return None
|
|
2008
|
+
|
|
2009
|
+
|
|
2010
|
+
_PROJECT_POLICY_PATH = _beside_pyproject()
|
|
2011
|
+
|
|
2012
|
+
|
|
2013
|
+
def resolve_policy_path() -> Path:
|
|
2014
|
+
"""`API_POLICY_PATH` when set; else `./api-policy.yaml`, else the one beside `pyproject.toml`."""
|
|
2015
|
+
configured = os.environ.get(POLICY_PATH_ENV, "").strip()
|
|
2016
|
+
if configured:
|
|
2017
|
+
return Path(configured)
|
|
2018
|
+
local = Path(POLICY_FILENAME)
|
|
2019
|
+
if local.is_file():
|
|
2020
|
+
return local
|
|
2021
|
+
return _PROJECT_POLICY_PATH or local
|
|
2022
|
+
|
|
2023
|
+
|
|
2024
|
+
_cache: dict[tuple[int, int, int, int], ApiPolicy] = {}
|
|
2025
|
+
|
|
2026
|
+
|
|
2027
|
+
def load_policy() -> ApiPolicy:
|
|
2028
|
+
"""The project's policy (cached until the file changes); `ApiPolicyError` when absent or invalid.
|
|
2029
|
+
|
|
2030
|
+
Tools call this inside the server's event loop: it never resolves the
|
|
2031
|
+
working directory (LangGraph's dev server refuses `os.getcwd()` there as
|
|
2032
|
+
a blocking call). The cache is keyed by the file's identity and mtime.
|
|
2033
|
+
"""
|
|
2034
|
+
path = resolve_policy_path()
|
|
2035
|
+
try:
|
|
2036
|
+
stat = path.stat()
|
|
2037
|
+
except OSError:
|
|
2038
|
+
return ApiPolicy.load(path) # raises the not-found error
|
|
2039
|
+
key = (stat.st_dev, stat.st_ino, stat.st_mtime_ns, stat.st_size)
|
|
2040
|
+
policy = _cache.get(key)
|
|
2041
|
+
if policy is None:
|
|
2042
|
+
policy = ApiPolicy.load(path)
|
|
2043
|
+
_cache.clear()
|
|
2044
|
+
_cache[key] = policy
|
|
2045
|
+
return policy
|
|
2046
|
+
|
|
2047
|
+
|
|
2048
|
+
def reset_policy_cache() -> None:
|
|
2049
|
+
"""For tests that switch `API_POLICY_PATH` or rewrite the file."""
|
|
2050
|
+
_cache.clear()
|
|
2051
|
+
|
|
2052
|
+
|
|
2053
|
+
# ---------------------------------------------------------------------------
|
|
2054
|
+
# Paths
|
|
2055
|
+
# ---------------------------------------------------------------------------
|
|
2056
|
+
|
|
2057
|
+
_PLACEHOLDER = re.compile(r"\{([^/{}]+)\}")
|
|
2058
|
+
|
|
2059
|
+
|
|
2060
|
+
def render_path(template: str, path_params: Mapping[str, Any]) -> str:
|
|
2061
|
+
"""Fill `{name}` placeholders with percent-encoded values; refuse anything that changes the shape.
|
|
2062
|
+
|
|
2063
|
+
A value is one opaque path segment: an empty, `.` or `..` value (which
|
|
2064
|
+
would step out of the template) and a value containing `/` or `\\` (a
|
|
2065
|
+
server decoding `%2F` would traverse) are refused; other reserved
|
|
2066
|
+
characters are percent-encoded. Unknown or unfilled parameters are refused too.
|
|
2067
|
+
"""
|
|
2068
|
+
names = _PLACEHOLDER.findall(template)
|
|
2069
|
+
unknown = sorted(set(path_params) - set(names))
|
|
2070
|
+
if unknown:
|
|
2071
|
+
raise ApiPolicyError(f"path {template!r} has no parameter(s) {', '.join(unknown)}.")
|
|
2072
|
+
missing = [n for n in names if n not in path_params]
|
|
2073
|
+
if missing:
|
|
2074
|
+
raise ApiPolicyError(f"path {template!r} needs value(s) for {', '.join(missing)}.")
|
|
2075
|
+
|
|
2076
|
+
def _fill(match: re.Match[str]) -> str:
|
|
2077
|
+
name = match.group(1)
|
|
2078
|
+
value = str(path_params[name])
|
|
2079
|
+
if value in ("", ".", "..") or value.strip() != value or "/" in value or "\\" in value:
|
|
2080
|
+
raise ApiPolicyError(
|
|
2081
|
+
f"path parameter {name}={value!r} is not a valid path segment (refused before sending)."
|
|
2082
|
+
)
|
|
2083
|
+
return quote(value, safe="")
|
|
2084
|
+
|
|
2085
|
+
return _PLACEHOLDER.sub(_fill, template)
|
|
2086
|
+
|
|
2087
|
+
|
|
2088
|
+
def validate_concrete_path(path: str) -> None:
|
|
2089
|
+
"""Refuse a path that httpx would normalise or a server would resolve elsewhere.
|
|
2090
|
+
|
|
2091
|
+
Dot segments (`.`/`..`, also percent-encoded), an encoded slash or
|
|
2092
|
+
backslash inside a segment, empty segments (`//`), a `;` (also
|
|
2093
|
+
percent-encoded), a control character anywhere, or whitespace at either
|
|
2094
|
+
end of a segment or next to a dot (also percent-encoded:
|
|
2095
|
+
`segment_text_problem`, which lint applies to declared paths too), and
|
|
2096
|
+
a query or fragment in the path (send them through `params=`) are
|
|
2097
|
+
refused: the policy check would otherwise pass a template while the wire
|
|
2098
|
+
path lands on another endpoint (for example `/items/1/../../admin` ->
|
|
2099
|
+
`/admin`, `/orders/7/cancel;x=1`, which servers that strip path
|
|
2100
|
+
parameters route to `/orders/7/cancel`, `/orders/7/cancel%20` and
|
|
2101
|
+
`/orders/7/cancel%20.json`, which servers that trim a segment, or the
|
|
2102
|
+
name before its suffix, route there too, or `/orders/7%00/cancel`,
|
|
2103
|
+
which servers that end a path at a NUL route to `/orders/7`).
|
|
2104
|
+
"""
|
|
2105
|
+
if not path.startswith("/"):
|
|
2106
|
+
raise ApiPolicyError(f"path {path!r} must start with /.")
|
|
2107
|
+
if "?" in path or "#" in path:
|
|
2108
|
+
raise ApiPolicyError(f"path {path!r} must not carry a query or fragment; use params=.")
|
|
2109
|
+
body = path[1:]
|
|
2110
|
+
if body.endswith("/"):
|
|
2111
|
+
body = body[:-1]
|
|
2112
|
+
if not body:
|
|
2113
|
+
return
|
|
2114
|
+
for segment in body.split("/"):
|
|
2115
|
+
decoded = unquote(segment)
|
|
2116
|
+
if segment == "":
|
|
2117
|
+
raise ApiPolicyError(f"path {path!r} contains an empty segment (refused).")
|
|
2118
|
+
if decoded in (".", ".."):
|
|
2119
|
+
raise ApiPolicyError(f"path {path!r} contains a dot segment (refused).")
|
|
2120
|
+
if "/" in decoded or "\\" in decoded:
|
|
2121
|
+
raise ApiPolicyError(f"path {path!r} contains an encoded slash (refused).")
|
|
2122
|
+
if ";" in decoded:
|
|
2123
|
+
raise ApiPolicyError(
|
|
2124
|
+
f"path {path!r} contains ';' (a path parameter, which some servers strip "
|
|
2125
|
+
"before routing): refused."
|
|
2126
|
+
)
|
|
2127
|
+
problem = segment_text_problem(decoded)
|
|
2128
|
+
if problem:
|
|
2129
|
+
raise ApiPolicyError(
|
|
2130
|
+
f"path {path!r} has a segment that {problem}, which some servers route to "
|
|
2131
|
+
"another endpoint: refused."
|
|
2132
|
+
)
|
|
2133
|
+
|
|
2134
|
+
|
|
2135
|
+
# ---------------------------------------------------------------------------
|
|
2136
|
+
# Limits (`limits: {max_calls_per_run, rate_per_minute}`)
|
|
2137
|
+
# ---------------------------------------------------------------------------
|
|
2138
|
+
|
|
2139
|
+
# A run's counters are dropped after this long without a call to a limited API
|
|
2140
|
+
# (and when the run ends, see `end_run`); at most MAX_TRACKED_RUNS runs are
|
|
2141
|
+
# tracked, the least recently active dropped first.
|
|
2142
|
+
RUN_COUNTER_TTL_S = 3600.0
|
|
2143
|
+
MAX_TRACKED_RUNS = 10_000
|
|
2144
|
+
# Calls made outside any run (no LangGraph run id, no request context) share
|
|
2145
|
+
# this one count: never looser than counting per run.
|
|
2146
|
+
NO_RUN = "<no run>"
|
|
2147
|
+
|
|
2148
|
+
|
|
2149
|
+
class CallLimiter:
|
|
2150
|
+
"""Per-run call counts and per-API token buckets, in this process only."""
|
|
2151
|
+
|
|
2152
|
+
def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
|
|
2153
|
+
self._clock = clock
|
|
2154
|
+
self._lock = threading.Lock()
|
|
2155
|
+
# run key -> (last call time, {api name: calls})
|
|
2156
|
+
self._runs: OrderedDict[str, tuple[float, dict[str, int]]] = OrderedDict()
|
|
2157
|
+
# (api name, rate) -> (tokens, last refill time)
|
|
2158
|
+
self._buckets: dict[tuple[str, int], tuple[float, float]] = {}
|
|
2159
|
+
|
|
2160
|
+
def acquire(self, api: str, limits: Mapping[str, Any], run_key: str) -> str | None:
|
|
2161
|
+
"""Count one call to `api` in `run_key`; the reason it is refused, or None."""
|
|
2162
|
+
max_calls = limits.get("max_calls_per_run")
|
|
2163
|
+
rate = limits.get("rate_per_minute")
|
|
2164
|
+
with self._lock:
|
|
2165
|
+
now = self._clock()
|
|
2166
|
+
self._evict(now)
|
|
2167
|
+
counts = self._runs.get(run_key, (now, {}))[1]
|
|
2168
|
+
if max_calls and counts.get(api, 0) >= max_calls:
|
|
2169
|
+
return (
|
|
2170
|
+
f"limits.max_calls_per_run ({max_calls}) reached: this run already made "
|
|
2171
|
+
f"{counts.get(api, 0)} call(s) to this API"
|
|
2172
|
+
)
|
|
2173
|
+
if rate:
|
|
2174
|
+
tokens, updated = self._buckets.get((api, rate), (float(rate), now))
|
|
2175
|
+
tokens = min(float(rate), tokens + (now - updated) * rate / 60.0)
|
|
2176
|
+
if tokens < 1.0:
|
|
2177
|
+
self._buckets[(api, rate)] = (tokens, now)
|
|
2178
|
+
wait = (1.0 - tokens) * 60.0 / rate
|
|
2179
|
+
return (
|
|
2180
|
+
f"limits.rate_per_minute ({rate}) exceeded in this process; "
|
|
2181
|
+
f"retry in {wait:.1f} s"
|
|
2182
|
+
)
|
|
2183
|
+
self._buckets[(api, rate)] = (tokens - 1.0, now)
|
|
2184
|
+
if max_calls:
|
|
2185
|
+
counts[api] = counts.get(api, 0) + 1
|
|
2186
|
+
self._runs[run_key] = (now, counts)
|
|
2187
|
+
self._runs.move_to_end(run_key)
|
|
2188
|
+
self._evict(now)
|
|
2189
|
+
return None
|
|
2190
|
+
|
|
2191
|
+
def end_run(self, run_key: str) -> None:
|
|
2192
|
+
with self._lock:
|
|
2193
|
+
self._runs.pop(run_key, None)
|
|
2194
|
+
|
|
2195
|
+
def tracked_runs(self) -> int:
|
|
2196
|
+
with self._lock:
|
|
2197
|
+
return len(self._runs)
|
|
2198
|
+
|
|
2199
|
+
def _evict(self, now: float) -> None:
|
|
2200
|
+
while self._runs:
|
|
2201
|
+
key, (seen, _counts) = next(iter(self._runs.items()))
|
|
2202
|
+
if len(self._runs) <= MAX_TRACKED_RUNS and now - seen <= RUN_COUNTER_TTL_S:
|
|
2203
|
+
break
|
|
2204
|
+
del self._runs[key]
|
|
2205
|
+
|
|
2206
|
+
|
|
2207
|
+
_limiter = CallLimiter()
|
|
2208
|
+
|
|
2209
|
+
|
|
2210
|
+
def reset_limits(clock: Callable[[], float] = time.monotonic) -> CallLimiter:
|
|
2211
|
+
"""Start from empty counters (tests); returns the new limiter."""
|
|
2212
|
+
global _limiter
|
|
2213
|
+
_limiter = CallLimiter(clock)
|
|
2214
|
+
return _limiter
|
|
2215
|
+
|
|
2216
|
+
|
|
2217
|
+
def end_run(run_id: str | None) -> None:
|
|
2218
|
+
"""Drop the call counts of a finished run (the chat runtime calls it)."""
|
|
2219
|
+
if run_id:
|
|
2220
|
+
_limiter.end_run(str(run_id))
|
|
2221
|
+
|
|
2222
|
+
|
|
2223
|
+
def current_run_id() -> str | None:
|
|
2224
|
+
"""The id of the agent run making the call.
|
|
2225
|
+
|
|
2226
|
+
The LangGraph run's (`run_id` in the run's config metadata, which the chat
|
|
2227
|
+
runtime and LangGraph Server set), else the request context's (the
|
|
2228
|
+
`run_id`, then the `request_id`, bound for the current request's logs),
|
|
2229
|
+
else None.
|
|
2230
|
+
"""
|
|
2231
|
+
try:
|
|
2232
|
+
from langgraph.config import get_config
|
|
2233
|
+
|
|
2234
|
+
config = get_config()
|
|
2235
|
+
except Exception: # outside a graph run
|
|
2236
|
+
config = None
|
|
2237
|
+
if isinstance(config, Mapping):
|
|
2238
|
+
for section in ("metadata", "configurable"):
|
|
2239
|
+
values = config.get(section)
|
|
2240
|
+
if isinstance(values, Mapping) and values.get("run_id"):
|
|
2241
|
+
return str(values["run_id"])
|
|
2242
|
+
try:
|
|
2243
|
+
from .telemetry import LOG_CONTEXT
|
|
2244
|
+
except ImportError: # loaded outside its package
|
|
2245
|
+
return None
|
|
2246
|
+
for name in ("run_id", "request_id"):
|
|
2247
|
+
value = LOG_CONTEXT[name].get()
|
|
2248
|
+
if value:
|
|
2249
|
+
return str(value)
|
|
2250
|
+
return None
|
|
2251
|
+
|
|
2252
|
+
|
|
2253
|
+
# ---------------------------------------------------------------------------
|
|
2254
|
+
# Human approval (`approval` in api-policy.yaml)
|
|
2255
|
+
# ---------------------------------------------------------------------------
|
|
2256
|
+
|
|
2257
|
+
# The `type` of the interrupt value a gated call raises, and of the resume value
|
|
2258
|
+
# the chat runtime answers it with.
|
|
2259
|
+
APPROVAL_INTERRUPT = "api_approval"
|
|
2260
|
+
APPROVAL_DECISION = "api_approval_decision"
|
|
2261
|
+
DECISION_APPROVE = "approve"
|
|
2262
|
+
DECISION_REJECT = "reject"
|
|
2263
|
+
DECISION_EXPIRED = "expired"
|
|
2264
|
+
# Not a decision: the answer a resume gives a paused call whose approval is still
|
|
2265
|
+
# pending (another call's decision resumed the run), so that call pauses again
|
|
2266
|
+
# for its own approval instead of being rebuilt under a policy that changed.
|
|
2267
|
+
DECISION_PENDING = "pending"
|
|
2268
|
+
# What a field named in `redact=` shows the approver instead of its value.
|
|
2269
|
+
REDACTED = "<redacted>"
|
|
2270
|
+
# How much of the model's text before a tool call the approval shows as its purpose.
|
|
2271
|
+
PURPOSE_MAX_CHARS = 500
|
|
2272
|
+
COMMENT_MAX_CHARS = 300
|
|
2273
|
+
_UNPRINTABLE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
|
2274
|
+
|
|
2275
|
+
# The graph-agents-cli A2A extension. In a message's metadata under this URI, a calling agent
|
|
2276
|
+
# puts `origin` (the user's own words it forwards: `text`, `truncated`, `hops`) and, in a
|
|
2277
|
+
# decision it relays, `approving` (the called agent's approval it is about, read from that
|
|
2278
|
+
# agent). An agent card that lists it in `capabilities.extensions` reads `origin`.
|
|
2279
|
+
A2A_ORIGIN_EXTENSION = "https://ss7172.github.io/graph-agents-cli/a2a/ext/origin/v1"
|
|
2280
|
+
ORIGIN_KEY = "origin"
|
|
2281
|
+
APPROVING_KEY = "approving"
|
|
2282
|
+
# The `type` of the data part an agent's failed (or refused) A2A task carries, with its `code`.
|
|
2283
|
+
A2A_ERROR_PART_TYPE = "error"
|
|
2284
|
+
# `A2A_ORIGIN_MAX_CHARS`: at most this much of the user's words is forwarded (and read).
|
|
2285
|
+
DEFAULT_A2A_ORIGIN_MAX_CHARS = 4000
|
|
2286
|
+
|
|
2287
|
+
|
|
2288
|
+
def origin_max_chars() -> int:
|
|
2289
|
+
"""`A2A_ORIGIN_MAX_CHARS` (default 4000, at least 1); `SettingsError` otherwise."""
|
|
2290
|
+
from .limits import SettingsError
|
|
2291
|
+
|
|
2292
|
+
raw = (os.environ.get("A2A_ORIGIN_MAX_CHARS") or "").strip()
|
|
2293
|
+
if not raw:
|
|
2294
|
+
return DEFAULT_A2A_ORIGIN_MAX_CHARS
|
|
2295
|
+
try:
|
|
2296
|
+
value = int(raw)
|
|
2297
|
+
except ValueError:
|
|
2298
|
+
raise SettingsError(f"A2A_ORIGIN_MAX_CHARS={raw!r} is not a whole number.") from None
|
|
2299
|
+
if value < 1:
|
|
2300
|
+
raise SettingsError(f"A2A_ORIGIN_MAX_CHARS={value} must be at least 1.")
|
|
2301
|
+
return value
|
|
2302
|
+
|
|
2303
|
+
|
|
2304
|
+
# How deep a relayed approval may nest (`AUTH_MAX_DELEGATION_DEPTH` allows at most 8 agents).
|
|
2305
|
+
NESTED_MAX_DEPTH = 8
|
|
2306
|
+
# The keys of a relayed approval (`nested`) and of the call it holds; any other is dropped.
|
|
2307
|
+
NESTED_KEYS = (
|
|
2308
|
+
"agent",
|
|
2309
|
+
"approval_id",
|
|
2310
|
+
"decide_with",
|
|
2311
|
+
"digest",
|
|
2312
|
+
"reason",
|
|
2313
|
+
"expires_at",
|
|
2314
|
+
"reported_by",
|
|
2315
|
+
)
|
|
2316
|
+
NESTED_CALL_KEYS = (
|
|
2317
|
+
"api",
|
|
2318
|
+
"method",
|
|
2319
|
+
"path",
|
|
2320
|
+
"operation_id",
|
|
2321
|
+
"rpc_method",
|
|
2322
|
+
"a2a_operation",
|
|
2323
|
+
"query",
|
|
2324
|
+
"body",
|
|
2325
|
+
)
|
|
2326
|
+
|
|
2327
|
+
|
|
2328
|
+
def relayed_approval(body: Any, depth: int = 0) -> dict[str, Any] | None:
|
|
2329
|
+
"""The approval a relayed A2A decision is about (`nested`), from the body it sends, or None.
|
|
2330
|
+
|
|
2331
|
+
Read from `params.message.metadata[A2A_ORIGIN_EXTENSION]["approving"]` of the
|
|
2332
|
+
JSON-RPC request (the approve message the relay tool builds from the called
|
|
2333
|
+
agent's own record of its approval), so the person approves exactly what is
|
|
2334
|
+
sent: the body is bound by the call hash. Only the known keys are kept, and
|
|
2335
|
+
a chain deeper than `NESTED_MAX_DEPTH` stops there.
|
|
2336
|
+
"""
|
|
2337
|
+
if depth == 0:
|
|
2338
|
+
params = body.get("params") if isinstance(body, Mapping) else None
|
|
2339
|
+
message = params.get("message") if isinstance(params, Mapping) else None
|
|
2340
|
+
metadata = message.get("metadata") if isinstance(message, Mapping) else None
|
|
2341
|
+
extension = metadata.get(A2A_ORIGIN_EXTENSION) if isinstance(metadata, Mapping) else None
|
|
2342
|
+
body = extension.get(APPROVING_KEY) if isinstance(extension, Mapping) else None
|
|
2343
|
+
if not isinstance(body, Mapping) or depth >= NESTED_MAX_DEPTH:
|
|
2344
|
+
return None
|
|
2345
|
+
nested: dict[str, Any] = {key: body.get(key) for key in NESTED_KEYS}
|
|
2346
|
+
call = body.get("call")
|
|
2347
|
+
nested["call"] = (
|
|
2348
|
+
{key: call[key] for key in NESTED_CALL_KEYS if key in call}
|
|
2349
|
+
if isinstance(call, Mapping)
|
|
2350
|
+
else None
|
|
2351
|
+
)
|
|
2352
|
+
nested["nested"] = relayed_approval(body.get("nested"), depth + 1)
|
|
2353
|
+
return nested
|
|
2354
|
+
|
|
2355
|
+
|
|
2356
|
+
def approval_effect(nested: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
2357
|
+
"""The call that will actually happen when a relayed approval is approved (`effect`).
|
|
2358
|
+
|
|
2359
|
+
The innermost `nested` level's call, the agent that makes it, and `via`: the
|
|
2360
|
+
agents the decision passes through to get there, the called one first (a
|
|
2361
|
+
single hop: just that agent).
|
|
2362
|
+
"""
|
|
2363
|
+
if not isinstance(nested, Mapping):
|
|
2364
|
+
return None
|
|
2365
|
+
via: list[Any] = []
|
|
2366
|
+
level: Mapping[str, Any] = nested
|
|
2367
|
+
while True:
|
|
2368
|
+
via.append(level.get("agent"))
|
|
2369
|
+
inner = level.get("nested")
|
|
2370
|
+
if not isinstance(inner, Mapping) or len(via) >= NESTED_MAX_DEPTH:
|
|
2371
|
+
break
|
|
2372
|
+
level = inner
|
|
2373
|
+
call = level.get("call") if isinstance(level.get("call"), Mapping) else {}
|
|
2374
|
+
effect: dict[str, Any] = {"agent": level.get("agent"), "via": via}
|
|
2375
|
+
for key in NESTED_CALL_KEYS:
|
|
2376
|
+
if key in call:
|
|
2377
|
+
effect[key] = call[key]
|
|
2378
|
+
effect["reason"] = level.get("reason")
|
|
2379
|
+
effect["expires_at"] = level.get("expires_at")
|
|
2380
|
+
return effect
|
|
2381
|
+
|
|
2382
|
+
|
|
2383
|
+
class BoundApproval(NamedTuple):
|
|
2384
|
+
"""An approval the ledger recorded for a tool call: the call it was asked for and its state."""
|
|
2385
|
+
|
|
2386
|
+
api: str
|
|
2387
|
+
method: str
|
|
2388
|
+
path: str
|
|
2389
|
+
# "pending", "approved", "rejected" or "expired" (a pending one past its expiry).
|
|
2390
|
+
status: str
|
|
2391
|
+
# Whether its approved call was sent (an approval is used once).
|
|
2392
|
+
used: bool
|
|
2393
|
+
# A call to a JSON-RPC API: the request its body was (`derive_rpc`), part of which
|
|
2394
|
+
# call it is (`call_identity`). None for other calls.
|
|
2395
|
+
rpc_method: str | None = None
|
|
2396
|
+
a2a_operation: str | None = None
|
|
2397
|
+
|
|
2398
|
+
|
|
2399
|
+
class ApprovalLedger(Protocol):
|
|
2400
|
+
"""Where approvals are recorded (the chat runtime's approvals table)."""
|
|
2401
|
+
|
|
2402
|
+
async def consume(
|
|
2403
|
+
self, approval_id: str, call_hash: str, thread_id: str | None = None
|
|
2404
|
+
) -> str | None:
|
|
2405
|
+
"""Mark the approval used for the request `call_hash` (of thread `thread_id`):
|
|
2406
|
+
None when it may be sent now (approved, for exactly this request on this
|
|
2407
|
+
thread, never used before), else why not."""
|
|
2408
|
+
...
|
|
2409
|
+
|
|
2410
|
+
async def bound_approvals(
|
|
2411
|
+
self, *, tool_call: tuple[str, str] | None = None, interrupt_id: str | None = None
|
|
2412
|
+
) -> list[BoundApproval]:
|
|
2413
|
+
"""The approvals recorded for a tool call, newest first: the ones asked by the
|
|
2414
|
+
tool call `(message id, tool call id)` or by the interrupt `interrupt_id`,
|
|
2415
|
+
on whichever thread (a copied thread keeps its tool calls)."""
|
|
2416
|
+
...
|
|
2417
|
+
|
|
2418
|
+
|
|
2419
|
+
_ledger: ApprovalLedger | None = None
|
|
2420
|
+
|
|
2421
|
+
|
|
2422
|
+
def set_approval_ledger(ledger: ApprovalLedger | None) -> None:
|
|
2423
|
+
"""Install the process's approvals ledger (the chat runtime does it when it starts).
|
|
2424
|
+
|
|
2425
|
+
Without one, an approved call is refused (nothing sent): no approval can be
|
|
2426
|
+
checked for single use.
|
|
2427
|
+
"""
|
|
2428
|
+
global _ledger
|
|
2429
|
+
_ledger = ledger
|
|
2430
|
+
|
|
2431
|
+
|
|
2432
|
+
def approval_ledger() -> ApprovalLedger | None:
|
|
2433
|
+
return _ledger
|
|
2434
|
+
|
|
2435
|
+
|
|
2436
|
+
# Headers that follow a request to the services it calls (see set_outbound_headers).
|
|
2437
|
+
_outbound_headers: Callable[[], Mapping[str, str]] | None = None
|
|
2438
|
+
# Whether every API receives them, not only those `propagates` names (see set_outbound_headers).
|
|
2439
|
+
_outbound_everywhere: Callable[[], bool] | None = None
|
|
2440
|
+
|
|
2441
|
+
# The `auth` modes whose APIs receive the correlation headers (see `propagates`): the
|
|
2442
|
+
# modes that act for the calling user, `forward` and `exchange` (a token exchanged for the
|
|
2443
|
+
# user's, RFC 8693: the owner's decision of 2026-09-28).
|
|
2444
|
+
PROPAGATING_AUTH_MODES = frozenset({"forward", "exchange"})
|
|
2445
|
+
# The protocols whose APIs receive them whatever their `auth`: `a2a`, another agent (an A2A
|
|
2446
|
+
# peer, the owner's decision of 2026-09-27: "only to A2A peers and auth: forward APIs").
|
|
2447
|
+
PROPAGATING_PROTOCOLS = frozenset({PROTOCOL_A2A})
|
|
2448
|
+
|
|
2449
|
+
|
|
2450
|
+
def set_outbound_headers(
|
|
2451
|
+
provider: Callable[[], Mapping[str, str]] | None,
|
|
2452
|
+
everywhere: Callable[[], bool] | None = None,
|
|
2453
|
+
) -> None:
|
|
2454
|
+
"""Install what adds the correlation headers to the calls that carry them.
|
|
2455
|
+
|
|
2456
|
+
The app installs `telemetry.outbound_trace_headers` (this request's
|
|
2457
|
+
`X-Request-ID` and, under OTLP tracing, its W3C trace context), so an agent
|
|
2458
|
+
this call reaches logs the same request id and continues the same trace.
|
|
2459
|
+
Only the APIs `propagates` names receive them; any other API receives
|
|
2460
|
+
neither. A header the tool sets itself wins, and a header a tool may not set
|
|
2461
|
+
(`forbidden_header`) is never added. The headers differ for every request,
|
|
2462
|
+
so they are not part of the request an approval binds (`canonical_call`).
|
|
2463
|
+
`everywhere` (the app installs `telemetry.propagate_to_every_api`,
|
|
2464
|
+
`PROPAGATE_TRACE_HEADERS=all`) says when every API receives them.
|
|
2465
|
+
"""
|
|
2466
|
+
global _outbound_headers, _outbound_everywhere
|
|
2467
|
+
_outbound_headers = provider
|
|
2468
|
+
_outbound_everywhere = everywhere
|
|
2469
|
+
|
|
2470
|
+
|
|
2471
|
+
def propagates(api_settings: Mapping[str, Any]) -> bool:
|
|
2472
|
+
"""Whether calls to this API carry the request's correlation headers.
|
|
2473
|
+
|
|
2474
|
+
Only the APIs that are part of the same request do: another agent
|
|
2475
|
+
(`protocol: a2a`, `PROPAGATING_PROTOCOLS`), whatever its `auth`, and the
|
|
2476
|
+
APIs that act for the calling user (`PROPAGATING_AUTH_MODES`), `auth:
|
|
2477
|
+
forward` and `auth: exchange` ones, as another agent does when it is
|
|
2478
|
+
reached with the caller's own credential or a token exchanged for it. Any
|
|
2479
|
+
other API (`auth: bearer` or `none` over `http` or `jsonrpc`) is a third
|
|
2480
|
+
party that never learns this request's id or trace. This is the one place
|
|
2481
|
+
that decides under `PROPAGATE_TRACE_HEADERS=peers` (the default); `all`
|
|
2482
|
+
sends the headers to every API, and `off` to none.
|
|
2483
|
+
"""
|
|
2484
|
+
return (
|
|
2485
|
+
api_settings.get("auth") in PROPAGATING_AUTH_MODES
|
|
2486
|
+
or api_protocol(api_settings) in PROPAGATING_PROTOCOLS
|
|
2487
|
+
)
|
|
2488
|
+
|
|
2489
|
+
|
|
2490
|
+
def outbound_headers(api_settings: Mapping[str, Any]) -> dict[str, str]:
|
|
2491
|
+
"""The correlation headers for a call to this API made now: empty for an API that
|
|
2492
|
+
does not receive them (`propagates`) or without a provider."""
|
|
2493
|
+
provider, everywhere = _outbound_headers, _outbound_everywhere
|
|
2494
|
+
if provider is None:
|
|
2495
|
+
return {}
|
|
2496
|
+
try:
|
|
2497
|
+
if not (propagates(api_settings) or (everywhere is not None and everywhere())):
|
|
2498
|
+
return {}
|
|
2499
|
+
headers = {str(name): str(value) for name, value in dict(provider()).items()}
|
|
2500
|
+
except Exception as exc: # correlation never stops a call
|
|
2501
|
+
logger.warning("api call: no correlation headers (%s)", type(exc).__name__)
|
|
2502
|
+
return {}
|
|
2503
|
+
return {name: value for name, value in headers.items() if not forbidden_header(name)}
|
|
2504
|
+
|
|
2505
|
+
|
|
2506
|
+
def _run_thread_id() -> str | None:
|
|
2507
|
+
"""The thread of the current graph run (its `configurable.thread_id`), if any."""
|
|
2508
|
+
try:
|
|
2509
|
+
from langgraph.config import get_config
|
|
2510
|
+
|
|
2511
|
+
thread_id = (get_config().get("configurable") or {}).get("thread_id")
|
|
2512
|
+
except Exception: # outside a graph run
|
|
2513
|
+
return None
|
|
2514
|
+
return str(thread_id) if thread_id else None
|
|
2515
|
+
|
|
2516
|
+
|
|
2517
|
+
@dataclass
|
|
2518
|
+
class ToolCallScope:
|
|
2519
|
+
"""The tool call a gated request is made for (set by the agent's middleware)."""
|
|
2520
|
+
|
|
2521
|
+
name: str
|
|
2522
|
+
call_id: str | None = None
|
|
2523
|
+
# The id of the model message that made the tool call: with `call_id`, it names
|
|
2524
|
+
# this tool call in the approvals ledger (a model may reuse call ids across turns).
|
|
2525
|
+
message_id: str | None = None
|
|
2526
|
+
# The text the model wrote with the tool call: its stated purpose, when any.
|
|
2527
|
+
purpose: str | None = None
|
|
2528
|
+
# An approved call was sent in this tool call (a second gated call is refused).
|
|
2529
|
+
gated_sent: bool = False
|
|
2530
|
+
# How many of the task's resume values this tool call took (`interrupt()` calls).
|
|
2531
|
+
resumes_taken: int = 0
|
|
2532
|
+
# The calls (`call_identity`) a decision stopped in this tool call.
|
|
2533
|
+
stopped: set[tuple[str, ...]] = field(default_factory=set)
|
|
2534
|
+
|
|
2535
|
+
|
|
2536
|
+
_TOOL_CALL: ContextVar[ToolCallScope | None] = ContextVar("api_tool_call", default=None)
|
|
2537
|
+
# The same, for a tool run without the middleware's scope (in its own context).
|
|
2538
|
+
_GATED_SENT: ContextVar[bool] = ContextVar("api_gated_sent", default=False)
|
|
2539
|
+
_RESUMES_TAKEN: ContextVar[int] = ContextVar("api_resumes_taken", default=0)
|
|
2540
|
+
_STOPPED: ContextVar[frozenset[tuple[str, ...]]] = ContextVar(
|
|
2541
|
+
"api_stopped_calls", default=frozenset()
|
|
2542
|
+
)
|
|
2543
|
+
|
|
2544
|
+
|
|
2545
|
+
def call_identity(
|
|
2546
|
+
api: str,
|
|
2547
|
+
method: str,
|
|
2548
|
+
path: str,
|
|
2549
|
+
rpc_method: str | None = None,
|
|
2550
|
+
a2a_operation: str | None = None,
|
|
2551
|
+
) -> tuple[str, ...]:
|
|
2552
|
+
"""Which call a decision is bound to: the API, the method and the path sent.
|
|
2553
|
+
|
|
2554
|
+
The path is compared normalised and ignoring letter case, as a gate
|
|
2555
|
+
compares it, so a rebuilt request that differs only in spelling is the
|
|
2556
|
+
same call (a body or query that changed is caught by the call hash).
|
|
2557
|
+
|
|
2558
|
+
A call to a JSON-RPC API (`protocol: jsonrpc|a2a`) is also known by the
|
|
2559
|
+
request its body is (`derive_rpc`): its JSON-RPC method and A2A decision.
|
|
2560
|
+
Every call to such an API shares one method and path, so without them a
|
|
2561
|
+
`GetTask` the resumed tool sends first would take the decision an approve
|
|
2562
|
+
message waits for, and a call a rejection stopped would stop the `reject`
|
|
2563
|
+
message that tells the other agent. A call to an `http` API keeps the
|
|
2564
|
+
three fields: its label is the tool's, not the request's.
|
|
2565
|
+
"""
|
|
2566
|
+
identity: tuple[str, ...] = (
|
|
2567
|
+
str(api),
|
|
2568
|
+
str(method).upper(),
|
|
2569
|
+
normalize_path(str(path)).casefold(),
|
|
2570
|
+
)
|
|
2571
|
+
if rpc_method is None and a2a_operation is None:
|
|
2572
|
+
return identity
|
|
2573
|
+
return (*identity, str(rpc_method or ""), str(a2a_operation or ""))
|
|
2574
|
+
|
|
2575
|
+
|
|
2576
|
+
def _is_decision(value: Any) -> bool:
|
|
2577
|
+
return isinstance(value, Mapping) and value.get("type") == APPROVAL_DECISION
|
|
2578
|
+
|
|
2579
|
+
|
|
2580
|
+
def _decision_identity(decision: Mapping[str, Any]) -> tuple[str, ...] | None:
|
|
2581
|
+
api, method, path = decision.get("api"), decision.get("method"), decision.get("path")
|
|
2582
|
+
if not (isinstance(api, str) and isinstance(method, str) and isinstance(path, str)):
|
|
2583
|
+
return None
|
|
2584
|
+
rpc_method, a2a_operation = decision.get(RPC_METHOD_KEY), decision.get(A2A_OPERATION_KEY)
|
|
2585
|
+
if not all(value is None or isinstance(value, str) for value in (rpc_method, a2a_operation)):
|
|
2586
|
+
return None
|
|
2587
|
+
return call_identity(api, method, path, rpc_method, a2a_operation)
|
|
2588
|
+
|
|
2589
|
+
|
|
2590
|
+
# Where LangGraph keeps a task's resume values in the run config (its interrupt()
|
|
2591
|
+
# reads them there); the constant's module is internal, so it is looked up lazily.
|
|
2592
|
+
_SCRATCHPAD_KEY_FALLBACK = "__pregel_scratchpad"
|
|
2593
|
+
|
|
2594
|
+
|
|
2595
|
+
def _scratchpad_key() -> str:
|
|
2596
|
+
try:
|
|
2597
|
+
from langgraph._internal._constants import CONFIG_KEY_SCRATCHPAD
|
|
2598
|
+
except ImportError: # moved in another LangGraph version: the name it has always had
|
|
2599
|
+
return _SCRATCHPAD_KEY_FALLBACK
|
|
2600
|
+
return str(CONFIG_KEY_SCRATCHPAD)
|
|
2601
|
+
|
|
2602
|
+
|
|
2603
|
+
def _next_resume_value(taken: int) -> Any:
|
|
2604
|
+
"""What the task's next `interrupt()` returns, without taking it; None when nothing.
|
|
2605
|
+
|
|
2606
|
+
The resume values of the current LangGraph task (the decisions a resumed
|
|
2607
|
+
run brought for its paused calls) in the order `interrupt()` hands them
|
|
2608
|
+
out; `taken` is how many this tool call took already. Outside a LangGraph
|
|
2609
|
+
task (no run, or a tool invoked directly) there are none. A task whose
|
|
2610
|
+
resume values cannot be read (another LangGraph version) is refused: a
|
|
2611
|
+
decision waiting there could not be honoured.
|
|
2612
|
+
"""
|
|
2613
|
+
try:
|
|
2614
|
+
from langgraph.config import get_config
|
|
2615
|
+
|
|
2616
|
+
configurable = get_config().get("configurable") or {}
|
|
2617
|
+
except Exception: # outside a graph run
|
|
2618
|
+
return None
|
|
2619
|
+
scratchpad = configurable.get(_scratchpad_key()) if isinstance(configurable, Mapping) else None
|
|
2620
|
+
if scratchpad is None:
|
|
2621
|
+
return None
|
|
2622
|
+
try:
|
|
2623
|
+
resumed = list(scratchpad.resume)
|
|
2624
|
+
if taken < len(resumed):
|
|
2625
|
+
return resumed[taken]
|
|
2626
|
+
return scratchpad.get_null_resume(False) if taken == len(resumed) else None
|
|
2627
|
+
except (AttributeError, TypeError) as exc:
|
|
2628
|
+
raise ApiPolicyError(
|
|
2629
|
+
"cannot read the run's approval decisions (an unsupported LangGraph version): "
|
|
2630
|
+
"refused, nothing was sent.",
|
|
2631
|
+
reason="approval decisions unreadable",
|
|
2632
|
+
) from exc
|
|
2633
|
+
|
|
2634
|
+
|
|
2635
|
+
def _task_interrupt_id() -> str | None:
|
|
2636
|
+
"""The id an `interrupt()` of the current LangGraph task gets (None outside a task).
|
|
2637
|
+
|
|
2638
|
+
LangGraph derives it from the task's checkpoint namespace, so the task a
|
|
2639
|
+
run continues without input interrupts with the same id as when it paused.
|
|
2640
|
+
"""
|
|
2641
|
+
try:
|
|
2642
|
+
from langgraph.config import get_config
|
|
2643
|
+
from langgraph.types import Interrupt
|
|
2644
|
+
|
|
2645
|
+
namespace = (get_config().get("configurable") or {}).get("checkpoint_ns")
|
|
2646
|
+
if not isinstance(namespace, str) or not namespace:
|
|
2647
|
+
return None
|
|
2648
|
+
return str(Interrupt.from_ns(None, namespace).id)
|
|
2649
|
+
except Exception: # outside a graph run, or another LangGraph version
|
|
2650
|
+
return None
|
|
2651
|
+
|
|
2652
|
+
|
|
2653
|
+
def _resumes_taken() -> int:
|
|
2654
|
+
scope = _TOOL_CALL.get()
|
|
2655
|
+
return scope.resumes_taken if scope is not None else _RESUMES_TAKEN.get()
|
|
2656
|
+
|
|
2657
|
+
|
|
2658
|
+
def _took_resume() -> None:
|
|
2659
|
+
scope = _TOOL_CALL.get()
|
|
2660
|
+
if scope is not None:
|
|
2661
|
+
scope.resumes_taken += 1
|
|
2662
|
+
else:
|
|
2663
|
+
_RESUMES_TAKEN.set(_RESUMES_TAKEN.get() + 1)
|
|
2664
|
+
|
|
2665
|
+
|
|
2666
|
+
def _stopped_calls() -> frozenset[tuple[str, ...]] | set[tuple[str, ...]]:
|
|
2667
|
+
scope = _TOOL_CALL.get()
|
|
2668
|
+
return scope.stopped if scope is not None else _STOPPED.get()
|
|
2669
|
+
|
|
2670
|
+
|
|
2671
|
+
def _stop_call(identity: tuple[str, ...]) -> None:
|
|
2672
|
+
scope = _TOOL_CALL.get()
|
|
2673
|
+
if scope is not None:
|
|
2674
|
+
scope.stopped.add(identity)
|
|
2675
|
+
else:
|
|
2676
|
+
_STOPPED.set(_STOPPED.get() | {identity})
|
|
2677
|
+
|
|
2678
|
+
|
|
2679
|
+
def _decision_waiting(identity: tuple[str, ...]) -> Mapping[str, Any] | None:
|
|
2680
|
+
"""The decision the resumed run brought for this call, when the next resume value is one.
|
|
2681
|
+
|
|
2682
|
+
A request the tool rebuilds on resume is the paused call when it has the
|
|
2683
|
+
paused call's API, method and path, and on a JSON-RPC API its JSON-RPC
|
|
2684
|
+
method and A2A decision (`call_identity`); the decision then applies to
|
|
2685
|
+
it whatever the policy now says about gating it.
|
|
2686
|
+
"""
|
|
2687
|
+
waiting = _next_resume_value(_resumes_taken())
|
|
2688
|
+
if _is_decision(waiting) and _decision_identity(waiting) == identity:
|
|
2689
|
+
return waiting
|
|
2690
|
+
return None
|
|
2691
|
+
|
|
2692
|
+
|
|
2693
|
+
def _decided_with(value: Mapping[str, Any]) -> tuple[str, tuple[str, ...]]:
|
|
2694
|
+
"""`(decide_with, relayers)` an interrupt or a decision carries (`direct` when absent: a
|
|
2695
|
+
call paused before 0.3)."""
|
|
2696
|
+
decide_with = value.get("decide_with")
|
|
2697
|
+
relayers = value.get("relayers")
|
|
2698
|
+
return (
|
|
2699
|
+
decide_with if isinstance(decide_with, str) else DEFAULT_DECIDE_WITH,
|
|
2700
|
+
tuple(str(r) for r in relayers) if isinstance(relayers, list | tuple) else (),
|
|
2701
|
+
)
|
|
2702
|
+
|
|
2703
|
+
|
|
2704
|
+
def _context_actor() -> str | None:
|
|
2705
|
+
"""The agent the current run acts through (its principal's `@actor`), or None."""
|
|
2706
|
+
context = current_context()
|
|
2707
|
+
attributes = getattr(context, "attributes", None)
|
|
2708
|
+
if attributes is None and isinstance(context, Mapping):
|
|
2709
|
+
attributes = context.get("attributes")
|
|
2710
|
+
actor = attributes.get("@actor") if isinstance(attributes, Mapping) else None
|
|
2711
|
+
actor_id = actor.get("id") if isinstance(actor, Mapping) else None
|
|
2712
|
+
return actor_id if isinstance(actor_id, str) and actor_id else None
|
|
2713
|
+
|
|
2714
|
+
|
|
2715
|
+
def _bound_refusal(bound: list[BoundApproval]) -> tuple[str, str]:
|
|
2716
|
+
"""What the model reads, and the log reason, for a call refused by its recorded approval.
|
|
2717
|
+
|
|
2718
|
+
`bound` is newest first; a used approval wins (the call was sent once).
|
|
2719
|
+
"""
|
|
2720
|
+
record = next((b for b in bound if b.used), bound[0])
|
|
2721
|
+
if record.used:
|
|
2722
|
+
return (
|
|
2723
|
+
"was sent already with its approval, which is used once (the tool call ran "
|
|
2724
|
+
"again without a new decision)",
|
|
2725
|
+
"approval already used",
|
|
2726
|
+
)
|
|
2727
|
+
if record.status == "rejected":
|
|
2728
|
+
return "was not approved: an approver rejected it", "approval rejected"
|
|
2729
|
+
if record.status == "expired":
|
|
2730
|
+
return (
|
|
2731
|
+
"was not approved: the approval request expired before anyone decided",
|
|
2732
|
+
"approval expired",
|
|
2733
|
+
)
|
|
2734
|
+
if record.status == "approved":
|
|
2735
|
+
return (
|
|
2736
|
+
"was approved, but only the run its decision resumed may send it (the tool "
|
|
2737
|
+
"call ran again without that decision)",
|
|
2738
|
+
"approval not resumed",
|
|
2739
|
+
)
|
|
2740
|
+
return (
|
|
2741
|
+
"needs human approval, and the run was resumed without an approval decision",
|
|
2742
|
+
"resumed without a decision",
|
|
2743
|
+
)
|
|
2744
|
+
|
|
2745
|
+
|
|
2746
|
+
def _plain(text: Any, limit: int) -> str | None:
|
|
2747
|
+
"""`text` as one printable line of at most `limit` characters, or None when empty."""
|
|
2748
|
+
if isinstance(text, list):
|
|
2749
|
+
text = "".join(
|
|
2750
|
+
str(block.get("text", "")) if isinstance(block, Mapping) else str(block)
|
|
2751
|
+
for block in text
|
|
2752
|
+
)
|
|
2753
|
+
if not isinstance(text, str):
|
|
2754
|
+
return None
|
|
2755
|
+
cleaned = " ".join(_UNPRINTABLE.sub("", text).split())
|
|
2756
|
+
if not cleaned:
|
|
2757
|
+
return None
|
|
2758
|
+
return cleaned if len(cleaned) <= limit else cleaned[: limit - 3].rstrip() + "..."
|
|
2759
|
+
|
|
2760
|
+
|
|
2761
|
+
def _field(message: Any, name: str) -> Any:
|
|
2762
|
+
return message.get(name) if isinstance(message, Mapping) else getattr(message, name, None)
|
|
2763
|
+
|
|
2764
|
+
|
|
2765
|
+
def calling_message(messages: Iterable[Any], call_id: str | None) -> Any:
|
|
2766
|
+
"""The assistant message that made tool call `call_id` (the latest one), or None."""
|
|
2767
|
+
if not call_id:
|
|
2768
|
+
return None
|
|
2769
|
+
for message in reversed(list(messages or [])):
|
|
2770
|
+
if any(
|
|
2771
|
+
(c.get("id") if isinstance(c, Mapping) else getattr(c, "id", None)) == call_id
|
|
2772
|
+
for c in _field(message, "tool_calls") or []
|
|
2773
|
+
):
|
|
2774
|
+
return message
|
|
2775
|
+
return None
|
|
2776
|
+
|
|
2777
|
+
|
|
2778
|
+
def stated_purpose(messages: Iterable[Any], call_id: str | None) -> str | None:
|
|
2779
|
+
"""The text of the assistant message that made tool call `call_id`, when it wrote any.
|
|
2780
|
+
|
|
2781
|
+
This is the model's own account of why it makes the call (the default
|
|
2782
|
+
system prompt asks for one before an action that needs approval). It is
|
|
2783
|
+
shown to the approver as the model's statement, beside the exact request.
|
|
2784
|
+
"""
|
|
2785
|
+
message = calling_message(messages, call_id)
|
|
2786
|
+
return None if message is None else _plain(_field(message, "content"), PURPOSE_MAX_CHARS)
|
|
2787
|
+
|
|
2788
|
+
|
|
2789
|
+
@contextlib.contextmanager
|
|
2790
|
+
def tool_call_scope(request: Any) -> Iterator[ToolCallScope]:
|
|
2791
|
+
"""Name the tool call a middleware is about to run (`request` is its ToolCallRequest).
|
|
2792
|
+
|
|
2793
|
+
A gated request made inside names that tool, and the model's stated
|
|
2794
|
+
purpose, in its approval.
|
|
2795
|
+
"""
|
|
2796
|
+
call = getattr(request, "tool_call", None) or {}
|
|
2797
|
+
state = getattr(request, "state", None)
|
|
2798
|
+
messages = state.get("messages") if isinstance(state, Mapping) else None
|
|
2799
|
+
call_id = call.get("id") if isinstance(call, Mapping) else None
|
|
2800
|
+
message = calling_message(messages or [], call_id)
|
|
2801
|
+
message_id = _field(message, "id") if message is not None else None
|
|
2802
|
+
scope = ToolCallScope(
|
|
2803
|
+
name=str((call.get("name") if isinstance(call, Mapping) else "") or ""),
|
|
2804
|
+
call_id=str(call_id) if call_id else None,
|
|
2805
|
+
message_id=str(message_id) if message_id else None,
|
|
2806
|
+
purpose=None if message is None else _plain(_field(message, "content"), PURPOSE_MAX_CHARS),
|
|
2807
|
+
)
|
|
2808
|
+
token = _TOOL_CALL.set(scope)
|
|
2809
|
+
try:
|
|
2810
|
+
yield scope
|
|
2811
|
+
finally:
|
|
2812
|
+
_TOOL_CALL.reset(token)
|
|
2813
|
+
|
|
2814
|
+
|
|
2815
|
+
def redact_fields(value: Any, names: frozenset[str]) -> Any:
|
|
2816
|
+
"""`value` with the value of every key in `names` (any depth, any letter case) masked."""
|
|
2817
|
+
if not names:
|
|
2818
|
+
return value
|
|
2819
|
+
if isinstance(value, Mapping):
|
|
2820
|
+
return {
|
|
2821
|
+
key: REDACTED if str(key).casefold() in names else redact_fields(item, names)
|
|
2822
|
+
for key, item in value.items()
|
|
2823
|
+
}
|
|
2824
|
+
if isinstance(value, list | tuple):
|
|
2825
|
+
return [redact_fields(item, names) for item in value]
|
|
2826
|
+
return value
|
|
2827
|
+
|
|
2828
|
+
|
|
2829
|
+
def _query_view(query: httpx.QueryParams | None, names: frozenset[str]) -> dict[str, Any]:
|
|
2830
|
+
"""The query as the approver sees it: a repeated key lists its values."""
|
|
2831
|
+
view: dict[str, Any] = {}
|
|
2832
|
+
for key, value in query.multi_items() if query is not None else ():
|
|
2833
|
+
shown = REDACTED if key.casefold() in names else value
|
|
2834
|
+
if key not in view:
|
|
2835
|
+
view[key] = shown
|
|
2836
|
+
elif isinstance(view[key], list):
|
|
2837
|
+
view[key].append(shown)
|
|
2838
|
+
else:
|
|
2839
|
+
view[key] = [view[key], shown]
|
|
2840
|
+
return view
|
|
2841
|
+
|
|
2842
|
+
|
|
2843
|
+
def canonical_call(
|
|
2844
|
+
api: str,
|
|
2845
|
+
method: str,
|
|
2846
|
+
url: httpx.URL | str,
|
|
2847
|
+
query: httpx.QueryParams | None,
|
|
2848
|
+
json_body: Any,
|
|
2849
|
+
operation_id: str | None,
|
|
2850
|
+
headers: Iterable[tuple[str, str]] = (),
|
|
2851
|
+
) -> dict[str, Any]:
|
|
2852
|
+
"""Everything that decides what a request does, in one comparable form.
|
|
2853
|
+
|
|
2854
|
+
The URL carries the rendered path under the API's base URL; the query keeps
|
|
2855
|
+
the order it is sent in; `headers` are the tool's own (the policy's
|
|
2856
|
+
credential is not part of the call: it may be renewed between the approval
|
|
2857
|
+
and the send).
|
|
2858
|
+
"""
|
|
2859
|
+
return {
|
|
2860
|
+
"api": api,
|
|
2861
|
+
"method": method.upper(),
|
|
2862
|
+
"url": str(url),
|
|
2863
|
+
"query": [[key, value] for key, value in (query.multi_items() if query else ())],
|
|
2864
|
+
"body": json_body,
|
|
2865
|
+
"operation_id": operation_id or None,
|
|
2866
|
+
"headers": sorted([name.lower(), value] for name, value in headers),
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
|
|
2870
|
+
def call_hash(call: Mapping[str, Any]) -> str:
|
|
2871
|
+
"""SHA-256 of `canonical_call`: equal exactly when the requests are the same.
|
|
2872
|
+
|
|
2873
|
+
Raises `TypeError` or `ValueError` for a body that is not plain JSON (a NaN,
|
|
2874
|
+
an object JSON has no form for).
|
|
2875
|
+
"""
|
|
2876
|
+
text = json.dumps(
|
|
2877
|
+
call, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False
|
|
2878
|
+
)
|
|
2879
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
2880
|
+
|
|
2881
|
+
|
|
2882
|
+
@dataclass
|
|
2883
|
+
class PreparedRequest:
|
|
2884
|
+
"""A request that passed every policy check (`ApiClient._prepare`)."""
|
|
2885
|
+
|
|
2886
|
+
wire_path: str
|
|
2887
|
+
url: httpx.URL
|
|
2888
|
+
query: httpx.QueryParams | None
|
|
2889
|
+
headers: httpx.Headers
|
|
2890
|
+
# The tool's own headers (without the policy's credential), bound by an approval.
|
|
2891
|
+
tool_headers: list[tuple[str, str]]
|
|
2892
|
+
secrets: tuple[str, ...]
|
|
2893
|
+
gate: ApprovalGate | None
|
|
2894
|
+
# What a request to a JSON-RPC API is, read from its body (`derive_rpc`).
|
|
2895
|
+
rpc: RpcCall = field(default_factory=RpcCall)
|
|
2896
|
+
|
|
2897
|
+
|
|
2898
|
+
@dataclass(frozen=True)
|
|
2899
|
+
class SubjectToken:
|
|
2900
|
+
"""The caller's own verified bearer token, as the auth policy kept it.
|
|
2901
|
+
|
|
2902
|
+
`credentials["@subject_token"]`, with its audiences (`@subject_aud`) and
|
|
2903
|
+
expiry (`@subject_exp`, epoch seconds; None when unknown). What `auth:
|
|
2904
|
+
exchange` exchanges and `auth: forward` with `forward_audience` forwards.
|
|
2905
|
+
"""
|
|
2906
|
+
|
|
2907
|
+
# Out of repr: a repr ends up in logs and tracebacks.
|
|
2908
|
+
token: str = field(repr=False)
|
|
2909
|
+
audience: tuple[str, ...] = ()
|
|
2910
|
+
expires_at: float | None = None
|
|
2911
|
+
|
|
2912
|
+
|
|
2913
|
+
# Where the delegation-carrying APIs send the caller's identity: `forward` and
|
|
2914
|
+
# `exchange` in `forward_header`, `bearer` in Authorization.
|
|
2915
|
+
_CREDENTIAL_HEADER_MODES = ("bearer", *HEADER_AUTH_MODES)
|
|
2916
|
+
# Hosts a credential-carrying call to another agent may reach over plain http outside
|
|
2917
|
+
# APP_ENV=dev: loopback, a single-label service name, a cluster-internal name.
|
|
2918
|
+
_INTERNAL_SUFFIXES = (".svc", ".svc.cluster.local")
|
|
2919
|
+
|
|
2920
|
+
|
|
2921
|
+
def internal_host(host: str) -> bool:
|
|
2922
|
+
"""Whether `host` is loopback, a single-label name or a cluster-internal (`.svc`) name."""
|
|
2923
|
+
host = host.strip("[]").lower().rstrip(".")
|
|
2924
|
+
if host == "localhost" or ("." not in host and ":" not in host):
|
|
2925
|
+
return True
|
|
2926
|
+
if host.endswith(_INTERNAL_SUFFIXES):
|
|
2927
|
+
return True
|
|
2928
|
+
try:
|
|
2929
|
+
return ipaddress.ip_address(host).is_loopback
|
|
2930
|
+
except ValueError:
|
|
2931
|
+
return False
|
|
2932
|
+
|
|
2933
|
+
|
|
2934
|
+
def peer_transport_problem(settings: Mapping[str, Any], url: httpx.URL, env: str) -> str | None:
|
|
2935
|
+
"""Why a call to another agent may not use this base URL, or None.
|
|
2936
|
+
|
|
2937
|
+
Outside `APP_ENV=dev`, an A2A peer (`protocol: a2a`) reached with a
|
|
2938
|
+
credential (`bearer`, `forward` or `exchange`) refuses a plain http base
|
|
2939
|
+
URL unless the host is internal (`internal_host`). Other APIs are not
|
|
2940
|
+
checked (a later release may extend it).
|
|
2941
|
+
"""
|
|
2942
|
+
if settings.get("protocol") != "a2a" or settings.get("auth") not in _CREDENTIAL_HEADER_MODES:
|
|
2943
|
+
return None
|
|
2944
|
+
if url.scheme != "http" or os.environ.get("APP_ENV") == "dev" or internal_host(url.host):
|
|
2945
|
+
return None
|
|
2946
|
+
return f"{env} must use https outside APP_ENV=dev to carry credentials"
|
|
2947
|
+
|
|
2948
|
+
|
|
2949
|
+
# ---------------------------------------------------------------------------
|
|
2950
|
+
# The client
|
|
2951
|
+
# ---------------------------------------------------------------------------
|
|
2952
|
+
|
|
2953
|
+
|
|
2954
|
+
class ApiClient:
|
|
2955
|
+
"""Policy-enforcing async HTTP client for one declared API."""
|
|
2956
|
+
|
|
2957
|
+
def __init__(
|
|
2958
|
+
self,
|
|
2959
|
+
policy: ApiPolicy,
|
|
2960
|
+
name: str,
|
|
2961
|
+
*,
|
|
2962
|
+
credential: str | None = None,
|
|
2963
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
2964
|
+
run_id: str | None = None,
|
|
2965
|
+
subject: SubjectToken | None = None,
|
|
2966
|
+
actor_chain: tuple[str, ...] = (),
|
|
2967
|
+
) -> None:
|
|
2968
|
+
self.policy = policy
|
|
2969
|
+
self.name = name
|
|
2970
|
+
self.settings = policy.api(name)
|
|
2971
|
+
self._credential = credential
|
|
2972
|
+
self._transport = transport
|
|
2973
|
+
self._run_id = run_id
|
|
2974
|
+
# `auth: exchange`: the caller's own token, exchanged just before sending.
|
|
2975
|
+
self._subject = subject
|
|
2976
|
+
# The agents the request came through, current first (loop refusal).
|
|
2977
|
+
self._actor_chain = tuple(actor_chain)
|
|
2978
|
+
|
|
2979
|
+
# -- configuration ------------------------------------------------------
|
|
2980
|
+
|
|
2981
|
+
def base_url(self) -> httpx.URL:
|
|
2982
|
+
env = self.settings["base_url_env"]
|
|
2983
|
+
raw = os.environ.get(env, "").strip()
|
|
2984
|
+
if not raw:
|
|
2985
|
+
raise ApiCallError(f"{env} is not set; cannot reach API {self.name!r}.")
|
|
2986
|
+
try:
|
|
2987
|
+
url = httpx.URL(raw)
|
|
2988
|
+
except httpx.InvalidURL as exc:
|
|
2989
|
+
raise ApiCallError(f"{env} is not a valid URL: {exc}") from exc
|
|
2990
|
+
if url.scheme not in ("http", "https") or not url.host:
|
|
2991
|
+
raise ApiCallError(f"{env} must be an absolute http(s) URL.")
|
|
2992
|
+
if url.query or url.fragment or url.userinfo:
|
|
2993
|
+
raise ApiCallError(f"{env} must not carry credentials, a query or a fragment.")
|
|
2994
|
+
problem = peer_transport_problem(self.settings, url, env)
|
|
2995
|
+
if problem:
|
|
2996
|
+
raise ApiCallError(f"{problem}; nothing was sent to API {self.name!r}.")
|
|
2997
|
+
return url
|
|
2998
|
+
|
|
2999
|
+
def credential_header(self) -> str | None:
|
|
3000
|
+
"""The header the policy's credential goes in (None: `auth: none`).
|
|
3001
|
+
|
|
3002
|
+
A tool header of that name is never sent nor bound by an approval: the
|
|
3003
|
+
policy's credential replaces it.
|
|
3004
|
+
"""
|
|
3005
|
+
mode = self.settings["auth"]
|
|
3006
|
+
if mode in HEADER_AUTH_MODES:
|
|
3007
|
+
return str(self.settings.get("forward_header") or DEFAULT_FORWARD_HEADER)
|
|
3008
|
+
return "Authorization" if mode == "bearer" else None
|
|
3009
|
+
|
|
3010
|
+
def delegation_target(self) -> str | None:
|
|
3011
|
+
"""The agent (audience) a call to this API acts for the caller at, or None.
|
|
3012
|
+
|
|
3013
|
+
`exchange.audience` for `auth: exchange`, `forward_audience` for `auth:
|
|
3014
|
+
forward`: the calls that carry the caller's identity onward.
|
|
3015
|
+
"""
|
|
3016
|
+
exchange = self.settings.get(EXCHANGE_KEY)
|
|
3017
|
+
if self.settings["auth"] == "exchange" and isinstance(exchange, Mapping):
|
|
3018
|
+
return str(exchange.get("audience") or "") or None
|
|
3019
|
+
if self.settings["auth"] == "forward" and self.settings.get("forward_audience"):
|
|
3020
|
+
return str(self.settings["forward_audience"])
|
|
3021
|
+
return None
|
|
3022
|
+
|
|
3023
|
+
def check_loop(self) -> None:
|
|
3024
|
+
"""Refuse (`ApiPolicyError`) a call that would come back to an agent of this request.
|
|
3025
|
+
|
|
3026
|
+
That is this agent itself, or an agent already in the request's
|
|
3027
|
+
delegation chain (A -> B -> A), named by the API's delegation target.
|
|
3028
|
+
"""
|
|
3029
|
+
target = self.delegation_target()
|
|
3030
|
+
if target is None:
|
|
3031
|
+
return
|
|
3032
|
+
try:
|
|
3033
|
+
from .token_exchange import loop_problem
|
|
3034
|
+
except ImportError: # loaded outside its package: fail closed
|
|
3035
|
+
raise ApiPolicyError(
|
|
3036
|
+
f"{self.name}: the delegation loop check is unavailable; nothing was sent.",
|
|
3037
|
+
reason="delegation loop check unavailable",
|
|
3038
|
+
) from None
|
|
3039
|
+
problem = loop_problem(target, self._actor_chain)
|
|
3040
|
+
if problem:
|
|
3041
|
+
raise ApiPolicyError(
|
|
3042
|
+
f"{self.name}: refused: {problem}; nothing was sent.", reason="delegation loop"
|
|
3043
|
+
)
|
|
3044
|
+
|
|
3045
|
+
async def exchanged_credential(self) -> tuple[str, str] | None:
|
|
3046
|
+
"""`(header, "Bearer <token>")` for an `auth: exchange` API; None for any other.
|
|
3047
|
+
|
|
3048
|
+
The caller's own token exchanged for one minted for the API's
|
|
3049
|
+
`exchange.audience` (`token_exchange.py`: cached, single flight, failures
|
|
3050
|
+
remembered briefly). Raises `ApiCallError` (nothing sent) when the run
|
|
3051
|
+
has no user token, it has expired, the issuer refuses or fails, or the
|
|
3052
|
+
token names no actor and the API does not set `exchange.allow_actorless`.
|
|
3053
|
+
"""
|
|
3054
|
+
if self.settings["auth"] != "exchange":
|
|
3055
|
+
return None
|
|
3056
|
+
if self._subject is None:
|
|
3057
|
+
raise ApiCallError(
|
|
3058
|
+
f"API {self.name!r} uses auth: exchange, but this run has no user token to "
|
|
3059
|
+
"exchange (shared-bearer, or a run resumed by another principal); nothing was "
|
|
3060
|
+
"sent.",
|
|
3061
|
+
reason="no user token to exchange",
|
|
3062
|
+
)
|
|
3063
|
+
try:
|
|
3064
|
+
from .token_exchange import TokenExchangeError, exchanger
|
|
3065
|
+
except ImportError: # loaded outside its package: fail closed
|
|
3066
|
+
raise ApiCallError(
|
|
3067
|
+
f"API {self.name!r} uses auth: exchange, but token exchange is unavailable; "
|
|
3068
|
+
"nothing was sent.",
|
|
3069
|
+
reason="token exchange unavailable",
|
|
3070
|
+
) from None
|
|
3071
|
+
exchange = self.settings[EXCHANGE_KEY]
|
|
3072
|
+
try:
|
|
3073
|
+
token = await exchanger().token(
|
|
3074
|
+
self.name,
|
|
3075
|
+
self._subject.token,
|
|
3076
|
+
audience=str(exchange["audience"]),
|
|
3077
|
+
scope=exchange.get("scope"),
|
|
3078
|
+
resource=exchange.get("resource"),
|
|
3079
|
+
subject_expires_at=self._subject.expires_at,
|
|
3080
|
+
allow_actorless=exchange.get(ALLOW_ACTORLESS_KEY) is True,
|
|
3081
|
+
)
|
|
3082
|
+
except TokenExchangeError as exc:
|
|
3083
|
+
raise ApiCallError(str(exc), reason=exc.reason) from None
|
|
3084
|
+
return self.credential_header() or DEFAULT_FORWARD_HEADER, f"Bearer {token}"
|
|
3085
|
+
|
|
3086
|
+
def auth_headers(self) -> dict[str, str]:
|
|
3087
|
+
mode = self.settings["auth"]
|
|
3088
|
+
if mode == "bearer":
|
|
3089
|
+
env = self.settings["token_env"]
|
|
3090
|
+
token = os.environ.get(env, "")
|
|
3091
|
+
if not token:
|
|
3092
|
+
raise ApiCallError(
|
|
3093
|
+
f"{env} is not set (API {self.name!r} uses auth: bearer); nothing was sent."
|
|
3094
|
+
)
|
|
3095
|
+
return {"Authorization": f"Bearer {token}"}
|
|
3096
|
+
if mode == "forward":
|
|
3097
|
+
if not self._credential:
|
|
3098
|
+
raise ApiCallError(
|
|
3099
|
+
f"the caller has no credential for API {self.name!r} "
|
|
3100
|
+
f"(principal.attributes['credentials'][{self.name!r}]); nothing was sent."
|
|
3101
|
+
)
|
|
3102
|
+
header = self.settings.get("forward_header") or DEFAULT_FORWARD_HEADER
|
|
3103
|
+
return {header: self._credential}
|
|
3104
|
+
return {}
|
|
3105
|
+
|
|
3106
|
+
def timeout(self) -> httpx.Timeout:
|
|
3107
|
+
timeouts = dict(DEFAULT_TIMEOUTS_MS)
|
|
3108
|
+
timeouts.update(self.settings.get("timeouts_ms") or {})
|
|
3109
|
+
return httpx.Timeout(
|
|
3110
|
+
connect=timeouts["connect"] / 1000,
|
|
3111
|
+
read=timeouts["read"] / 1000,
|
|
3112
|
+
write=10.0,
|
|
3113
|
+
pool=10.0,
|
|
3114
|
+
)
|
|
3115
|
+
|
|
3116
|
+
def query(self, params: Any) -> httpx.QueryParams | None:
|
|
3117
|
+
"""`params` as the query that will be sent, with `pagination.max_page_size` enforced.
|
|
3118
|
+
|
|
3119
|
+
`params` may be anything httpx accepts (a mapping, a list of pairs or a
|
|
3120
|
+
query string); it is converted once and the converted query is what is
|
|
3121
|
+
sent. Every value of the page-size parameter is checked, whatever the
|
|
3122
|
+
letter case of its name, and each must be a plain number from 1 to the cap.
|
|
3123
|
+
A `_method` parameter (a method override) is refused.
|
|
3124
|
+
"""
|
|
3125
|
+
if params is None:
|
|
3126
|
+
return None
|
|
3127
|
+
try:
|
|
3128
|
+
query = httpx.QueryParams(params)
|
|
3129
|
+
except (TypeError, ValueError) as exc:
|
|
3130
|
+
raise ApiPolicyError(f"{self.name}: params are not a valid query ({exc}).") from exc
|
|
3131
|
+
for key in query:
|
|
3132
|
+
if key.casefold() == METHOD_OVERRIDE_PARAM:
|
|
3133
|
+
raise ApiPolicyError(
|
|
3134
|
+
f"{self.name}: the query parameter {key!r} refused: it overrides the "
|
|
3135
|
+
"request method on some servers, and the policy checks the method sent."
|
|
3136
|
+
)
|
|
3137
|
+
pagination = self.settings.get("pagination")
|
|
3138
|
+
if not pagination:
|
|
3139
|
+
return query
|
|
3140
|
+
param = pagination["page_size_param"]
|
|
3141
|
+
cap = pagination["max_page_size"]
|
|
3142
|
+
for key, value in query.multi_items():
|
|
3143
|
+
if key.casefold() != param.casefold():
|
|
3144
|
+
continue
|
|
3145
|
+
digits = value.isascii() and value.isdigit() and len(value) <= len(str(cap))
|
|
3146
|
+
if not (digits and 1 <= int(value) <= cap):
|
|
3147
|
+
raise ApiPolicyError(
|
|
3148
|
+
f"{self.name}: {key}={value!r} refused: page size must be 1-{cap} "
|
|
3149
|
+
"(the API's pagination.max_page_size)."
|
|
3150
|
+
)
|
|
3151
|
+
return query
|
|
3152
|
+
|
|
3153
|
+
def take_limits(self, method: str, what: str) -> None:
|
|
3154
|
+
"""Count the call against the API's `limits`; `ApiPolicyError` when one is exceeded."""
|
|
3155
|
+
limits = self.settings.get("limits")
|
|
3156
|
+
if not limits:
|
|
3157
|
+
return
|
|
3158
|
+
run_key = self._run_id or current_run_id() or NO_RUN
|
|
3159
|
+
reason = _limiter.acquire(self.name, limits, run_key)
|
|
3160
|
+
if reason:
|
|
3161
|
+
raise ApiPolicyError(
|
|
3162
|
+
f"{self.name}: {method} {what} refused by the API policy: {reason}.", reason=reason
|
|
3163
|
+
)
|
|
3164
|
+
|
|
3165
|
+
# -- requests -------------------------------------------------------------
|
|
3166
|
+
|
|
3167
|
+
async def request(
|
|
3168
|
+
self,
|
|
3169
|
+
method: str,
|
|
3170
|
+
path: str,
|
|
3171
|
+
*,
|
|
3172
|
+
operation_id: str | None = None,
|
|
3173
|
+
path_params: Mapping[str, Any] | None = None,
|
|
3174
|
+
params: Any = None,
|
|
3175
|
+
json_body: Any = None,
|
|
3176
|
+
headers: Mapping[str, str] | None = None,
|
|
3177
|
+
redact: Iterable[str] = (),
|
|
3178
|
+
) -> Any:
|
|
3179
|
+
"""Send `method` on `path` after the policy check; return the JSON body or the text.
|
|
3180
|
+
|
|
3181
|
+
Pass `path` as the template declared in `API_CALLS` (for example
|
|
3182
|
+
`/orders/{order_id}`) with the values in `path_params`: the policy is
|
|
3183
|
+
checked against the template and against the rendered path, and the
|
|
3184
|
+
client encodes each value as one segment, so model-chosen input cannot
|
|
3185
|
+
change which endpoint is hit. A concrete `path` is accepted too but
|
|
3186
|
+
validated (no dot segments, encoded slashes, empty segments, query or
|
|
3187
|
+
fragment); policy paths match it after decoding percent-encoded
|
|
3188
|
+
unreserved characters and ignoring one trailing slash, and denials
|
|
3189
|
+
also ignore letter case. A denial that pins a path refuses every call
|
|
3190
|
+
to that path whatever `operation_id` it names; name `operation_id`
|
|
3191
|
+
whenever the API has a denial by `operationId` alone, which refuses a
|
|
3192
|
+
call without one.
|
|
3193
|
+
`params` (a mapping, a list of pairs or a query string) is checked
|
|
3194
|
+
against `pagination.max_page_size`. The API's base URL may carry a
|
|
3195
|
+
path prefix (`https://host/v2`); `path` is joined under it.
|
|
3196
|
+
`json_body` is sent as JSON with any method the policy allows. The
|
|
3197
|
+
API's `limits` are counted last, just before sending. Redirects are
|
|
3198
|
+
never followed. With `limits.max_response_bytes`, the body is read
|
|
3199
|
+
only up to that many bytes (decoded): past it the response is
|
|
3200
|
+
discarded and `ApiCallError` raised; the call asks for gzip or deflate
|
|
3201
|
+
at most, and an answer in another content encoding is refused unread
|
|
3202
|
+
(`ApiCallError`). An empty response body returns "". Raises
|
|
3203
|
+
`ApiPolicyError` (nothing sent) or `ApiCallError` (with `status_code`
|
|
3204
|
+
and a bounded `body` for a non-2xx response).
|
|
3205
|
+
|
|
3206
|
+
`headers` may not change where the request goes or which method the
|
|
3207
|
+
server applies: `Host`, method-override (`X-HTTP-Method-Override`,
|
|
3208
|
+
`X-HTTP-Method`, `X-Method-Override`), `X-Forwarded-*`, `Forwarded`,
|
|
3209
|
+
`X-Original-URL`, `X-Rewrite-URL` and hop-by-hop headers are dropped
|
|
3210
|
+
(`FORBIDDEN_HEADERS`), and a `_method` query parameter or top-level
|
|
3211
|
+
JSON body key is refused. Each call is logged by API, method,
|
|
3212
|
+
operation id and path template (never the query, the concrete path
|
|
3213
|
+
or the body).
|
|
3214
|
+
|
|
3215
|
+
A call the API's `approval` gates pauses the run for a human decision
|
|
3216
|
+
before it is sent (see the module docstring); `redact` names body and
|
|
3217
|
+
query fields (any depth, any letter case) the approver sees masked,
|
|
3218
|
+
for values they need not read (a card number, say). The approval is
|
|
3219
|
+
bound to the request as sent, masked fields included, and a decision
|
|
3220
|
+
to the call it was taken for: on resume it applies to that call even
|
|
3221
|
+
when the policy no longer gates it, and a tool call that runs again
|
|
3222
|
+
without a decision does not send a call an approval was asked for (a
|
|
3223
|
+
rejected call is never sent, an approved one once).
|
|
3224
|
+
"""
|
|
3225
|
+
method = method.upper()
|
|
3226
|
+
label = operation_id or (path if path_params is not None else "<concrete path>")
|
|
3227
|
+
log_fields = {
|
|
3228
|
+
"api": self.name,
|
|
3229
|
+
"method": method,
|
|
3230
|
+
"operation_id": operation_id,
|
|
3231
|
+
"path_template": path if path_params is not None else None,
|
|
3232
|
+
}
|
|
3233
|
+
try:
|
|
3234
|
+
prepared = self._prepare(
|
|
3235
|
+
method, path, operation_id, path_params, params, json_body, headers
|
|
3236
|
+
)
|
|
3237
|
+
bound = await self._bound_approvals(prepared, method, operation_id or label)
|
|
3238
|
+
approved = self._await_approval(
|
|
3239
|
+
prepared, method, operation_id, label, json_body, redact, log_fields, bound
|
|
3240
|
+
)
|
|
3241
|
+
# Counted just before sending: a call held for approval counts once, when sent.
|
|
3242
|
+
self.take_limits(method, operation_id or path)
|
|
3243
|
+
if approved is not None:
|
|
3244
|
+
await self._use_approval(approved, method, operation_id or label, log_fields)
|
|
3245
|
+
wire_path, url, query = prepared.wire_path, prepared.url, prepared.query
|
|
3246
|
+
request_headers, secrets = prepared.headers, prepared.secrets
|
|
3247
|
+
# Last, just before sending: a refused or paused call never exchanges a token.
|
|
3248
|
+
exchanged = await self.exchanged_credential()
|
|
3249
|
+
if exchanged is not None:
|
|
3250
|
+
header, value = exchanged
|
|
3251
|
+
request_headers[header] = value
|
|
3252
|
+
secrets = (*secrets, value, value.split(" ", 1)[1])
|
|
3253
|
+
except ApiPolicyError as exc:
|
|
3254
|
+
logger.warning(
|
|
3255
|
+
"api call refused: %s %s %s: %s",
|
|
3256
|
+
self.name,
|
|
3257
|
+
method,
|
|
3258
|
+
label,
|
|
3259
|
+
exc.reason or "invalid request",
|
|
3260
|
+
extra=log_fields,
|
|
3261
|
+
)
|
|
3262
|
+
raise
|
|
3263
|
+
except ApiCallError as exc:
|
|
3264
|
+
logger.warning(
|
|
3265
|
+
"api call not sent: %s %s %s: %s",
|
|
3266
|
+
self.name,
|
|
3267
|
+
method,
|
|
3268
|
+
label,
|
|
3269
|
+
exc.reason or "not configured",
|
|
3270
|
+
extra=log_fields,
|
|
3271
|
+
)
|
|
3272
|
+
raise
|
|
3273
|
+
started = time.perf_counter()
|
|
3274
|
+
cap = self.response_cap()
|
|
3275
|
+
async with httpx.AsyncClient(
|
|
3276
|
+
transport=self._transport, timeout=self.timeout(), follow_redirects=False
|
|
3277
|
+
) as client:
|
|
3278
|
+
try:
|
|
3279
|
+
if cap is None:
|
|
3280
|
+
response = await client.request(
|
|
3281
|
+
method, url, params=query, json=json_body, headers=request_headers
|
|
3282
|
+
)
|
|
3283
|
+
else:
|
|
3284
|
+
request = client.build_request(
|
|
3285
|
+
method, url, params=query, json=json_body, headers=request_headers
|
|
3286
|
+
)
|
|
3287
|
+
response = await self._send_capped(client, request, cap)
|
|
3288
|
+
response.raise_for_status()
|
|
3289
|
+
except ResponseTooLarge:
|
|
3290
|
+
self._log_call(label, "too large", started, log_fields, failed=True)
|
|
3291
|
+
raise ApiCallError(
|
|
3292
|
+
f"{self.name} answered with more than {cap} bytes; discarded",
|
|
3293
|
+
reason="response over limits.max_response_bytes",
|
|
3294
|
+
) from None
|
|
3295
|
+
except ResponseEncodingRefused:
|
|
3296
|
+
self._log_call(label, "encoding refused", started, log_fields, failed=True)
|
|
3297
|
+
raise ApiCallError(
|
|
3298
|
+
f"{self.name} answered in a content encoding other than gzip or deflate, "
|
|
3299
|
+
"which limits.max_response_bytes cannot bound; discarded",
|
|
3300
|
+
reason="response encoding not readable within limits.max_response_bytes",
|
|
3301
|
+
) from None
|
|
3302
|
+
except httpx.HTTPStatusError as exc:
|
|
3303
|
+
status = exc.response.status_code
|
|
3304
|
+
self._log_call(label, status, started, log_fields, failed=True)
|
|
3305
|
+
body = error_body_excerpt(exc.response.text, secrets) or None
|
|
3306
|
+
reason = f": {' '.join(body.split())[:ERROR_MESSAGE_BODY_CHARS]}" if body else ""
|
|
3307
|
+
raise ApiCallError(
|
|
3308
|
+
f"{self.name}: {method} {wire_path} -> HTTP {status}{reason}",
|
|
3309
|
+
status_code=status,
|
|
3310
|
+
body=body,
|
|
3311
|
+
) from exc
|
|
3312
|
+
except httpx.HTTPError as exc:
|
|
3313
|
+
self._log_call(label, type(exc).__name__, started, log_fields, failed=True)
|
|
3314
|
+
raise ApiCallError(
|
|
3315
|
+
f"{self.name}: {method} {wire_path} failed: {type(exc).__name__}"
|
|
3316
|
+
) from exc
|
|
3317
|
+
self._log_call(label, response.status_code, started, log_fields)
|
|
3318
|
+
if response.content and "json" in response.headers.get("content-type", ""):
|
|
3319
|
+
try:
|
|
3320
|
+
return response.json()
|
|
3321
|
+
except ValueError as exc:
|
|
3322
|
+
raise ApiCallError(
|
|
3323
|
+
f"{self.name}: {method} {wire_path} returned invalid JSON",
|
|
3324
|
+
status_code=response.status_code,
|
|
3325
|
+
) from exc
|
|
3326
|
+
return response.text
|
|
3327
|
+
|
|
3328
|
+
def response_cap(self) -> int | None:
|
|
3329
|
+
"""`limits.max_response_bytes`: the most a response body may hold, or None (no cap)."""
|
|
3330
|
+
cap = (self.settings.get("limits") or {}).get("max_response_bytes")
|
|
3331
|
+
return cap if isinstance(cap, int) and not isinstance(cap, bool) and cap > 0 else None
|
|
3332
|
+
|
|
3333
|
+
@staticmethod
|
|
3334
|
+
async def _send_capped(
|
|
3335
|
+
client: httpx.AsyncClient, request: httpx.Request, cap: int
|
|
3336
|
+
) -> httpx.Response:
|
|
3337
|
+
"""Send `request` and read at most `cap` bytes of its body (decoded, as the tool gets
|
|
3338
|
+
it); `ResponseTooLarge` past that, with the rest never read.
|
|
3339
|
+
|
|
3340
|
+
The request asks for gzip or deflate at most (`CAPPED_ACCEPT_ENCODING`, in place of
|
|
3341
|
+
httpx's default or the tool's own `Accept-Encoding`), and the raw body is decoded
|
|
3342
|
+
here by `CappedBody`, never more than the cap at a time: an answer in any other
|
|
3343
|
+
encoding raises `ResponseEncodingRefused` unread. A declared `Content-Length` over
|
|
3344
|
+
the cap (an uncompressed body) is refused before reading."""
|
|
3345
|
+
request.headers["Accept-Encoding"] = CAPPED_ACCEPT_ENCODING
|
|
3346
|
+
response = await client.send(request, stream=True)
|
|
3347
|
+
try:
|
|
3348
|
+
body = CappedBody(response.headers.get("content-encoding", ""), cap)
|
|
3349
|
+
declared = response.headers.get("content-length", "")
|
|
3350
|
+
if body.coding is None and declared.isdigit() and int(declared) > cap:
|
|
3351
|
+
raise ResponseTooLarge
|
|
3352
|
+
try:
|
|
3353
|
+
if response.is_stream_consumed:
|
|
3354
|
+
# Already read whole and decoded by httpx: a response built with its
|
|
3355
|
+
# content, as a MockTransport handler returns it (a network answer is
|
|
3356
|
+
# always streamed). Only the cap is left to check.
|
|
3357
|
+
body = CappedBody("", cap)
|
|
3358
|
+
body.feed(response.content)
|
|
3359
|
+
else:
|
|
3360
|
+
async for chunk in response.aiter_raw():
|
|
3361
|
+
body.feed(chunk)
|
|
3362
|
+
content = body.finish()
|
|
3363
|
+
except zlib.error as exc:
|
|
3364
|
+
raise httpx.DecodingError(str(exc), request=request) from exc
|
|
3365
|
+
finally:
|
|
3366
|
+
await response.aclose()
|
|
3367
|
+
# The body as read (decoded): a response that holds it, for the usual handling.
|
|
3368
|
+
headers = [
|
|
3369
|
+
(name, value)
|
|
3370
|
+
for name, value in response.headers.multi_items()
|
|
3371
|
+
if name.lower() not in ("content-encoding", "content-length", "transfer-encoding")
|
|
3372
|
+
]
|
|
3373
|
+
return httpx.Response(
|
|
3374
|
+
response.status_code, headers=headers, content=content, request=request
|
|
3375
|
+
)
|
|
3376
|
+
|
|
3377
|
+
def _prepare(
|
|
3378
|
+
self,
|
|
3379
|
+
method: str,
|
|
3380
|
+
path: str,
|
|
3381
|
+
operation_id: str | None,
|
|
3382
|
+
path_params: Mapping[str, Any] | None,
|
|
3383
|
+
params: Any,
|
|
3384
|
+
json_body: Any,
|
|
3385
|
+
headers: Mapping[str, str] | None,
|
|
3386
|
+
) -> PreparedRequest:
|
|
3387
|
+
"""Every policy check before sending: the path, URL, query, headers, the credential
|
|
3388
|
+
sent, and the approval the call needs (`gate`, None when none)."""
|
|
3389
|
+
if method not in HTTP_METHODS:
|
|
3390
|
+
raise ApiPolicyError(f"{self.name}: unknown HTTP method {method!r}.")
|
|
3391
|
+
self.check_loop()
|
|
3392
|
+
# What the request is, read from the body sent (a JSON-RPC API): a malformed one is
|
|
3393
|
+
# refused before any rule is asked, and no rule trusts the tool's label for it.
|
|
3394
|
+
rpc = self.rpc_call(method, json_body)
|
|
3395
|
+
if path_params is not None:
|
|
3396
|
+
self.policy.check(self.name, method, operation_id, path, rpc=rpc)
|
|
3397
|
+
wire_path = render_path(path, path_params)
|
|
3398
|
+
elif _PLACEHOLDER.search(path):
|
|
3399
|
+
raise ApiPolicyError(f"path {path!r} has unfilled parameters; pass path_params=.")
|
|
3400
|
+
else:
|
|
3401
|
+
wire_path = path
|
|
3402
|
+
validate_concrete_path(wire_path)
|
|
3403
|
+
self.policy.check(self.name, method, operation_id, wire_path, rpc=rpc)
|
|
3404
|
+
mislabelled = label_problem(self.settings, operation_id, rpc.rpc_method, rpc.a2a_operation)
|
|
3405
|
+
if mislabelled:
|
|
3406
|
+
raise ApiPolicyError(
|
|
3407
|
+
f"{self.name}: {mislabelled}.", reason="operation_id does not match the request"
|
|
3408
|
+
)
|
|
3409
|
+
query = self.query(params)
|
|
3410
|
+
if isinstance(json_body, Mapping) and any(
|
|
3411
|
+
isinstance(k, str) and k.casefold() == METHOD_OVERRIDE_PARAM for k in json_body
|
|
3412
|
+
):
|
|
3413
|
+
raise ApiPolicyError(
|
|
3414
|
+
f"{self.name}: the JSON body key {METHOD_OVERRIDE_PARAM!r} refused: it overrides "
|
|
3415
|
+
"the request method on some servers, and the policy checks the method sent."
|
|
3416
|
+
)
|
|
3417
|
+
|
|
3418
|
+
base = self.base_url()
|
|
3419
|
+
prefix = base.raw_path.decode("ascii").split("?", 1)[0].rstrip("/")
|
|
3420
|
+
expected = f"{prefix}/{wire_path.lstrip('/')}"
|
|
3421
|
+
url = httpx.URL(f"{str(base).rstrip('/')}/{wire_path.lstrip('/')}")
|
|
3422
|
+
if (
|
|
3423
|
+
url.raw_path.decode("ascii").split("?", 1)[0] != expected
|
|
3424
|
+
or url.host != base.host
|
|
3425
|
+
or url.port != base.port
|
|
3426
|
+
or url.scheme != base.scheme
|
|
3427
|
+
):
|
|
3428
|
+
raise ApiPolicyError(f"path {wire_path!r} would be rewritten before sending (refused).")
|
|
3429
|
+
|
|
3430
|
+
request_headers = httpx.Headers(headers or {})
|
|
3431
|
+
dropped = sorted({name for name in request_headers if forbidden_header(name)})
|
|
3432
|
+
for name in dropped:
|
|
3433
|
+
del request_headers[name]
|
|
3434
|
+
if dropped:
|
|
3435
|
+
logger.warning(
|
|
3436
|
+
"api call: dropped header(s) a tool may not set: %s",
|
|
3437
|
+
", ".join(dropped),
|
|
3438
|
+
extra={"api": self.name},
|
|
3439
|
+
)
|
|
3440
|
+
credentials = self.auth_headers()
|
|
3441
|
+
credential_header = self.credential_header()
|
|
3442
|
+
excluded = {c.lower() for c in credentials}
|
|
3443
|
+
if credential_header is not None:
|
|
3444
|
+
excluded.add(credential_header.lower())
|
|
3445
|
+
if credential_header not in credentials and credential_header in request_headers:
|
|
3446
|
+
# `exchange`: the token is added just before sending; never the tool's own.
|
|
3447
|
+
del request_headers[credential_header]
|
|
3448
|
+
tool_headers = [
|
|
3449
|
+
(name, value)
|
|
3450
|
+
for name, value in request_headers.multi_items()
|
|
3451
|
+
if name.lower() not in excluded
|
|
3452
|
+
]
|
|
3453
|
+
for name, value in outbound_headers(self.settings).items():
|
|
3454
|
+
if name not in request_headers: # the tool's own header wins
|
|
3455
|
+
request_headers[name] = value # not in tool_headers: no approval binds it
|
|
3456
|
+
for name, value in credentials.items():
|
|
3457
|
+
request_headers[name] = value # the policy's credential always wins
|
|
3458
|
+
secrets = tuple(credentials.values()) + tuple(
|
|
3459
|
+
value.split(" ", 1)[1] for value in credentials.values() if " " in value
|
|
3460
|
+
)
|
|
3461
|
+
gate = self.gate_for(method, operation_id, path, wire_path, path_params is not None, rpc)
|
|
3462
|
+
if rpc.a2a_operation == A2A_APPROVE and gate is None:
|
|
3463
|
+
# The policy's validator already refuses an A2A API that could send one: this
|
|
3464
|
+
# holds even for a policy that was never validated.
|
|
3465
|
+
reason = "a message that approves must wait for an approval or be denied"
|
|
3466
|
+
raise ApiPolicyError(
|
|
3467
|
+
f"{self.name}: {method} {(operation_id or wire_path) + describe_rpc(rpc)} refused "
|
|
3468
|
+
f"by the API policy: {reason} (gate or deny a2a_operation: approve).",
|
|
3469
|
+
reason=reason,
|
|
3470
|
+
)
|
|
3471
|
+
return PreparedRequest(
|
|
3472
|
+
wire_path=wire_path,
|
|
3473
|
+
url=url,
|
|
3474
|
+
query=query,
|
|
3475
|
+
headers=request_headers,
|
|
3476
|
+
tool_headers=tool_headers,
|
|
3477
|
+
secrets=secrets,
|
|
3478
|
+
gate=gate,
|
|
3479
|
+
rpc=rpc,
|
|
3480
|
+
)
|
|
3481
|
+
|
|
3482
|
+
def rpc_call(self, method: str, json_body: Any) -> RpcCall:
|
|
3483
|
+
"""What a request to this API is, read from the JSON body sent (`derive_rpc`).
|
|
3484
|
+
|
|
3485
|
+
Empty for an `http` API. A body a JSON-RPC API cannot send as one
|
|
3486
|
+
JSON-RPC request raises `ApiPolicyError`, before anything is sent.
|
|
3487
|
+
"""
|
|
3488
|
+
try:
|
|
3489
|
+
return derive_rpc(self.settings, method, json_body)
|
|
3490
|
+
except RpcRequestError as exc:
|
|
3491
|
+
raise ApiPolicyError(
|
|
3492
|
+
f"{self.name}: {exc}.", reason="not one JSON-RPC request"
|
|
3493
|
+
) from None
|
|
3494
|
+
|
|
3495
|
+
def gate_for(
|
|
3496
|
+
self,
|
|
3497
|
+
method: str,
|
|
3498
|
+
operation_id: str | None,
|
|
3499
|
+
path: str,
|
|
3500
|
+
wire_path: str,
|
|
3501
|
+
templated: bool,
|
|
3502
|
+
rpc: RpcCall | None = None,
|
|
3503
|
+
) -> ApprovalGate | None:
|
|
3504
|
+
"""The approval the API's `approval` requires before this call, or None.
|
|
3505
|
+
|
|
3506
|
+
A rule covers the call when it covers the sent path or the template it
|
|
3507
|
+
was rendered from; the first such rule in file order gates it, and its
|
|
3508
|
+
approvers are the ones the approval is asked of (and bound to). On a
|
|
3509
|
+
JSON-RPC API an entry naming `rpc_method` or `a2a_operation` covers
|
|
3510
|
+
the call by what its body is (`rpc`).
|
|
3511
|
+
Asked only once the policy allowed the call: approval never widens access.
|
|
3512
|
+
"""
|
|
3513
|
+
return self.policy.gate(
|
|
3514
|
+
self.name,
|
|
3515
|
+
method,
|
|
3516
|
+
operation_id,
|
|
3517
|
+
wire_path,
|
|
3518
|
+
template=path if templated else None,
|
|
3519
|
+
rpc=rpc,
|
|
3520
|
+
)
|
|
3521
|
+
|
|
3522
|
+
async def _bound_approvals(
|
|
3523
|
+
self, prepared: PreparedRequest, method: str, what: str
|
|
3524
|
+
) -> list[BoundApproval]:
|
|
3525
|
+
"""The approvals the ledger holds for this call in this tool call, when none is resumed now.
|
|
3526
|
+
|
|
3527
|
+
Asked when no decision waits for the call in this run: the tool call
|
|
3528
|
+
runs again without one (a run continued without input, or replayed
|
|
3529
|
+
from a checkpoint, through LangGraph Server's own API; a copy of the
|
|
3530
|
+
thread). The ledger then answers, by the tool call (the model message
|
|
3531
|
+
and the call id) or by the task's interrupt, with the approvals it
|
|
3532
|
+
recorded, and `_await_approval` refuses the call when one was asked
|
|
3533
|
+
for it, whatever the policy now says about gating it. Nothing to ask
|
|
3534
|
+
(no ledger, outside a tool call and a task): empty. A ledger that
|
|
3535
|
+
cannot answer refuses the call.
|
|
3536
|
+
"""
|
|
3537
|
+
identity = self._identity(method, prepared)
|
|
3538
|
+
if identity in _stopped_calls() or _decision_waiting(identity) is not None:
|
|
3539
|
+
return [] # `_await_approval` refuses it, or applies its decision
|
|
3540
|
+
ledger = _ledger
|
|
3541
|
+
scope = _TOOL_CALL.get()
|
|
3542
|
+
tool_call = (
|
|
3543
|
+
(scope.message_id, scope.call_id)
|
|
3544
|
+
if scope is not None and scope.message_id and scope.call_id
|
|
3545
|
+
else None
|
|
3546
|
+
)
|
|
3547
|
+
interrupt_id = _task_interrupt_id()
|
|
3548
|
+
if ledger is None or (tool_call is None and interrupt_id is None):
|
|
3549
|
+
return []
|
|
3550
|
+
try:
|
|
3551
|
+
found = await ledger.bound_approvals(tool_call=tool_call, interrupt_id=interrupt_id)
|
|
3552
|
+
except Exception as exc:
|
|
3553
|
+
raise ApiPolicyError(
|
|
3554
|
+
f"{self.name}: {method} {what}: the approvals of this tool call could not be "
|
|
3555
|
+
f"read ({type(exc).__name__}), so it may have been decided already; nothing "
|
|
3556
|
+
"was sent.",
|
|
3557
|
+
reason="approvals unreadable",
|
|
3558
|
+
) from exc
|
|
3559
|
+
return [
|
|
3560
|
+
b
|
|
3561
|
+
for b in found
|
|
3562
|
+
if call_identity(b.api, b.method, b.path, b.rpc_method, b.a2a_operation) == identity
|
|
3563
|
+
]
|
|
3564
|
+
|
|
3565
|
+
def _identity(self, method: str, prepared: PreparedRequest) -> tuple[str, ...]:
|
|
3566
|
+
"""Which call this is, for the decisions bound to calls (`call_identity`)."""
|
|
3567
|
+
return call_identity(
|
|
3568
|
+
self.name,
|
|
3569
|
+
method,
|
|
3570
|
+
prepared.wire_path,
|
|
3571
|
+
prepared.rpc.rpc_method,
|
|
3572
|
+
prepared.rpc.a2a_operation,
|
|
3573
|
+
)
|
|
3574
|
+
|
|
3575
|
+
def _await_approval(
|
|
3576
|
+
self,
|
|
3577
|
+
prepared: PreparedRequest,
|
|
3578
|
+
method: str,
|
|
3579
|
+
operation_id: str | None,
|
|
3580
|
+
label: str,
|
|
3581
|
+
json_body: Any,
|
|
3582
|
+
redact: Iterable[str],
|
|
3583
|
+
log_fields: Mapping[str, Any],
|
|
3584
|
+
bound: list[BoundApproval] | None = None,
|
|
3585
|
+
) -> tuple[str, str] | None:
|
|
3586
|
+
"""Hold a gated call for a human decision (see the module doc); None when not gated.
|
|
3587
|
+
|
|
3588
|
+
The first time, `interrupt()` pauses the run with the approval payload
|
|
3589
|
+
(raising LangGraph's `GraphInterrupt`, which ends the tool call). On
|
|
3590
|
+
resume it returns the decision: this returns `(approval_id, call_hash)`
|
|
3591
|
+
only for an approval of exactly this request (`_use_approval` then
|
|
3592
|
+
marks it used); anything else raises `ApiPolicyError`, nothing sent.
|
|
3593
|
+
The decision is taken for the call it was made for even when the
|
|
3594
|
+
policy no longer gates it (`_decision_waiting`), and a call a decision
|
|
3595
|
+
stopped is refused again for the rest of the tool call. A call the
|
|
3596
|
+
ledger holds an approval for (`bound`, from `_bound_approvals`) while
|
|
3597
|
+
no decision waits for it is refused, gated or not: it is sent only
|
|
3598
|
+
through its own decision, once.
|
|
3599
|
+
"""
|
|
3600
|
+
identity = self._identity(method, prepared)
|
|
3601
|
+
what = operation_id or label
|
|
3602
|
+
gate = prepared.gate
|
|
3603
|
+
if identity in _stopped_calls():
|
|
3604
|
+
raise ApiPolicyError(
|
|
3605
|
+
f"{self.name}: {method} {what} was stopped by its approval decision earlier "
|
|
3606
|
+
"in this tool call; nothing was sent.",
|
|
3607
|
+
reason="stopped by an approval decision",
|
|
3608
|
+
)
|
|
3609
|
+
waiting = _decision_waiting(identity)
|
|
3610
|
+
if waiting is None and bound:
|
|
3611
|
+
why, reason = _bound_refusal(bound)
|
|
3612
|
+
_stop_call(identity)
|
|
3613
|
+
raise ApiPolicyError(
|
|
3614
|
+
f"{self.name}: {method} {what} {why}; nothing was sent.", reason=reason
|
|
3615
|
+
)
|
|
3616
|
+
if gate is None and waiting is None:
|
|
3617
|
+
return None
|
|
3618
|
+
if gate is not None:
|
|
3619
|
+
approvers: tuple[str, ...] = gate.approvers
|
|
3620
|
+
timeout_s, rule = gate.timeout_s, gate.rule
|
|
3621
|
+
decide_with, relayers = gate.decide_with, gate.relayers
|
|
3622
|
+
else:
|
|
3623
|
+
# The paused call's gate is gone from the policy: its decision still binds it.
|
|
3624
|
+
assert waiting is not None
|
|
3625
|
+
asked = waiting.get("approvers")
|
|
3626
|
+
approvers = tuple(str(a) for a in asked) if isinstance(asked, list | tuple) else ()
|
|
3627
|
+
timeout_s = DEFAULT_APPROVAL_TIMEOUT_S
|
|
3628
|
+
rule = "the approval this call was paused for (the policy no longer gates it)"
|
|
3629
|
+
decide_with, relayers = _decided_with(waiting)
|
|
3630
|
+
rule_reason = f"approval required by {rule}"
|
|
3631
|
+
|
|
3632
|
+
def refuse(why: str, reason: str = rule_reason) -> ApiPolicyError:
|
|
3633
|
+
return ApiPolicyError(
|
|
3634
|
+
f"{self.name}: {method} {what} {why}; nothing was sent.", reason=reason
|
|
3635
|
+
)
|
|
3636
|
+
|
|
3637
|
+
call = canonical_call(
|
|
3638
|
+
self.name,
|
|
3639
|
+
method,
|
|
3640
|
+
prepared.url,
|
|
3641
|
+
prepared.query,
|
|
3642
|
+
json_body,
|
|
3643
|
+
operation_id,
|
|
3644
|
+
prepared.tool_headers,
|
|
3645
|
+
)
|
|
3646
|
+
try:
|
|
3647
|
+
digest = call_hash(call)
|
|
3648
|
+
except (TypeError, ValueError):
|
|
3649
|
+
raise refuse(
|
|
3650
|
+
"needs human approval, but its JSON body is not plain JSON, so it cannot be "
|
|
3651
|
+
"shown for approval (refused)"
|
|
3652
|
+
) from None
|
|
3653
|
+
scope = _TOOL_CALL.get()
|
|
3654
|
+
if scope.gated_sent if scope is not None else _GATED_SENT.get():
|
|
3655
|
+
raise refuse(
|
|
3656
|
+
"needs human approval, and this tool call already sent an approved call: a tool "
|
|
3657
|
+
"call sends at most one (refused). Make this call in a new tool call"
|
|
3658
|
+
)
|
|
3659
|
+
names = frozenset(str(n).casefold() for n in redact or ())
|
|
3660
|
+
tool = scope.name if scope is not None and scope.name else None
|
|
3661
|
+
purpose = scope.purpose if scope is not None else None
|
|
3662
|
+
payload = {
|
|
3663
|
+
"type": APPROVAL_INTERRUPT,
|
|
3664
|
+
"api": self.name,
|
|
3665
|
+
"method": method,
|
|
3666
|
+
"path": prepared.wire_path,
|
|
3667
|
+
"query": _query_view(prepared.query, names),
|
|
3668
|
+
"body": redact_fields(json_body, names),
|
|
3669
|
+
"operation_id": operation_id or None,
|
|
3670
|
+
"tool": tool,
|
|
3671
|
+
"tool_call_id": scope.call_id if scope is not None else None,
|
|
3672
|
+
"message_id": scope.message_id if scope is not None else None,
|
|
3673
|
+
"reason": f"{tool}: {purpose}" if tool and purpose else (tool or purpose or None),
|
|
3674
|
+
"approvers": list(approvers),
|
|
3675
|
+
# How the requester decides (bound, as the approvers are: see `_check_decision`),
|
|
3676
|
+
# and the agent this run acts through (None: the user directly).
|
|
3677
|
+
"decide_with": decide_with,
|
|
3678
|
+
"relayers": list(relayers),
|
|
3679
|
+
"requester_actor": _context_actor(),
|
|
3680
|
+
"timeout_s": timeout_s,
|
|
3681
|
+
"rule": rule,
|
|
3682
|
+
"call_hash": digest,
|
|
3683
|
+
}
|
|
3684
|
+
# A JSON-RPC call: the request its body is, which is part of which call it is.
|
|
3685
|
+
for key, value in (
|
|
3686
|
+
(RPC_METHOD_KEY, prepared.rpc.rpc_method),
|
|
3687
|
+
(A2A_OPERATION_KEY, prepared.rpc.a2a_operation),
|
|
3688
|
+
):
|
|
3689
|
+
if value is not None:
|
|
3690
|
+
payload[key] = value
|
|
3691
|
+
if prepared.rpc.a2a_operation == A2A_APPROVE:
|
|
3692
|
+
# A decision relayed to another agent: the person sees the approval it decides
|
|
3693
|
+
# there (`nested`) and the call that will then happen (`effect`), as sent.
|
|
3694
|
+
nested = relayed_approval(json_body)
|
|
3695
|
+
if nested is not None:
|
|
3696
|
+
payload["nested"] = redact_fields(nested, names)
|
|
3697
|
+
payload["effect"] = approval_effect(payload["nested"])
|
|
3698
|
+
outside_run = refuse(
|
|
3699
|
+
f"needs human approval ({', '.join(approvers)}) before it is sent, which "
|
|
3700
|
+
"is possible only inside an agent run: refused"
|
|
3701
|
+
)
|
|
3702
|
+
try:
|
|
3703
|
+
from langgraph.errors import GraphBubbleUp
|
|
3704
|
+
from langgraph.types import interrupt
|
|
3705
|
+
except ImportError: # loaded where LangGraph is not installed: nothing can pause
|
|
3706
|
+
raise outside_run from None
|
|
3707
|
+
try:
|
|
3708
|
+
decision = interrupt(payload)
|
|
3709
|
+
_took_resume()
|
|
3710
|
+
# Another call's decision resumed the run while this one still waits:
|
|
3711
|
+
# pause again, for the same approval.
|
|
3712
|
+
while _is_decision(decision) and decision.get("decision") == DECISION_PENDING:
|
|
3713
|
+
decision = interrupt(payload)
|
|
3714
|
+
_took_resume()
|
|
3715
|
+
except GraphBubbleUp:
|
|
3716
|
+
logger.info(
|
|
3717
|
+
"api call held for approval: %s %s %s (%s)",
|
|
3718
|
+
self.name,
|
|
3719
|
+
method,
|
|
3720
|
+
label,
|
|
3721
|
+
", ".join(approvers),
|
|
3722
|
+
extra=dict(log_fields),
|
|
3723
|
+
)
|
|
3724
|
+
raise
|
|
3725
|
+
except (RuntimeError, KeyError):
|
|
3726
|
+
# Outside an agent run (`get_config` fails): nothing can pause and ask.
|
|
3727
|
+
raise outside_run from None
|
|
3728
|
+
try:
|
|
3729
|
+
approval_id = self._check_decision(decision, digest, gate, refuse)
|
|
3730
|
+
except ApiPolicyError:
|
|
3731
|
+
_stop_call(identity)
|
|
3732
|
+
raise
|
|
3733
|
+
return approval_id, digest
|
|
3734
|
+
|
|
3735
|
+
async def _use_approval(
|
|
3736
|
+
self, approved: tuple[str, str], method: str, what: str, log_fields: Mapping[str, Any]
|
|
3737
|
+
) -> None:
|
|
3738
|
+
"""Mark the approval used in the ledger, once, just before the call is sent."""
|
|
3739
|
+
approval_id, digest = approved
|
|
3740
|
+
ledger = _ledger
|
|
3741
|
+
problem = (
|
|
3742
|
+
"this process has no approvals ledger to mark the approval used"
|
|
3743
|
+
if ledger is None
|
|
3744
|
+
else await ledger.consume(approval_id, digest, _run_thread_id())
|
|
3745
|
+
)
|
|
3746
|
+
if problem:
|
|
3747
|
+
raise ApiPolicyError(
|
|
3748
|
+
f"{self.name}: {method} {what} was approved, but the approval cannot be used: "
|
|
3749
|
+
f"{problem}; nothing was sent.",
|
|
3750
|
+
reason=f"approval not usable: {problem}",
|
|
3751
|
+
)
|
|
3752
|
+
scope = _TOOL_CALL.get()
|
|
3753
|
+
if scope is not None:
|
|
3754
|
+
scope.gated_sent = True
|
|
3755
|
+
else:
|
|
3756
|
+
_GATED_SENT.set(True)
|
|
3757
|
+
logger.info(
|
|
3758
|
+
"api call approved: %s %s %s (approval %s)",
|
|
3759
|
+
self.name,
|
|
3760
|
+
method,
|
|
3761
|
+
what,
|
|
3762
|
+
approval_id,
|
|
3763
|
+
extra=dict(log_fields),
|
|
3764
|
+
)
|
|
3765
|
+
|
|
3766
|
+
@staticmethod
|
|
3767
|
+
def _check_decision(
|
|
3768
|
+
decision: Any,
|
|
3769
|
+
digest: str,
|
|
3770
|
+
gate: ApprovalGate | None,
|
|
3771
|
+
refuse: Callable[..., ApiPolicyError],
|
|
3772
|
+
) -> str:
|
|
3773
|
+
"""The approval id of a decision that approves the request `digest`; else raise.
|
|
3774
|
+
|
|
3775
|
+
`gate` is what the policy requires of the call now (None: it no longer
|
|
3776
|
+
gates it). A rejection or an expiry refuses whatever the policy says.
|
|
3777
|
+
An approval must also have been asked of the approvers the policy's
|
|
3778
|
+
gate names now, to be decided the same way (`decide_with`, `relayers`):
|
|
3779
|
+
a policy that changed while the call waited (a new image with other
|
|
3780
|
+
approvers, one that lets other agents relay the decision, or one that
|
|
3781
|
+
no longer gates the call) is not satisfied by a decision taken under
|
|
3782
|
+
the old one.
|
|
3783
|
+
"""
|
|
3784
|
+
if not isinstance(decision, Mapping) or decision.get("type") != APPROVAL_DECISION:
|
|
3785
|
+
raise refuse(
|
|
3786
|
+
"needs human approval, and the run was resumed without an approval decision",
|
|
3787
|
+
reason="resumed without a decision",
|
|
3788
|
+
)
|
|
3789
|
+
verdict = decision.get("decision")
|
|
3790
|
+
if verdict == DECISION_REJECT:
|
|
3791
|
+
comment = _plain(decision.get("comment"), COMMENT_MAX_CHARS)
|
|
3792
|
+
raise refuse(
|
|
3793
|
+
"was not approved: an approver rejected it"
|
|
3794
|
+
+ (f" (their comment: {comment})" if comment else ""),
|
|
3795
|
+
reason="approval rejected",
|
|
3796
|
+
)
|
|
3797
|
+
if verdict == DECISION_EXPIRED:
|
|
3798
|
+
raise refuse(
|
|
3799
|
+
"was not approved: the approval request expired before anyone decided",
|
|
3800
|
+
reason="approval expired",
|
|
3801
|
+
)
|
|
3802
|
+
approval_id = decision.get("approval_id")
|
|
3803
|
+
if verdict != DECISION_APPROVE or not isinstance(approval_id, str) or not approval_id:
|
|
3804
|
+
raise refuse("was not approved (an unknown decision)", reason="unknown decision")
|
|
3805
|
+
if decision.get("call_hash") != digest:
|
|
3806
|
+
raise refuse(
|
|
3807
|
+
"differs from the request that was approved (it changed after the approval), "
|
|
3808
|
+
"so the approval does not cover it",
|
|
3809
|
+
reason="request differs from the approved one",
|
|
3810
|
+
)
|
|
3811
|
+
asked = decision.get("approvers")
|
|
3812
|
+
if gate is None:
|
|
3813
|
+
raise refuse(
|
|
3814
|
+
"was approved under an approval gate the policy no longer has (it changed "
|
|
3815
|
+
"while the call waited), so the approval does not cover it; ask again",
|
|
3816
|
+
reason="approval gate changed",
|
|
3817
|
+
)
|
|
3818
|
+
if not isinstance(asked, list | tuple) or {str(a) for a in asked} != set(gate.approvers):
|
|
3819
|
+
raise refuse(
|
|
3820
|
+
"was approved under an approval gate that has changed since (its approvers "
|
|
3821
|
+
"differ from the policy's now), so the approval does not cover it; ask again",
|
|
3822
|
+
reason="approval gate changed",
|
|
3823
|
+
)
|
|
3824
|
+
decide_with, relayers = _decided_with(decision)
|
|
3825
|
+
if (decide_with, frozenset(relayers)) != (gate.decide_with, frozenset(gate.relayers)):
|
|
3826
|
+
raise refuse(
|
|
3827
|
+
"was approved under an approval gate that has changed since (how its approvers "
|
|
3828
|
+
"decide, decide_with or relayers, differs from the policy's now), so the "
|
|
3829
|
+
"approval does not cover it; ask again",
|
|
3830
|
+
reason="approval gate changed",
|
|
3831
|
+
)
|
|
3832
|
+
return approval_id
|
|
3833
|
+
|
|
3834
|
+
def _log_call(
|
|
3835
|
+
self,
|
|
3836
|
+
label: str,
|
|
3837
|
+
outcome: int | str,
|
|
3838
|
+
started: float,
|
|
3839
|
+
fields: Mapping[str, Any],
|
|
3840
|
+
*,
|
|
3841
|
+
failed: bool = False,
|
|
3842
|
+
) -> None:
|
|
3843
|
+
latency_ms = int((time.perf_counter() - started) * 1000)
|
|
3844
|
+
logger.log(
|
|
3845
|
+
logging.WARNING if failed else logging.INFO,
|
|
3846
|
+
"api call %s: %s %s %s -> %s (%d ms)",
|
|
3847
|
+
"failed" if failed else "done",
|
|
3848
|
+
self.name,
|
|
3849
|
+
fields["method"],
|
|
3850
|
+
label,
|
|
3851
|
+
outcome,
|
|
3852
|
+
latency_ms,
|
|
3853
|
+
extra={**fields, "outcome": str(outcome), "latency_ms": latency_ms},
|
|
3854
|
+
)
|
|
3855
|
+
|
|
3856
|
+
async def get(self, path: str, **kwargs: Any) -> Any:
|
|
3857
|
+
return await self.request("GET", path, **kwargs)
|
|
3858
|
+
|
|
3859
|
+
async def head(self, path: str, **kwargs: Any) -> Any:
|
|
3860
|
+
return await self.request("HEAD", path, **kwargs)
|
|
3861
|
+
|
|
3862
|
+
async def post(self, path: str, **kwargs: Any) -> Any:
|
|
3863
|
+
return await self.request("POST", path, **kwargs)
|
|
3864
|
+
|
|
3865
|
+
async def put(self, path: str, **kwargs: Any) -> Any:
|
|
3866
|
+
return await self.request("PUT", path, **kwargs)
|
|
3867
|
+
|
|
3868
|
+
async def patch(self, path: str, **kwargs: Any) -> Any:
|
|
3869
|
+
return await self.request("PATCH", path, **kwargs)
|
|
3870
|
+
|
|
3871
|
+
async def delete(self, path: str, **kwargs: Any) -> Any:
|
|
3872
|
+
return await self.request("DELETE", path, **kwargs)
|
|
3873
|
+
|
|
3874
|
+
async def options(self, path: str, **kwargs: Any) -> Any:
|
|
3875
|
+
return await self.request("OPTIONS", path, **kwargs)
|
|
3876
|
+
|
|
3877
|
+
|
|
3878
|
+
# ---------------------------------------------------------------------------
|
|
3879
|
+
# Entry point for tools
|
|
3880
|
+
# ---------------------------------------------------------------------------
|
|
3881
|
+
|
|
3882
|
+
|
|
3883
|
+
def current_context() -> Any:
|
|
3884
|
+
"""The run context LangGraph set for the current graph run (who is calling), or None."""
|
|
3885
|
+
try:
|
|
3886
|
+
from langgraph.runtime import get_runtime
|
|
3887
|
+
|
|
3888
|
+
return get_runtime().context
|
|
3889
|
+
except Exception: # outside a graph run
|
|
3890
|
+
return None
|
|
3891
|
+
|
|
3892
|
+
|
|
3893
|
+
def _context_credentials(context: Any) -> Mapping[str, Any]:
|
|
3894
|
+
attributes = getattr(context, "attributes", None)
|
|
3895
|
+
if attributes is None and isinstance(context, Mapping):
|
|
3896
|
+
attributes = context.get("attributes")
|
|
3897
|
+
credentials = attributes.get("credentials") if isinstance(attributes, Mapping) else None
|
|
3898
|
+
return credentials if isinstance(credentials, Mapping) else {}
|
|
3899
|
+
|
|
3900
|
+
|
|
3901
|
+
def subject_token_of(context: Any) -> SubjectToken | None:
|
|
3902
|
+
"""The calling principal's own verified bearer token (`credentials["@subject_token"]`).
|
|
3903
|
+
|
|
3904
|
+
Kept by the `jwt` policy only when an API acts with it (`auth: exchange`,
|
|
3905
|
+
or `auth: forward` with `forward_audience`), or by a custom policy's
|
|
3906
|
+
`keep_subject_token`; None otherwise (and never under langgraph-server,
|
|
3907
|
+
whose run context carries no credentials).
|
|
3908
|
+
"""
|
|
3909
|
+
credentials = _context_credentials(context)
|
|
3910
|
+
token = credentials.get("@subject_token")
|
|
3911
|
+
if not isinstance(token, str) or not token:
|
|
3912
|
+
return None
|
|
3913
|
+
raw_aud = credentials.get("@subject_aud")
|
|
3914
|
+
audience = (
|
|
3915
|
+
tuple(str(a) for a in raw_aud if isinstance(a, str))
|
|
3916
|
+
if isinstance(raw_aud, list | tuple)
|
|
3917
|
+
else (raw_aud,)
|
|
3918
|
+
if isinstance(raw_aud, str)
|
|
3919
|
+
else ()
|
|
3920
|
+
)
|
|
3921
|
+
raw_exp = credentials.get("@subject_exp")
|
|
3922
|
+
expires_at = (
|
|
3923
|
+
float(raw_exp)
|
|
3924
|
+
if isinstance(raw_exp, int | float) and not isinstance(raw_exp, bool)
|
|
3925
|
+
else None
|
|
3926
|
+
)
|
|
3927
|
+
return SubjectToken(token=token, audience=audience, expires_at=expires_at)
|
|
3928
|
+
|
|
3929
|
+
|
|
3930
|
+
def _context_actor_chain(context: Any) -> tuple[str, ...]:
|
|
3931
|
+
"""The agents the run's request came through (its `@actor` chain), current first."""
|
|
3932
|
+
actor = _context_attributes(context).get("@actor")
|
|
3933
|
+
if not isinstance(actor, Mapping):
|
|
3934
|
+
return ()
|
|
3935
|
+
chain = actor.get("chain")
|
|
3936
|
+
if isinstance(chain, list | tuple) and chain:
|
|
3937
|
+
return tuple(str(a) for a in chain)
|
|
3938
|
+
actor_id = actor.get("id")
|
|
3939
|
+
return (actor_id,) if isinstance(actor_id, str) and actor_id else ()
|
|
3940
|
+
|
|
3941
|
+
|
|
3942
|
+
def forwarded_credential(
|
|
3943
|
+
api_name: str, context: Any, forward_audience: str | None = None
|
|
3944
|
+
) -> str | None:
|
|
3945
|
+
"""The credential `auth: forward` sends for API `api_name`, or None (nothing is sent).
|
|
3946
|
+
|
|
3947
|
+
In order: the calling principal's `attributes["credentials"][api_name]` (a
|
|
3948
|
+
policy set it); else, with `forward_audience`, `Bearer <the caller's own
|
|
3949
|
+
token>` when its `aud` names that audience (the issuer minted it for the
|
|
3950
|
+
target too): a token minted only for this agent is never replayed at another.
|
|
3951
|
+
"""
|
|
3952
|
+
credentials = _context_credentials(context)
|
|
3953
|
+
value = credentials.get(api_name)
|
|
3954
|
+
if isinstance(value, str) and value:
|
|
3955
|
+
return value
|
|
3956
|
+
if forward_audience:
|
|
3957
|
+
subject = subject_token_of(context)
|
|
3958
|
+
if subject is not None and forward_audience in subject.audience:
|
|
3959
|
+
return f"Bearer {subject.token}"
|
|
3960
|
+
return None
|
|
3961
|
+
|
|
3962
|
+
|
|
3963
|
+
def get_client(
|
|
3964
|
+
api_name: str,
|
|
3965
|
+
*,
|
|
3966
|
+
context: Any = None,
|
|
3967
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
3968
|
+
run_id: str | None = None,
|
|
3969
|
+
) -> ApiClient:
|
|
3970
|
+
"""A policy-enforcing client for API `api_name` of `api-policy.yaml`.
|
|
3971
|
+
|
|
3972
|
+
`context` is the run context holding the calling principal (a tool's
|
|
3973
|
+
`runtime.context`); by default it is read from the current graph run. For
|
|
3974
|
+
an `auth: forward` API it gives the credential sent; for an `auth:
|
|
3975
|
+
exchange` API, the caller's own token (`subject_token_of`), exchanged when a
|
|
3976
|
+
request is sent, not here. For both, the delegation chain the loop check
|
|
3977
|
+
reads. `run_id` names the run `limits.max_calls_per_run` counts against; by
|
|
3978
|
+
default it is read from the current run (`current_run_id`).
|
|
3979
|
+
Raises `ApiPolicyError` when the policy file is missing or invalid, or does
|
|
3980
|
+
not declare `api_name`.
|
|
3981
|
+
"""
|
|
3982
|
+
policy = load_policy()
|
|
3983
|
+
settings = policy.api(api_name)
|
|
3984
|
+
credential = None
|
|
3985
|
+
subject = None
|
|
3986
|
+
actor_chain: tuple[str, ...] = ()
|
|
3987
|
+
if settings["auth"] in HEADER_AUTH_MODES:
|
|
3988
|
+
ctx = context if context is not None else current_context()
|
|
3989
|
+
actor_chain = _context_actor_chain(ctx)
|
|
3990
|
+
if settings["auth"] == "forward":
|
|
3991
|
+
credential = forwarded_credential(api_name, ctx, settings.get("forward_audience"))
|
|
3992
|
+
else:
|
|
3993
|
+
subject = subject_token_of(ctx)
|
|
3994
|
+
return ApiClient(
|
|
3995
|
+
policy,
|
|
3996
|
+
api_name,
|
|
3997
|
+
credential=credential,
|
|
3998
|
+
transport=transport,
|
|
3999
|
+
run_id=run_id,
|
|
4000
|
+
subject=subject,
|
|
4001
|
+
actor_chain=actor_chain,
|
|
4002
|
+
)
|
|
4003
|
+
|
|
4004
|
+
|
|
4005
|
+
# ---------------------------------------------------------------------------
|
|
4006
|
+
# The caller: act only on what the calling user may act on
|
|
4007
|
+
# ---------------------------------------------------------------------------
|
|
4008
|
+
#
|
|
4009
|
+
# The API policy decides which endpoints a tool may call, not on whose behalf.
|
|
4010
|
+
# Text a tool returns (a customer's free-text note, an upstream error) reaches
|
|
4011
|
+
# the model too, and can ask it to act on some other record. Prefer per-user
|
|
4012
|
+
# authorization upstream (`auth: forward`, so the API itself refuses what the
|
|
4013
|
+
# user may not do); where a shared service token is used, the tool is the only
|
|
4014
|
+
# place that knows the caller, so write tools check it themselves with these.
|
|
4015
|
+
|
|
4016
|
+
# The principal of a run context the server did not fill in (AgentContext's default).
|
|
4017
|
+
ANONYMOUS_PRINCIPAL = "anonymous"
|
|
4018
|
+
|
|
4019
|
+
|
|
4020
|
+
@dataclass(frozen=True)
|
|
4021
|
+
class Caller:
|
|
4022
|
+
"""The principal a run acts for, as tools see it (from the run context).
|
|
4023
|
+
|
|
4024
|
+
`principal_id` is the user (the subject). When another agent presents the
|
|
4025
|
+
request for that user, `actor` names that agent and `actor_chain` every
|
|
4026
|
+
agent in between, current first (`delegated`); `roles` are then only
|
|
4027
|
+
those `AUTH_DELEGATED_ROLES` lends to agents.
|
|
4028
|
+
"""
|
|
4029
|
+
|
|
4030
|
+
principal_id: str
|
|
4031
|
+
roles: frozenset[str]
|
|
4032
|
+
actor: str | None = None
|
|
4033
|
+
actor_chain: tuple[str, ...] = ()
|
|
4034
|
+
|
|
4035
|
+
def has_role(self, *roles: str) -> bool:
|
|
4036
|
+
return bool(self.roles.intersection(roles))
|
|
4037
|
+
|
|
4038
|
+
@property
|
|
4039
|
+
def delegated(self) -> bool:
|
|
4040
|
+
"""Whether an agent presents this request for the user (see `actor`)."""
|
|
4041
|
+
return self.actor is not None
|
|
4042
|
+
|
|
4043
|
+
|
|
4044
|
+
def _context_attributes(ctx: Any) -> Mapping[str, Any]:
|
|
4045
|
+
attributes = getattr(ctx, "attributes", None)
|
|
4046
|
+
if attributes is None and isinstance(ctx, Mapping):
|
|
4047
|
+
attributes = ctx.get("attributes")
|
|
4048
|
+
return attributes if isinstance(attributes, Mapping) else {}
|
|
4049
|
+
|
|
4050
|
+
|
|
4051
|
+
def _context_origin(ctx: Any) -> str | None:
|
|
4052
|
+
"""The user's own words the calling agent forwarded (`credentials["@origin"]`), if any."""
|
|
4053
|
+
credentials = _context_attributes(ctx).get("credentials")
|
|
4054
|
+
origin = credentials.get("@origin") if isinstance(credentials, Mapping) else None
|
|
4055
|
+
text = origin.get("text") if isinstance(origin, Mapping) else None
|
|
4056
|
+
return text if isinstance(text, str) else None
|
|
4057
|
+
|
|
4058
|
+
|
|
4059
|
+
def current_caller(context: Any = None) -> Caller:
|
|
4060
|
+
"""The calling principal of the current run (or of `context`, a tool's `runtime.context`).
|
|
4061
|
+
|
|
4062
|
+
Fails closed: without an authenticated principal in the run context it
|
|
4063
|
+
raises `ApiPolicyError` (which the agent turns into a tool error), so a
|
|
4064
|
+
tool never acts on anyone's behalf by default.
|
|
4065
|
+
"""
|
|
4066
|
+
ctx = context if context is not None else current_context()
|
|
4067
|
+
if isinstance(ctx, Mapping):
|
|
4068
|
+
principal_id, roles = ctx.get("principal_id"), ctx.get("roles")
|
|
4069
|
+
else:
|
|
4070
|
+
principal_id, roles = getattr(ctx, "principal_id", None), getattr(ctx, "roles", None)
|
|
4071
|
+
if not isinstance(principal_id, str) or principal_id in ("", ANONYMOUS_PRINCIPAL):
|
|
4072
|
+
raise ApiPolicyError(
|
|
4073
|
+
"refused: this run has no authenticated caller, so no tool may act on anyone's behalf."
|
|
4074
|
+
)
|
|
4075
|
+
names = roles if isinstance(roles, list | tuple | set | frozenset) else ()
|
|
4076
|
+
actor = _context_attributes(ctx).get("@actor")
|
|
4077
|
+
actor_id = actor.get("id") if isinstance(actor, Mapping) else None
|
|
4078
|
+
chain = actor.get("chain") if isinstance(actor, Mapping) else None
|
|
4079
|
+
if not isinstance(actor_id, str) or not actor_id:
|
|
4080
|
+
actor_id, chain = None, None
|
|
4081
|
+
actor_chain = tuple(str(a) for a in chain) if isinstance(chain, list | tuple) else ()
|
|
4082
|
+
if actor_id and not actor_chain:
|
|
4083
|
+
actor_chain = (actor_id,)
|
|
4084
|
+
return Caller(
|
|
4085
|
+
principal_id,
|
|
4086
|
+
frozenset(r for r in names if isinstance(r, str)),
|
|
4087
|
+
actor=actor_id,
|
|
4088
|
+
actor_chain=actor_chain,
|
|
4089
|
+
)
|
|
4090
|
+
|
|
4091
|
+
|
|
4092
|
+
def require_direct_caller(context: Any = None) -> Caller:
|
|
4093
|
+
"""Refuse (`ApiPolicyError`) when an agent presents the request for the user.
|
|
4094
|
+
|
|
4095
|
+
For tools only a person may trigger (a transfer, a password reset): the
|
|
4096
|
+
user must ask this agent directly. Returns the caller otherwise.
|
|
4097
|
+
"""
|
|
4098
|
+
caller = current_caller(context)
|
|
4099
|
+
if caller.actor is not None:
|
|
4100
|
+
raise ApiPolicyError(
|
|
4101
|
+
f"refused: only the user directly may ask for this, not agent {caller.actor!r} "
|
|
4102
|
+
"acting for them; ask the user to use this agent themselves."
|
|
4103
|
+
)
|
|
4104
|
+
return caller
|
|
4105
|
+
|
|
4106
|
+
|
|
4107
|
+
def require_owner(owner: Any, *, context: Any = None, allow_roles: tuple[str, ...] = ()) -> Caller:
|
|
4108
|
+
"""Refuse (`ApiPolicyError`) unless the record `owner` (its owner's principal id, as the
|
|
4109
|
+
upstream API reports it) is the caller, or the caller holds one of `allow_roles`.
|
|
4110
|
+
|
|
4111
|
+
The ids must match exactly (no case folding). The refusal names neither
|
|
4112
|
+
the owner nor the caller. Under the `shared-bearer` auth policy every
|
|
4113
|
+
caller is the one principal `shared`, so this check needs a per-user
|
|
4114
|
+
policy (`jwt` or `custom`).
|
|
4115
|
+
"""
|
|
4116
|
+
caller = current_caller(context)
|
|
4117
|
+
if isinstance(owner, str) and owner and owner == caller.principal_id:
|
|
4118
|
+
return caller
|
|
4119
|
+
if allow_roles and caller.has_role(*allow_roles):
|
|
4120
|
+
return caller
|
|
4121
|
+
raise ApiPolicyError(
|
|
4122
|
+
"refused: the record belongs to someone other than the caller; act only on the "
|
|
4123
|
+
"caller's own records."
|
|
4124
|
+
)
|
|
4125
|
+
|
|
4126
|
+
|
|
4127
|
+
_MENTION_MAX_CHARS = 80
|
|
4128
|
+
|
|
4129
|
+
|
|
4130
|
+
def latest_user_message(runtime: Any) -> str:
|
|
4131
|
+
"""The text of the last user (human) message in the run's state (`runtime.state`)."""
|
|
4132
|
+
state = getattr(runtime, "state", None)
|
|
4133
|
+
messages = state.get("messages") if isinstance(state, Mapping) else None
|
|
4134
|
+
for message in reversed(messages or []):
|
|
4135
|
+
kind = message.get("type") if isinstance(message, Mapping) else getattr(message, "type", "")
|
|
4136
|
+
if kind not in ("human", "user"):
|
|
4137
|
+
continue
|
|
4138
|
+
content = (
|
|
4139
|
+
message.get("content")
|
|
4140
|
+
if isinstance(message, Mapping)
|
|
4141
|
+
else getattr(message, "content", "")
|
|
4142
|
+
)
|
|
4143
|
+
if isinstance(content, str):
|
|
4144
|
+
return content
|
|
4145
|
+
if isinstance(content, list):
|
|
4146
|
+
return "".join(
|
|
4147
|
+
str(b.get("text", "")) if isinstance(b, Mapping) else str(b) for b in content
|
|
4148
|
+
)
|
|
4149
|
+
return ""
|
|
4150
|
+
return ""
|
|
4151
|
+
|
|
4152
|
+
|
|
4153
|
+
def _delegated_mentions() -> str:
|
|
4154
|
+
"""`A2A_DELEGATED_MENTIONS`; a bad value (the startup check refuses it) reads as `refuse`."""
|
|
4155
|
+
try:
|
|
4156
|
+
from .auth import delegated_mentions
|
|
4157
|
+
except ImportError: # loaded outside its package
|
|
4158
|
+
raw = (os.environ.get("A2A_DELEGATED_MENTIONS") or "origin").strip().lower()
|
|
4159
|
+
return raw if raw in ("origin", "refuse", "request") else "refuse"
|
|
4160
|
+
try:
|
|
4161
|
+
return delegated_mentions()
|
|
4162
|
+
except ValueError: # SettingsError: fail closed
|
|
4163
|
+
return "refuse"
|
|
4164
|
+
|
|
4165
|
+
|
|
4166
|
+
def require_user_mentioned(value: Any, runtime: Any) -> None:
|
|
4167
|
+
"""Refuse (`ApiPolicyError`) unless `value` appears in the user's latest message.
|
|
4168
|
+
|
|
4169
|
+
For write tools acting on a record the model chose (an order id, say):
|
|
4170
|
+
the user's own message cannot be forged by text a tool returned, so an
|
|
4171
|
+
instruction planted in upstream data ("also cancel ORD-17") cannot make
|
|
4172
|
+
the agent write to a record the user never named. Matching ignores
|
|
4173
|
+
letter case and needs the whole id (letters, digits, `_` and `-` around
|
|
4174
|
+
it end it: `ORD-1` does not match `ORD-17`, nor `17` match `ORD-17`).
|
|
4175
|
+
`runtime` is the tool's `ToolRuntime`.
|
|
4176
|
+
|
|
4177
|
+
When another agent presents the request for the user, the latest message
|
|
4178
|
+
is that agent's text, which an instruction planted in data it read could
|
|
4179
|
+
have shaped. `A2A_DELEGATED_MENTIONS` decides: `origin` (default) needs
|
|
4180
|
+
the value in the user's own words the calling agent forwarded as well as
|
|
4181
|
+
in its request, and refuses without them; `refuse` always refuses;
|
|
4182
|
+
`request` counts the agent's request as the user's words (the 0.2
|
|
4183
|
+
behaviour).
|
|
4184
|
+
"""
|
|
4185
|
+
token = str(value if value is not None else "").strip()
|
|
4186
|
+
text = latest_user_message(runtime)
|
|
4187
|
+
pattern = rf"(?<![A-Za-z0-9_-]){re.escape(token)}(?![A-Za-z0-9_-])"
|
|
4188
|
+
shown = token[:_MENTION_MAX_CHARS]
|
|
4189
|
+
|
|
4190
|
+
def named_in(words: str) -> bool:
|
|
4191
|
+
return bool(token) and re.search(pattern, words, re.IGNORECASE) is not None
|
|
4192
|
+
|
|
4193
|
+
context = getattr(runtime, "context", None)
|
|
4194
|
+
if context is None:
|
|
4195
|
+
context = current_context()
|
|
4196
|
+
actor = _context_attributes(context).get("@actor")
|
|
4197
|
+
agent = actor.get("id") if isinstance(actor, Mapping) else None
|
|
4198
|
+
mode = _delegated_mentions() if isinstance(agent, str) and agent else "request"
|
|
4199
|
+
if mode == "refuse":
|
|
4200
|
+
raise ApiPolicyError(
|
|
4201
|
+
f"refused: {shown!r} was asked for by agent {agent!r} acting for the user; the user "
|
|
4202
|
+
"must ask this agent directly to act on it."
|
|
4203
|
+
)
|
|
4204
|
+
if mode == "origin":
|
|
4205
|
+
origin = _context_origin(context)
|
|
4206
|
+
if origin is None:
|
|
4207
|
+
raise ApiPolicyError(
|
|
4208
|
+
f"refused: {shown!r} was asked for by agent {agent!r}, which forwarded no user "
|
|
4209
|
+
"message to check it against; the user must name it."
|
|
4210
|
+
)
|
|
4211
|
+
if not named_in(origin):
|
|
4212
|
+
raise ApiPolicyError(
|
|
4213
|
+
f"refused: {shown!r} is not named in the user's own words that agent {agent!r} "
|
|
4214
|
+
"forwarded; ask the user to confirm it before acting on it."
|
|
4215
|
+
)
|
|
4216
|
+
if not named_in(text):
|
|
4217
|
+
raise ApiPolicyError(
|
|
4218
|
+
f"refused: {shown!r} is not named in the user's latest message; ask the user to "
|
|
4219
|
+
"confirm it before acting on it."
|
|
4220
|
+
)
|