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,1287 @@
|
|
|
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
|
+
"""Static API-policy check run by ``graph-agents-cli lint``.
|
|
16
|
+
|
|
17
|
+
Every tool module under ``<agent_dir>/tools/`` (subpackages included) declares, at module level,
|
|
18
|
+
the external API calls it makes::
|
|
19
|
+
|
|
20
|
+
API_CALLS = [
|
|
21
|
+
{"api": "orders", "method": "POST", "operation_id": "createOrder", "path": "/orders"},
|
|
22
|
+
{"api": "orders", "method": "GET", "path": "/orders/{order_id}"},
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
The list is read with :mod:`ast` (``ast.literal_eval`` on the assigned value),
|
|
26
|
+
so the check never imports a tool module and therefore never loads a model SDK
|
|
27
|
+
or the API client. Each declared call must name an API declared in
|
|
28
|
+
``api-policy.yaml`` and be allowed by that API's rules. The file is validated
|
|
29
|
+
with the same strict schema, and calls are matched with the same rules, as the
|
|
30
|
+
runtime client of the scaffolded project (``graph_agents_cli._api_policy``
|
|
31
|
+
holds the shared copy). When an API sets ``openapi:``, every call must also
|
|
32
|
+
exist in that spec by ``operationId`` or by ``path`` + ``method``; a declared
|
|
33
|
+
``operation_id`` must be the one the spec gives that method and path (the id is
|
|
34
|
+
a label the tool chooses, so a typo or a relabelled call must not pass as
|
|
35
|
+
another operation), and a call declared by ``operation_id`` alone is judged
|
|
36
|
+
with the path the spec gives it (the client always sends one), so path denials
|
|
37
|
+
apply to it.
|
|
38
|
+
|
|
39
|
+
Fail closed: without a policy file every declared call is refused, as the
|
|
40
|
+
runtime would refuse it. A module that still declares the retired
|
|
41
|
+
``PRODUCT_CALLS`` is an error with a rename hint, and ``auth: forward`` is an
|
|
42
|
+
error under the ``langgraph-server`` runtime.
|
|
43
|
+
|
|
44
|
+
Every refused call carries a hint: the ``graph-agents-cli api`` command that
|
|
45
|
+
would allow it (a reviewed change to ``api-policy.yaml``), or what to change in
|
|
46
|
+
the tool. An allowed call that the API's ``approval`` block gates (``gated``,
|
|
47
|
+
the runtime's rule) carries its gate: the report says who must approve it
|
|
48
|
+
before it is sent. Approval never widens access: a refused call stays refused.
|
|
49
|
+
|
|
50
|
+
``example_call`` uses the same judgement to pick the call that ``create``
|
|
51
|
+
renders into the example tool, so a fresh project passes this check.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import ast
|
|
57
|
+
import json
|
|
58
|
+
import keyword
|
|
59
|
+
import os
|
|
60
|
+
import re
|
|
61
|
+
from collections.abc import Mapping
|
|
62
|
+
from dataclasses import dataclass, field
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
from typing import Any
|
|
65
|
+
|
|
66
|
+
import click
|
|
67
|
+
import yaml
|
|
68
|
+
from rich.markup import escape
|
|
69
|
+
from rich.table import Table
|
|
70
|
+
|
|
71
|
+
from graph_agents_cli._api_policy import (
|
|
72
|
+
_RPC_METHOD_RE,
|
|
73
|
+
A2A_APPROVE,
|
|
74
|
+
A2A_MESSAGE_METHODS,
|
|
75
|
+
A2A_OPERATIONS,
|
|
76
|
+
ANY_METHOD,
|
|
77
|
+
CALLS_NAME,
|
|
78
|
+
DESCRIPTION_KEY,
|
|
79
|
+
HTTP_METHODS,
|
|
80
|
+
LEGACY_CALLS_NAME,
|
|
81
|
+
POLICY_FILENAME,
|
|
82
|
+
PROTOCOL_A2A,
|
|
83
|
+
RPC_PROTOCOLS,
|
|
84
|
+
ApiPolicyFileError,
|
|
85
|
+
ApprovalGate,
|
|
86
|
+
ApprovalRuleConflict,
|
|
87
|
+
ExampleCall,
|
|
88
|
+
api_protocol,
|
|
89
|
+
approval_notes,
|
|
90
|
+
auth_policy_findings,
|
|
91
|
+
canonical_rpc_method,
|
|
92
|
+
denial_match,
|
|
93
|
+
describe_gate,
|
|
94
|
+
forward_runtime_problem,
|
|
95
|
+
gated,
|
|
96
|
+
label_problem,
|
|
97
|
+
load_policy_document,
|
|
98
|
+
operation_matches,
|
|
99
|
+
path_matches,
|
|
100
|
+
path_template_problem,
|
|
101
|
+
refusal_reason,
|
|
102
|
+
summarize,
|
|
103
|
+
)
|
|
104
|
+
from graph_agents_cli._output import Console, print_table
|
|
105
|
+
|
|
106
|
+
TOOLS_SUBDIR = "tools"
|
|
107
|
+
_CALL_KEYS = ("api", "method", "operation_id", "path", "rpc_method", "a2a_operation")
|
|
108
|
+
# More peers than this make the model's roster of agents long (lint warns).
|
|
109
|
+
MAX_PEERS = 40
|
|
110
|
+
|
|
111
|
+
STATUS_ALLOWED = "allowed"
|
|
112
|
+
STATUS_DENIED = "denied"
|
|
113
|
+
STATUS_UNKNOWN = "unknown"
|
|
114
|
+
STATUS_INVALID = "invalid"
|
|
115
|
+
VIOLATION_STATUSES = frozenset({STATUS_DENIED, STATUS_UNKNOWN, STATUS_INVALID})
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class DeclaredCall:
|
|
120
|
+
"""One entry of a tool's ``API_CALLS`` list (or a problem's location)."""
|
|
121
|
+
|
|
122
|
+
tool: str
|
|
123
|
+
method: str
|
|
124
|
+
api: str = ""
|
|
125
|
+
operation_id: str | None = None
|
|
126
|
+
path: str | None = None
|
|
127
|
+
# A call to a JSON-RPC API (protocol: jsonrpc|a2a): the JSON-RPC method it sends and,
|
|
128
|
+
# for an A2A message that decides a pending approval, approve or reject.
|
|
129
|
+
rpc_method: str | None = None
|
|
130
|
+
a2a_operation: str | None = None
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def operation(self) -> str:
|
|
134
|
+
if self.operation_id and self.path:
|
|
135
|
+
text = f"{self.operation_id} {self.path}"
|
|
136
|
+
else:
|
|
137
|
+
text = self.operation_id or self.path or "-"
|
|
138
|
+
rpc = " ".join(part for part in (self.rpc_method, self.a2a_operation) if part)
|
|
139
|
+
return f"{text} ({rpc})" if rpc else text
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class CheckResult:
|
|
144
|
+
call: DeclaredCall
|
|
145
|
+
status: str
|
|
146
|
+
reason: str = ""
|
|
147
|
+
# For a refused call: the `graph-agents-cli api` command that would allow it,
|
|
148
|
+
# or what to change in the tool.
|
|
149
|
+
hint: str = ""
|
|
150
|
+
# For a call the policy allows: the human approval it needs before it is
|
|
151
|
+
# sent (the API's `approval` block), or None.
|
|
152
|
+
gate: ApprovalGate | None = None
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def is_violation(self) -> bool:
|
|
156
|
+
return self.status in VIOLATION_STATUSES
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class PolicyReport:
|
|
161
|
+
"""Everything the check found, ready to print."""
|
|
162
|
+
|
|
163
|
+
results: list[CheckResult] = field(default_factory=list)
|
|
164
|
+
notes: list[str] = field(default_factory=list)
|
|
165
|
+
policy_path: Path | None = None
|
|
166
|
+
openapi_paths: dict[str, Path] = field(default_factory=dict)
|
|
167
|
+
# The policy file itself is unusable (schema, a spec it names, a declared
|
|
168
|
+
# file that is missing): a configuration error, not a refused call.
|
|
169
|
+
policy_invalid: bool = False
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def violations(self) -> int:
|
|
173
|
+
return sum(1 for r in self.results if r.is_violation)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def gated(self) -> int:
|
|
177
|
+
"""Declared calls the policy allows only after a human approves them."""
|
|
178
|
+
return sum(1 for r in self.results if r.gate is not None)
|
|
179
|
+
|
|
180
|
+
def invalid(self, where: str, reason: str) -> None:
|
|
181
|
+
self.results.append(
|
|
182
|
+
CheckResult(DeclaredCall(tool=where, method="-"), STATUS_INVALID, reason)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def invalid_policy(self, where: str, reason: str) -> None:
|
|
186
|
+
self.invalid(where, reason)
|
|
187
|
+
self.policy_invalid = True
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class InvalidPolicyFile(click.ClickException):
|
|
191
|
+
"""``lint`` / ``api check``: the policy file is invalid (a configuration error, exit 3)."""
|
|
192
|
+
|
|
193
|
+
exit_code = 3
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Loading
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def load_openapi(path: Path) -> dict[str, Any]:
|
|
202
|
+
"""Load an OpenAPI document from YAML or JSON."""
|
|
203
|
+
text = path.read_text(encoding="utf-8")
|
|
204
|
+
if path.suffix.lower() == ".json":
|
|
205
|
+
data = json.loads(text)
|
|
206
|
+
else:
|
|
207
|
+
data = yaml.safe_load(text)
|
|
208
|
+
if not isinstance(data, dict):
|
|
209
|
+
raise ValueError(f"{path}: expected an OpenAPI mapping")
|
|
210
|
+
return data
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _literal(node: ast.AST) -> Any:
|
|
214
|
+
try:
|
|
215
|
+
return ast.literal_eval(node)
|
|
216
|
+
except (ValueError, SyntaxError, TypeError):
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _assigned_names(node: ast.stmt) -> tuple[list[str], ast.expr | None]:
|
|
221
|
+
if isinstance(node, ast.Assign):
|
|
222
|
+
return [t.id for t in node.targets if isinstance(t, ast.Name)], node.value
|
|
223
|
+
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
224
|
+
return [node.target.id], node.value
|
|
225
|
+
return [], None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _entry_problem(entry: Any) -> str | None:
|
|
229
|
+
"""Why an ``API_CALLS`` entry is malformed, or None."""
|
|
230
|
+
if not isinstance(entry, dict):
|
|
231
|
+
return "is not a dict"
|
|
232
|
+
unknown = sorted(set(entry) - set(_CALL_KEYS), key=str)
|
|
233
|
+
if unknown:
|
|
234
|
+
return (
|
|
235
|
+
f"has unknown key(s) {', '.join(map(repr, unknown))} (allowed: {', '.join(_CALL_KEYS)})"
|
|
236
|
+
)
|
|
237
|
+
if not isinstance(entry.get("api"), str) or not entry.get("api"):
|
|
238
|
+
return 'has no "api" (the name of an API in api-policy.yaml)'
|
|
239
|
+
method = entry.get("method")
|
|
240
|
+
if not isinstance(method, str) or method.upper() not in HTTP_METHODS:
|
|
241
|
+
return f"has no valid method (one of {', '.join(HTTP_METHODS)})"
|
|
242
|
+
operation_id, path = entry.get("operation_id"), entry.get("path")
|
|
243
|
+
if not operation_id and not path:
|
|
244
|
+
return "has neither operation_id nor path"
|
|
245
|
+
if operation_id is not None and not isinstance(operation_id, str):
|
|
246
|
+
return "has a non-string operation_id"
|
|
247
|
+
if path is not None:
|
|
248
|
+
problem = path_template_problem(path)
|
|
249
|
+
if problem:
|
|
250
|
+
return f"path {problem}"
|
|
251
|
+
rpc_method, a2a_operation = entry.get("rpc_method"), entry.get("a2a_operation")
|
|
252
|
+
if rpc_method is not None:
|
|
253
|
+
if not (isinstance(rpc_method, str) and _RPC_METHOD_RE.fullmatch(rpc_method)):
|
|
254
|
+
return (
|
|
255
|
+
"has an rpc_method that is not a JSON-RPC method name (a letter, then up to 63 "
|
|
256
|
+
"letters, digits, '_', '/' or '.')"
|
|
257
|
+
)
|
|
258
|
+
if method.upper() != "POST":
|
|
259
|
+
return f"has an rpc_method on a {method.upper()} (a JSON-RPC request is a POST)"
|
|
260
|
+
if a2a_operation is not None:
|
|
261
|
+
if a2a_operation not in A2A_OPERATIONS:
|
|
262
|
+
return "has an a2a_operation other than approve or reject"
|
|
263
|
+
if rpc_method is None:
|
|
264
|
+
return "has an a2a_operation without rpc_method (SendMessage or SendStreamingMessage)"
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# Methods that change a list or a dict in place.
|
|
269
|
+
_MUTATING_METHODS = frozenset(
|
|
270
|
+
{
|
|
271
|
+
"append",
|
|
272
|
+
"clear",
|
|
273
|
+
"extend",
|
|
274
|
+
"insert",
|
|
275
|
+
"pop",
|
|
276
|
+
"popitem",
|
|
277
|
+
"remove",
|
|
278
|
+
"reverse",
|
|
279
|
+
"setdefault",
|
|
280
|
+
"sort",
|
|
281
|
+
"update",
|
|
282
|
+
"__delitem__",
|
|
283
|
+
"__iadd__",
|
|
284
|
+
"__setitem__",
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _root_name(node: ast.AST) -> str | None:
|
|
290
|
+
"""``API_CALLS`` for ``API_CALLS``, ``API_CALLS[0]["path"]`` or ``API_CALLS.append``."""
|
|
291
|
+
while isinstance(node, ast.Attribute | ast.Subscript):
|
|
292
|
+
node = node.value
|
|
293
|
+
return node.id if isinstance(node, ast.Name) else None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _is_declaration(node: ast.stmt) -> bool:
|
|
297
|
+
"""A plain ``API_CALLS = ...`` or ``API_CALLS: ... = ...`` (one target, a value)."""
|
|
298
|
+
if isinstance(node, ast.Assign):
|
|
299
|
+
target = node.targets[0] if len(node.targets) == 1 else None
|
|
300
|
+
elif isinstance(node, ast.AnnAssign) and node.value is not None:
|
|
301
|
+
target = node.target
|
|
302
|
+
else:
|
|
303
|
+
return False
|
|
304
|
+
return isinstance(target, ast.Name) and target.id == CALLS_NAME
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _unread_changes(tree: ast.Module, declaration: ast.stmt | None) -> list[int]:
|
|
308
|
+
"""Lines outside ``declaration`` that bind or change ``API_CALLS``, in order.
|
|
309
|
+
|
|
310
|
+
The check reads one literal. ``+=``, ``.append()``, an item assignment, a
|
|
311
|
+
second or conditional assignment, an import or a ``def`` of the name could
|
|
312
|
+
change the calls the tool makes without the check seeing them, so each is
|
|
313
|
+
reported instead of trusted. (Changes through another name, such as an
|
|
314
|
+
alias or ``globals()``, are out of reach of a static check; the runtime
|
|
315
|
+
client still refuses every call outside the policy.)
|
|
316
|
+
"""
|
|
317
|
+
skip: set[int] = set() # Name nodes that are not a change: the declaration's target
|
|
318
|
+
if isinstance(declaration, ast.Assign):
|
|
319
|
+
skip.add(id(declaration.targets[0]))
|
|
320
|
+
elif isinstance(declaration, ast.AnnAssign):
|
|
321
|
+
skip.add(id(declaration.target))
|
|
322
|
+
lines: set[int] = set()
|
|
323
|
+
for node in ast.walk(tree):
|
|
324
|
+
if isinstance(node, ast.AnnAssign) and node.value is None:
|
|
325
|
+
skip.add(id(node.target)) # an annotation alone binds nothing
|
|
326
|
+
for node in ast.walk(tree):
|
|
327
|
+
changed = False
|
|
328
|
+
if isinstance(node, ast.Name):
|
|
329
|
+
changed = (
|
|
330
|
+
node.id == CALLS_NAME
|
|
331
|
+
and isinstance(node.ctx, ast.Store | ast.Del)
|
|
332
|
+
and id(node) not in skip
|
|
333
|
+
)
|
|
334
|
+
elif isinstance(node, ast.Attribute | ast.Subscript):
|
|
335
|
+
changed = isinstance(node.ctx, ast.Store | ast.Del) and _root_name(node) == CALLS_NAME
|
|
336
|
+
elif isinstance(node, ast.Call):
|
|
337
|
+
func = node.func
|
|
338
|
+
changed = (
|
|
339
|
+
isinstance(func, ast.Attribute)
|
|
340
|
+
and func.attr in _MUTATING_METHODS
|
|
341
|
+
and _root_name(func.value) == CALLS_NAME
|
|
342
|
+
)
|
|
343
|
+
elif isinstance(node, ast.alias):
|
|
344
|
+
changed = (node.asname or node.name.split(".")[0]) == CALLS_NAME
|
|
345
|
+
elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
|
|
346
|
+
changed = node.name == CALLS_NAME
|
|
347
|
+
elif isinstance(node, ast.ExceptHandler | ast.MatchAs | ast.MatchStar):
|
|
348
|
+
changed = node.name == CALLS_NAME
|
|
349
|
+
elif isinstance(node, ast.MatchMapping):
|
|
350
|
+
changed = node.rest == CALLS_NAME
|
|
351
|
+
if changed:
|
|
352
|
+
lines.add(getattr(node, "lineno", 0))
|
|
353
|
+
return sorted(lines)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def read_api_calls(
|
|
357
|
+
tool_path: Path, label: str | None = None
|
|
358
|
+
) -> tuple[list[DeclaredCall], list[str]]:
|
|
359
|
+
"""Return the ``API_CALLS`` declared in ``tool_path`` plus any problems.
|
|
360
|
+
|
|
361
|
+
``label`` names the module in the report (default: the file name); the
|
|
362
|
+
tools directory walk passes the path relative to ``tools/``.
|
|
363
|
+
|
|
364
|
+
A tool without the name declares no calls. The declaration must be one
|
|
365
|
+
module-level assignment of a literal list of dicts: anything the check
|
|
366
|
+
cannot read (a non-literal value, ``+=``, ``.append()``, a second or
|
|
367
|
+
conditional assignment) is reported as a problem rather than trusted, as
|
|
368
|
+
are a malformed entry and a leftover ``PRODUCT_CALLS``.
|
|
369
|
+
"""
|
|
370
|
+
name = label or tool_path.name
|
|
371
|
+
problems: list[str] = []
|
|
372
|
+
try:
|
|
373
|
+
tree = ast.parse(tool_path.read_text(encoding="utf-8"), filename=str(tool_path))
|
|
374
|
+
except SyntaxError as exc:
|
|
375
|
+
return [], [f"{name}: syntax error: {exc}"]
|
|
376
|
+
except (OSError, UnicodeDecodeError, ValueError) as exc:
|
|
377
|
+
# Unreadable is not "declares no calls": the check cannot vouch for it.
|
|
378
|
+
return [], [f"{name}: cannot be read as UTF-8 Python source: {exc}"]
|
|
379
|
+
|
|
380
|
+
calls: list[DeclaredCall] = []
|
|
381
|
+
declaration: ast.stmt | None = None
|
|
382
|
+
for node in tree.body:
|
|
383
|
+
names, value = _assigned_names(node)
|
|
384
|
+
if LEGACY_CALLS_NAME in names:
|
|
385
|
+
problems.append(
|
|
386
|
+
f"{name}: {LEGACY_CALLS_NAME} was renamed to {CALLS_NAME}; rename it "
|
|
387
|
+
'and add "api": "<name of an API in api-policy.yaml>" to every entry'
|
|
388
|
+
)
|
|
389
|
+
continue
|
|
390
|
+
if declaration is not None or not _is_declaration(node) or value is None:
|
|
391
|
+
continue
|
|
392
|
+
declaration = node
|
|
393
|
+
literal = _literal(value)
|
|
394
|
+
if not isinstance(literal, list | tuple):
|
|
395
|
+
problems.append(
|
|
396
|
+
f"{name}: {CALLS_NAME} is not a literal list; "
|
|
397
|
+
"declare calls as plain dict literals so the check can read them"
|
|
398
|
+
)
|
|
399
|
+
continue
|
|
400
|
+
for index, entry in enumerate(literal):
|
|
401
|
+
problem = _entry_problem(entry)
|
|
402
|
+
if problem:
|
|
403
|
+
problems.append(f"{name}: {CALLS_NAME}[{index}] {problem}")
|
|
404
|
+
continue
|
|
405
|
+
calls.append(
|
|
406
|
+
DeclaredCall(
|
|
407
|
+
tool=name,
|
|
408
|
+
api=str(entry["api"]),
|
|
409
|
+
method=str(entry["method"]).upper(),
|
|
410
|
+
operation_id=entry.get("operation_id") or None,
|
|
411
|
+
path=entry.get("path") or None,
|
|
412
|
+
rpc_method=entry.get("rpc_method") or None,
|
|
413
|
+
a2a_operation=entry.get("a2a_operation") or None,
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
for line in _unread_changes(tree, declaration):
|
|
417
|
+
problems.append(
|
|
418
|
+
f"{name}: line {line} binds or changes {CALLS_NAME} outside its "
|
|
419
|
+
"module-level literal (for example +=, .append() or an assignment inside a "
|
|
420
|
+
"block), so the check cannot read those calls; declare every call in the "
|
|
421
|
+
"one literal list"
|
|
422
|
+
)
|
|
423
|
+
return calls, problems
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def collect_declared_calls(tools_dir: Path) -> tuple[list[DeclaredCall], list[str]]:
|
|
427
|
+
"""Read every ``*.py`` under ``tools_dir``, subpackages included, except its ``__init__.py``.
|
|
428
|
+
|
|
429
|
+
The walk covers at least what ``tools.get_tools()`` imports (every module and
|
|
430
|
+
subpackage of the package, ``_``-prefixed ones too) plus the modules those
|
|
431
|
+
subpackages hold, so no module that can make calls escapes the check. A
|
|
432
|
+
subpackage's own ``__init__.py`` is read; the top-level one is the registry.
|
|
433
|
+
Cache and hidden directories are skipped.
|
|
434
|
+
"""
|
|
435
|
+
calls: list[DeclaredCall] = []
|
|
436
|
+
problems: list[str] = []
|
|
437
|
+
if not tools_dir.is_dir():
|
|
438
|
+
return calls, problems
|
|
439
|
+
for relative in _tool_module_paths(tools_dir):
|
|
440
|
+
found, found_problems = read_api_calls(tools_dir / relative, relative)
|
|
441
|
+
calls.extend(found)
|
|
442
|
+
problems.extend(found_problems)
|
|
443
|
+
return calls, problems
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _tool_module_paths(tools_dir: Path) -> list[str]:
|
|
447
|
+
"""``*.py`` paths under ``tools_dir`` relative to it, sorted, top ``__init__.py`` excluded.
|
|
448
|
+
|
|
449
|
+
Symlinked directories are followed (the import system follows them too),
|
|
450
|
+
each real directory once, so a link loop cannot hang the walk.
|
|
451
|
+
"""
|
|
452
|
+
found: list[str] = []
|
|
453
|
+
seen: set[str] = set()
|
|
454
|
+
for root, dirs, files in os.walk(tools_dir, followlinks=True):
|
|
455
|
+
real = os.path.realpath(root)
|
|
456
|
+
if real in seen:
|
|
457
|
+
dirs[:] = []
|
|
458
|
+
continue
|
|
459
|
+
seen.add(real)
|
|
460
|
+
dirs[:] = sorted(d for d in dirs if not d.startswith(".") and d != "__pycache__")
|
|
461
|
+
base = Path(root).relative_to(tools_dir)
|
|
462
|
+
for filename in files:
|
|
463
|
+
if not filename.endswith(".py"):
|
|
464
|
+
continue
|
|
465
|
+
relative = (base / filename).as_posix()
|
|
466
|
+
if relative != "__init__.py" and (Path(root) / filename).is_file():
|
|
467
|
+
found.append(relative)
|
|
468
|
+
return sorted(found)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ---------------------------------------------------------------------------
|
|
472
|
+
# Matching
|
|
473
|
+
# ---------------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _spec_operations(spec: dict[str, Any]) -> list[tuple[str, str, str | None]]:
|
|
477
|
+
"""Every operation of an OpenAPI spec as ``(path, METHOD, operationId or None)``, in order."""
|
|
478
|
+
operations: list[tuple[str, str, str | None]] = []
|
|
479
|
+
paths = spec.get("paths") or {}
|
|
480
|
+
if not isinstance(paths, dict):
|
|
481
|
+
return operations
|
|
482
|
+
http_methods = {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
|
|
483
|
+
for path, item in paths.items():
|
|
484
|
+
if not isinstance(item, dict):
|
|
485
|
+
continue
|
|
486
|
+
for method, op in item.items():
|
|
487
|
+
if not isinstance(method, str) or method.lower() not in http_methods:
|
|
488
|
+
continue
|
|
489
|
+
if not isinstance(op, dict):
|
|
490
|
+
continue
|
|
491
|
+
op_id = op.get("operationId")
|
|
492
|
+
operations.append((str(path), method.upper(), str(op_id) if op_id else None))
|
|
493
|
+
return operations
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _index_openapi(spec: dict[str, Any]) -> tuple[dict[str, tuple[str, str]], set[tuple[str, str]]]:
|
|
497
|
+
"""Map operationId -> (path, METHOD) and the set of (path, METHOD) pairs."""
|
|
498
|
+
by_id: dict[str, tuple[str, str]] = {}
|
|
499
|
+
pairs: set[tuple[str, str]] = set()
|
|
500
|
+
for path, method, op_id in _spec_operations(spec):
|
|
501
|
+
pairs.add((path, method))
|
|
502
|
+
if op_id:
|
|
503
|
+
by_id[op_id] = (path, method)
|
|
504
|
+
return by_id, pairs
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _spec_ids(call: DeclaredCall, by_id: Mapping[str, tuple[str, str]]) -> list[str]:
|
|
508
|
+
"""The operation ids the spec gives the call's method and path (none without a path)."""
|
|
509
|
+
if not call.path:
|
|
510
|
+
return []
|
|
511
|
+
return sorted(
|
|
512
|
+
op_id
|
|
513
|
+
for op_id, (spec_path, spec_method) in by_id.items()
|
|
514
|
+
if spec_method == call.method and path_matches(spec_path, call.path)
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _spec_operation_hint(call: DeclaredCall, by_id: Mapping[str, tuple[str, str]]) -> str:
|
|
519
|
+
"""For a refused call declared without an operation id: the spec's id(s) for its path.
|
|
520
|
+
|
|
521
|
+
The operation id is not filled in for the policy decision: the client
|
|
522
|
+
judges a call by the ``operation_id`` the tool passes, so a check that
|
|
523
|
+
assumed the spec's id would pass calls the client refuses.
|
|
524
|
+
"""
|
|
525
|
+
ids = [] if call.operation_id else _spec_ids(call, by_id)
|
|
526
|
+
if not ids:
|
|
527
|
+
return ""
|
|
528
|
+
return f" (the OpenAPI spec names this operation {' or '.join(ids)})"
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _spec_mismatch(
|
|
532
|
+
call: DeclaredCall,
|
|
533
|
+
by_id: Mapping[str, tuple[str, str]],
|
|
534
|
+
pairs: set[tuple[str, str]],
|
|
535
|
+
) -> str | None:
|
|
536
|
+
"""Why the API's OpenAPI spec does not define the call as declared, or None.
|
|
537
|
+
|
|
538
|
+
A declared ``operation_id`` must be one the spec defines, for the call's
|
|
539
|
+
method and path. It is a label the tool chooses, and the allow-list can
|
|
540
|
+
match on it: a typo, or a relabelled call, must not reach an endpoint
|
|
541
|
+
under a name the spec gives another operation.
|
|
542
|
+
"""
|
|
543
|
+
if call.operation_id:
|
|
544
|
+
if call.operation_id not in by_id:
|
|
545
|
+
ids = _spec_ids(call, by_id)
|
|
546
|
+
named = f"; the spec names {call.method} {call.path} {' or '.join(ids)}" if ids else ""
|
|
547
|
+
return f"operationId {call.operation_id} is not in the OpenAPI spec{named}"
|
|
548
|
+
spec_path, spec_method = by_id[call.operation_id]
|
|
549
|
+
if spec_method != call.method:
|
|
550
|
+
return (
|
|
551
|
+
f"operationId {call.operation_id} is {spec_method} {spec_path} in the spec, "
|
|
552
|
+
f"not {call.method}"
|
|
553
|
+
)
|
|
554
|
+
if call.path and not path_matches(spec_path, call.path):
|
|
555
|
+
return (
|
|
556
|
+
f"operationId {call.operation_id} is {spec_method} {spec_path} in the spec, "
|
|
557
|
+
f"not {call.path}"
|
|
558
|
+
)
|
|
559
|
+
return None
|
|
560
|
+
if call.path and any(
|
|
561
|
+
spec_method == call.method and path_matches(spec_path, call.path)
|
|
562
|
+
for spec_path, spec_method in pairs
|
|
563
|
+
):
|
|
564
|
+
return None
|
|
565
|
+
return "not found in the OpenAPI spec"
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _spec_fix_hint(call: DeclaredCall, by_id: Mapping[str, tuple[str, str]]) -> str:
|
|
569
|
+
"""What to change in the tool when its declared operation id disagrees with the spec."""
|
|
570
|
+
ids = _spec_ids(call, by_id)
|
|
571
|
+
if len(ids) == 1:
|
|
572
|
+
return (
|
|
573
|
+
f'declare the call as the OpenAPI spec does: "operation_id": "{ids[0]}" for '
|
|
574
|
+
f"{call.method} {call.path}, in {CALLS_NAME} and on the call"
|
|
575
|
+
)
|
|
576
|
+
return (
|
|
577
|
+
f"declare an operation_id and path the OpenAPI spec defines, in {CALLS_NAME} and on "
|
|
578
|
+
"the call"
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def check_call(
|
|
583
|
+
call: DeclaredCall,
|
|
584
|
+
document: Mapping[str, Any] | None,
|
|
585
|
+
specs: Mapping[str, dict[str, Any]] | None = None,
|
|
586
|
+
*,
|
|
587
|
+
policy_file: str = POLICY_FILENAME,
|
|
588
|
+
) -> CheckResult:
|
|
589
|
+
"""Evaluate one declared call against the policy and (optionally) the API's spec.
|
|
590
|
+
|
|
591
|
+
A call the policy refuses is ``denied`` (with the ``graph-agents-cli api``
|
|
592
|
+
command that would allow it). With a spec, a call it does not define as
|
|
593
|
+
declared is ``unknown`` (a declared ``operation_id`` must be the spec's
|
|
594
|
+
one for that method and path); a call that is both says so, and its hint
|
|
595
|
+
fixes the declaration first. A call the policy allows carries the
|
|
596
|
+
approval its API requires before sending it (``gate``), if any.
|
|
597
|
+
"""
|
|
598
|
+
if document is None:
|
|
599
|
+
return CheckResult(
|
|
600
|
+
call,
|
|
601
|
+
STATUS_DENIED,
|
|
602
|
+
f"no {policy_file}: outbound API calls are refused (fail closed)",
|
|
603
|
+
add_hint(call.api),
|
|
604
|
+
)
|
|
605
|
+
api = document["apis"].get(call.api)
|
|
606
|
+
if api is None:
|
|
607
|
+
declared = ", ".join(sorted(document["apis"])) or "none"
|
|
608
|
+
return CheckResult(
|
|
609
|
+
call,
|
|
610
|
+
STATUS_DENIED,
|
|
611
|
+
f"API {call.api!r} is not declared in {policy_file} (declared: {declared})",
|
|
612
|
+
add_hint(call.api),
|
|
613
|
+
)
|
|
614
|
+
rpc_refusal = _rpc_refusal(call, api)
|
|
615
|
+
if rpc_refusal is not None:
|
|
616
|
+
return rpc_refusal
|
|
617
|
+
rpc = _declared_rpc(call, api)
|
|
618
|
+
spec = (specs or {}).get(call.api)
|
|
619
|
+
by_id, pairs = _index_openapi(spec) if spec is not None else ({}, set())
|
|
620
|
+
path = call.path
|
|
621
|
+
if path is None and call.operation_id in by_id and by_id[call.operation_id][1] == call.method:
|
|
622
|
+
# The client always sends a path: judge the one the spec gives the operation,
|
|
623
|
+
# so path denials apply to a call declared by operation_id alone.
|
|
624
|
+
path = by_id[call.operation_id][0]
|
|
625
|
+
reason = refusal_reason(api, call.method, call.operation_id, path, **rpc)
|
|
626
|
+
mismatch = _spec_mismatch(call, by_id, pairs) if spec is not None else None
|
|
627
|
+
# A declared operation_id the spec does not give this call: fix the declaration first.
|
|
628
|
+
id_mismatch = mismatch is not None and call.operation_id is not None
|
|
629
|
+
if reason:
|
|
630
|
+
return CheckResult(
|
|
631
|
+
call,
|
|
632
|
+
STATUS_DENIED,
|
|
633
|
+
reason
|
|
634
|
+
+ _spec_operation_hint(call, by_id)
|
|
635
|
+
+ (f"; also, {mismatch}" if id_mismatch else ""),
|
|
636
|
+
_spec_fix_hint(call, by_id) if id_mismatch else refusal_hint(call, api, path),
|
|
637
|
+
)
|
|
638
|
+
# Only now, with the call allowed: approval never widens access.
|
|
639
|
+
try:
|
|
640
|
+
gate = gated(api, call.method, call.operation_id, path, **rpc)
|
|
641
|
+
except ApprovalRuleConflict as exc:
|
|
642
|
+
# The runtime refuses it too: it could be either rule's call (fail closed).
|
|
643
|
+
return CheckResult(call, STATUS_DENIED, str(exc), conflict_hint(call, exc))
|
|
644
|
+
if rpc["a2a_operation"] == A2A_APPROVE and gate is None:
|
|
645
|
+
# The validator refuses such a policy, and the client such a message.
|
|
646
|
+
return CheckResult(
|
|
647
|
+
call,
|
|
648
|
+
STATUS_DENIED,
|
|
649
|
+
"a message that approves must wait for an approval or be denied",
|
|
650
|
+
f"{API_COMMAND} approval {call.api} --a2a-operations approve --approvers requester",
|
|
651
|
+
)
|
|
652
|
+
if mismatch is not None:
|
|
653
|
+
return CheckResult(
|
|
654
|
+
call,
|
|
655
|
+
STATUS_UNKNOWN,
|
|
656
|
+
mismatch,
|
|
657
|
+
_spec_fix_hint(call, by_id) if id_mismatch else "",
|
|
658
|
+
gate=gate,
|
|
659
|
+
)
|
|
660
|
+
if spec is not None:
|
|
661
|
+
if call.operation_id:
|
|
662
|
+
spec_path, spec_method = by_id[call.operation_id]
|
|
663
|
+
return CheckResult(call, STATUS_ALLOWED, f"spec: {spec_method} {spec_path}", gate=gate)
|
|
664
|
+
spec_path = next(
|
|
665
|
+
p for p, m in sorted(pairs) if m == call.method and path_matches(p, str(call.path))
|
|
666
|
+
)
|
|
667
|
+
return CheckResult(call, STATUS_ALLOWED, f"spec: {call.method} {spec_path}", gate=gate)
|
|
668
|
+
return CheckResult(call, STATUS_ALLOWED, "", gate=gate)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _declared_rpc(call: DeclaredCall, api: Mapping[str, Any]) -> dict[str, str | None]:
|
|
672
|
+
"""The declared JSON-RPC method (an A2A 0.3 name read as its 1.0 name) and decision."""
|
|
673
|
+
method = call.rpc_method
|
|
674
|
+
if method is not None:
|
|
675
|
+
method = canonical_rpc_method(api_protocol(api), method)
|
|
676
|
+
return {"rpc_method": method, "a2a_operation": call.a2a_operation}
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _rpc_refusal(call: DeclaredCall, api: Mapping[str, Any]) -> CheckResult | None:
|
|
680
|
+
"""A declared call whose JSON-RPC keys do not fit its API's `protocol`, or None.
|
|
681
|
+
|
|
682
|
+
`rpc_method` and `a2a_operation` go with JSON-RPC APIs only; every POST to one
|
|
683
|
+
declares its `rpc_method` (the client reads it from the body, so lint must know it);
|
|
684
|
+
`a2a_operation` goes with an A2A message; and an `operation_id` naming an entry for
|
|
685
|
+
another request is refused, as the client refuses it (`label_problem`).
|
|
686
|
+
"""
|
|
687
|
+
protocol = api_protocol(api)
|
|
688
|
+
rpc = _declared_rpc(call, api)
|
|
689
|
+
fix = f"in the call's {CALLS_NAME} entry"
|
|
690
|
+
if protocol not in RPC_PROTOCOLS:
|
|
691
|
+
if call.rpc_method is not None or call.a2a_operation is not None:
|
|
692
|
+
return CheckResult(
|
|
693
|
+
call,
|
|
694
|
+
STATUS_DENIED,
|
|
695
|
+
f"rpc_method and a2a_operation are for JSON-RPC APIs; {call.api} is protocol "
|
|
696
|
+
f"{protocol}",
|
|
697
|
+
f"remove rpc_method and a2a_operation {fix}, or set the API's protocol",
|
|
698
|
+
)
|
|
699
|
+
return None
|
|
700
|
+
if call.method == "POST" and call.rpc_method is None:
|
|
701
|
+
return CheckResult(
|
|
702
|
+
call,
|
|
703
|
+
STATUS_DENIED,
|
|
704
|
+
f"a POST to a protocol {protocol} API sends a JSON-RPC request: declare its "
|
|
705
|
+
"rpc_method (the client reads it from the body and judges the call by it)",
|
|
706
|
+
f'add "rpc_method": "<the JSON-RPC method>" {fix}',
|
|
707
|
+
)
|
|
708
|
+
if call.a2a_operation is not None and (
|
|
709
|
+
protocol != PROTOCOL_A2A or rpc["rpc_method"] not in A2A_MESSAGE_METHODS
|
|
710
|
+
):
|
|
711
|
+
return CheckResult(
|
|
712
|
+
call,
|
|
713
|
+
STATUS_DENIED,
|
|
714
|
+
"a2a_operation is for an A2A message (protocol a2a, rpc_method SendMessage or "
|
|
715
|
+
"SendStreamingMessage)",
|
|
716
|
+
f"remove a2a_operation {fix}",
|
|
717
|
+
)
|
|
718
|
+
mislabelled = label_problem(api, call.operation_id, rpc["rpc_method"], rpc["a2a_operation"])
|
|
719
|
+
if mislabelled:
|
|
720
|
+
return CheckResult(
|
|
721
|
+
call,
|
|
722
|
+
STATUS_DENIED,
|
|
723
|
+
mislabelled,
|
|
724
|
+
f"name the operation_id of the entry for this request {fix}, or none",
|
|
725
|
+
)
|
|
726
|
+
return None
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
# ---------------------------------------------------------------------------
|
|
730
|
+
# Hints: the `graph-agents-cli api` command that would allow a refused call
|
|
731
|
+
# ---------------------------------------------------------------------------
|
|
732
|
+
|
|
733
|
+
API_COMMAND = "graph-agents-cli api"
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def add_hint(api_name: str) -> str:
|
|
737
|
+
"""How to declare an API the policy does not know (every choice is explicit)."""
|
|
738
|
+
return (
|
|
739
|
+
f"{API_COMMAND} add {api_name} --base-url-env <NAME>_API_BASE_URL "
|
|
740
|
+
"--auth <none|bearer|forward> --access <read-only|read-write|custom>"
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _allow_args(call: DeclaredCall) -> str:
|
|
745
|
+
"""``api allow`` arguments for an entry that covers exactly the declared call.
|
|
746
|
+
|
|
747
|
+
The method is always pinned, and the path whenever the call names one, so
|
|
748
|
+
the entry never allows the label on another method or path. A JSON-RPC call
|
|
749
|
+
pins its ``rpc_method`` too.
|
|
750
|
+
"""
|
|
751
|
+
if call.rpc_method and call.path:
|
|
752
|
+
label = f"{call.operation_id} " if call.operation_id else ""
|
|
753
|
+
return f"{label}--method {call.method} --path {call.path} --rpc-method {call.rpc_method}"
|
|
754
|
+
if call.operation_id and call.path:
|
|
755
|
+
return f"{call.operation_id} --method {call.method} --path {call.path}"
|
|
756
|
+
if call.operation_id:
|
|
757
|
+
return f"{call.operation_id} --methods {call.method}"
|
|
758
|
+
return f"--method {call.method} --path {call.path}"
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _allowed_methods(api: Mapping[str, Any]) -> list[str]:
|
|
762
|
+
methods = [str(m).upper() for m in api.get("allowed_methods") or []]
|
|
763
|
+
return list(HTTP_METHODS) if ANY_METHOD in methods else list(dict.fromkeys(methods))
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def conflict_hint(call: DeclaredCall, conflict: ApprovalRuleConflict) -> str:
|
|
767
|
+
"""What makes a call that two approval rules with other approvers may cover gateable."""
|
|
768
|
+
rule = f"approval[{conflict.index}]"
|
|
769
|
+
if conflict.unnamed == "path":
|
|
770
|
+
return (
|
|
771
|
+
f'name the path: add "path" to the call\'s {CALLS_NAME} entry ({rule} knows the '
|
|
772
|
+
"operation by its path; the client always sends one)"
|
|
773
|
+
)
|
|
774
|
+
return (
|
|
775
|
+
f"name the operation: add operation_id to the call and to {CALLS_NAME}, or pin path "
|
|
776
|
+
f"and methods in {rule} ({API_COMMAND} approval {call.api} --rule {conflict.index} "
|
|
777
|
+
"--operations ... pins them from the API's openapi: spec)"
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def refusal_hint(call: DeclaredCall, api: Mapping[str, Any], path: str | None) -> str:
|
|
782
|
+
"""What would make ``api`` accept ``call``: the exact ``graph-agents-cli api`` commands.
|
|
783
|
+
|
|
784
|
+
Each is a reviewed change to api-policy.yaml (CODEOWNERS covers it), and
|
|
785
|
+
together they are every change the call needs: the method, the denial and
|
|
786
|
+
the allow-list (an entry pinning the call's method, and its path when it
|
|
787
|
+
names one). A call refused only because it leaves out what a denial knows
|
|
788
|
+
the operation by is fixed in the tool instead.
|
|
789
|
+
"""
|
|
790
|
+
steps: list[str] = []
|
|
791
|
+
allowed = _allowed_methods(api)
|
|
792
|
+
if call.method not in allowed:
|
|
793
|
+
methods = ",".join(m for m in HTTP_METHODS if m in {*allowed, call.method})
|
|
794
|
+
steps.append(f"{API_COMMAND} access {call.api} custom --methods {methods}")
|
|
795
|
+
rpc = _declared_rpc(call, api)
|
|
796
|
+
for entry in api.get("denied_operations") or []:
|
|
797
|
+
unnamed = denial_match(entry, call.method, call.operation_id, path, **rpc)
|
|
798
|
+
if unnamed == "operation_id":
|
|
799
|
+
return (
|
|
800
|
+
f"name the operation: add operation_id to the call and to {CALLS_NAME} "
|
|
801
|
+
"(a denial by operationId alone refuses calls that name none)"
|
|
802
|
+
)
|
|
803
|
+
if unnamed == "path":
|
|
804
|
+
return (
|
|
805
|
+
f'name the path: add "path" to the call\'s {CALLS_NAME} entry (a denial by path '
|
|
806
|
+
"refuses declared calls that name none; the client always sends one)"
|
|
807
|
+
)
|
|
808
|
+
if unnamed is not None:
|
|
809
|
+
if entry.get("rpc_method") is not None or entry.get("a2a_operation") is not None:
|
|
810
|
+
revoke = " ".join(
|
|
811
|
+
f"--{key.replace('_', '-')} {entry[key]}"
|
|
812
|
+
for key in ("rpc_method", "a2a_operation")
|
|
813
|
+
if entry.get(key) is not None
|
|
814
|
+
)
|
|
815
|
+
elif entry.get("operationId") is None and entry.get("path") is not None:
|
|
816
|
+
revoke = f"--method {call.method} --path {entry['path']}"
|
|
817
|
+
else:
|
|
818
|
+
revoke = str(entry.get("operationId"))
|
|
819
|
+
step = (
|
|
820
|
+
f"{API_COMMAND} revoke {call.api} {revoke} --from denied (lifts a deliberate "
|
|
821
|
+
"denial: make sure it should go)"
|
|
822
|
+
)
|
|
823
|
+
if step not in steps:
|
|
824
|
+
steps.append(step)
|
|
825
|
+
allowed_operations = api.get("allowed_operations")
|
|
826
|
+
if allowed_operations is not None and not any(
|
|
827
|
+
operation_matches(entry, call.method, call.operation_id, path, **rpc)
|
|
828
|
+
for entry in allowed_operations
|
|
829
|
+
):
|
|
830
|
+
if call.operation_id or call.path:
|
|
831
|
+
steps.append(f"{API_COMMAND} allow {call.api} {_allow_args(call)}")
|
|
832
|
+
return "; then ".join(steps)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
# ---------------------------------------------------------------------------
|
|
836
|
+
# The example tool's call
|
|
837
|
+
# ---------------------------------------------------------------------------
|
|
838
|
+
|
|
839
|
+
EXAMPLE_TOOL = "example_api.py"
|
|
840
|
+
# The last resort when the policy leaves every operation open: one per method,
|
|
841
|
+
# tried in the order of the API's allowed_methods.
|
|
842
|
+
DEFAULT_EXAMPLE_OPERATIONS = {
|
|
843
|
+
"GET": ("getItem", "/items/{item_id}"),
|
|
844
|
+
"HEAD": ("checkItem", "/items/{item_id}"),
|
|
845
|
+
"POST": ("createItem", "/items"),
|
|
846
|
+
"PUT": ("replaceItem", "/items/{item_id}"),
|
|
847
|
+
"PATCH": ("updateItem", "/items/{item_id}"),
|
|
848
|
+
"DELETE": ("deleteItem", "/items/{item_id}"),
|
|
849
|
+
"OPTIONS": ("describeItems", "/items"),
|
|
850
|
+
}
|
|
851
|
+
# The example is rendered into Python source: operation ids and paths outside
|
|
852
|
+
# these character sets are skipped rather than escaped.
|
|
853
|
+
_EXAMPLE_OPERATION_ID_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]{0,99}$")
|
|
854
|
+
_EXAMPLE_PATH_RE = re.compile(r"^/[A-Za-z0-9_.~{}/-]{0,199}$")
|
|
855
|
+
# Each path placeholder becomes a parameter of the example tool, so it must not
|
|
856
|
+
# shadow a name the tool's body uses, or one LangChain or pydantic treat specially.
|
|
857
|
+
_EXAMPLE_RESERVED_PARAMS = frozenset(
|
|
858
|
+
{
|
|
859
|
+
"Any",
|
|
860
|
+
"ToolRuntime",
|
|
861
|
+
"body",
|
|
862
|
+
"callbacks",
|
|
863
|
+
"client",
|
|
864
|
+
"config",
|
|
865
|
+
"construct",
|
|
866
|
+
"context",
|
|
867
|
+
"copy",
|
|
868
|
+
"data",
|
|
869
|
+
"dict",
|
|
870
|
+
"fields",
|
|
871
|
+
"get_client",
|
|
872
|
+
"getattr",
|
|
873
|
+
"isinstance",
|
|
874
|
+
"json",
|
|
875
|
+
"run_manager",
|
|
876
|
+
"runtime",
|
|
877
|
+
"schema",
|
|
878
|
+
"str",
|
|
879
|
+
"tool",
|
|
880
|
+
"validate",
|
|
881
|
+
}
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _example_param_ok(name: str) -> bool:
|
|
886
|
+
return (
|
|
887
|
+
name.isidentifier()
|
|
888
|
+
and not keyword.iskeyword(name)
|
|
889
|
+
and not name.startswith(("_", "model_"))
|
|
890
|
+
and name not in _EXAMPLE_RESERVED_PARAMS
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _example_renderable(operation_id: str | None, path: str) -> bool:
|
|
895
|
+
if operation_id is not None and not _EXAMPLE_OPERATION_ID_RE.match(operation_id):
|
|
896
|
+
return False
|
|
897
|
+
if not _EXAMPLE_PATH_RE.match(path) or path_template_problem(path) is not None:
|
|
898
|
+
return False
|
|
899
|
+
params = ExampleCall(api="x", method="GET", path=path).params
|
|
900
|
+
return all(_example_param_ok(name) for name in params)
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _load_example_spec(api: Mapping[str, Any], base_dir: Path | None) -> dict[str, Any] | None:
|
|
904
|
+
reference = api.get("openapi")
|
|
905
|
+
path = Path(str(reference))
|
|
906
|
+
if not path.is_absolute():
|
|
907
|
+
if base_dir is None:
|
|
908
|
+
return None
|
|
909
|
+
path = base_dir / path
|
|
910
|
+
try:
|
|
911
|
+
return load_openapi(path)
|
|
912
|
+
except (OSError, ValueError, yaml.YAMLError):
|
|
913
|
+
return None
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _example_candidates(
|
|
917
|
+
api: Mapping[str, Any], spec: dict[str, Any] | None
|
|
918
|
+
) -> list[tuple[str, str | None, str]]:
|
|
919
|
+
"""``(METHOD, operation_id, path)`` candidates for the example, best first.
|
|
920
|
+
|
|
921
|
+
Every method counts: the policy's own order decides (its entries first,
|
|
922
|
+
each with its pinned methods or else ``allowed_methods`` in the order the
|
|
923
|
+
file lists them).
|
|
924
|
+
"""
|
|
925
|
+
operations = _spec_operations(spec) if spec is not None else []
|
|
926
|
+
allowed = _allowed_methods(api)
|
|
927
|
+
candidates: list[tuple[str, str | None, str]] = []
|
|
928
|
+
for entry in api.get("allowed_operations") or []:
|
|
929
|
+
pinned = [str(m).upper() for m in entry.get("methods") or []]
|
|
930
|
+
for method in [m for m in (pinned or allowed) if m in allowed]:
|
|
931
|
+
operation_id = entry.get("operationId")
|
|
932
|
+
path = entry.get("path")
|
|
933
|
+
if path is None:
|
|
934
|
+
# An entry by operationId alone: only the spec knows its path.
|
|
935
|
+
path = next(
|
|
936
|
+
(p for p, m, op_id in operations if op_id == operation_id and m == method),
|
|
937
|
+
None,
|
|
938
|
+
)
|
|
939
|
+
elif operation_id is None:
|
|
940
|
+
# Name the operation when the spec does, so denials by operationId pass.
|
|
941
|
+
operation_id = next(
|
|
942
|
+
(
|
|
943
|
+
op_id
|
|
944
|
+
for p, m, op_id in operations
|
|
945
|
+
if op_id and m == method and path_matches(p, path)
|
|
946
|
+
),
|
|
947
|
+
None,
|
|
948
|
+
)
|
|
949
|
+
if path is not None:
|
|
950
|
+
candidates.append((method, operation_id, path))
|
|
951
|
+
candidates.extend((m, op_id, p) for p, m, op_id in operations if m in allowed)
|
|
952
|
+
if spec is not None or not api.get("openapi"):
|
|
953
|
+
# When the API names a spec that cannot be read here, lint would judge the
|
|
954
|
+
# default against a spec this check never saw, so it is not offered.
|
|
955
|
+
for method in allowed:
|
|
956
|
+
operation_id, path = DEFAULT_EXAMPLE_OPERATIONS[method]
|
|
957
|
+
candidates.append((method, operation_id, path))
|
|
958
|
+
return list(dict.fromkeys(candidates))
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def example_call(
|
|
962
|
+
document: Mapping[str, Any], *, base_dir: Path | None = None
|
|
963
|
+
) -> ExampleCall | None:
|
|
964
|
+
"""The call the example tool makes on the policy's first API; None when it allows none.
|
|
965
|
+
|
|
966
|
+
The first operation that API allows, whatever its method. Candidates, in
|
|
967
|
+
order: each ``allowed_operations`` entry (with its pinned methods, else
|
|
968
|
+
each of ``allowed_methods``; a missing path or operationId taken from the
|
|
969
|
+
API's OpenAPI spec), each operation of that spec, then one generic
|
|
970
|
+
operation per allowed method (``GET getItem /items/{item_id}``,
|
|
971
|
+
``POST createItem /items``, ...). The first one this check accepts wins
|
|
972
|
+
(``check_call``: the runtime client's rules plus the spec), so the rendered
|
|
973
|
+
example passes ``lint`` and the project's policy test. ``base_dir``
|
|
974
|
+
resolves a relative ``openapi:`` path (the directory holding the policy
|
|
975
|
+
file).
|
|
976
|
+
"""
|
|
977
|
+
apis = document.get("apis") or {}
|
|
978
|
+
if not apis:
|
|
979
|
+
return None
|
|
980
|
+
name, api = next(iter(apis.items()))
|
|
981
|
+
spec = _load_example_spec(api, base_dir) if api.get("openapi") else None
|
|
982
|
+
specs = {name: spec} if spec is not None else {}
|
|
983
|
+
for method, operation_id, path in _example_candidates(api, spec):
|
|
984
|
+
if not _example_renderable(operation_id, path):
|
|
985
|
+
continue
|
|
986
|
+
call = DeclaredCall(
|
|
987
|
+
tool=EXAMPLE_TOOL, api=name, method=method, operation_id=operation_id, path=path
|
|
988
|
+
)
|
|
989
|
+
if check_call(call, document, specs).status == STATUS_ALLOWED:
|
|
990
|
+
return ExampleCall(api=name, method=method, path=path, operation_id=operation_id)
|
|
991
|
+
return None
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
# ---------------------------------------------------------------------------
|
|
995
|
+
# Driver
|
|
996
|
+
# ---------------------------------------------------------------------------
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def build_report(
|
|
1000
|
+
project_root: Path,
|
|
1001
|
+
agent_dir: str,
|
|
1002
|
+
*,
|
|
1003
|
+
policy_file: str = POLICY_FILENAME,
|
|
1004
|
+
runtime: str = "fastapi",
|
|
1005
|
+
policy_declared: bool = False,
|
|
1006
|
+
auth_policy: str | None = None,
|
|
1007
|
+
) -> PolicyReport:
|
|
1008
|
+
"""Run the check for a project and return the report (nothing printed).
|
|
1009
|
+
|
|
1010
|
+
``policy_declared`` is True when the manifest names the policy file: a
|
|
1011
|
+
missing file is then an error rather than a note. ``auth_policy`` (the
|
|
1012
|
+
project's, from the manifest) checks the APIs that act with the caller's
|
|
1013
|
+
identity against it (``auth_policy_findings``); None skips that check.
|
|
1014
|
+
"""
|
|
1015
|
+
report = PolicyReport()
|
|
1016
|
+
document: dict[str, Any] | None = None
|
|
1017
|
+
specs: dict[str, dict[str, Any]] = {}
|
|
1018
|
+
|
|
1019
|
+
policy_path = project_root / policy_file
|
|
1020
|
+
if policy_path.is_file():
|
|
1021
|
+
report.policy_path = policy_path
|
|
1022
|
+
try:
|
|
1023
|
+
document = load_policy_document(policy_path)
|
|
1024
|
+
except ApiPolicyFileError as exc:
|
|
1025
|
+
for error in exc.errors:
|
|
1026
|
+
report.invalid_policy(policy_file, error)
|
|
1027
|
+
return report
|
|
1028
|
+
problem = forward_runtime_problem(summarize(document), runtime)
|
|
1029
|
+
if problem:
|
|
1030
|
+
report.invalid_policy(policy_file, problem)
|
|
1031
|
+
if auth_policy is not None:
|
|
1032
|
+
errors, notes = auth_policy_findings(document, auth_policy)
|
|
1033
|
+
for error in errors:
|
|
1034
|
+
report.invalid_policy(policy_file, error)
|
|
1035
|
+
report.notes.extend(notes)
|
|
1036
|
+
report.notes.extend(peer_notes(document))
|
|
1037
|
+
for name, api in document["apis"].items():
|
|
1038
|
+
report.notes.extend(approval_notes(name, api))
|
|
1039
|
+
openapi_ref = api.get("openapi")
|
|
1040
|
+
if not openapi_ref:
|
|
1041
|
+
continue
|
|
1042
|
+
openapi_path = Path(openapi_ref)
|
|
1043
|
+
if not openapi_path.is_absolute():
|
|
1044
|
+
openapi_path = project_root / openapi_path
|
|
1045
|
+
report.openapi_paths[name] = openapi_path
|
|
1046
|
+
try:
|
|
1047
|
+
specs[name] = load_openapi(openapi_path)
|
|
1048
|
+
except (OSError, ValueError, yaml.YAMLError) as exc:
|
|
1049
|
+
report.invalid_policy(
|
|
1050
|
+
policy_file, f"apis.{name}.openapi: cannot load {openapi_ref}: {exc}"
|
|
1051
|
+
)
|
|
1052
|
+
elif policy_declared:
|
|
1053
|
+
report.invalid_policy(
|
|
1054
|
+
policy_file,
|
|
1055
|
+
f"the manifest declares {policy_file} but the file does not exist; every API call "
|
|
1056
|
+
"would be refused at runtime",
|
|
1057
|
+
)
|
|
1058
|
+
else:
|
|
1059
|
+
report.notes.append(f"no {policy_file} at the project root: outbound API calls are refused")
|
|
1060
|
+
|
|
1061
|
+
report.notes.extend(delegated_mentions_notes(project_root))
|
|
1062
|
+
if not report.policy_invalid:
|
|
1063
|
+
drift = peers_module_problem(project_root, agent_dir, document)
|
|
1064
|
+
if drift:
|
|
1065
|
+
report.invalid(drift.split(":", 1)[0], drift)
|
|
1066
|
+
tools_dir = project_root / agent_dir / TOOLS_SUBDIR
|
|
1067
|
+
calls, problems = collect_declared_calls(tools_dir)
|
|
1068
|
+
for problem in problems:
|
|
1069
|
+
report.invalid(problem.split(":", 1)[0], problem)
|
|
1070
|
+
report.notes.extend(direct_peer_call_notes(calls, document))
|
|
1071
|
+
for call in calls:
|
|
1072
|
+
report.results.append(check_call(call, document, specs, policy_file=policy_file))
|
|
1073
|
+
if not calls and not problems:
|
|
1074
|
+
report.notes.append(f"no {CALLS_NAME} declarations under {agent_dir}/{TOOLS_SUBDIR}/")
|
|
1075
|
+
return report
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def peers_module_problem(
|
|
1079
|
+
project_root: Path, agent_dir: str, document: Mapping[str, Any] | None
|
|
1080
|
+
) -> str | None:
|
|
1081
|
+
"""Why the generated `tools/a2a_peers.py` is out of step with the policy, or None.
|
|
1082
|
+
|
|
1083
|
+
Only the module `graph-agents-cli peer` wrote (its marker line) is compared,
|
|
1084
|
+
with the text the policy generates now: a module of your own is yours.
|
|
1085
|
+
"""
|
|
1086
|
+
from graph_agents_cli.peer import _generate as generated
|
|
1087
|
+
|
|
1088
|
+
path = generated.module_path(project_root, agent_dir)
|
|
1089
|
+
try:
|
|
1090
|
+
text = path.read_text(encoding="utf-8")
|
|
1091
|
+
except OSError:
|
|
1092
|
+
return None
|
|
1093
|
+
if not generated.is_generated(text):
|
|
1094
|
+
return None
|
|
1095
|
+
peers = generated.peers_of(document, generated.names_in(text))
|
|
1096
|
+
expected = generated.module_text(peers, agent_dir)
|
|
1097
|
+
if text == expected:
|
|
1098
|
+
return None
|
|
1099
|
+
rel = path.relative_to(project_root).as_posix()
|
|
1100
|
+
return (
|
|
1101
|
+
f"{rel}: out of sync with {POLICY_FILENAME} (its peers, calls or descriptions differ); "
|
|
1102
|
+
"fix: graph-agents-cli peer sync"
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def direct_peer_call_notes(
|
|
1107
|
+
calls: list[DeclaredCall], document: Mapping[str, Any] | None
|
|
1108
|
+
) -> list[str]:
|
|
1109
|
+
"""A note for each tool module (other than the generated one) that calls an A2A peer."""
|
|
1110
|
+
from graph_agents_cli.peer._generate import MODULE_NAME
|
|
1111
|
+
|
|
1112
|
+
apis = (document or {}).get("apis") or {}
|
|
1113
|
+
modules = sorted(
|
|
1114
|
+
{
|
|
1115
|
+
call.tool
|
|
1116
|
+
for call in calls
|
|
1117
|
+
if call.api in apis
|
|
1118
|
+
and api_protocol(apis[call.api]) == PROTOCOL_A2A
|
|
1119
|
+
and Path(call.tool).name != MODULE_NAME
|
|
1120
|
+
}
|
|
1121
|
+
)
|
|
1122
|
+
return [
|
|
1123
|
+
f"{module} calls an A2A peer (protocol: a2a) directly: prefer app_utils.a2a_client "
|
|
1124
|
+
"(A2APeerClient, peer_tools), which checks the peer's card, keys the conversation and "
|
|
1125
|
+
"relays approvals"
|
|
1126
|
+
for module in modules
|
|
1127
|
+
]
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def peer_notes(document: Mapping[str, Any]) -> list[str]:
|
|
1131
|
+
"""Warnings about the policy's A2A peers (`protocol: a2a` APIs).
|
|
1132
|
+
|
|
1133
|
+
A peer without a `description`: the model picks an agent by what it is told the
|
|
1134
|
+
agent does. More than `MAX_PEERS` peers: the roster of agents the model reads grows
|
|
1135
|
+
long (about 1.5k tokens at 20 peers of 300 characters).
|
|
1136
|
+
"""
|
|
1137
|
+
peers = [
|
|
1138
|
+
str(name)
|
|
1139
|
+
for name, api in (document.get("apis") or {}).items()
|
|
1140
|
+
if api_protocol(api) == PROTOCOL_A2A
|
|
1141
|
+
]
|
|
1142
|
+
notes = [
|
|
1143
|
+
f"warning: A2A peer {name} has no {DESCRIPTION_KEY}: the model chooses an agent by "
|
|
1144
|
+
f"what it does (set apis.{name}.{DESCRIPTION_KEY}, 1-300 characters)"
|
|
1145
|
+
for name in peers
|
|
1146
|
+
if not document["apis"][name].get(DESCRIPTION_KEY)
|
|
1147
|
+
]
|
|
1148
|
+
if len(peers) > MAX_PEERS:
|
|
1149
|
+
notes.append(
|
|
1150
|
+
f"warning: {len(peers)} A2A peers (more than {MAX_PEERS}): the roster of agents the "
|
|
1151
|
+
"model reads grows long; split the work across fewer agents"
|
|
1152
|
+
)
|
|
1153
|
+
return notes
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
# `A2A_DELEGATED_MENTIONS=request` turns off the template's check of a record id against the
|
|
1157
|
+
# user's own words when another agent asks for the user (an explicit opt-out: lint says so).
|
|
1158
|
+
MENTIONS_SETTING = "A2A_DELEGATED_MENTIONS"
|
|
1159
|
+
MENTIONS_OPT_OUT = "request"
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _settings_files(project_root: Path) -> list[tuple[str, dict[str, Any]]]:
|
|
1163
|
+
"""The project's `.env` and chart values `env:` maps, by their path from the root."""
|
|
1164
|
+
found: list[tuple[str, dict[str, Any]]] = []
|
|
1165
|
+
env_file = project_root / ".env"
|
|
1166
|
+
if env_file.is_file():
|
|
1167
|
+
from dotenv import dotenv_values
|
|
1168
|
+
|
|
1169
|
+
try:
|
|
1170
|
+
found.append((".env", dict(dotenv_values(env_file))))
|
|
1171
|
+
except Exception: # an unreadable file: the app reports it when it loads it
|
|
1172
|
+
pass
|
|
1173
|
+
for values in sorted(project_root.glob("deployment/helm/*/values*.yaml")):
|
|
1174
|
+
try:
|
|
1175
|
+
data = yaml.safe_load(values.read_text(encoding="utf-8"))
|
|
1176
|
+
except (OSError, yaml.YAMLError):
|
|
1177
|
+
continue
|
|
1178
|
+
env = data.get("env") if isinstance(data, Mapping) else None
|
|
1179
|
+
if isinstance(env, Mapping):
|
|
1180
|
+
found.append((values.relative_to(project_root).as_posix(), dict(env)))
|
|
1181
|
+
return found
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def delegated_mentions_notes(project_root: Path) -> list[str]:
|
|
1185
|
+
"""A note for each settings file that sets `A2A_DELEGATED_MENTIONS=request`."""
|
|
1186
|
+
return [
|
|
1187
|
+
f"{where} sets {MENTIONS_SETTING}={MENTIONS_OPT_OUT}: when another agent asks for a user, "
|
|
1188
|
+
"require_user_mentioned counts that agent's request as the user's words (the 0.2 "
|
|
1189
|
+
"behaviour), so an instruction planted in data it read can name the record a write "
|
|
1190
|
+
"tool acts on. Keep the default (origin) unless every calling agent is trusted"
|
|
1191
|
+
for where, settings in _settings_files(project_root)
|
|
1192
|
+
if str(settings.get(MENTIONS_SETTING) or "").strip().lower() == MENTIONS_OPT_OUT
|
|
1193
|
+
]
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def print_report(report: PolicyReport, console: Console | None = None) -> None:
|
|
1197
|
+
console = console or Console()
|
|
1198
|
+
for note in report.notes:
|
|
1199
|
+
console.print(f"[dim]policy check: {escape(note)}[/]")
|
|
1200
|
+
if not report.results:
|
|
1201
|
+
console.print("[green]API policy check: nothing to check.[/]")
|
|
1202
|
+
return
|
|
1203
|
+
table = Table(title="API policy check", show_lines=False)
|
|
1204
|
+
# The Approval column (who approves a gated call) only when some call is gated.
|
|
1205
|
+
headers = ["Tool", "API", "Method", "Operation", "Status", "Reason"]
|
|
1206
|
+
if report.gated:
|
|
1207
|
+
headers.append("Approval")
|
|
1208
|
+
# fold: a narrow terminal wraps a long tool or path onto more lines, never cuts it.
|
|
1209
|
+
for header in headers:
|
|
1210
|
+
table.add_column(header, overflow="fold")
|
|
1211
|
+
styles = {
|
|
1212
|
+
STATUS_ALLOWED: "green",
|
|
1213
|
+
STATUS_DENIED: "red",
|
|
1214
|
+
STATUS_UNKNOWN: "yellow",
|
|
1215
|
+
STATUS_INVALID: "red",
|
|
1216
|
+
}
|
|
1217
|
+
for result in report.results:
|
|
1218
|
+
style = styles.get(result.status, "")
|
|
1219
|
+
row = [
|
|
1220
|
+
escape(result.call.tool),
|
|
1221
|
+
escape(result.call.api or "-"),
|
|
1222
|
+
escape(result.call.method),
|
|
1223
|
+
escape(result.call.operation),
|
|
1224
|
+
f"[{style}]{result.status}[/]" if style else result.status,
|
|
1225
|
+
escape(result.reason),
|
|
1226
|
+
]
|
|
1227
|
+
if report.gated:
|
|
1228
|
+
row.append(escape(describe_gate(result.gate)) if result.gate else "-")
|
|
1229
|
+
table.add_row(*row)
|
|
1230
|
+
print_table(console, table)
|
|
1231
|
+
hints = list(dict.fromkeys(r.hint for r in report.results if r.is_violation and r.hint))
|
|
1232
|
+
if hints:
|
|
1233
|
+
console.print(
|
|
1234
|
+
"To fix a refused call, change the tool as shown, or change api-policy.yaml in a "
|
|
1235
|
+
"reviewed pull request (CODEOWNERS covers it), for example:"
|
|
1236
|
+
)
|
|
1237
|
+
for hint in hints:
|
|
1238
|
+
console.print(f" {escape(hint)}", style="cyan", highlight=False)
|
|
1239
|
+
if report.gated:
|
|
1240
|
+
console.print(
|
|
1241
|
+
f"[yellow]{report.gated} declared call(s) wait for a human approval before they are "
|
|
1242
|
+
"sent (the Approval column: who approves, and why).[/]"
|
|
1243
|
+
)
|
|
1244
|
+
overlapping = sum(1 for r in report.results if r.gate is not None and r.gate.also)
|
|
1245
|
+
if overlapping:
|
|
1246
|
+
console.print(
|
|
1247
|
+
f"[yellow]{overlapping} gated call(s) are covered by more than one approval rule: "
|
|
1248
|
+
"the first rule in file order gates each one, with its approvers (the Approval "
|
|
1249
|
+
"column names it, and the later rules that do not apply).[/]"
|
|
1250
|
+
)
|
|
1251
|
+
if report.violations:
|
|
1252
|
+
console.print(f"[red]{report.violations} violation(s).[/]")
|
|
1253
|
+
else:
|
|
1254
|
+
console.print("[green]All declared API calls are allowed.[/]")
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def run_policy_check(
|
|
1258
|
+
project_root: Path,
|
|
1259
|
+
agent_dir: str,
|
|
1260
|
+
*,
|
|
1261
|
+
policy_file: str = POLICY_FILENAME,
|
|
1262
|
+
runtime: str = "fastapi",
|
|
1263
|
+
policy_declared: bool = False,
|
|
1264
|
+
console: Console | None = None,
|
|
1265
|
+
auth_policy: str | None = None,
|
|
1266
|
+
) -> int:
|
|
1267
|
+
"""Run the check, print the table, and return the number of violations.
|
|
1268
|
+
|
|
1269
|
+
An invalid policy file raises :class:`InvalidPolicyFile` (exit 3) after the
|
|
1270
|
+
table: like an invalid manifest, it is a configuration error, and the
|
|
1271
|
+
``api`` commands refuse the same file with the same code.
|
|
1272
|
+
"""
|
|
1273
|
+
report = build_report(
|
|
1274
|
+
project_root,
|
|
1275
|
+
agent_dir,
|
|
1276
|
+
policy_file=policy_file,
|
|
1277
|
+
runtime=runtime,
|
|
1278
|
+
policy_declared=policy_declared,
|
|
1279
|
+
auth_policy=auth_policy,
|
|
1280
|
+
)
|
|
1281
|
+
print_report(report, console)
|
|
1282
|
+
if report.policy_invalid:
|
|
1283
|
+
raise InvalidPolicyFile(
|
|
1284
|
+
f"API policy check failed: {policy_file} is invalid (see above); fix it first "
|
|
1285
|
+
"(a configuration error)."
|
|
1286
|
+
)
|
|
1287
|
+
return report.violations
|