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,2145 @@
|
|
|
1
|
+
# Copyright 2026 graph-agents-cli contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""The outbound API access policy (``api-policy.yaml``) as the CLI sees it.
|
|
16
|
+
|
|
17
|
+
A project declares every external API its tools may call in ``api-policy.yaml``
|
|
18
|
+
at the project root::
|
|
19
|
+
|
|
20
|
+
apis:
|
|
21
|
+
orders:
|
|
22
|
+
base_url_env: ORDERS_API_BASE_URL
|
|
23
|
+
auth: bearer # none | bearer | forward | exchange
|
|
24
|
+
token_env: ORDERS_API_TOKEN # auth: bearer only
|
|
25
|
+
# auth: exchange takes exchange: {audience, scope, resource,
|
|
26
|
+
# allow_actorless} (RFC 8693); auth: forward may take forward_audience
|
|
27
|
+
allowed_methods: [GET, POST] # required, explicit; ["*"] allows every method
|
|
28
|
+
allowed_operations: # optional; omitted = every operation
|
|
29
|
+
- operationId: createOrder
|
|
30
|
+
path: /orders
|
|
31
|
+
limits: {max_calls_per_run: 20} # optional
|
|
32
|
+
approval: # optional: calls a human approves before sending
|
|
33
|
+
required_for: {methods: [POST]}
|
|
34
|
+
approvers: [requester] # and/or role:<name>
|
|
35
|
+
timeout_s: 900 # optional, 30-86400
|
|
36
|
+
|
|
37
|
+
``approval`` may also be a list of rules of that shape (different approvers
|
|
38
|
+
for different calls); the first rule in file order that covers a call gates it.
|
|
39
|
+
|
|
40
|
+
The schema rules and the matching rules live in the block between the
|
|
41
|
+
``SHARED API POLICY RULES`` markers. The scaffolded runtime
|
|
42
|
+
(``app_utils/api_client.py``) carries a byte-identical copy of that block, so
|
|
43
|
+
``create --api-policy``, ``lint`` and the running agent accept the same files,
|
|
44
|
+
report the same errors and refuse the same calls. The rest of this module is
|
|
45
|
+
CLI-only: reading the file, summarising it for the templates, and detecting
|
|
46
|
+
the retired single-API ``product_api`` format. ``graph-agents-cli api`` edits
|
|
47
|
+
the file (``graph_agents_cli.api``).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import json
|
|
53
|
+
import logging
|
|
54
|
+
import re
|
|
55
|
+
from collections.abc import Mapping
|
|
56
|
+
from dataclasses import dataclass
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
from typing import Any
|
|
59
|
+
from urllib.parse import unquote
|
|
60
|
+
|
|
61
|
+
import click
|
|
62
|
+
import yaml
|
|
63
|
+
|
|
64
|
+
# --- BEGIN SHARED API POLICY RULES ---
|
|
65
|
+
# Identical in graph-agents-cli (graph_agents_cli/_api_policy.py) and in every
|
|
66
|
+
# scaffolded project (app_utils/api_client.py). A CLI test keeps the two copies
|
|
67
|
+
# byte-identical: change both or neither.
|
|
68
|
+
|
|
69
|
+
POLICY_FILENAME = "api-policy.yaml"
|
|
70
|
+
AUTH_MODES = ("none", "bearer", "forward", "exchange")
|
|
71
|
+
# The modes that send the caller's identity in `forward_header` (default Authorization):
|
|
72
|
+
# `forward` the caller's own credential, `exchange` a token the issuer mints for the API in
|
|
73
|
+
# exchange for the caller's (RFC 8693, configured by the API's `exchange` block).
|
|
74
|
+
HEADER_AUTH_MODES = ("forward", "exchange")
|
|
75
|
+
EXCHANGE_KEY = "exchange"
|
|
76
|
+
# `exchange.allow_actorless: true` lets an `auth: exchange` API be called with an exchanged
|
|
77
|
+
# token that names no actor (no `act` claim, or one that is not a readable JWT); the calling
|
|
78
|
+
# agent refuses such tokens otherwise, since the agent behind the API would read them as the
|
|
79
|
+
# user's own unless it sets AUTH_JWT_DIRECT_CLIENTS.
|
|
80
|
+
ALLOW_ACTORLESS_KEY = "allow_actorless"
|
|
81
|
+
HTTP_METHODS = ("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
|
82
|
+
ANY_METHOD = "*"
|
|
83
|
+
DEFAULT_FORWARD_HEADER = "Authorization"
|
|
84
|
+
DEFAULT_TIMEOUTS_MS = (("connect", 2000), ("read", 5000))
|
|
85
|
+
|
|
86
|
+
API_NAME_RE = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
|
|
87
|
+
API_NAME_RULE = "lowercase letters, digits and underscores, starting with a letter, 1-32 characters"
|
|
88
|
+
ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
89
|
+
HEADER_NAME_RE = re.compile(r"^[A-Za-z0-9-]+$")
|
|
90
|
+
_PATH_SEGMENT_RE = re.compile(r"^(?:[^/?#\s{}]|\{[A-Za-z_][A-Za-z0-9_]*\})+$")
|
|
91
|
+
_PLACEHOLDER_SPLIT_RE = re.compile(r"(\{[^/{}]+\})")
|
|
92
|
+
_SPACE_BY_DOT_RE = re.compile(r"\s\.|\.\s")
|
|
93
|
+
|
|
94
|
+
_ESCAPE_RE = re.compile(r"%[0-9A-Fa-f]{2}")
|
|
95
|
+
_UNRESERVED = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
|
|
96
|
+
|
|
97
|
+
# An API's `protocol` says how its calls are judged: `http` (the default) by method, path
|
|
98
|
+
# and the operation id the tool names; `jsonrpc` also by the JSON-RPC request every POST
|
|
99
|
+
# sends, whose method (`rpc_method`) the policy client reads from the body, never from the
|
|
100
|
+
# tool; `a2a` (another agent, over A2A 1.0 JSON-RPC) as `jsonrpc`, plus what a message
|
|
101
|
+
# decides (`a2a_operation`: `approve` or `reject` a pending approval of that agent). A
|
|
102
|
+
# JSON-RPC API allows GET, POST and HEAD only, and an `a2a` one names its endpoint
|
|
103
|
+
# (`a2a.path`) and must gate or deny `a2a_operation: approve` if it can send messages.
|
|
104
|
+
PROTOCOL_KEY = "protocol"
|
|
105
|
+
PROTOCOL_HTTP = "http"
|
|
106
|
+
PROTOCOL_JSONRPC = "jsonrpc"
|
|
107
|
+
PROTOCOL_A2A = "a2a"
|
|
108
|
+
PROTOCOLS = (PROTOCOL_HTTP, PROTOCOL_JSONRPC, PROTOCOL_A2A)
|
|
109
|
+
DEFAULT_PROTOCOL = PROTOCOL_HTTP
|
|
110
|
+
RPC_PROTOCOLS = (PROTOCOL_JSONRPC, PROTOCOL_A2A)
|
|
111
|
+
RPC_HTTP_METHODS = ("GET", "POST", "HEAD")
|
|
112
|
+
A2A_KEY = "a2a"
|
|
113
|
+
_A2A_KEYS = ("path",)
|
|
114
|
+
DESCRIPTION_KEY = "description"
|
|
115
|
+
DESCRIPTION_MAX_CHARS = 300
|
|
116
|
+
RPC_METHOD_KEY = "rpc_method"
|
|
117
|
+
A2A_OPERATION_KEY = "a2a_operation"
|
|
118
|
+
A2A_APPROVE = "approve"
|
|
119
|
+
A2A_REJECT = "reject"
|
|
120
|
+
A2A_OPERATIONS = (A2A_APPROVE, A2A_REJECT)
|
|
121
|
+
# A JSON-RPC method name as an operation entry pins it.
|
|
122
|
+
_RPC_METHOD_RE = re.compile(r"[A-Za-z][A-Za-z0-9_/.]{0,63}")
|
|
123
|
+
# The A2A 0.3 method names and the A2A 1.0 names they are read as under `protocol: a2a`,
|
|
124
|
+
# so a 0.3 spelling of a call cannot slip past an entry that names it.
|
|
125
|
+
A2A_V03_METHODS = {
|
|
126
|
+
"message/send": "SendMessage",
|
|
127
|
+
"message/stream": "SendStreamingMessage",
|
|
128
|
+
"tasks/get": "GetTask",
|
|
129
|
+
"tasks/list": "ListTasks",
|
|
130
|
+
"tasks/cancel": "CancelTask",
|
|
131
|
+
"tasks/resubscribe": "SubscribeToTask",
|
|
132
|
+
"tasks/pushNotificationConfig/set": "CreateTaskPushNotificationConfig",
|
|
133
|
+
"tasks/pushNotificationConfig/get": "GetTaskPushNotificationConfig",
|
|
134
|
+
"tasks/pushNotificationConfig/list": "ListTaskPushNotificationConfigs",
|
|
135
|
+
"tasks/pushNotificationConfig/delete": "DeleteTaskPushNotificationConfig",
|
|
136
|
+
"agent/getAuthenticatedExtendedCard": "GetExtendedAgentCard",
|
|
137
|
+
}
|
|
138
|
+
# The A2A methods that send a message, which may carry a decision on an approval.
|
|
139
|
+
A2A_MESSAGE_METHODS = ("SendMessage", "SendStreamingMessage")
|
|
140
|
+
# Their names in any letter case, 0.3 spellings included: a message sent under any of them
|
|
141
|
+
# is read for a decision (failing closed toward a server that matched names loosely).
|
|
142
|
+
_A2A_MESSAGE_NAMES = frozenset(
|
|
143
|
+
name.casefold() for name in (*A2A_MESSAGE_METHODS, "message/send", "message/stream")
|
|
144
|
+
)
|
|
145
|
+
# The members of one JSON-RPC 2.0 request object.
|
|
146
|
+
_JSONRPC_KEYS = ("jsonrpc", "method", "params", "id")
|
|
147
|
+
|
|
148
|
+
_POLICY_KEYS = ("apis",)
|
|
149
|
+
_API_KEYS = (
|
|
150
|
+
DESCRIPTION_KEY,
|
|
151
|
+
PROTOCOL_KEY,
|
|
152
|
+
A2A_KEY,
|
|
153
|
+
"base_url_env",
|
|
154
|
+
"auth",
|
|
155
|
+
"token_env",
|
|
156
|
+
"forward_header",
|
|
157
|
+
"forward_audience",
|
|
158
|
+
EXCHANGE_KEY,
|
|
159
|
+
"allowed_methods",
|
|
160
|
+
"allowed_operations",
|
|
161
|
+
"denied_operations",
|
|
162
|
+
"openapi",
|
|
163
|
+
"timeouts_ms",
|
|
164
|
+
"pagination",
|
|
165
|
+
"limits",
|
|
166
|
+
"approval",
|
|
167
|
+
)
|
|
168
|
+
_OPERATION_KEYS = ("operationId", "path", "methods", RPC_METHOD_KEY, A2A_OPERATION_KEY)
|
|
169
|
+
_TIMEOUT_KEYS = ("connect", "read")
|
|
170
|
+
_PAGINATION_KEYS = ("page_size_param", "max_page_size")
|
|
171
|
+
_LIMIT_KEYS = ("max_calls_per_run", "rate_per_minute", "max_response_bytes")
|
|
172
|
+
# `limits.max_response_bytes`: the most a response body may hold (decoded) before the
|
|
173
|
+
# client stops reading it and discards it. Unset: no cap (as in 0.2).
|
|
174
|
+
MAX_RESPONSE_BYTES_LIMIT = 67108864
|
|
175
|
+
_APPROVAL_KEYS = ("required_for", "approvers", "timeout_s", "decide_with", "relayers")
|
|
176
|
+
_REQUIRED_FOR_KEYS = ("methods", "operations")
|
|
177
|
+
_EXCHANGE_KEYS = ("audience", "scope", "resource", ALLOW_ACTORLESS_KEY)
|
|
178
|
+
# An RFC 6749 scope: space-separated scope tokens (printable ASCII but space, " " and "\").
|
|
179
|
+
_SCOPE_RE = re.compile(r"[\x21\x23-\x5b\x5d-\x7e]+(?: [\x21\x23-\x5b\x5d-\x7e]+)*")
|
|
180
|
+
# An absolute URI (RFC 8707 `resource`): a scheme, then no whitespace and no fragment.
|
|
181
|
+
_ABSOLUTE_URI_RE = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:[^\s#\x00-\x1f\x7f]+")
|
|
182
|
+
|
|
183
|
+
# An API's `approval` block names the calls a human must approve before they are
|
|
184
|
+
# sent (`required_for`), who may approve them (`approvers`) and how long a
|
|
185
|
+
# pending approval waits before it expires, which rejects the call
|
|
186
|
+
# (`timeout_s`). It is one such rule (a mapping), or a non-empty list of rules
|
|
187
|
+
# of that same shape when different calls need different approvers: a call is
|
|
188
|
+
# gated by the FIRST rule, in file order, whose `required_for` covers it, and a
|
|
189
|
+
# later rule that also covers it does not apply to it. A call that an earlier
|
|
190
|
+
# rule covers only because it leaves out what the rule knows the operation by
|
|
191
|
+
# (no operation id, no path), and that a later rule with other approvers also
|
|
192
|
+
# covers, is refused (`ApprovalRuleConflict`): it could be either rule's call.
|
|
193
|
+
# It never widens access: a gated call must still be allowed, and denials still
|
|
194
|
+
# win. It belongs to the API only: on an operation entry the key is refused,
|
|
195
|
+
# with a pointer to `approval.required_for.operations`. A rule may also say how
|
|
196
|
+
# the requester decides (`decide_with`): `direct` (the default: with their own
|
|
197
|
+
# credentials, at this agent), or `relayed`, where the agents `relayers` names
|
|
198
|
+
# (by their actor ids) may deliver the requester's decision from another agent.
|
|
199
|
+
APPROVAL_KEY = "approval"
|
|
200
|
+
REQUESTER_APPROVER = "requester" # the principal who started the run confirms
|
|
201
|
+
ROLE_APPROVER_PREFIX = "role:" # any principal holding the role decides
|
|
202
|
+
DEFAULT_APPROVAL_TIMEOUT_S = 900
|
|
203
|
+
MIN_APPROVAL_TIMEOUT_S = 30
|
|
204
|
+
MAX_APPROVAL_TIMEOUT_S = 86400
|
|
205
|
+
# How approvers decide once an approval rule takes `decide_with` (0.3): `direct` by
|
|
206
|
+
# default, each with their own credential. `relayed`, with the `relayers` it names, lets
|
|
207
|
+
# those agents deliver the requester's decision: an opt-in that each callee's gate reviews.
|
|
208
|
+
DEFAULT_DECIDE_WITH = "direct"
|
|
209
|
+
DECIDE_DIRECT = "direct"
|
|
210
|
+
DECIDE_RELAYED = "relayed"
|
|
211
|
+
DECIDE_WITH_VALUES = (DECIDE_DIRECT, DECIDE_RELAYED)
|
|
212
|
+
# A value kept for a later release: a decision signed by the identity provider.
|
|
213
|
+
DECIDE_STEP_UP = "step_up"
|
|
214
|
+
# Role names and actor ids (`relayers`): 1-256 characters, no whitespace, commas or
|
|
215
|
+
# control characters.
|
|
216
|
+
_ROLE_NAME_RE = re.compile(r"[^\s,\x00-\x1f\x7f]{1,256}")
|
|
217
|
+
|
|
218
|
+
LEGACY_POLICY_HINT = (
|
|
219
|
+
"product_api: is the retired single-API format: move its fields under "
|
|
220
|
+
"apis: <name>: (for example apis: example:), add the now required "
|
|
221
|
+
"allowed_methods (for example [GET]), write auth: forward instead of "
|
|
222
|
+
"forwarded-session, and name the file api-policy.yaml"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class PolicyLoader(yaml.SafeLoader):
|
|
227
|
+
"""``yaml.SafeLoader`` that refuses a key repeated within one mapping, at any level.
|
|
228
|
+
|
|
229
|
+
Plain ``safe_load`` silently keeps the last duplicate, so a reviewer reading
|
|
230
|
+
``allowed_methods: [GET, POST]`` would miss a later ``allowed_methods: ["*"]``
|
|
231
|
+
that is the one applied. Merge keys (``<<: *anchor``) still work.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def construct_mapping(self, node: Any, deep: bool = False) -> Any:
|
|
235
|
+
if isinstance(node, yaml.MappingNode):
|
|
236
|
+
seen: set[Any] = set()
|
|
237
|
+
for key_node, _value_node in node.value:
|
|
238
|
+
if key_node.tag == "tag:yaml.org,2002:merge":
|
|
239
|
+
continue
|
|
240
|
+
key = self.construct_object(key_node, deep=deep)
|
|
241
|
+
try:
|
|
242
|
+
duplicate = key in seen
|
|
243
|
+
except TypeError: # an unhashable key: the base loader reports it
|
|
244
|
+
continue
|
|
245
|
+
if duplicate:
|
|
246
|
+
raise yaml.constructor.ConstructorError(
|
|
247
|
+
"while constructing a mapping",
|
|
248
|
+
node.start_mark,
|
|
249
|
+
f"found duplicate key {key!r}",
|
|
250
|
+
key_node.start_mark,
|
|
251
|
+
)
|
|
252
|
+
seen.add(key)
|
|
253
|
+
return super().construct_mapping(node, deep=deep)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def parse_policy_yaml(text: str) -> tuple[Any, list[str]]:
|
|
257
|
+
"""Parse api-policy.yaml text: ``(data, [])``, or ``(None, [error])`` when it is
|
|
258
|
+
not valid YAML (a duplicate key included)."""
|
|
259
|
+
try:
|
|
260
|
+
return yaml.load(text, Loader=PolicyLoader), []
|
|
261
|
+
except yaml.YAMLError as exc:
|
|
262
|
+
return None, [f"not valid YAML: {exc}"]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def policy_errors(data: Any) -> list[str]:
|
|
266
|
+
"""Every schema error in a parsed api-policy.yaml document; empty when it is valid.
|
|
267
|
+
|
|
268
|
+
Strict: unknown keys at any level are errors, so a typo can never widen access.
|
|
269
|
+
"""
|
|
270
|
+
if not isinstance(data, Mapping):
|
|
271
|
+
return ["the document must be a mapping with a top-level 'apis' key"]
|
|
272
|
+
errors: list[str] = []
|
|
273
|
+
if "product_api" in data:
|
|
274
|
+
errors.append(LEGACY_POLICY_HINT)
|
|
275
|
+
for key in sorted(set(data) - set(_POLICY_KEYS) - {"product_api"}, key=str):
|
|
276
|
+
errors.append(f"unknown top-level key {key!r} (allowed: apis)")
|
|
277
|
+
if "apis" not in data:
|
|
278
|
+
if "product_api" not in data:
|
|
279
|
+
errors.append("apis: required (a mapping of API name to its settings)")
|
|
280
|
+
return errors
|
|
281
|
+
apis = data["apis"]
|
|
282
|
+
if not isinstance(apis, Mapping) or not apis:
|
|
283
|
+
errors.append("apis: must be a non-empty mapping of API name to its settings")
|
|
284
|
+
return errors
|
|
285
|
+
for name, api in apis.items():
|
|
286
|
+
errors.extend(_api_errors(name, api))
|
|
287
|
+
return errors
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _is_env_name(value: Any) -> bool:
|
|
291
|
+
return isinstance(value, str) and ENV_NAME_RE.match(value) is not None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _is_audience(value: Any) -> bool:
|
|
295
|
+
"""An audience (a token's `aud`): 1-256 characters, no whitespace, commas or control
|
|
296
|
+
characters (the target's `AUTH_JWT_AUDIENCE` is a comma list of them)."""
|
|
297
|
+
return isinstance(value, str) and _ROLE_NAME_RE.fullmatch(value) is not None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _exchange_errors(where: str, value: Any) -> list[str]:
|
|
301
|
+
"""Errors of an API's `exchange` block (`auth: exchange`, RFC 8693)."""
|
|
302
|
+
if not isinstance(value, Mapping):
|
|
303
|
+
return [
|
|
304
|
+
f"{where}: must be a mapping with audience, and optionally scope, resource and "
|
|
305
|
+
f"{ALLOW_ACTORLESS_KEY}"
|
|
306
|
+
]
|
|
307
|
+
errors = [
|
|
308
|
+
f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_EXCHANGE_KEYS), key=str)
|
|
309
|
+
]
|
|
310
|
+
if "audience" not in value:
|
|
311
|
+
errors.append(
|
|
312
|
+
f"{where}.audience: required (the audience the issuer mints the token for: the "
|
|
313
|
+
"target's AUTH_JWT_AUDIENCE)"
|
|
314
|
+
)
|
|
315
|
+
elif not _is_audience(value["audience"]):
|
|
316
|
+
errors.append(
|
|
317
|
+
f"{where}.audience: must be an audience (1-256 characters without spaces, commas or "
|
|
318
|
+
"control characters)"
|
|
319
|
+
)
|
|
320
|
+
if "scope" in value:
|
|
321
|
+
scope = value["scope"]
|
|
322
|
+
if not (isinstance(scope, str) and _SCOPE_RE.fullmatch(scope)):
|
|
323
|
+
errors.append(
|
|
324
|
+
f"{where}.scope: must be scopes separated by single spaces (RFC 6749: printable "
|
|
325
|
+
"ASCII, no quotes or backslashes)"
|
|
326
|
+
)
|
|
327
|
+
if "resource" in value:
|
|
328
|
+
resource = value["resource"]
|
|
329
|
+
if not (isinstance(resource, str) and _ABSOLUTE_URI_RE.fullmatch(resource)):
|
|
330
|
+
errors.append(
|
|
331
|
+
f"{where}.resource: must be an absolute URI without a fragment (RFC 8707), "
|
|
332
|
+
"such as https://orders.example.com"
|
|
333
|
+
)
|
|
334
|
+
if ALLOW_ACTORLESS_KEY in value and not isinstance(value[ALLOW_ACTORLESS_KEY], bool):
|
|
335
|
+
errors.append(
|
|
336
|
+
f"{where}.{ALLOW_ACTORLESS_KEY}: must be true or false (true: accept exchanged "
|
|
337
|
+
"tokens that name no actor, once the agent behind the API sets "
|
|
338
|
+
"AUTH_JWT_DIRECT_CLIENTS)"
|
|
339
|
+
)
|
|
340
|
+
return errors
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _api_errors(name: Any, api: Any) -> list[str]:
|
|
344
|
+
where = f"apis.{name}"
|
|
345
|
+
errors: list[str] = []
|
|
346
|
+
if not isinstance(name, str) or not API_NAME_RE.match(name):
|
|
347
|
+
errors.append(f"{where}: invalid API name ({API_NAME_RULE})")
|
|
348
|
+
if not isinstance(api, Mapping):
|
|
349
|
+
errors.append(f"{where}: must be a mapping")
|
|
350
|
+
return errors
|
|
351
|
+
for key in sorted(set(api) - set(_API_KEYS), key=str):
|
|
352
|
+
errors.append(f"{where}: unknown key {key!r}")
|
|
353
|
+
|
|
354
|
+
if "base_url_env" not in api:
|
|
355
|
+
errors.append(f"{where}.base_url_env: required")
|
|
356
|
+
elif not _is_env_name(api["base_url_env"]):
|
|
357
|
+
errors.append(f"{where}.base_url_env: must be an environment variable name")
|
|
358
|
+
|
|
359
|
+
auth = api.get("auth")
|
|
360
|
+
modes = ", ".join(AUTH_MODES)
|
|
361
|
+
if "auth" not in api:
|
|
362
|
+
errors.append(f"{where}.auth: required (one of {modes})")
|
|
363
|
+
elif auth not in AUTH_MODES:
|
|
364
|
+
errors.append(f"{where}.auth: must be one of {modes} (got {auth!r})")
|
|
365
|
+
|
|
366
|
+
if auth == "bearer":
|
|
367
|
+
if "token_env" not in api:
|
|
368
|
+
errors.append(f"{where}.token_env: required when auth is bearer")
|
|
369
|
+
elif not _is_env_name(api["token_env"]):
|
|
370
|
+
errors.append(f"{where}.token_env: must be an environment variable name")
|
|
371
|
+
elif "token_env" in api:
|
|
372
|
+
errors.append(f"{where}.token_env: only valid with auth: bearer")
|
|
373
|
+
|
|
374
|
+
if "forward_header" in api:
|
|
375
|
+
header = api["forward_header"]
|
|
376
|
+
if auth not in HEADER_AUTH_MODES:
|
|
377
|
+
errors.append(f"{where}.forward_header: only valid with auth: forward or exchange")
|
|
378
|
+
elif not (isinstance(header, str) and HEADER_NAME_RE.match(header)):
|
|
379
|
+
errors.append(f"{where}.forward_header: must be an HTTP header name")
|
|
380
|
+
|
|
381
|
+
if "forward_audience" in api:
|
|
382
|
+
if auth != "forward":
|
|
383
|
+
errors.append(f"{where}.forward_audience: only valid with auth: forward")
|
|
384
|
+
elif not _is_audience(api["forward_audience"]):
|
|
385
|
+
errors.append(
|
|
386
|
+
f"{where}.forward_audience: must be an audience (1-256 characters without "
|
|
387
|
+
"spaces, commas or control characters)"
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
if auth == "exchange":
|
|
391
|
+
if EXCHANGE_KEY not in api:
|
|
392
|
+
errors.append(
|
|
393
|
+
f"{where}.{EXCHANGE_KEY}: required when auth is exchange (a mapping with the "
|
|
394
|
+
"audience the issuer mints the token for, and optionally scope, resource and "
|
|
395
|
+
f"{ALLOW_ACTORLESS_KEY})"
|
|
396
|
+
)
|
|
397
|
+
else:
|
|
398
|
+
errors.extend(_exchange_errors(f"{where}.{EXCHANGE_KEY}", api[EXCHANGE_KEY]))
|
|
399
|
+
elif EXCHANGE_KEY in api:
|
|
400
|
+
errors.append(f"{where}.{EXCHANGE_KEY}: only valid with auth: exchange")
|
|
401
|
+
|
|
402
|
+
protocol = api.get(PROTOCOL_KEY, DEFAULT_PROTOCOL)
|
|
403
|
+
errors.extend(_protocol_errors(where, api, protocol, auth))
|
|
404
|
+
|
|
405
|
+
if "allowed_methods" not in api:
|
|
406
|
+
errors.append(f'{where}.allowed_methods: required (a list of HTTP methods, or ["*"])')
|
|
407
|
+
else:
|
|
408
|
+
method_errors = _methods_errors(f"{where}.allowed_methods", api["allowed_methods"], True)
|
|
409
|
+
errors.extend(method_errors)
|
|
410
|
+
if protocol in RPC_PROTOCOLS and not method_errors:
|
|
411
|
+
outside = [
|
|
412
|
+
str(m).upper()
|
|
413
|
+
for m in api["allowed_methods"]
|
|
414
|
+
if str(m).upper() not in RPC_HTTP_METHODS
|
|
415
|
+
]
|
|
416
|
+
if outside:
|
|
417
|
+
errors.append(
|
|
418
|
+
f"{where}.allowed_methods: protocol {protocol} allows GET, POST and HEAD "
|
|
419
|
+
f"only (a JSON-RPC request is a POST), not {', '.join(outside)}"
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
if "allowed_operations" in api:
|
|
423
|
+
errors.extend(
|
|
424
|
+
_operations_errors(
|
|
425
|
+
f"{where}.allowed_operations",
|
|
426
|
+
api["allowed_operations"],
|
|
427
|
+
where,
|
|
428
|
+
"must not be empty; omit the key to allow every operation within allowed_methods",
|
|
429
|
+
protocol=protocol,
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
if "denied_operations" in api:
|
|
433
|
+
errors.extend(
|
|
434
|
+
_operations_errors(
|
|
435
|
+
f"{where}.denied_operations", api["denied_operations"], where, protocol=protocol
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
if "openapi" in api:
|
|
440
|
+
openapi = api["openapi"]
|
|
441
|
+
if not (isinstance(openapi, str) and openapi.strip()):
|
|
442
|
+
errors.append(f"{where}.openapi: must be a file path")
|
|
443
|
+
|
|
444
|
+
if "timeouts_ms" in api:
|
|
445
|
+
errors.extend(_timeouts_errors(f"{where}.timeouts_ms", api["timeouts_ms"]))
|
|
446
|
+
if "pagination" in api:
|
|
447
|
+
errors.extend(_pagination_errors(f"{where}.pagination", api["pagination"]))
|
|
448
|
+
if "limits" in api:
|
|
449
|
+
errors.extend(_limits_errors(f"{where}.limits", api["limits"]))
|
|
450
|
+
if APPROVAL_KEY in api:
|
|
451
|
+
errors.extend(_approval_errors(where, api[APPROVAL_KEY], protocol=protocol))
|
|
452
|
+
if not errors:
|
|
453
|
+
errors.extend(_approve_errors(where, api))
|
|
454
|
+
return errors
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _protocol_errors(where: str, api: Mapping[str, Any], protocol: Any, auth: Any) -> list[str]:
|
|
458
|
+
"""Errors of an API's `protocol`, `a2a` and `description`."""
|
|
459
|
+
errors: list[str] = []
|
|
460
|
+
if protocol not in PROTOCOLS:
|
|
461
|
+
errors.append(
|
|
462
|
+
f"{where}.{PROTOCOL_KEY}: must be one of {', '.join(PROTOCOLS)} (got {protocol!r})"
|
|
463
|
+
)
|
|
464
|
+
if protocol == PROTOCOL_A2A:
|
|
465
|
+
if A2A_KEY not in api:
|
|
466
|
+
errors.append(
|
|
467
|
+
f"{where}.{A2A_KEY}: required with protocol a2a (a mapping with path, the "
|
|
468
|
+
"agent's A2A endpoint, such as /a2a/orders)"
|
|
469
|
+
)
|
|
470
|
+
else:
|
|
471
|
+
errors.extend(_a2a_errors(f"{where}.{A2A_KEY}", api[A2A_KEY]))
|
|
472
|
+
if auth == "none":
|
|
473
|
+
errors.append(
|
|
474
|
+
f"{where}.auth: protocol a2a needs a credential (bearer, forward or exchange): "
|
|
475
|
+
"an agent's A2A endpoint authenticates its callers"
|
|
476
|
+
)
|
|
477
|
+
elif A2A_KEY in api:
|
|
478
|
+
errors.append(f"{where}.{A2A_KEY}: only valid with protocol a2a")
|
|
479
|
+
if DESCRIPTION_KEY in api:
|
|
480
|
+
description = api[DESCRIPTION_KEY]
|
|
481
|
+
if not (
|
|
482
|
+
isinstance(description, str)
|
|
483
|
+
and description.strip()
|
|
484
|
+
and len(description) <= DESCRIPTION_MAX_CHARS
|
|
485
|
+
and not any(ord(c) < 0x20 or 0x7F <= ord(c) < 0xA0 for c in description)
|
|
486
|
+
):
|
|
487
|
+
errors.append(
|
|
488
|
+
f"{where}.{DESCRIPTION_KEY}: must be text of 1-{DESCRIPTION_MAX_CHARS} "
|
|
489
|
+
"characters without control characters"
|
|
490
|
+
)
|
|
491
|
+
return errors
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _a2a_errors(where: str, value: Any) -> list[str]:
|
|
495
|
+
"""Errors of an API's `a2a` block: `path`, the literal path of the agent's A2A endpoint."""
|
|
496
|
+
if not isinstance(value, Mapping):
|
|
497
|
+
return [f"{where}: must be a mapping with path (the agent's A2A endpoint, /a2a/<name>)"]
|
|
498
|
+
errors = [
|
|
499
|
+
f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_A2A_KEYS), key=str)
|
|
500
|
+
]
|
|
501
|
+
if "path" not in value:
|
|
502
|
+
errors.append(f"{where}.path: required (the agent's A2A endpoint, such as /a2a/orders)")
|
|
503
|
+
return errors
|
|
504
|
+
path = value["path"]
|
|
505
|
+
problem = path_template_problem(path)
|
|
506
|
+
if problem:
|
|
507
|
+
errors.append(f"{where}.path: {problem}")
|
|
508
|
+
elif "{" in path or path.rstrip("/") == "":
|
|
509
|
+
errors.append(
|
|
510
|
+
f"{where}.path: must be the literal path of one endpoint (no placeholders), such "
|
|
511
|
+
"as /a2a/orders"
|
|
512
|
+
)
|
|
513
|
+
return errors
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def api_protocol(api: Mapping[str, Any]) -> str:
|
|
517
|
+
"""An API's `protocol`: `http` when it sets none."""
|
|
518
|
+
return str(api.get(PROTOCOL_KEY) or DEFAULT_PROTOCOL)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _rpc_pins(entry: Mapping[str, Any]) -> bool:
|
|
522
|
+
"""Whether an operation entry pins what a JSON-RPC request is (`rpc_method`, `a2a_operation`)."""
|
|
523
|
+
return entry.get(RPC_METHOD_KEY) is not None or entry.get(A2A_OPERATION_KEY) is not None
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _may_send_approve(api: Mapping[str, Any]) -> bool:
|
|
527
|
+
"""Whether an A2A API's allow-list may let through a message that approves (`approve`)."""
|
|
528
|
+
methods = {str(m).upper() for m in api.get("allowed_methods") or []}
|
|
529
|
+
if "POST" not in methods and ANY_METHOD not in methods:
|
|
530
|
+
return False
|
|
531
|
+
allowed = api.get("allowed_operations")
|
|
532
|
+
if allowed is None:
|
|
533
|
+
return True
|
|
534
|
+
return any(
|
|
535
|
+
_methods_match(entry, "POST")
|
|
536
|
+
and entry.get(RPC_METHOD_KEY) in (None, *A2A_MESSAGE_METHODS)
|
|
537
|
+
and entry.get(A2A_OPERATION_KEY) in (None, A2A_APPROVE)
|
|
538
|
+
for entry in allowed
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _covers_every_approve(entry: Mapping[str, Any]) -> bool:
|
|
543
|
+
"""Whether a denial or gate entry covers every message that approves, on any path."""
|
|
544
|
+
return entry.get(A2A_OPERATION_KEY) == A2A_APPROVE and _methods_match(entry, "POST")
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def approve_is_held(api: Mapping[str, Any]) -> bool:
|
|
548
|
+
"""Whether every message that approves waits for a human approval, or is denied.
|
|
549
|
+
|
|
550
|
+
An approval rule gating POST (or `"*"`), or an entry `a2a_operation: approve` (with no
|
|
551
|
+
methods, or POST among them) in a rule's `required_for.operations` or in
|
|
552
|
+
`denied_operations`. An entry pinning `rpc_method: SendMessage` does not count: it
|
|
553
|
+
leaves `SendStreamingMessage` out.
|
|
554
|
+
"""
|
|
555
|
+
for rule in approval_rules(api):
|
|
556
|
+
required_for = rule.get("required_for") or {}
|
|
557
|
+
methods = {str(m).upper() for m in required_for.get("methods") or []}
|
|
558
|
+
if "POST" in methods or ANY_METHOD in methods:
|
|
559
|
+
return True
|
|
560
|
+
if any(_covers_every_approve(entry) for entry in required_for.get("operations") or []):
|
|
561
|
+
return True
|
|
562
|
+
return any(_covers_every_approve(entry) for entry in api.get("denied_operations") or [])
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _approve_errors(where: str, api: Mapping[str, Any]) -> list[str]:
|
|
566
|
+
"""A `protocol: a2a` API that may send a message must gate or deny `approve`: otherwise
|
|
567
|
+
this agent could decide, on its own, the approvals the agent behind it waits for."""
|
|
568
|
+
if api_protocol(api) != PROTOCOL_A2A or not _may_send_approve(api) or approve_is_held(api):
|
|
569
|
+
return []
|
|
570
|
+
name = where.split(".", 1)[1] if "." in where else where
|
|
571
|
+
agent = str((api.get(A2A_KEY) or {}).get("path") or "").rstrip("/").rsplit("/", 1)[-1]
|
|
572
|
+
return [
|
|
573
|
+
f"{where}: protocol a2a allows SendMessage, so this agent could decide approvals at "
|
|
574
|
+
f"{agent or name}: gate them (graph-agents-cli api approval {name} --a2a-operations "
|
|
575
|
+
f"approve --approvers requester) or deny them (graph-agents-cli api deny {name} "
|
|
576
|
+
"--a2a-operation approve)"
|
|
577
|
+
]
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _methods_errors(where: str, value: Any, allow_any: bool) -> list[str]:
|
|
581
|
+
if not isinstance(value, list) or not value:
|
|
582
|
+
return [f"{where}: must be a non-empty list of HTTP methods"]
|
|
583
|
+
errors: list[str] = []
|
|
584
|
+
if allow_any and ANY_METHOD in value and len(value) != 1:
|
|
585
|
+
errors.append(f'{where}: "*" must be the only entry when present')
|
|
586
|
+
for method in value:
|
|
587
|
+
if allow_any and method == ANY_METHOD:
|
|
588
|
+
continue
|
|
589
|
+
if not isinstance(method, str) or method.upper() not in HTTP_METHODS:
|
|
590
|
+
errors.append(
|
|
591
|
+
f"{where}: unknown HTTP method {method!r} (allowed: {', '.join(HTTP_METHODS)})"
|
|
592
|
+
)
|
|
593
|
+
return errors
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _operations_errors(
|
|
597
|
+
where: str,
|
|
598
|
+
value: Any,
|
|
599
|
+
api_where: str,
|
|
600
|
+
empty_error: str | None = None,
|
|
601
|
+
*,
|
|
602
|
+
protocol: Any = DEFAULT_PROTOCOL,
|
|
603
|
+
) -> list[str]:
|
|
604
|
+
"""Errors of a list of operation entries; ``empty_error`` refuses an empty list.
|
|
605
|
+
|
|
606
|
+
``rpc_method`` is valid only with `protocol` jsonrpc or a2a, and ``a2a_operation``
|
|
607
|
+
only with a2a.
|
|
608
|
+
"""
|
|
609
|
+
if not isinstance(value, list):
|
|
610
|
+
return [f"{where}: must be a list of operations"]
|
|
611
|
+
if not value and empty_error:
|
|
612
|
+
return [f"{where}: {empty_error}"]
|
|
613
|
+
errors: list[str] = []
|
|
614
|
+
for index, entry in enumerate(value):
|
|
615
|
+
at = f"{where}[{index}]"
|
|
616
|
+
if not isinstance(entry, Mapping):
|
|
617
|
+
errors.append(f"{at}: must be a mapping with operationId and/or path")
|
|
618
|
+
continue
|
|
619
|
+
for key in sorted(set(entry) - set(_OPERATION_KEYS) - {APPROVAL_KEY}, key=str):
|
|
620
|
+
errors.append(f"{at}: unknown key {key!r}")
|
|
621
|
+
if APPROVAL_KEY in entry:
|
|
622
|
+
errors.append(
|
|
623
|
+
f"{at}.{APPROVAL_KEY}: not valid on an operation entry; gate the operation "
|
|
624
|
+
f"with {api_where}.{APPROVAL_KEY}.required_for.operations"
|
|
625
|
+
)
|
|
626
|
+
if protocol in RPC_PROTOCOLS:
|
|
627
|
+
if not any(key in entry for key in _OPERATION_KEYS if key != "methods"):
|
|
628
|
+
errors.append(f"{at}: needs operationId, path, rpc_method and/or a2a_operation")
|
|
629
|
+
elif "operationId" not in entry and "path" not in entry:
|
|
630
|
+
errors.append(f"{at}: needs operationId and/or path")
|
|
631
|
+
errors.extend(_rpc_entry_errors(at, entry, protocol))
|
|
632
|
+
if "operationId" in entry:
|
|
633
|
+
op_id = entry["operationId"]
|
|
634
|
+
if not isinstance(op_id, str) or not op_id or any(c.isspace() for c in op_id):
|
|
635
|
+
errors.append(f"{at}.operationId: must be a non-empty string without spaces")
|
|
636
|
+
if "path" in entry:
|
|
637
|
+
problem = path_template_problem(entry["path"])
|
|
638
|
+
if problem:
|
|
639
|
+
errors.append(f"{at}.path: {problem}")
|
|
640
|
+
if "methods" in entry:
|
|
641
|
+
errors.extend(_methods_errors(f"{at}.methods", entry["methods"], False))
|
|
642
|
+
return errors
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _rpc_entry_errors(at: str, entry: Mapping[str, Any], protocol: Any) -> list[str]:
|
|
646
|
+
"""Errors of an operation entry's `rpc_method` and `a2a_operation`."""
|
|
647
|
+
errors: list[str] = []
|
|
648
|
+
rpc_method = entry.get(RPC_METHOD_KEY)
|
|
649
|
+
if RPC_METHOD_KEY in entry:
|
|
650
|
+
if protocol not in RPC_PROTOCOLS:
|
|
651
|
+
errors.append(f"{at}.{RPC_METHOD_KEY}: only valid with protocol jsonrpc or a2a")
|
|
652
|
+
elif not (isinstance(rpc_method, str) and _RPC_METHOD_RE.fullmatch(rpc_method)):
|
|
653
|
+
errors.append(
|
|
654
|
+
f"{at}.{RPC_METHOD_KEY}: must be a JSON-RPC method name (a letter, then up to 63 "
|
|
655
|
+
"letters, digits, '_', '/' or '.')"
|
|
656
|
+
)
|
|
657
|
+
elif protocol == PROTOCOL_A2A and rpc_method in A2A_V03_METHODS:
|
|
658
|
+
errors.append(
|
|
659
|
+
f"{at}.{RPC_METHOD_KEY}: {rpc_method} is the A2A 0.3 name; write "
|
|
660
|
+
f"{A2A_V03_METHODS[rpc_method]} (a 0.3 name in a request is read as its 1.0 name)"
|
|
661
|
+
)
|
|
662
|
+
if A2A_OPERATION_KEY in entry:
|
|
663
|
+
operation = entry[A2A_OPERATION_KEY]
|
|
664
|
+
if protocol != PROTOCOL_A2A:
|
|
665
|
+
errors.append(f"{at}.{A2A_OPERATION_KEY}: only valid with protocol a2a")
|
|
666
|
+
elif operation not in A2A_OPERATIONS:
|
|
667
|
+
errors.append(f"{at}.{A2A_OPERATION_KEY}: must be approve or reject")
|
|
668
|
+
elif rpc_method is not None and rpc_method not in A2A_MESSAGE_METHODS:
|
|
669
|
+
errors.append(
|
|
670
|
+
f"{at}.{A2A_OPERATION_KEY}: goes with rpc_method SendMessage or "
|
|
671
|
+
f"SendStreamingMessage (the messages that decide an approval), not {rpc_method}"
|
|
672
|
+
)
|
|
673
|
+
return errors
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _is_positive_int(value: Any) -> bool:
|
|
677
|
+
return isinstance(value, int) and not isinstance(value, bool) and value > 0
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _timeouts_errors(where: str, value: Any) -> list[str]:
|
|
681
|
+
if not isinstance(value, Mapping):
|
|
682
|
+
return [f"{where}: must be a mapping with connect and/or read (milliseconds)"]
|
|
683
|
+
errors = [
|
|
684
|
+
f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_TIMEOUT_KEYS), key=str)
|
|
685
|
+
]
|
|
686
|
+
for key in _TIMEOUT_KEYS:
|
|
687
|
+
if key in value and not _is_positive_int(value[key]):
|
|
688
|
+
errors.append(f"{where}.{key}: must be a positive integer (milliseconds)")
|
|
689
|
+
return errors
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _pagination_errors(where: str, value: Any) -> list[str]:
|
|
693
|
+
if not isinstance(value, Mapping):
|
|
694
|
+
return [f"{where}: must be a mapping with page_size_param and max_page_size"]
|
|
695
|
+
errors = [
|
|
696
|
+
f"{where}: unknown key {key!r}"
|
|
697
|
+
for key in sorted(set(value) - set(_PAGINATION_KEYS), key=str)
|
|
698
|
+
]
|
|
699
|
+
param = value.get("page_size_param")
|
|
700
|
+
if "page_size_param" not in value:
|
|
701
|
+
errors.append(f"{where}.page_size_param: required")
|
|
702
|
+
elif not (isinstance(param, str) and param.strip()):
|
|
703
|
+
errors.append(f"{where}.page_size_param: must be a non-empty string")
|
|
704
|
+
if "max_page_size" not in value:
|
|
705
|
+
errors.append(f"{where}.max_page_size: required")
|
|
706
|
+
elif not _is_positive_int(value["max_page_size"]):
|
|
707
|
+
errors.append(f"{where}.max_page_size: must be a positive integer")
|
|
708
|
+
return errors
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _limits_errors(where: str, value: Any) -> list[str]:
|
|
712
|
+
if not isinstance(value, Mapping) or not value:
|
|
713
|
+
return [
|
|
714
|
+
f"{where}: must be a mapping with max_calls_per_run, rate_per_minute and/or "
|
|
715
|
+
"max_response_bytes"
|
|
716
|
+
]
|
|
717
|
+
errors = [
|
|
718
|
+
f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_LIMIT_KEYS), key=str)
|
|
719
|
+
]
|
|
720
|
+
for key in _LIMIT_KEYS:
|
|
721
|
+
if key in value and not _is_positive_int(value[key]):
|
|
722
|
+
errors.append(f"{where}.{key}: must be an integer >= 1")
|
|
723
|
+
size = value.get("max_response_bytes")
|
|
724
|
+
if _is_positive_int(size) and size > MAX_RESPONSE_BYTES_LIMIT:
|
|
725
|
+
errors.append(
|
|
726
|
+
f"{where}.max_response_bytes: must be an integer from 1 to "
|
|
727
|
+
f"{MAX_RESPONSE_BYTES_LIMIT} (bytes; 64 MiB at most)"
|
|
728
|
+
)
|
|
729
|
+
return errors
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _approval_errors(api_where: str, value: Any, *, protocol: Any = DEFAULT_PROTOCOL) -> list[str]:
|
|
733
|
+
"""Errors of an API's `approval`: one rule (a mapping), or a non-empty list of rules."""
|
|
734
|
+
where = f"{api_where}.{APPROVAL_KEY}"
|
|
735
|
+
if isinstance(value, list):
|
|
736
|
+
if not value:
|
|
737
|
+
return [f"{where}: must not be empty; omit the key when no call needs approval"]
|
|
738
|
+
errors: list[str] = []
|
|
739
|
+
for index, rule in enumerate(value):
|
|
740
|
+
errors.extend(
|
|
741
|
+
_approval_rule_errors(f"{where}[{index}]", rule, api_where, protocol=protocol)
|
|
742
|
+
)
|
|
743
|
+
return errors
|
|
744
|
+
if not isinstance(value, Mapping):
|
|
745
|
+
return [
|
|
746
|
+
f"{where}: must be a mapping with required_for and approvers, or a non-empty "
|
|
747
|
+
"list of such mappings (rules; the first that covers a call gates it)"
|
|
748
|
+
]
|
|
749
|
+
return _approval_rule_errors(where, value, api_where, protocol=protocol)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _approval_rule_errors(
|
|
753
|
+
where: str, value: Any, api_where: str, *, protocol: Any = DEFAULT_PROTOCOL
|
|
754
|
+
) -> list[str]:
|
|
755
|
+
"""Errors of one approval rule (``where``: ``apis.<name>.approval`` or ``...approval[i]``)."""
|
|
756
|
+
if not isinstance(value, Mapping):
|
|
757
|
+
return [f"{where}: must be a mapping with required_for and approvers"]
|
|
758
|
+
errors = [
|
|
759
|
+
f"{where}: unknown key {key!r}" for key in sorted(set(value) - set(_APPROVAL_KEYS), key=str)
|
|
760
|
+
]
|
|
761
|
+
if "required_for" not in value:
|
|
762
|
+
errors.append(f"{where}.required_for: required (the methods and/or operations it gates)")
|
|
763
|
+
else:
|
|
764
|
+
errors.extend(
|
|
765
|
+
_required_for_errors(
|
|
766
|
+
f"{where}.required_for", value["required_for"], api_where, protocol=protocol
|
|
767
|
+
)
|
|
768
|
+
)
|
|
769
|
+
if "approvers" not in value:
|
|
770
|
+
errors.append(f'{where}.approvers: required (a list of "requester" and/or "role:<name>")')
|
|
771
|
+
else:
|
|
772
|
+
errors.extend(_approvers_errors(f"{where}.approvers", value["approvers"]))
|
|
773
|
+
if "timeout_s" in value:
|
|
774
|
+
timeout = value["timeout_s"]
|
|
775
|
+
if not (
|
|
776
|
+
isinstance(timeout, int)
|
|
777
|
+
and not isinstance(timeout, bool)
|
|
778
|
+
and MIN_APPROVAL_TIMEOUT_S <= timeout <= MAX_APPROVAL_TIMEOUT_S
|
|
779
|
+
):
|
|
780
|
+
errors.append(
|
|
781
|
+
f"{where}.timeout_s: must be an integer from {MIN_APPROVAL_TIMEOUT_S} to "
|
|
782
|
+
f"{MAX_APPROVAL_TIMEOUT_S} (seconds)"
|
|
783
|
+
)
|
|
784
|
+
errors.extend(_decide_with_errors(where, value))
|
|
785
|
+
return errors
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _decide_with_errors(where: str, rule: Mapping[str, Any]) -> list[str]:
|
|
789
|
+
"""Errors of a rule's `decide_with` and `relayers`."""
|
|
790
|
+
decide_with = rule.get("decide_with", DEFAULT_DECIDE_WITH)
|
|
791
|
+
errors: list[str] = []
|
|
792
|
+
if decide_with == DECIDE_STEP_UP:
|
|
793
|
+
errors.append(f"{where}.decide_with: step_up is not supported yet (direct or relayed)")
|
|
794
|
+
elif decide_with not in DECIDE_WITH_VALUES:
|
|
795
|
+
errors.append(f"{where}.decide_with: must be direct or relayed (got {decide_with!r})")
|
|
796
|
+
if decide_with != DECIDE_RELAYED:
|
|
797
|
+
if "relayers" in rule:
|
|
798
|
+
errors.append(f"{where}.relayers: only valid with decide_with: relayed")
|
|
799
|
+
return errors
|
|
800
|
+
approvers = rule.get("approvers")
|
|
801
|
+
if isinstance(approvers, list) and REQUESTER_APPROVER not in approvers:
|
|
802
|
+
errors.append(
|
|
803
|
+
f"{where}.decide_with: relayed needs requester in approvers (role approvers always "
|
|
804
|
+
"decide with their own direct credentials, never relayed)"
|
|
805
|
+
)
|
|
806
|
+
if "relayers" not in rule:
|
|
807
|
+
errors.append(
|
|
808
|
+
f"{where}.relayers: required with decide_with: relayed (the agents, by actor id, "
|
|
809
|
+
"that may deliver the requester's decision)"
|
|
810
|
+
)
|
|
811
|
+
return errors
|
|
812
|
+
relayers = rule["relayers"]
|
|
813
|
+
if not isinstance(relayers, list) or not relayers:
|
|
814
|
+
return [*errors, f"{where}.relayers: must be a non-empty list of agent (actor) ids"]
|
|
815
|
+
for index, relayer in enumerate(relayers):
|
|
816
|
+
if not (isinstance(relayer, str) and _ROLE_NAME_RE.fullmatch(relayer)):
|
|
817
|
+
errors.append(
|
|
818
|
+
f"{where}.relayers[{index}]: {relayer!r} is not an agent id (1-256 characters "
|
|
819
|
+
"without spaces, commas or control characters)"
|
|
820
|
+
)
|
|
821
|
+
return errors
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _required_for_errors(
|
|
825
|
+
where: str, value: Any, api_where: str, *, protocol: Any = DEFAULT_PROTOCOL
|
|
826
|
+
) -> list[str]:
|
|
827
|
+
if not isinstance(value, Mapping) or not value:
|
|
828
|
+
return [f"{where}: must be a mapping with methods and/or operations"]
|
|
829
|
+
errors = [
|
|
830
|
+
f"{where}: unknown key {key!r}"
|
|
831
|
+
for key in sorted(set(value) - set(_REQUIRED_FOR_KEYS), key=str)
|
|
832
|
+
]
|
|
833
|
+
if "methods" not in value and "operations" not in value:
|
|
834
|
+
errors.append(f"{where}: needs methods and/or operations")
|
|
835
|
+
if "methods" in value:
|
|
836
|
+
errors.extend(_methods_errors(f"{where}.methods", value["methods"], True))
|
|
837
|
+
if "operations" in value:
|
|
838
|
+
errors.extend(
|
|
839
|
+
_operations_errors(
|
|
840
|
+
f"{where}.operations",
|
|
841
|
+
value["operations"],
|
|
842
|
+
api_where,
|
|
843
|
+
"must not be empty; omit the key when no operation needs approval",
|
|
844
|
+
protocol=protocol,
|
|
845
|
+
)
|
|
846
|
+
)
|
|
847
|
+
return errors
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _approvers_errors(where: str, value: Any) -> list[str]:
|
|
851
|
+
if not isinstance(value, list) or not value:
|
|
852
|
+
return [f'{where}: must be a non-empty list of "requester" and/or "role:<name>"']
|
|
853
|
+
errors: list[str] = []
|
|
854
|
+
for index, approver in enumerate(value):
|
|
855
|
+
if approver == REQUESTER_APPROVER:
|
|
856
|
+
continue
|
|
857
|
+
if (
|
|
858
|
+
isinstance(approver, str)
|
|
859
|
+
and approver.startswith(ROLE_APPROVER_PREFIX)
|
|
860
|
+
and _ROLE_NAME_RE.fullmatch(approver[len(ROLE_APPROVER_PREFIX) :])
|
|
861
|
+
):
|
|
862
|
+
continue
|
|
863
|
+
errors.append(
|
|
864
|
+
f'{where}[{index}]: {approver!r} is not an approver ("requester", or "role:<name>" '
|
|
865
|
+
"with a role name of 1-256 characters without spaces or commas)"
|
|
866
|
+
)
|
|
867
|
+
return errors
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def segment_text_problem(text: str) -> str | None:
|
|
871
|
+
"""Why a path segment's text (percent-decoded) is refused, or None.
|
|
872
|
+
|
|
873
|
+
Each of these would send a call to an endpoint other than the one the
|
|
874
|
+
policy judged: a control character anywhere (``%00``: servers that end a
|
|
875
|
+
path at a NUL route it to the part before); whitespace at either end
|
|
876
|
+
(``cancel%20``: servers that trim path segments route it to ``cancel``)
|
|
877
|
+
or next to a dot (``cancel%20.json``, ``cancel%20%2e``: servers that
|
|
878
|
+
trim the name before a format suffix, or strip trailing dots and spaces,
|
|
879
|
+
route it to ``cancel``); a backslash or a slash (``%5C``, ``%2F``:
|
|
880
|
+
servers that decode them before routing split the segment); a ``;``
|
|
881
|
+
(``%3B``: servers that strip path parameters route ``cancel;x`` to
|
|
882
|
+
``cancel``). Whitespace elsewhere in a segment (``red%20shirt``) is kept:
|
|
883
|
+
trimming does not touch it.
|
|
884
|
+
"""
|
|
885
|
+
if any(ord(char) < 0x20 or ord(char) == 0x7F for char in text):
|
|
886
|
+
return "holds a control character (also percent-encoded, such as %00)"
|
|
887
|
+
if text[:1].isspace() or text[-1:].isspace():
|
|
888
|
+
return "starts or ends with whitespace (also percent-encoded, such as %20)"
|
|
889
|
+
if _SPACE_BY_DOT_RE.search(text):
|
|
890
|
+
return "has whitespace next to a dot (also percent-encoded, such as cancel%20.json)"
|
|
891
|
+
if "/" in text or "\\" in text:
|
|
892
|
+
return "holds a backslash or a percent-encoded slash (%5C, %2F)"
|
|
893
|
+
if ";" in text:
|
|
894
|
+
return "holds ';' (also percent-encoded, %3B), which some servers strip with what follows"
|
|
895
|
+
return None
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def path_template_problem(path: Any) -> str | None:
|
|
899
|
+
"""Why ``path`` is not a valid path template, or None.
|
|
900
|
+
|
|
901
|
+
A template starts with ``/``; each segment holds literal characters and
|
|
902
|
+
``{name}`` placeholders only (no query, fragment, spaces, empty, ``.`` or
|
|
903
|
+
``..`` segments, also percent-encoded), and none of the characters a
|
|
904
|
+
sent path is refused for, percent-encoded or not (``segment_text_problem``:
|
|
905
|
+
control characters, whitespace at either end or next to a dot, a
|
|
906
|
+
backslash or an encoded slash, ``;``), so lint passes no declared call the
|
|
907
|
+
client would refuse to send. One trailing slash is allowed.
|
|
908
|
+
"""
|
|
909
|
+
if not isinstance(path, str) or not path.startswith("/"):
|
|
910
|
+
return "must be a string starting with /"
|
|
911
|
+
body = path[1:]
|
|
912
|
+
if body.endswith("/"):
|
|
913
|
+
body = body[:-1]
|
|
914
|
+
if not body:
|
|
915
|
+
return None
|
|
916
|
+
for segment in body.split("/"):
|
|
917
|
+
if segment in ("", ".", ".."):
|
|
918
|
+
return "must not contain empty, '.' or '..' segments"
|
|
919
|
+
if not _PATH_SEGMENT_RE.match(segment):
|
|
920
|
+
return (
|
|
921
|
+
"segments may hold literal characters and {name} placeholders only "
|
|
922
|
+
"(no query, fragment or whitespace)"
|
|
923
|
+
)
|
|
924
|
+
text = unquote(_PLACEHOLDER_SPLIT_RE.sub("x", segment))
|
|
925
|
+
if text in (".", ".."):
|
|
926
|
+
return "must not contain '.' or '..' segments, also percent-encoded (%2E)"
|
|
927
|
+
problem = segment_text_problem(text)
|
|
928
|
+
if problem:
|
|
929
|
+
return f"has a segment that {problem}"
|
|
930
|
+
return None
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def normalize_path(path: str) -> str:
|
|
934
|
+
"""``path`` in the form policy paths are compared in.
|
|
935
|
+
|
|
936
|
+
Percent-encoded unreserved characters are decoded (``/%61dmin`` is
|
|
937
|
+
``/admin``), other escapes are upper-cased (``%2f`` is ``%2F``), and one
|
|
938
|
+
trailing slash is dropped (``/items/1/`` is ``/items/1``), so equivalent
|
|
939
|
+
spellings of a path match the same entries.
|
|
940
|
+
"""
|
|
941
|
+
|
|
942
|
+
def _escape(match: re.Match[str]) -> str:
|
|
943
|
+
char = chr(int(match.group(0)[1:], 16))
|
|
944
|
+
return char if char in _UNRESERVED else match.group(0).upper()
|
|
945
|
+
|
|
946
|
+
path = _ESCAPE_RE.sub(_escape, path)
|
|
947
|
+
return path[:-1] if len(path) > 1 and path.endswith("/") else path
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def path_matches(
|
|
951
|
+
template: str, path: str, *, ignore_case: bool = False, suffixes: bool = False
|
|
952
|
+
) -> bool:
|
|
953
|
+
"""Whether ``path`` is covered by ``template``.
|
|
954
|
+
|
|
955
|
+
A ``{name}`` placeholder matches exactly one non-empty segment, so
|
|
956
|
+
``/items/{item_id}`` covers ``/items/42``, ``/items/{id}`` and itself.
|
|
957
|
+
Both sides are compared normalised (``normalize_path``). Letter case
|
|
958
|
+
counts unless ``ignore_case``: denials ignore it, so ``/ADMIN/1`` cannot
|
|
959
|
+
slip past a denial of ``/admin/{x}`` on a case-insensitive server. With
|
|
960
|
+
``suffixes`` (denials and approval gates, which fail closed), a segment
|
|
961
|
+
that ends in literal text also covers that segment with a dot suffix:
|
|
962
|
+
``/orders/{id}/cancel`` covers ``/orders/7/cancel.json`` and
|
|
963
|
+
``/orders/7/cancel.`` (also spelled ``cancel%2e``), which servers that
|
|
964
|
+
route format suffixes (``.json``) or drop a trailing dot send to the
|
|
965
|
+
same endpoint. An allow never matches that way: it must be shown.
|
|
966
|
+
"""
|
|
967
|
+
template, path = normalize_path(template), normalize_path(path)
|
|
968
|
+
if template == path or (ignore_case and template.casefold() == path.casefold()):
|
|
969
|
+
return True
|
|
970
|
+
segments = []
|
|
971
|
+
for segment in template.split("/"):
|
|
972
|
+
parts = _PLACEHOLDER_SPLIT_RE.split(segment)
|
|
973
|
+
pattern = "".join(
|
|
974
|
+
"[^/]+" if part.startswith("{") and part.endswith("}") else re.escape(part)
|
|
975
|
+
for part in parts
|
|
976
|
+
)
|
|
977
|
+
if suffixes and parts[-1] and not parts[-1].endswith("}"):
|
|
978
|
+
pattern += r"(?:\.[^/]*)?"
|
|
979
|
+
segments.append(pattern)
|
|
980
|
+
flags = re.IGNORECASE if ignore_case else 0
|
|
981
|
+
return re.fullmatch("/".join(segments), path, flags) is not None
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _methods_match(entry: Mapping[str, Any], method: str) -> bool:
|
|
985
|
+
methods = entry.get("methods")
|
|
986
|
+
return not methods or method.upper() in {str(m).upper() for m in methods}
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def operation_matches(
|
|
990
|
+
entry: Mapping[str, Any],
|
|
991
|
+
method: str,
|
|
992
|
+
operation_id: str | None,
|
|
993
|
+
path: str | None,
|
|
994
|
+
*,
|
|
995
|
+
rpc_method: str | None = None,
|
|
996
|
+
a2a_operation: str | None = None,
|
|
997
|
+
) -> bool:
|
|
998
|
+
"""Whether an ``allowed_operations`` entry covers the call.
|
|
999
|
+
|
|
1000
|
+
AND semantics: every field the entry pins (``operationId``, ``path``,
|
|
1001
|
+
``methods``, and on a JSON-RPC API ``rpc_method`` and ``a2a_operation``,
|
|
1002
|
+
compared with the values derived from the request body) must match. A
|
|
1003
|
+
call that does not name a pinned field (no operation id, no path, no
|
|
1004
|
+
JSON-RPC method or no decision) does not match: an allow must be shown.
|
|
1005
|
+
"""
|
|
1006
|
+
if not _methods_match(entry, method):
|
|
1007
|
+
return False
|
|
1008
|
+
pinned_id = entry.get("operationId")
|
|
1009
|
+
if pinned_id is not None and operation_id != pinned_id:
|
|
1010
|
+
return False
|
|
1011
|
+
pinned_path = entry.get("path")
|
|
1012
|
+
if pinned_path is not None and (path is None or not path_matches(pinned_path, path)):
|
|
1013
|
+
return False
|
|
1014
|
+
pinned_rpc = entry.get(RPC_METHOD_KEY)
|
|
1015
|
+
if pinned_rpc is not None and rpc_method != pinned_rpc:
|
|
1016
|
+
return False
|
|
1017
|
+
pinned_operation = entry.get(A2A_OPERATION_KEY)
|
|
1018
|
+
if pinned_operation is not None and a2a_operation != pinned_operation:
|
|
1019
|
+
return False
|
|
1020
|
+
return (
|
|
1021
|
+
pinned_id is not None
|
|
1022
|
+
or pinned_path is not None
|
|
1023
|
+
or pinned_rpc is not None
|
|
1024
|
+
or pinned_operation is not None
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def denial_match(
|
|
1029
|
+
entry: Mapping[str, Any],
|
|
1030
|
+
method: str,
|
|
1031
|
+
operation_id: str | None,
|
|
1032
|
+
path: str | None,
|
|
1033
|
+
*,
|
|
1034
|
+
rpc_method: str | None = None,
|
|
1035
|
+
a2a_operation: str | None = None,
|
|
1036
|
+
) -> str | None:
|
|
1037
|
+
"""How a ``denied_operations`` entry covers the call: None when it does not.
|
|
1038
|
+
|
|
1039
|
+
A denial names an endpoint and must hold whatever label a call gives it,
|
|
1040
|
+
so, unlike an allow, the fields it pins are alternatives, not
|
|
1041
|
+
requirements. With its ``methods`` (when pinned) covering the call's
|
|
1042
|
+
method, it covers a call whose path its ``path`` covers, whatever
|
|
1043
|
+
operation id the call names, and a call that names its ``operationId``
|
|
1044
|
+
(both return ``""``). Failing closed, it also covers a call that leaves
|
|
1045
|
+
out what the denial knows the operation by: no path when it pins
|
|
1046
|
+
``path`` (returns ``"path"``), no operation id when it pins
|
|
1047
|
+
``operationId`` alone (returns ``"operation_id"``). A denial by
|
|
1048
|
+
``operationId`` alone knows only that label: pin ``path`` too so it holds
|
|
1049
|
+
on the wire. Operation ids and paths are compared ignoring letter case,
|
|
1050
|
+
and a path's literal segments also cover their dot-suffixed spellings
|
|
1051
|
+
(``cancel.json``, ``cancel.``: ``path_matches`` with ``suffixes``).
|
|
1052
|
+
|
|
1053
|
+
An entry of a JSON-RPC API that pins ``rpc_method`` or ``a2a_operation``
|
|
1054
|
+
(the values derived from the request body) covers, with its ``methods``,
|
|
1055
|
+
a call whose JSON-RPC method is its ``rpc_method`` (ignoring letter case),
|
|
1056
|
+
a call that decides as its ``a2a_operation`` says, and a call that names
|
|
1057
|
+
its ``operationId``, whatever the path: its ``path``, if any, neither
|
|
1058
|
+
widens nor narrows it. Every POST to such an API names its method (a body
|
|
1059
|
+
that does not is refused first), so there is nothing left unnamed.
|
|
1060
|
+
"""
|
|
1061
|
+
if not _methods_match(entry, method):
|
|
1062
|
+
return None
|
|
1063
|
+
pinned_id = entry.get("operationId")
|
|
1064
|
+
if _rpc_pins(entry):
|
|
1065
|
+
pinned_rpc = entry.get(RPC_METHOD_KEY)
|
|
1066
|
+
if (
|
|
1067
|
+
pinned_rpc is not None
|
|
1068
|
+
and rpc_method is not None
|
|
1069
|
+
and str(rpc_method).casefold() == str(pinned_rpc).casefold()
|
|
1070
|
+
):
|
|
1071
|
+
return ""
|
|
1072
|
+
pinned_operation = entry.get(A2A_OPERATION_KEY)
|
|
1073
|
+
if pinned_operation is not None and a2a_operation == pinned_operation:
|
|
1074
|
+
return ""
|
|
1075
|
+
if (
|
|
1076
|
+
pinned_id is not None
|
|
1077
|
+
and operation_id is not None
|
|
1078
|
+
and str(operation_id).casefold() == str(pinned_id).casefold()
|
|
1079
|
+
):
|
|
1080
|
+
return ""
|
|
1081
|
+
return None
|
|
1082
|
+
pinned_path = entry.get("path")
|
|
1083
|
+
if (
|
|
1084
|
+
pinned_path is not None
|
|
1085
|
+
and path is not None
|
|
1086
|
+
and path_matches(pinned_path, path, ignore_case=True, suffixes=True)
|
|
1087
|
+
):
|
|
1088
|
+
return ""
|
|
1089
|
+
if (
|
|
1090
|
+
pinned_id is not None
|
|
1091
|
+
and operation_id is not None
|
|
1092
|
+
and str(operation_id).casefold() == str(pinned_id).casefold()
|
|
1093
|
+
):
|
|
1094
|
+
return ""
|
|
1095
|
+
if pinned_path is not None and path is None:
|
|
1096
|
+
return "path"
|
|
1097
|
+
if pinned_id is not None and pinned_path is None and operation_id is None:
|
|
1098
|
+
return "operation_id"
|
|
1099
|
+
return None
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def denial_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 a ``denied_operations`` entry covers the call (see ``denial_match``)."""
|
|
1112
|
+
return (
|
|
1113
|
+
denial_match(
|
|
1114
|
+
entry, method, operation_id, path, rpc_method=rpc_method, a2a_operation=a2a_operation
|
|
1115
|
+
)
|
|
1116
|
+
is not None
|
|
1117
|
+
)
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def describe_operation(entry: Mapping[str, Any]) -> str:
|
|
1121
|
+
"""``operationId=updateOrder path=/orders/{order_id} methods=['PATCH']`` for messages
|
|
1122
|
+
(and ``rpc_method=GetTask a2a_operation=approve`` for an entry that pins them)."""
|
|
1123
|
+
parts = []
|
|
1124
|
+
if entry.get("operationId") is not None:
|
|
1125
|
+
parts.append(f"operationId={entry['operationId']}")
|
|
1126
|
+
if entry.get("path") is not None:
|
|
1127
|
+
parts.append(f"path={entry['path']}")
|
|
1128
|
+
if entry.get("methods"):
|
|
1129
|
+
parts.append(f"methods={sorted(str(m).upper() for m in entry['methods'])}")
|
|
1130
|
+
for key in (RPC_METHOD_KEY, A2A_OPERATION_KEY):
|
|
1131
|
+
if entry.get(key) is not None:
|
|
1132
|
+
parts.append(f"{key}={entry[key]}")
|
|
1133
|
+
return " ".join(parts)
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def refusal_reason(
|
|
1137
|
+
api: Mapping[str, Any],
|
|
1138
|
+
method: str,
|
|
1139
|
+
operation_id: str | None = None,
|
|
1140
|
+
path: str | None = None,
|
|
1141
|
+
*,
|
|
1142
|
+
rpc_method: str | None = None,
|
|
1143
|
+
a2a_operation: str | None = None,
|
|
1144
|
+
) -> str | None:
|
|
1145
|
+
"""Why the API's policy refuses the call, or None when it is allowed.
|
|
1146
|
+
|
|
1147
|
+
Every rule must pass: the method is in ``allowed_methods`` (``["*"]``
|
|
1148
|
+
allows every method); no ``denied_operations`` entry may cover the call
|
|
1149
|
+
(denials win: a denial pinning a path refuses every call to that path,
|
|
1150
|
+
whatever operation id it names, and a call that leaves out what a denial
|
|
1151
|
+
knows the operation by is refused by it: ``denial_match``); and, when
|
|
1152
|
+
``allowed_operations`` is present, one of its entries matches
|
|
1153
|
+
(``operation_matches``: every field it pins). On a JSON-RPC API
|
|
1154
|
+
(``protocol: jsonrpc|a2a``) ``rpc_method`` and ``a2a_operation`` are the
|
|
1155
|
+
values ``derive_rpc`` reads from the request body.
|
|
1156
|
+
"""
|
|
1157
|
+
method = method.upper()
|
|
1158
|
+
operation_id = operation_id or None
|
|
1159
|
+
path = path or None
|
|
1160
|
+
rpc = {"rpc_method": rpc_method or None, "a2a_operation": a2a_operation or None}
|
|
1161
|
+
allowed = [str(m).upper() for m in api.get("allowed_methods") or []]
|
|
1162
|
+
if ANY_METHOD not in allowed and method not in allowed:
|
|
1163
|
+
return f"method {method} is not in allowed_methods {allowed}"
|
|
1164
|
+
for entry in api.get("denied_operations") or []:
|
|
1165
|
+
unnamed = denial_match(entry, method, operation_id, path, **rpc)
|
|
1166
|
+
if unnamed is not None:
|
|
1167
|
+
reason = f"denied by denied_operations ({describe_operation(entry)})"
|
|
1168
|
+
if unnamed:
|
|
1169
|
+
reason += (
|
|
1170
|
+
f": the call names no {unnamed}, so it cannot be ruled out; name it on "
|
|
1171
|
+
"the call and in API_CALLS"
|
|
1172
|
+
)
|
|
1173
|
+
return reason
|
|
1174
|
+
allowed_operations = api.get("allowed_operations")
|
|
1175
|
+
if allowed_operations is not None and not any(
|
|
1176
|
+
operation_matches(entry, method, operation_id, path, **rpc) for entry in allowed_operations
|
|
1177
|
+
):
|
|
1178
|
+
return "not in allowed_operations"
|
|
1179
|
+
return None
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
@dataclass(frozen=True)
|
|
1183
|
+
class ApprovalGate:
|
|
1184
|
+
"""The human approval an API's policy requires before a call is sent (``gated``)."""
|
|
1185
|
+
|
|
1186
|
+
# "requester" and/or "role:<name>" entries of the rule that gates the call,
|
|
1187
|
+
# in the policy's order.
|
|
1188
|
+
approvers: tuple[str, ...]
|
|
1189
|
+
# Seconds a pending approval waits for a decision; then it expires (= rejected).
|
|
1190
|
+
timeout_s: int
|
|
1191
|
+
# The rule and the part of its required_for that gates the call, for messages
|
|
1192
|
+
# ("approval.required_for.methods ['POST']", "approval[1].required_for.operations (...)").
|
|
1193
|
+
rule: str
|
|
1194
|
+
# The gating rule's index when `approval` is a list of rules; None for one mapping.
|
|
1195
|
+
index: int | None = None
|
|
1196
|
+
# Later rules that also cover the call; they do not apply to it (the first one does).
|
|
1197
|
+
also: tuple[int, ...] = ()
|
|
1198
|
+
# How the requester decides: `direct`, or `relayed` by the agents `relayers` names.
|
|
1199
|
+
decide_with: str = DEFAULT_DECIDE_WITH
|
|
1200
|
+
relayers: tuple[str, ...] = ()
|
|
1201
|
+
|
|
1202
|
+
def deciders(self) -> tuple[frozenset[str], str, frozenset[str]]:
|
|
1203
|
+
"""Who decides, and how: what an approval is bound to (`rule_deciders`)."""
|
|
1204
|
+
return frozenset(self.approvers), self.decide_with, frozenset(self.relayers)
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def approval_rules(api: Mapping[str, Any]) -> list[Mapping[str, Any]]:
|
|
1208
|
+
"""An API's approval rules in file order: none, its one ``approval`` mapping, or its list."""
|
|
1209
|
+
approval = api.get(APPROVAL_KEY)
|
|
1210
|
+
if approval is None:
|
|
1211
|
+
return []
|
|
1212
|
+
return list(approval) if isinstance(approval, list) else [approval]
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def rule_deciders(rule: Mapping[str, Any]) -> tuple[frozenset[str], str, frozenset[str]]:
|
|
1216
|
+
"""Who decides the calls a rule gates, and how: its approvers, `decide_with` and
|
|
1217
|
+
relayers. Two rules with other deciders are two gates, and an approval taken under one
|
|
1218
|
+
does not cover the other's calls."""
|
|
1219
|
+
return (
|
|
1220
|
+
frozenset(str(a) for a in rule.get("approvers") or ()),
|
|
1221
|
+
str(rule.get("decide_with", DEFAULT_DECIDE_WITH)),
|
|
1222
|
+
frozenset(str(r) for r in rule.get("relayers") or ()),
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def describe_deciders(rule: Mapping[str, Any]) -> str:
|
|
1227
|
+
"""``requester, role:ops``, or ``requester; relayed by concierge`` for a relayed rule."""
|
|
1228
|
+
approvers = ", ".join(str(a) for a in rule.get("approvers") or ())
|
|
1229
|
+
if rule.get("decide_with", DEFAULT_DECIDE_WITH) != DECIDE_RELAYED:
|
|
1230
|
+
return approvers
|
|
1231
|
+
return f"{approvers}; relayed by {', '.join(str(r) for r in rule.get('relayers') or ())}"
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def approval_rule_label(api: Mapping[str, Any], index: int) -> str:
|
|
1235
|
+
"""``approval`` for an API's one approval mapping, ``approval[<index>]`` in a list of rules."""
|
|
1236
|
+
if isinstance(api.get(APPROVAL_KEY), list):
|
|
1237
|
+
return f"{APPROVAL_KEY}[{index}]"
|
|
1238
|
+
return APPROVAL_KEY
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
class ApprovalRuleConflict(ValueError):
|
|
1242
|
+
"""``gated``: the call cannot be given to one approval rule, so it is refused.
|
|
1243
|
+
|
|
1244
|
+
The message says why (the rule that cannot rule the call out, the later
|
|
1245
|
+
rule with other approvers that covers it) and what to name. ``unnamed``
|
|
1246
|
+
is what the call leaves out (``"operation_id"`` or ``"path"``), ``index``
|
|
1247
|
+
and ``later`` are the two rules' indexes.
|
|
1248
|
+
"""
|
|
1249
|
+
|
|
1250
|
+
def __init__(self, message: str, *, unnamed: str, index: int, later: int) -> None:
|
|
1251
|
+
super().__init__(message)
|
|
1252
|
+
self.unnamed = unnamed
|
|
1253
|
+
self.index = index
|
|
1254
|
+
self.later = later
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def _rule_match(
|
|
1258
|
+
rule: Mapping[str, Any],
|
|
1259
|
+
method: str,
|
|
1260
|
+
operation_id: str | None,
|
|
1261
|
+
path: str | None,
|
|
1262
|
+
rpc: Mapping[str, str | None] | None = None,
|
|
1263
|
+
) -> tuple[str, str] | None:
|
|
1264
|
+
"""How one approval rule covers the call: ``(part, unnamed)``, or None.
|
|
1265
|
+
|
|
1266
|
+
``part`` names what covers it, for messages. ``unnamed`` is ``""`` when
|
|
1267
|
+
the rule surely covers the call, else what the call leaves out
|
|
1268
|
+
(``"operation_id"``, ``"path"``) that the covering entry knows the
|
|
1269
|
+
operation by: the rule covers it only because it cannot be ruled out. A
|
|
1270
|
+
sure match wins over one that only cannot be ruled out.
|
|
1271
|
+
"""
|
|
1272
|
+
required_for = rule.get("required_for") or {}
|
|
1273
|
+
methods = [str(m).upper() for m in required_for.get("methods") or []]
|
|
1274
|
+
if ANY_METHOD in methods or method in methods:
|
|
1275
|
+
return f"required_for.methods {methods}", ""
|
|
1276
|
+
unsure: tuple[str, str] | None = None
|
|
1277
|
+
for entry in required_for.get("operations") or []:
|
|
1278
|
+
unnamed = denial_match(entry, method, operation_id, path, **(rpc or {}))
|
|
1279
|
+
if unnamed is None:
|
|
1280
|
+
continue
|
|
1281
|
+
part = f"required_for.operations ({describe_operation(entry)})"
|
|
1282
|
+
if not unnamed:
|
|
1283
|
+
return part, ""
|
|
1284
|
+
if unsure is None:
|
|
1285
|
+
unsure = (part, unnamed)
|
|
1286
|
+
return unsure
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _unsure(part: str, unnamed: str) -> str:
|
|
1290
|
+
"""``part``, and why it covers the call when the call only leaves out what it names."""
|
|
1291
|
+
return f"{part}: the call names no {unnamed}, so it cannot be ruled out" if unnamed else part
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def rule_covers(
|
|
1295
|
+
rule: Mapping[str, Any],
|
|
1296
|
+
method: str,
|
|
1297
|
+
operation_id: str | None,
|
|
1298
|
+
path: str | None,
|
|
1299
|
+
*,
|
|
1300
|
+
rpc_method: str | None = None,
|
|
1301
|
+
a2a_operation: str | None = None,
|
|
1302
|
+
) -> str | None:
|
|
1303
|
+
"""Which part of one approval rule's ``required_for`` covers the call, or None.
|
|
1304
|
+
|
|
1305
|
+
``required_for.methods`` covers a call with one of its methods (``["*"]``:
|
|
1306
|
+
every method). An entry of ``required_for.operations`` covers a call as a
|
|
1307
|
+
denial does (``denial_match``: fail closed), not as an allow; an entry
|
|
1308
|
+
that surely covers it is named before one that only cannot rule it out.
|
|
1309
|
+
"""
|
|
1310
|
+
rpc = {"rpc_method": rpc_method or None, "a2a_operation": a2a_operation or None}
|
|
1311
|
+
match = _rule_match(rule, method.upper(), operation_id or None, path or None, rpc)
|
|
1312
|
+
return None if match is None else _unsure(*match)
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def gated(
|
|
1316
|
+
api: Mapping[str, Any],
|
|
1317
|
+
method: str,
|
|
1318
|
+
operation_id: str | None = None,
|
|
1319
|
+
path: str | None = None,
|
|
1320
|
+
*,
|
|
1321
|
+
template: str | None = None,
|
|
1322
|
+
rpc_method: str | None = None,
|
|
1323
|
+
a2a_operation: str | None = None,
|
|
1324
|
+
) -> ApprovalGate | None:
|
|
1325
|
+
"""The approval the API's policy (a validated one) requires before the call, or None.
|
|
1326
|
+
|
|
1327
|
+
Ask it only about a call ``refusal_reason`` allows: approval never widens
|
|
1328
|
+
access, so a refused call stays refused whatever its gate, and denials
|
|
1329
|
+
still win. A rule covers the call when its ``required_for.methods`` holds
|
|
1330
|
+
the call's method (``["*"]``: every method), or when an entry of its
|
|
1331
|
+
``required_for.operations`` covers it (``rule_covers``). Such an entry
|
|
1332
|
+
fails closed, as a denial does (``denial_match``), not as an allow: with
|
|
1333
|
+
its ``methods`` (when pinned) covering the call's method, its ``path``
|
|
1334
|
+
gates every call to that path whatever operation id the call names, its
|
|
1335
|
+
``operationId`` gates the calls that name it, and a call that leaves out
|
|
1336
|
+
what the entry knows the operation by is gated too. Paths are compared
|
|
1337
|
+
normalised and ignoring letter case, and a literal segment also covers its
|
|
1338
|
+
dot-suffixed spellings (``cancel.json``, ``cancel.``), as for a denial.
|
|
1339
|
+
|
|
1340
|
+
``approval`` is one rule, or a list of rules: the FIRST rule in file order
|
|
1341
|
+
that covers the call gates it, with that rule's approvers and timeout, and
|
|
1342
|
+
the later rules that also cover it are listed in ``also`` (they do not
|
|
1343
|
+
apply to it). Failing closed across rules: when the first rule covers the
|
|
1344
|
+
call only because the call leaves out what the rule knows the operation by
|
|
1345
|
+
(no operation id, no path), and a later rule with other approvers also
|
|
1346
|
+
covers it, the call may be that later rule's, so neither rule's approvers
|
|
1347
|
+
get it: ``ApprovalRuleConflict`` is raised and the call is refused. At
|
|
1348
|
+
runtime, ask with the path that is sent and, when there is one, the
|
|
1349
|
+
``template`` it was rendered from: a rule covers the call when it covers
|
|
1350
|
+
either (surely, when it surely covers either). On a JSON-RPC API an entry
|
|
1351
|
+
pinning ``rpc_method`` or ``a2a_operation`` covers the call as a denial
|
|
1352
|
+
does (``denial_match``), by the values derived from the request body.
|
|
1353
|
+
"""
|
|
1354
|
+
rules = approval_rules(api)
|
|
1355
|
+
if not rules:
|
|
1356
|
+
return None
|
|
1357
|
+
method = method.upper()
|
|
1358
|
+
operation_id = operation_id or None
|
|
1359
|
+
path = path or None
|
|
1360
|
+
template = template or None
|
|
1361
|
+
rpc = {"rpc_method": rpc_method or None, "a2a_operation": a2a_operation or None}
|
|
1362
|
+
covering: list[tuple[int, str, str]] = []
|
|
1363
|
+
for index, rule in enumerate(rules):
|
|
1364
|
+
match = _rule_match(rule, method, operation_id, path, rpc)
|
|
1365
|
+
if template is not None and (match is None or match[1]):
|
|
1366
|
+
other = _rule_match(rule, method, operation_id, template, rpc)
|
|
1367
|
+
if other is not None and (match is None or not other[1]):
|
|
1368
|
+
match = other
|
|
1369
|
+
if match is not None:
|
|
1370
|
+
covering.append((index, *match))
|
|
1371
|
+
if not covering:
|
|
1372
|
+
return None
|
|
1373
|
+
index, part, unnamed = covering[0]
|
|
1374
|
+
rule = rules[index]
|
|
1375
|
+
approvers = tuple(str(a) for a in rule.get("approvers") or ())
|
|
1376
|
+
label = approval_rule_label(api, index)
|
|
1377
|
+
if unnamed:
|
|
1378
|
+
pin = f", or pin path and methods in {label}" if unnamed == "operation_id" else ""
|
|
1379
|
+
for later, _part, _unnamed in covering[1:]:
|
|
1380
|
+
if rule_deciders(rules[later]) != rule_deciders(rule):
|
|
1381
|
+
raise ApprovalRuleConflict(
|
|
1382
|
+
f"{label} (approved by {describe_deciders(rule)}) covers it only because the "
|
|
1383
|
+
f"call names no {unnamed} ({label}.{part}), and "
|
|
1384
|
+
f"{approval_rule_label(api, later)} (approved by "
|
|
1385
|
+
f"{describe_deciders(rules[later])}) also covers it: it could be either "
|
|
1386
|
+
"rule's call, so neither rule's approvers are asked; name the "
|
|
1387
|
+
f"{unnamed} on the call and in API_CALLS{pin}",
|
|
1388
|
+
unnamed=unnamed,
|
|
1389
|
+
index=index,
|
|
1390
|
+
later=later,
|
|
1391
|
+
)
|
|
1392
|
+
listed = isinstance(api.get(APPROVAL_KEY), list)
|
|
1393
|
+
return ApprovalGate(
|
|
1394
|
+
approvers=approvers,
|
|
1395
|
+
timeout_s=int(rule.get("timeout_s", DEFAULT_APPROVAL_TIMEOUT_S)),
|
|
1396
|
+
rule=f"{label}.{_unsure(part, unnamed)}",
|
|
1397
|
+
index=index if listed else None,
|
|
1398
|
+
also=tuple(i for i, _, _ in covering[1:]),
|
|
1399
|
+
decide_with=str(rule.get("decide_with", DEFAULT_DECIDE_WITH)),
|
|
1400
|
+
relayers=tuple(str(r) for r in rule.get("relayers") or ()),
|
|
1401
|
+
)
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
class RpcRequestError(ValueError):
|
|
1405
|
+
"""A request a JSON-RPC API (``protocol: jsonrpc|a2a``) refuses to send (``derive_rpc``)."""
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
@dataclass(frozen=True)
|
|
1409
|
+
class RpcCall:
|
|
1410
|
+
"""What a request to a JSON-RPC API is, read from its body (``derive_rpc``).
|
|
1411
|
+
|
|
1412
|
+
``rpc_method``: the JSON-RPC method of a POST (an A2A 0.3 name read as its 1.0
|
|
1413
|
+
name under ``protocol: a2a``); None for GET and HEAD, and for any call to an
|
|
1414
|
+
``http`` API. ``a2a_operation``: under ``protocol: a2a``, ``approve`` or
|
|
1415
|
+
``reject`` for a message that decides a pending approval, else None.
|
|
1416
|
+
"""
|
|
1417
|
+
|
|
1418
|
+
rpc_method: str | None = None
|
|
1419
|
+
a2a_operation: str | None = None
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
def canonical_rpc_method(protocol: str, name: str) -> str:
|
|
1423
|
+
"""``name`` as the policy compares it: an A2A 0.3 name as its 1.0 name under a2a."""
|
|
1424
|
+
return A2A_V03_METHODS.get(name, name) if protocol == PROTOCOL_A2A else name
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
def _is_rpc_id(value: Any) -> bool:
|
|
1428
|
+
return isinstance(value, str) or (isinstance(value, int) and not isinstance(value, bool))
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def _names_approval(data: Any) -> bool:
|
|
1432
|
+
"""Whether a message part's data names an approval (as the called agent reads it)."""
|
|
1433
|
+
return isinstance(data, Mapping) and ("approval_id" in data or "decision" in data)
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def _a2a_operation(protocol: str, params: Any) -> str | None:
|
|
1437
|
+
"""What an A2A message decides: ``reject`` only when every part that names an approval
|
|
1438
|
+
says ``reject``, ``approve`` when any other does (approve wins), None when none does."""
|
|
1439
|
+
message = params.get("message") if isinstance(params, Mapping) else None
|
|
1440
|
+
parts = message.get("parts", []) if isinstance(message, Mapping) else None
|
|
1441
|
+
if not isinstance(parts, list):
|
|
1442
|
+
raise RpcRequestError(
|
|
1443
|
+
f"protocol {protocol}: a message request needs params.message with a list of parts"
|
|
1444
|
+
)
|
|
1445
|
+
decisions = [
|
|
1446
|
+
part["data"].get("decision")
|
|
1447
|
+
for part in parts
|
|
1448
|
+
if isinstance(part, Mapping) and _names_approval(part.get("data"))
|
|
1449
|
+
]
|
|
1450
|
+
if not decisions:
|
|
1451
|
+
return None
|
|
1452
|
+
return A2A_REJECT if all(d == A2A_REJECT for d in decisions) else A2A_APPROVE
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def _rpc_body_problem(sent: Any) -> str | None:
|
|
1456
|
+
"""Why a parsed JSON body is not one JSON-RPC 2.0 request object, or None."""
|
|
1457
|
+
if isinstance(sent, list):
|
|
1458
|
+
return "a batch"
|
|
1459
|
+
if not isinstance(sent, dict):
|
|
1460
|
+
return "not a JSON-RPC request object"
|
|
1461
|
+
if set(sent) - set(_JSONRPC_KEYS):
|
|
1462
|
+
return "members other than jsonrpc, method, params and id"
|
|
1463
|
+
if sent.get("jsonrpc") != "2.0":
|
|
1464
|
+
return 'jsonrpc is not "2.0"'
|
|
1465
|
+
if not isinstance(sent.get("method"), str) or not sent["method"]:
|
|
1466
|
+
return "no method name"
|
|
1467
|
+
if "id" not in sent:
|
|
1468
|
+
return "a notification (no id)"
|
|
1469
|
+
if not _is_rpc_id(sent["id"]):
|
|
1470
|
+
return "an id that is not a string or an integer"
|
|
1471
|
+
if "params" in sent and not isinstance(sent["params"], dict | list):
|
|
1472
|
+
return "params that are not an object or an array"
|
|
1473
|
+
return None
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
def derive_rpc(api: Mapping[str, Any], method: str, body: Any) -> RpcCall:
|
|
1477
|
+
"""What a request to ``api`` is, read from the body sent (never from the tool's labels).
|
|
1478
|
+
|
|
1479
|
+
Nothing for an ``http`` API. For ``protocol: jsonrpc|a2a``: a POST must send
|
|
1480
|
+
one JSON-RPC 2.0 request object (``jsonrpc: "2.0"``, a method name, an ``id``
|
|
1481
|
+
that is a string or an integer, optional ``params`` that are an object or an
|
|
1482
|
+
array, and no other member), read as the server reads the JSON sent; a
|
|
1483
|
+
batch, a notification (no ``id``), a body that is not plain JSON or any
|
|
1484
|
+
other body raises ``RpcRequestError``, as does a GET or HEAD with a body.
|
|
1485
|
+
Its ``rpc_method`` is the request's method (under ``a2a``, an A2A 0.3 name as
|
|
1486
|
+
its 1.0 name). Under ``a2a``, a ``SendMessage`` or ``SendStreamingMessage``
|
|
1487
|
+
whose parts name an approval (a data part with ``approval_id`` or
|
|
1488
|
+
``decision``) is ``a2a_operation: reject`` only when every such part says
|
|
1489
|
+
``reject``, and ``approve`` otherwise: failing closed, approve wins. A
|
|
1490
|
+
message method in another letter case (``sendmessage``) is read for a
|
|
1491
|
+
decision too, though an A2A server answers it as an unknown method.
|
|
1492
|
+
"""
|
|
1493
|
+
protocol = api_protocol(api)
|
|
1494
|
+
if protocol not in RPC_PROTOCOLS:
|
|
1495
|
+
return RpcCall()
|
|
1496
|
+
method = method.upper()
|
|
1497
|
+
if method != "POST":
|
|
1498
|
+
if body is not None:
|
|
1499
|
+
raise RpcRequestError(
|
|
1500
|
+
f"protocol {protocol}: a {method} sends no body (a JSON-RPC request is a POST)"
|
|
1501
|
+
)
|
|
1502
|
+
return RpcCall()
|
|
1503
|
+
try:
|
|
1504
|
+
# The JSON the server reads: tuples become lists, keys strings (never trust a
|
|
1505
|
+
# Python object that serializes as something other than it looks).
|
|
1506
|
+
sent = json.loads(json.dumps(body, allow_nan=False))
|
|
1507
|
+
except (TypeError, ValueError):
|
|
1508
|
+
sent = None
|
|
1509
|
+
problem: str | None = "not plain JSON"
|
|
1510
|
+
else:
|
|
1511
|
+
problem = _rpc_body_problem(sent)
|
|
1512
|
+
if problem is not None:
|
|
1513
|
+
raise RpcRequestError(
|
|
1514
|
+
f"protocol {protocol} sends one JSON-RPC request per call (a batch or non-request "
|
|
1515
|
+
f"body refused: {problem})"
|
|
1516
|
+
)
|
|
1517
|
+
name = canonical_rpc_method(protocol, sent["method"])
|
|
1518
|
+
if protocol != PROTOCOL_A2A or name.casefold() not in _A2A_MESSAGE_NAMES:
|
|
1519
|
+
return RpcCall(rpc_method=name)
|
|
1520
|
+
return RpcCall(rpc_method=name, a2a_operation=_a2a_operation(protocol, sent.get("params")))
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def _operation_entries(api: Mapping[str, Any]) -> list[Mapping[str, Any]]:
|
|
1524
|
+
"""Every operation entry of an API: allowed, denied and those its approval rules gate."""
|
|
1525
|
+
entries = [*(api.get("allowed_operations") or []), *(api.get("denied_operations") or [])]
|
|
1526
|
+
for rule in approval_rules(api):
|
|
1527
|
+
entries.extend((rule.get("required_for") or {}).get("operations") or [])
|
|
1528
|
+
return [entry for entry in entries if isinstance(entry, Mapping)]
|
|
1529
|
+
|
|
1530
|
+
|
|
1531
|
+
def label_problem(
|
|
1532
|
+
api: Mapping[str, Any],
|
|
1533
|
+
operation_id: str | None,
|
|
1534
|
+
rpc_method: str | None = None,
|
|
1535
|
+
a2a_operation: str | None = None,
|
|
1536
|
+
) -> str | None:
|
|
1537
|
+
"""Why a tool's ``operation_id`` does not name the request it labels, or None.
|
|
1538
|
+
|
|
1539
|
+
On a JSON-RPC API, an entry that pins ``operationId`` with ``rpc_method``
|
|
1540
|
+
or ``a2a_operation`` says what a call so labelled is. A call labelled so
|
|
1541
|
+
whose request (``derive_rpc``) is something else is refused, so a label
|
|
1542
|
+
never carries a decision past a rule written for another request.
|
|
1543
|
+
Operation ids and JSON-RPC methods are compared ignoring letter case.
|
|
1544
|
+
"""
|
|
1545
|
+
if not operation_id or api_protocol(api) not in RPC_PROTOCOLS:
|
|
1546
|
+
return None
|
|
1547
|
+
label = str(operation_id).casefold()
|
|
1548
|
+
for entry in _operation_entries(api):
|
|
1549
|
+
pinned_id = entry.get("operationId")
|
|
1550
|
+
if pinned_id is None or str(pinned_id).casefold() != label:
|
|
1551
|
+
continue
|
|
1552
|
+
pinned_rpc = entry.get(RPC_METHOD_KEY)
|
|
1553
|
+
if pinned_rpc is not None and (
|
|
1554
|
+
rpc_method is None or str(pinned_rpc).casefold() != str(rpc_method).casefold()
|
|
1555
|
+
):
|
|
1556
|
+
return (
|
|
1557
|
+
f"operation_id {operation_id!r} does not match the request (rpc_method "
|
|
1558
|
+
f"{rpc_method or 'none'}); refused"
|
|
1559
|
+
)
|
|
1560
|
+
pinned_operation = entry.get(A2A_OPERATION_KEY)
|
|
1561
|
+
if pinned_operation is not None and pinned_operation != a2a_operation:
|
|
1562
|
+
return (
|
|
1563
|
+
f"operation_id {operation_id!r} does not match the request (a2a_operation "
|
|
1564
|
+
f"{a2a_operation or 'none'}); refused"
|
|
1565
|
+
)
|
|
1566
|
+
return None
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
# --- END SHARED API POLICY RULES ---
|
|
1570
|
+
|
|
1571
|
+
# ---------------------------------------------------------------------------
|
|
1572
|
+
# CLI-only helpers
|
|
1573
|
+
# ---------------------------------------------------------------------------
|
|
1574
|
+
|
|
1575
|
+
MANIFEST_KEY = "api_policy"
|
|
1576
|
+
LEGACY_POLICY_FILENAME = "product-policy.yaml"
|
|
1577
|
+
LEGACY_MANIFEST_KEY = "product_api"
|
|
1578
|
+
LEGACY_CALLS_NAME = "PRODUCT_CALLS"
|
|
1579
|
+
CALLS_NAME = "API_CALLS"
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
class ApiPolicyFileError(click.ClickException):
|
|
1583
|
+
"""An ``api-policy.yaml`` that cannot be read or breaks the schema (exit 3)."""
|
|
1584
|
+
|
|
1585
|
+
exit_code = 3
|
|
1586
|
+
|
|
1587
|
+
def __init__(self, path: str | Path, errors: list[str]) -> None:
|
|
1588
|
+
self.path = Path(path)
|
|
1589
|
+
self.errors = list(errors)
|
|
1590
|
+
lines = "\n".join(f" - {e}" for e in self.errors)
|
|
1591
|
+
super().__init__(f"Invalid API policy {self.path}:\n{lines}")
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
class LegacyApiPolicyError(click.ClickException):
|
|
1595
|
+
"""The project still uses the retired product API policy (exit 3)."""
|
|
1596
|
+
|
|
1597
|
+
exit_code = 3
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
class ApiPolicyConfigError(click.ClickException):
|
|
1601
|
+
"""The manifest names a policy file the agent would not load (exit 3)."""
|
|
1602
|
+
|
|
1603
|
+
exit_code = 3
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
def load_policy_document(path: str | Path) -> dict[str, Any]:
|
|
1607
|
+
"""Read and validate an api-policy file; raise ``ApiPolicyFileError`` listing every problem."""
|
|
1608
|
+
policy_path = Path(path)
|
|
1609
|
+
try:
|
|
1610
|
+
text = policy_path.read_text(encoding="utf-8")
|
|
1611
|
+
except OSError as exc:
|
|
1612
|
+
raise ApiPolicyFileError(policy_path, [f"cannot read the file: {exc}"]) from exc
|
|
1613
|
+
data, errors = parse_policy_yaml(text)
|
|
1614
|
+
errors = errors or policy_errors(data)
|
|
1615
|
+
if errors:
|
|
1616
|
+
raise ApiPolicyFileError(policy_path, errors)
|
|
1617
|
+
return dict(data)
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
def manifest_policy_file_problem(value: Any, manifest_name: str) -> str | None:
|
|
1621
|
+
"""Why the manifest's ``api_policy.policy_file`` cannot be used, or None.
|
|
1622
|
+
|
|
1623
|
+
The agent loads ``api-policy.yaml`` from the project root (or
|
|
1624
|
+
``API_POLICY_PATH``) and the Dockerfiles copy only that file, so a manifest
|
|
1625
|
+
naming another file would make ``lint`` check a file the agent never
|
|
1626
|
+
enforces.
|
|
1627
|
+
"""
|
|
1628
|
+
if not value or Path(str(value)) == Path(POLICY_FILENAME):
|
|
1629
|
+
return None
|
|
1630
|
+
return (
|
|
1631
|
+
f"api_policy.policy_file in {manifest_name} is {str(value)!r}, but the agent loads "
|
|
1632
|
+
f"{POLICY_FILENAME} from the project root (the Dockerfiles copy only that file), so "
|
|
1633
|
+
f"lint would check a file the agent never enforces. Rename the file to "
|
|
1634
|
+
f"{POLICY_FILENAME} and set api_policy: {{policy_file: {POLICY_FILENAME}}}."
|
|
1635
|
+
)
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
@dataclass(frozen=True)
|
|
1639
|
+
class ApiSummary:
|
|
1640
|
+
"""What the templates need to know about one declared API."""
|
|
1641
|
+
|
|
1642
|
+
name: str
|
|
1643
|
+
base_url_env: str
|
|
1644
|
+
auth: str
|
|
1645
|
+
token_env: str = ""
|
|
1646
|
+
|
|
1647
|
+
def as_context(self) -> dict[str, str]:
|
|
1648
|
+
return {
|
|
1649
|
+
"name": self.name,
|
|
1650
|
+
"base_url_env": self.base_url_env,
|
|
1651
|
+
"auth": self.auth,
|
|
1652
|
+
"token_env": self.token_env,
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
def summarize(document: Mapping[str, Any]) -> tuple[ApiSummary, ...]:
|
|
1657
|
+
"""One summary per declared API, in file order (the document must be valid)."""
|
|
1658
|
+
return tuple(
|
|
1659
|
+
ApiSummary(
|
|
1660
|
+
name=str(name),
|
|
1661
|
+
base_url_env=str(api["base_url_env"]),
|
|
1662
|
+
auth=str(api["auth"]),
|
|
1663
|
+
token_env=str(api.get("token_env") or ""),
|
|
1664
|
+
)
|
|
1665
|
+
for name, api in document["apis"].items()
|
|
1666
|
+
)
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
# Methods whose example call sends a JSON body (the example tool takes a `body` argument).
|
|
1670
|
+
BODY_METHODS = ("POST", "PUT", "PATCH")
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
@dataclass(frozen=True)
|
|
1674
|
+
class ExampleCall:
|
|
1675
|
+
"""The call that the rendered ``tools/example_api.py`` makes.
|
|
1676
|
+
|
|
1677
|
+
Chosen when the project is rendered (``dev.policy_check.example_call``):
|
|
1678
|
+
the first operation the policy's first API allows, whatever its method, so
|
|
1679
|
+
the example passes ``lint`` and the project's policy test from the first
|
|
1680
|
+
commit.
|
|
1681
|
+
"""
|
|
1682
|
+
|
|
1683
|
+
api: str
|
|
1684
|
+
method: str
|
|
1685
|
+
path: str
|
|
1686
|
+
operation_id: str | None = None
|
|
1687
|
+
|
|
1688
|
+
@property
|
|
1689
|
+
def params(self) -> tuple[str, ...]:
|
|
1690
|
+
"""The path's ``{name}`` placeholders in order, each once: the tool's parameters."""
|
|
1691
|
+
return tuple(dict.fromkeys(_PLACEHOLDER_NAME_RE.findall(self.path)))
|
|
1692
|
+
|
|
1693
|
+
@property
|
|
1694
|
+
def has_body(self) -> bool:
|
|
1695
|
+
"""True when the call sends a JSON body (POST, PUT, PATCH)."""
|
|
1696
|
+
return self.method in BODY_METHODS
|
|
1697
|
+
|
|
1698
|
+
def as_context(self) -> dict[str, Any]:
|
|
1699
|
+
return {
|
|
1700
|
+
"api": self.api,
|
|
1701
|
+
"method": self.method,
|
|
1702
|
+
"operation_id": self.operation_id or "",
|
|
1703
|
+
"path": self.path,
|
|
1704
|
+
"params": list(self.params),
|
|
1705
|
+
"has_body": self.has_body,
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
|
|
1709
|
+
_PLACEHOLDER_NAME_RE = re.compile(r"\{([^/{}]+)\}")
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
def _effective_rule(rule: Mapping[str, Any]) -> dict[str, Any]:
|
|
1713
|
+
required_for = rule["required_for"]
|
|
1714
|
+
effective: dict[str, Any] = {}
|
|
1715
|
+
if "methods" in required_for:
|
|
1716
|
+
effective["methods"] = [str(m).upper() for m in required_for["methods"]]
|
|
1717
|
+
if "operations" in required_for:
|
|
1718
|
+
effective["operations"] = [dict(entry) for entry in required_for["operations"]]
|
|
1719
|
+
out: dict[str, Any] = {
|
|
1720
|
+
"required_for": effective,
|
|
1721
|
+
"approvers": [str(a) for a in rule["approvers"]],
|
|
1722
|
+
"timeout_s": int(rule.get("timeout_s", DEFAULT_APPROVAL_TIMEOUT_S)),
|
|
1723
|
+
}
|
|
1724
|
+
# How the requester decides, when the rule says (absent: `direct`).
|
|
1725
|
+
if "decide_with" in rule:
|
|
1726
|
+
out["decide_with"] = str(rule["decide_with"])
|
|
1727
|
+
if "relayers" in rule:
|
|
1728
|
+
out["relayers"] = [str(r) for r in rule["relayers"]]
|
|
1729
|
+
return out
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
def effective_approval(api: Mapping[str, Any]) -> dict[str, Any] | list[dict[str, Any]] | None:
|
|
1733
|
+
"""An API's ``approval`` (the API must be valid) with defaults filled in, or None.
|
|
1734
|
+
|
|
1735
|
+
Shaped as the file writes it: one rule ``{"required_for": {"methods": [...],
|
|
1736
|
+
"operations": [...]}, "approvers": [...], "timeout_s": N}``, or a list of
|
|
1737
|
+
such rules; ``required_for`` holds only the keys the policy sets, methods
|
|
1738
|
+
upper-cased. ``decide_with`` and ``relayers`` are there when the rule sets
|
|
1739
|
+
them (absent: ``direct``).
|
|
1740
|
+
"""
|
|
1741
|
+
approval = api.get(APPROVAL_KEY)
|
|
1742
|
+
if approval is None:
|
|
1743
|
+
return None
|
|
1744
|
+
if isinstance(approval, list):
|
|
1745
|
+
return [_effective_rule(rule) for rule in approval]
|
|
1746
|
+
return _effective_rule(approval)
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
def effective_approval_rules(api: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
1750
|
+
"""Every approval rule of a valid API in file order, defaults filled in (empty: none).
|
|
1751
|
+
|
|
1752
|
+
Each is ``_effective_rule``'s mapping plus ``rule``, its name in messages:
|
|
1753
|
+
``approval`` for the one mapping, ``approval[<index>]`` in a list.
|
|
1754
|
+
"""
|
|
1755
|
+
return [
|
|
1756
|
+
{"rule": approval_rule_label(api, index), **_effective_rule(rule)}
|
|
1757
|
+
for index, rule in enumerate(approval_rules(api))
|
|
1758
|
+
]
|
|
1759
|
+
|
|
1760
|
+
|
|
1761
|
+
def gate_payload(gate: ApprovalGate | None) -> dict[str, Any] | None:
|
|
1762
|
+
"""A gate as JSON-ready data (``api show --json``), or None.
|
|
1763
|
+
|
|
1764
|
+
``rule_index`` is the gating rule's index in a list of rules (None for one
|
|
1765
|
+
mapping); ``also_covered_by`` names the later rules that also cover the
|
|
1766
|
+
call, which do not apply to it. A relayed gate adds ``decide_with`` and
|
|
1767
|
+
``relayers`` (a gate without them is ``direct``).
|
|
1768
|
+
"""
|
|
1769
|
+
if gate is None:
|
|
1770
|
+
return None
|
|
1771
|
+
payload: dict[str, Any] = {
|
|
1772
|
+
"approvers": list(gate.approvers),
|
|
1773
|
+
"timeout_s": gate.timeout_s,
|
|
1774
|
+
"rule": gate.rule,
|
|
1775
|
+
"rule_index": gate.index,
|
|
1776
|
+
"also_covered_by": [f"{APPROVAL_KEY}[{i}]" for i in gate.also],
|
|
1777
|
+
}
|
|
1778
|
+
if gate.decide_with != DEFAULT_DECIDE_WITH:
|
|
1779
|
+
payload["decide_with"] = gate.decide_with
|
|
1780
|
+
payload["relayers"] = list(gate.relayers)
|
|
1781
|
+
return payload
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
def describe_gate(gate: ApprovalGate) -> str:
|
|
1785
|
+
"""``requester, role:ops (approval.required_for.methods ['POST']; expires after 900 s)``.
|
|
1786
|
+
|
|
1787
|
+
With overlapping rules it also names the later ones that cover the call
|
|
1788
|
+
and says they do not apply (the first rule in file order gates it).
|
|
1789
|
+
"""
|
|
1790
|
+
also = ""
|
|
1791
|
+
if gate.also:
|
|
1792
|
+
later = ", ".join(f"{APPROVAL_KEY}[{i}]" for i in gate.also)
|
|
1793
|
+
also = f"; also covered by {later}, which does not apply: the first rule gates the call"
|
|
1794
|
+
deciders = describe_deciders(
|
|
1795
|
+
{"approvers": gate.approvers, "decide_with": gate.decide_with, "relayers": gate.relayers}
|
|
1796
|
+
)
|
|
1797
|
+
return f"{deciders} ({gate.rule}; expires after {gate.timeout_s} s{also})"
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
|
+
def _rule_methods(rule: Mapping[str, Any]) -> set[str]:
|
|
1801
|
+
"""The methods one rule's ``required_for.methods`` gates outright (``"*"``: every one)."""
|
|
1802
|
+
methods = {str(m).upper() for m in (rule.get("required_for") or {}).get("methods") or []}
|
|
1803
|
+
return set(HTTP_METHODS) if ANY_METHOD in methods else methods
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
def _entry_covers(earlier: Mapping[str, Any], later: Mapping[str, Any]) -> bool:
|
|
1807
|
+
"""Whether gate entry ``earlier`` covers every call gate entry ``later`` covers.
|
|
1808
|
+
|
|
1809
|
+
Entries cover calls as denials do (``denial_match``): with the same
|
|
1810
|
+
operationId and path, the one pinning at least the other's methods (none:
|
|
1811
|
+
every method) covers the same calls, and more.
|
|
1812
|
+
"""
|
|
1813
|
+
if earlier.get("operationId") != later.get("operationId"):
|
|
1814
|
+
return False
|
|
1815
|
+
# JSON-RPC entries (0.3) cover by the request's method and decision: the same ones.
|
|
1816
|
+
if any(earlier.get(key) != later.get(key) for key in (RPC_METHOD_KEY, A2A_OPERATION_KEY)):
|
|
1817
|
+
return False
|
|
1818
|
+
paths = [earlier.get("path"), later.get("path")]
|
|
1819
|
+
if (paths[0] is None) != (paths[1] is None):
|
|
1820
|
+
return False
|
|
1821
|
+
if paths[0] is not None and normalize_path(str(paths[0])) != normalize_path(str(paths[1])):
|
|
1822
|
+
return False
|
|
1823
|
+
if not earlier.get("methods"):
|
|
1824
|
+
return True
|
|
1825
|
+
if not later.get("methods"):
|
|
1826
|
+
return False
|
|
1827
|
+
return {str(m).upper() for m in earlier["methods"]} >= {
|
|
1828
|
+
str(m).upper() for m in later["methods"]
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
|
|
1832
|
+
def rule_never_applies(api: Mapping[str, Any], index: int) -> bool:
|
|
1833
|
+
"""Whether every call approval rule ``index`` covers is covered by an earlier rule.
|
|
1834
|
+
|
|
1835
|
+
The first rule in file order that covers a call gates it, so such a rule
|
|
1836
|
+
never gates anything: its approvers never decide a call. Sound, not
|
|
1837
|
+
complete: True only when earlier methods, or equal or wider earlier
|
|
1838
|
+
entries, provably cover each part of it.
|
|
1839
|
+
"""
|
|
1840
|
+
rules = approval_rules(api)
|
|
1841
|
+
if not 0 < index < len(rules):
|
|
1842
|
+
return False
|
|
1843
|
+
earlier = rules[:index]
|
|
1844
|
+
methods = set().union(*(_rule_methods(r) for r in earlier))
|
|
1845
|
+
rule = rules[index]
|
|
1846
|
+
if not _rule_methods(rule) <= methods:
|
|
1847
|
+
return False
|
|
1848
|
+
earlier_entries = [
|
|
1849
|
+
entry for r in earlier for entry in (r.get("required_for") or {}).get("operations") or []
|
|
1850
|
+
]
|
|
1851
|
+
for entry in (rule.get("required_for") or {}).get("operations") or []:
|
|
1852
|
+
pinned = {str(m).upper() for m in entry.get("methods") or []} or set(HTTP_METHODS)
|
|
1853
|
+
if pinned <= methods or any(_entry_covers(e, entry) for e in earlier_entries):
|
|
1854
|
+
continue
|
|
1855
|
+
return False
|
|
1856
|
+
return True
|
|
1857
|
+
|
|
1858
|
+
|
|
1859
|
+
def _rule_cover_methods(rule: Mapping[str, Any]) -> set[str]:
|
|
1860
|
+
"""Every method some call one rule covers may have (its methods and its entries')."""
|
|
1861
|
+
methods = _rule_methods(rule)
|
|
1862
|
+
for entry in (rule.get("required_for") or {}).get("operations") or []:
|
|
1863
|
+
methods |= {str(m).upper() for m in entry.get("methods") or []} or set(HTTP_METHODS)
|
|
1864
|
+
return methods
|
|
1865
|
+
|
|
1866
|
+
|
|
1867
|
+
def rule_conflicts(api: Mapping[str, Any]) -> list[tuple[int, list[Mapping[str, Any]], list[int]]]:
|
|
1868
|
+
"""Rules that may leave a call no operation id names to either of two approver sets.
|
|
1869
|
+
|
|
1870
|
+
``gated`` refuses a call that the first covering rule covers only because
|
|
1871
|
+
the call names no operation id (an entry pinning ``operationId`` without
|
|
1872
|
+
``path``) when a later rule with other approvers (or approvers who decide
|
|
1873
|
+
otherwise: ``decide_with``, ``relayers``) also covers it. For each such
|
|
1874
|
+
rule: its index, those entries, and the later rules with other deciders
|
|
1875
|
+
that may cover the same calls (by method; conservative).
|
|
1876
|
+
"""
|
|
1877
|
+
rules = approval_rules(api)
|
|
1878
|
+
allowed = {str(m).upper() for m in api.get("allowed_methods") or []}
|
|
1879
|
+
allowed = set(HTTP_METHODS) if ANY_METHOD in allowed else allowed
|
|
1880
|
+
found = []
|
|
1881
|
+
sure: set[str] = set() # methods an earlier (or this) rule gates outright
|
|
1882
|
+
for index, rule in enumerate(rules):
|
|
1883
|
+
sure |= _rule_methods(rule)
|
|
1884
|
+
deciders = rule_deciders(rule)
|
|
1885
|
+
entries = [
|
|
1886
|
+
entry
|
|
1887
|
+
for entry in (rule.get("required_for") or {}).get("operations") or []
|
|
1888
|
+
# A JSON-RPC entry never leaves a call unnamed (every POST names its method).
|
|
1889
|
+
if entry.get("operationId") is not None
|
|
1890
|
+
and entry.get("path") is None
|
|
1891
|
+
and not _rpc_pins(entry)
|
|
1892
|
+
]
|
|
1893
|
+
methods = {
|
|
1894
|
+
m
|
|
1895
|
+
for entry in entries
|
|
1896
|
+
for m in ({str(x).upper() for x in entry.get("methods") or []} or set(HTTP_METHODS))
|
|
1897
|
+
} & (allowed - sure)
|
|
1898
|
+
later = [
|
|
1899
|
+
j
|
|
1900
|
+
for j in range(index + 1, len(rules))
|
|
1901
|
+
if rule_deciders(rules[j]) != deciders and methods & _rule_cover_methods(rules[j])
|
|
1902
|
+
]
|
|
1903
|
+
if entries and methods and later:
|
|
1904
|
+
found.append((index, entries, later))
|
|
1905
|
+
return found
|
|
1906
|
+
|
|
1907
|
+
|
|
1908
|
+
def approval_notes(name: str, api: Mapping[str, Any]) -> list[str]:
|
|
1909
|
+
"""What an API's valid ``approval`` names that can never take effect, or refuses.
|
|
1910
|
+
|
|
1911
|
+
Approval never widens access, so a gate on a method outside
|
|
1912
|
+
``allowed_methods`` changes nothing: those calls stay refused. In a list
|
|
1913
|
+
of rules, a rule whose every call an earlier rule covers first never
|
|
1914
|
+
gates anything (rules apply in file order), and a rule that names
|
|
1915
|
+
operations by ``operationId`` alone, before a rule with other approvers,
|
|
1916
|
+
has the calls that name no operation id and that both may cover refused
|
|
1917
|
+
(``rule_conflicts``).
|
|
1918
|
+
"""
|
|
1919
|
+
notes: list[str] = []
|
|
1920
|
+
for index, entries, later in rule_conflicts(api):
|
|
1921
|
+
label = approval_rule_label(api, index)
|
|
1922
|
+
others = ", ".join(
|
|
1923
|
+
f"{approval_rule_label(api, j)} (approved by "
|
|
1924
|
+
f"{describe_deciders(approval_rules(api)[j])})"
|
|
1925
|
+
for j in later
|
|
1926
|
+
)
|
|
1927
|
+
named = "; ".join(describe_operation(entry) for entry in entries)
|
|
1928
|
+
notes.append(
|
|
1929
|
+
f"apis.{name}.{label} names operations by operationId alone ({named}), so it "
|
|
1930
|
+
f"cannot rule out a call that names no operation_id, and {others} may also cover "
|
|
1931
|
+
"such a call: the agent refuses it (it could be either rule's call). Name the "
|
|
1932
|
+
f"operation_id on every call to {name} and in API_CALLS, or pin path and methods "
|
|
1933
|
+
f"in {label} (api approval --operations pins them from the API's openapi: spec)"
|
|
1934
|
+
)
|
|
1935
|
+
allowed = [str(m).upper() for m in api.get("allowed_methods") or []]
|
|
1936
|
+
for index, rule in enumerate(approval_rules(api)):
|
|
1937
|
+
label = approval_rule_label(api, index)
|
|
1938
|
+
if ANY_METHOD not in allowed:
|
|
1939
|
+
required_for = rule["required_for"]
|
|
1940
|
+
named = [str(m).upper() for m in required_for.get("methods") or []]
|
|
1941
|
+
for entry in required_for.get("operations") or []:
|
|
1942
|
+
named.extend(str(m).upper() for m in entry.get("methods") or [])
|
|
1943
|
+
outside = [m for m in dict.fromkeys(named) if m != ANY_METHOD and m not in allowed]
|
|
1944
|
+
if outside:
|
|
1945
|
+
notes.append(
|
|
1946
|
+
f"apis.{name}.{label} gates {', '.join(outside)}, which allowed_methods does "
|
|
1947
|
+
"not allow: approval never widens access, so those calls stay refused"
|
|
1948
|
+
)
|
|
1949
|
+
if rule_never_applies(api, index):
|
|
1950
|
+
notes.append(
|
|
1951
|
+
f"apis.{name}.{label} never gates a call: an earlier rule covers every call it "
|
|
1952
|
+
"covers, and the first rule in file order gates a call. Move it above that rule, "
|
|
1953
|
+
"narrow the earlier rule, or remove it"
|
|
1954
|
+
)
|
|
1955
|
+
return notes
|
|
1956
|
+
|
|
1957
|
+
|
|
1958
|
+
def read_policy_document(path: str | Path | None) -> dict[str, Any] | None:
|
|
1959
|
+
"""An existing project policy, validated; None (with a warning) when absent or invalid.
|
|
1960
|
+
|
|
1961
|
+
Used when a project is re-rendered: the policy belongs to the project and
|
|
1962
|
+
``lint`` reports its problems, so a broken file must not stop the re-render.
|
|
1963
|
+
"""
|
|
1964
|
+
if not path or not Path(path).is_file():
|
|
1965
|
+
return None
|
|
1966
|
+
try:
|
|
1967
|
+
return load_policy_document(path)
|
|
1968
|
+
except ApiPolicyFileError as exc:
|
|
1969
|
+
logging.warning("%s", exc.format_message())
|
|
1970
|
+
return None
|
|
1971
|
+
|
|
1972
|
+
|
|
1973
|
+
def read_summaries(path: str | Path | None) -> tuple[ApiSummary, ...]:
|
|
1974
|
+
"""Summaries of an existing project policy; empty (with a warning) when unreadable."""
|
|
1975
|
+
document = read_policy_document(path)
|
|
1976
|
+
return summarize(document) if document is not None else ()
|
|
1977
|
+
|
|
1978
|
+
|
|
1979
|
+
def bearer_token_envs(summaries: tuple[ApiSummary, ...] | list[ApiSummary]) -> list[str]:
|
|
1980
|
+
"""The ``token_env`` of every ``auth: bearer`` API, first occurrence first."""
|
|
1981
|
+
envs: list[str] = []
|
|
1982
|
+
for summary in summaries:
|
|
1983
|
+
if summary.auth == "bearer" and summary.token_env and summary.token_env not in envs:
|
|
1984
|
+
envs.append(summary.token_env)
|
|
1985
|
+
return envs
|
|
1986
|
+
|
|
1987
|
+
|
|
1988
|
+
# `auth: exchange` (RFC 8693): the issuer's token endpoint and this agent's client there. The
|
|
1989
|
+
# URL and the client id are plain settings (.env, the chart's values); the secret joins
|
|
1990
|
+
# `secrets.keys`, so it reaches the Secret and never the values files.
|
|
1991
|
+
TOKEN_EXCHANGE_URL_ENV = "TOKEN_EXCHANGE_URL"
|
|
1992
|
+
TOKEN_EXCHANGE_CLIENT_ID_ENV = "TOKEN_EXCHANGE_CLIENT_ID"
|
|
1993
|
+
TOKEN_EXCHANGE_SECRET_ENV = "TOKEN_EXCHANGE_CLIENT_SECRET"
|
|
1994
|
+
|
|
1995
|
+
|
|
1996
|
+
def uses_exchange(summaries: tuple[ApiSummary, ...] | list[ApiSummary]) -> bool:
|
|
1997
|
+
"""Whether an API of the policy uses ``auth: exchange``."""
|
|
1998
|
+
return any(summary.auth == "exchange" for summary in summaries)
|
|
1999
|
+
|
|
2000
|
+
|
|
2001
|
+
def secret_envs(summaries: tuple[ApiSummary, ...] | list[ApiSummary]) -> list[str]:
|
|
2002
|
+
"""The secrets the policy's APIs need in ``secrets.keys``, first occurrence first.
|
|
2003
|
+
|
|
2004
|
+
Every ``auth: bearer`` API's ``token_env``, and ``TOKEN_EXCHANGE_CLIENT_SECRET``
|
|
2005
|
+
once when an API uses ``auth: exchange``.
|
|
2006
|
+
"""
|
|
2007
|
+
envs = bearer_token_envs(summaries)
|
|
2008
|
+
if uses_exchange(summaries) and TOKEN_EXCHANGE_SECRET_ENV not in envs:
|
|
2009
|
+
envs.append(TOKEN_EXCHANGE_SECRET_ENV)
|
|
2010
|
+
return envs
|
|
2011
|
+
|
|
2012
|
+
|
|
2013
|
+
def forward_runtime_problem(
|
|
2014
|
+
summaries: tuple[ApiSummary, ...] | list[ApiSummary], runtime: str
|
|
2015
|
+
) -> str | None:
|
|
2016
|
+
"""Why ``auth: forward`` or ``auth: exchange`` cannot be used with ``runtime``, or None."""
|
|
2017
|
+
carrying = [s for s in summaries if s.auth in HEADER_AUTH_MODES]
|
|
2018
|
+
if runtime != "langgraph-server" or not carrying:
|
|
2019
|
+
return None
|
|
2020
|
+
modes = [mode for mode in HEADER_AUTH_MODES if any(s.auth == mode for s in carrying)]
|
|
2021
|
+
stored = (
|
|
2022
|
+
"forwarded credentials" if modes == ["forward"] else "the caller's credentials (tokens)"
|
|
2023
|
+
)
|
|
2024
|
+
return (
|
|
2025
|
+
f"{' and '.join(f'auth: {mode}' for mode in modes)} (apis: "
|
|
2026
|
+
f"{', '.join(s.name for s in carrying)}) is not supported with runtime "
|
|
2027
|
+
f"langgraph-server: LangGraph Server persists the run context, so {stored} would be "
|
|
2028
|
+
"stored. Use auth: bearer or none, or the fastapi runtime."
|
|
2029
|
+
)
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
def auth_policy_findings(
|
|
2033
|
+
document: Mapping[str, Any], auth_policy: str
|
|
2034
|
+
) -> tuple[list[str], list[str]]:
|
|
2035
|
+
"""The APIs that act with the caller's identity against the project's auth policy.
|
|
2036
|
+
|
|
2037
|
+
Returns ``(errors, notes)``, the compatibility matrix of `lint` and `api add`:
|
|
2038
|
+
|
|
2039
|
+
* ``auth: exchange`` under ``shared-bearer``: an error (there is no user token
|
|
2040
|
+
to exchange).
|
|
2041
|
+
* ``auth: forward`` under ``shared-bearer``: an error (there is no user
|
|
2042
|
+
credential to forward: every caller is the one principal ``shared``).
|
|
2043
|
+
* ``auth: forward`` under ``jwt`` without ``forward_audience``: an error (jwt
|
|
2044
|
+
sets no per-API credential); with it, a note to prefer ``auth: exchange``.
|
|
2045
|
+
* ``custom``: every mode is the policy's to serve (``keep_subject_token`` for
|
|
2046
|
+
exchange, ``attributes["credentials"]`` for forward).
|
|
2047
|
+
* ``auth: exchange`` with ``exchange.allow_actorless: true`` (``jwt`` or
|
|
2048
|
+
``custom``): a note naming what the agent behind the API must set, since
|
|
2049
|
+
the calling agent then sends tokens that name no actor.
|
|
2050
|
+
"""
|
|
2051
|
+
apis = document.get("apis") or {}
|
|
2052
|
+
exchange = [str(n) for n, a in apis.items() if a.get("auth") == "exchange"]
|
|
2053
|
+
forward = [str(n) for n, a in apis.items() if a.get("auth") == "forward"]
|
|
2054
|
+
actorless = [
|
|
2055
|
+
str(n)
|
|
2056
|
+
for n, a in apis.items()
|
|
2057
|
+
if a.get("auth") == "exchange"
|
|
2058
|
+
and isinstance(a.get(EXCHANGE_KEY), Mapping)
|
|
2059
|
+
and a[EXCHANGE_KEY].get(ALLOW_ACTORLESS_KEY) is True
|
|
2060
|
+
]
|
|
2061
|
+
errors: list[str] = []
|
|
2062
|
+
notes: list[str] = []
|
|
2063
|
+
if actorless and auth_policy != "shared-bearer":
|
|
2064
|
+
notes.append(
|
|
2065
|
+
f"auth: exchange (apis: {', '.join(actorless)}) sets exchange.allow_actorless: this "
|
|
2066
|
+
"agent sends exchanged tokens that name no actor, so the agent behind each must set "
|
|
2067
|
+
"AUTH_JWT_DIRECT_CLIENTS to the clients people sign in with and list this agent in "
|
|
2068
|
+
"AUTH_ALLOWED_ACTORS as client:<its client id>; without them it reads this agent's "
|
|
2069
|
+
"calls as the person's own, and this agent could decide the person's approvals there"
|
|
2070
|
+
)
|
|
2071
|
+
if auth_policy == "shared-bearer":
|
|
2072
|
+
if exchange:
|
|
2073
|
+
errors.append(
|
|
2074
|
+
f"auth: exchange (apis: {', '.join(exchange)}) is not supported with the "
|
|
2075
|
+
"shared-bearer auth policy: shared-bearer has no user token to exchange; use "
|
|
2076
|
+
"auth: bearer with the peer's agent key"
|
|
2077
|
+
)
|
|
2078
|
+
if forward:
|
|
2079
|
+
errors.append(
|
|
2080
|
+
f"auth: forward (apis: {', '.join(forward)}) is not supported with the "
|
|
2081
|
+
"shared-bearer auth policy: there is no user credential to forward (every "
|
|
2082
|
+
"caller is the one principal `shared`); use auth: bearer or none"
|
|
2083
|
+
)
|
|
2084
|
+
elif auth_policy == "jwt":
|
|
2085
|
+
unaimed = [n for n in forward if "forward_audience" not in apis[n]]
|
|
2086
|
+
aimed = [n for n in forward if "forward_audience" in apis[n]]
|
|
2087
|
+
if unaimed:
|
|
2088
|
+
errors.append(
|
|
2089
|
+
f"auth: forward (apis: {', '.join(unaimed)}) needs forward_audience with the jwt "
|
|
2090
|
+
"auth policy: jwt sets no per-API credential, and forwards the caller's own "
|
|
2091
|
+
"token only to an audience the issuer minted it for; prefer auth: exchange"
|
|
2092
|
+
)
|
|
2093
|
+
if aimed:
|
|
2094
|
+
notes.append(
|
|
2095
|
+
f"auth: forward (apis: {', '.join(aimed)}): prefer auth: exchange; forward sends "
|
|
2096
|
+
"the caller's own token and needs one minted for both audiences"
|
|
2097
|
+
)
|
|
2098
|
+
return errors, notes
|
|
2099
|
+
|
|
2100
|
+
|
|
2101
|
+
def legacy_findings(project_dir: str | Path) -> list[str]:
|
|
2102
|
+
"""What still uses the retired product API policy in ``project_dir``."""
|
|
2103
|
+
root = Path(project_dir)
|
|
2104
|
+
findings: list[str] = []
|
|
2105
|
+
if (root / LEGACY_POLICY_FILENAME).is_file():
|
|
2106
|
+
findings.append(f"{LEGACY_POLICY_FILENAME} exists")
|
|
2107
|
+
manifest = root / "graph-agents-cli-manifest.yaml"
|
|
2108
|
+
if manifest.is_file():
|
|
2109
|
+
try:
|
|
2110
|
+
data = yaml.safe_load(manifest.read_text(encoding="utf-8"))
|
|
2111
|
+
except (OSError, yaml.YAMLError):
|
|
2112
|
+
data = None
|
|
2113
|
+
if isinstance(data, Mapping) and LEGACY_MANIFEST_KEY in data:
|
|
2114
|
+
findings.append(f"the manifest has a {LEGACY_MANIFEST_KEY}: block")
|
|
2115
|
+
return findings
|
|
2116
|
+
|
|
2117
|
+
|
|
2118
|
+
def legacy_migration_message(findings: list[str]) -> str:
|
|
2119
|
+
"""How to move a project from the retired product API policy to api-policy.yaml."""
|
|
2120
|
+
return (
|
|
2121
|
+
"This project uses the retired product API policy ("
|
|
2122
|
+
+ "; ".join(findings)
|
|
2123
|
+
+ "). Migrate it to api-policy.yaml, then re-run the command:\n"
|
|
2124
|
+
f" 1. Rename {LEGACY_POLICY_FILENAME} to {POLICY_FILENAME}.\n"
|
|
2125
|
+
f" 2. Replace its top-level `{LEGACY_MANIFEST_KEY}:` with `apis:` and move the fields\n"
|
|
2126
|
+
" under an API name, adding the now required allowed_methods:\n"
|
|
2127
|
+
" apis:\n"
|
|
2128
|
+
" example:\n"
|
|
2129
|
+
" base_url_env: EXAMPLE_API_BASE_URL\n"
|
|
2130
|
+
" auth: bearer # none | bearer | forward (was forwarded-session)\n"
|
|
2131
|
+
" token_env: EXAMPLE_API_TOKEN\n"
|
|
2132
|
+
" allowed_methods: [GET, POST] # list every method it may use\n"
|
|
2133
|
+
f" 3. In graph-agents-cli-manifest.yaml replace `{LEGACY_MANIFEST_KEY}:` with\n"
|
|
2134
|
+
f" `{MANIFEST_KEY}: {{policy_file: {POLICY_FILENAME}}}`.\n"
|
|
2135
|
+
f" 4. In every tool module rename {LEGACY_CALLS_NAME} to {CALLS_NAME}, add\n"
|
|
2136
|
+
' "api": "<name>" to each entry, and call the API through\n'
|
|
2137
|
+
' app_utils.api_client.get_client("<name>").'
|
|
2138
|
+
)
|
|
2139
|
+
|
|
2140
|
+
|
|
2141
|
+
def ensure_no_legacy_api_policy(project_dir: str | Path) -> None:
|
|
2142
|
+
"""Raise ``LegacyApiPolicyError`` (exit 3) when the project uses the retired format."""
|
|
2143
|
+
findings = legacy_findings(project_dir)
|
|
2144
|
+
if findings:
|
|
2145
|
+
raise LegacyApiPolicyError(legacy_migration_message(findings))
|