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,2480 @@
|
|
|
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
|
+
"""graph-agents-cli api commands: evolve the project's outbound API access policy.
|
|
16
|
+
|
|
17
|
+
``api-policy.yaml`` belongs to the project and changes over the agent's life:
|
|
18
|
+
``create`` only seeds it (``--api-policy``) or leaves it out, and these
|
|
19
|
+
commands add, widen, narrow and remove access afterwards. Each mutating
|
|
20
|
+
command loads the current file with the rules the runtime enforces, applies
|
|
21
|
+
one change, validates the result, prints a unified diff of every file it
|
|
22
|
+
touches and writes them atomically (``--dry-run`` stops after the diff).
|
|
23
|
+
Comments and key order are kept. Widening access is a reviewed change
|
|
24
|
+
(CODEOWNERS covers the file); the runtime still refuses anything outside the
|
|
25
|
+
policy, whatever a tool declares. ``api approval`` sets the calls that wait
|
|
26
|
+
for a human approval before they are sent (an API's ``approval`` block);
|
|
27
|
+
loosening that gate is reviewed like widening access.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import copy
|
|
33
|
+
import json
|
|
34
|
+
from collections.abc import Callable, Mapping
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
import click
|
|
40
|
+
import yaml
|
|
41
|
+
from rich.markup import escape
|
|
42
|
+
from rich.table import Table
|
|
43
|
+
|
|
44
|
+
from graph_agents_cli._api_policy import (
|
|
45
|
+
A2A_APPROVE,
|
|
46
|
+
A2A_KEY,
|
|
47
|
+
A2A_OPERATION_KEY,
|
|
48
|
+
A2A_OPERATIONS,
|
|
49
|
+
ALLOW_ACTORLESS_KEY,
|
|
50
|
+
ANY_METHOD,
|
|
51
|
+
APPROVAL_KEY,
|
|
52
|
+
AUTH_MODES,
|
|
53
|
+
DECIDE_DIRECT,
|
|
54
|
+
DECIDE_RELAYED,
|
|
55
|
+
DEFAULT_APPROVAL_TIMEOUT_S,
|
|
56
|
+
DEFAULT_FORWARD_HEADER,
|
|
57
|
+
DEFAULT_TIMEOUTS_MS,
|
|
58
|
+
DESCRIPTION_KEY,
|
|
59
|
+
DESCRIPTION_MAX_CHARS,
|
|
60
|
+
EXCHANGE_KEY,
|
|
61
|
+
HEADER_AUTH_MODES,
|
|
62
|
+
HTTP_METHODS,
|
|
63
|
+
MAX_APPROVAL_TIMEOUT_S,
|
|
64
|
+
MAX_RESPONSE_BYTES_LIMIT,
|
|
65
|
+
MIN_APPROVAL_TIMEOUT_S,
|
|
66
|
+
POLICY_FILENAME,
|
|
67
|
+
PROTOCOL_A2A,
|
|
68
|
+
PROTOCOL_HTTP,
|
|
69
|
+
PROTOCOL_KEY,
|
|
70
|
+
PROTOCOLS,
|
|
71
|
+
REQUESTER_APPROVER,
|
|
72
|
+
ROLE_APPROVER_PREFIX,
|
|
73
|
+
RPC_METHOD_KEY,
|
|
74
|
+
RPC_PROTOCOLS,
|
|
75
|
+
api_protocol,
|
|
76
|
+
approval_notes,
|
|
77
|
+
approval_rule_label,
|
|
78
|
+
approval_rules,
|
|
79
|
+
auth_policy_findings,
|
|
80
|
+
denial_match,
|
|
81
|
+
describe_deciders,
|
|
82
|
+
effective_approval,
|
|
83
|
+
effective_approval_rules,
|
|
84
|
+
ensure_no_legacy_api_policy,
|
|
85
|
+
forward_runtime_problem,
|
|
86
|
+
gate_payload,
|
|
87
|
+
parse_policy_yaml,
|
|
88
|
+
policy_errors,
|
|
89
|
+
rule_conflicts,
|
|
90
|
+
rule_deciders,
|
|
91
|
+
rule_never_applies,
|
|
92
|
+
summarize,
|
|
93
|
+
uses_exchange,
|
|
94
|
+
)
|
|
95
|
+
from graph_agents_cli._click import LazyGroup
|
|
96
|
+
from graph_agents_cli._output import Console, print_table
|
|
97
|
+
from graph_agents_cli._project import ProjectConfig, chdir_project_root, read_project_config
|
|
98
|
+
from graph_agents_cli.api import _changes as ch
|
|
99
|
+
from graph_agents_cli.api._files import (
|
|
100
|
+
Plan,
|
|
101
|
+
env_example_add,
|
|
102
|
+
env_example_remove,
|
|
103
|
+
print_diff,
|
|
104
|
+
read_text,
|
|
105
|
+
sync_manifest,
|
|
106
|
+
values_add,
|
|
107
|
+
values_exchange_add,
|
|
108
|
+
values_exchange_remove,
|
|
109
|
+
values_remove,
|
|
110
|
+
)
|
|
111
|
+
from graph_agents_cli.dev import policy_check
|
|
112
|
+
from graph_agents_cli.scaffold.utils.keyedit import EditError, YamlText, block_lines
|
|
113
|
+
|
|
114
|
+
REVIEW_NOTE = (
|
|
115
|
+
"Widening access is a reviewed change: open a pull request, where CODEOWNERS "
|
|
116
|
+
"approves api-policy.yaml."
|
|
117
|
+
)
|
|
118
|
+
GATE_REVIEW_NOTE = (
|
|
119
|
+
"Loosening an approval gate is a reviewed change, like widening access: open a pull "
|
|
120
|
+
"request, where CODEOWNERS approves api-policy.yaml."
|
|
121
|
+
)
|
|
122
|
+
NEXT_STEPS = (
|
|
123
|
+
"Next: declare each call in the tool's API_CALLS, run `graph-agents-cli api check` (or "
|
|
124
|
+
"`graph-agents-cli lint`), add eval cases for the new behaviour, then open a pull request."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@click.group("api", cls=LazyGroup)
|
|
129
|
+
def api_group() -> None:
|
|
130
|
+
"""Declare and change the outbound APIs tools may call (api-policy.yaml).
|
|
131
|
+
|
|
132
|
+
The policy belongs to the project and evolves with the agent: add an API,
|
|
133
|
+
then widen or narrow its access as tools need it. There is no default
|
|
134
|
+
access: read-only (GET, HEAD) and read-write (GET, HEAD, POST, PUT, PATCH,
|
|
135
|
+
DELETE) are written into the file as those methods, and custom takes
|
|
136
|
+
--methods. Every change is validated with the rules the agent
|
|
137
|
+
enforces, keeps comments and key order, prints a unified diff and writes
|
|
138
|
+
atomically; --dry-run prints the diff only. The manifest (api_policy,
|
|
139
|
+
secrets.keys), .env.example and the chart's values.yaml follow the change.
|
|
140
|
+
|
|
141
|
+
`api approval` makes chosen calls wait for a human approval before they
|
|
142
|
+
are sent (requester confirmation, or role:<name> approvers for a second
|
|
143
|
+
person's review; --add-rule gives other calls of the API other
|
|
144
|
+
approvers); approval never widens access.
|
|
145
|
+
|
|
146
|
+
A JSON-RPC API (`add --protocol jsonrpc`), or another agent over A2A
|
|
147
|
+
(`--protocol a2a --a2a-path /a2a/<name>`), is judged by the request each
|
|
148
|
+
POST sends, read from its body: allow, deny and gate it by --rpc-method,
|
|
149
|
+
and an agent's approve decisions by --a2a-operation(s) approve.
|
|
150
|
+
|
|
151
|
+
\b
|
|
152
|
+
Exit codes:
|
|
153
|
+
0 changed, or nothing to change
|
|
154
|
+
1 check: a declared call is refused
|
|
155
|
+
2 usage error
|
|
156
|
+
3 invalid result, invalid api-policy.yaml (check too), or not in a project
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _dry_run_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
|
161
|
+
return click.option(
|
|
162
|
+
"--dry-run",
|
|
163
|
+
"dry_run",
|
|
164
|
+
is_flag=True,
|
|
165
|
+
default=False,
|
|
166
|
+
help="Print the diff only; write nothing.",
|
|
167
|
+
)(function)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _operation_options(function: Callable[..., Any]) -> Callable[..., Any]:
|
|
171
|
+
function = click.option(
|
|
172
|
+
"--path", "path", default=None, help="The operation's path template (with --method)."
|
|
173
|
+
)(function)
|
|
174
|
+
function = click.option(
|
|
175
|
+
"--method", "method", default=None, help="The operation's HTTP method (with --path)."
|
|
176
|
+
)(function)
|
|
177
|
+
return click.argument("operation_id", required=False)(function)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _entry_options(function: Callable[..., Any]) -> Callable[..., Any]:
|
|
181
|
+
"""allow / deny: OPERATION_ID, --method M --path P, or both (the entry pins all three)."""
|
|
182
|
+
function = click.option(
|
|
183
|
+
"--path",
|
|
184
|
+
"path",
|
|
185
|
+
default=None,
|
|
186
|
+
help="The operation's path template (with --method; also with OPERATION_ID).",
|
|
187
|
+
)(function)
|
|
188
|
+
function = click.option(
|
|
189
|
+
"--method",
|
|
190
|
+
"method",
|
|
191
|
+
default=None,
|
|
192
|
+
help="The operation's HTTP method (with --path; also with OPERATION_ID).",
|
|
193
|
+
)(function)
|
|
194
|
+
return click.argument("operation_id", required=False)(function)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# The project and its policy
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass
|
|
203
|
+
class _Project:
|
|
204
|
+
root: Path
|
|
205
|
+
config: ProjectConfig
|
|
206
|
+
text: str | None # api-policy.yaml, None when absent
|
|
207
|
+
document: dict[str, Any] | None
|
|
208
|
+
|
|
209
|
+
def api(self, name: str) -> dict[str, Any]:
|
|
210
|
+
if self.document is None:
|
|
211
|
+
raise ch.ApiCommandError(
|
|
212
|
+
f"this project has no {POLICY_FILENAME}: declare an API first with "
|
|
213
|
+
"`graph-agents-cli api add`"
|
|
214
|
+
)
|
|
215
|
+
api = self.document["apis"].get(name)
|
|
216
|
+
if api is None:
|
|
217
|
+
declared = ", ".join(sorted(self.document["apis"]))
|
|
218
|
+
raise ch.ApiCommandError(
|
|
219
|
+
f"API {name!r} is not declared in {POLICY_FILENAME} (declared: {declared})"
|
|
220
|
+
)
|
|
221
|
+
return api
|
|
222
|
+
|
|
223
|
+
def editor(self) -> YamlText:
|
|
224
|
+
assert self.text is not None
|
|
225
|
+
try:
|
|
226
|
+
return YamlText(self.text)
|
|
227
|
+
except EditError as exc:
|
|
228
|
+
raise ch.ApiCommandError(f"{POLICY_FILENAME} cannot be edited safely: {exc}") from exc
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _load_project() -> _Project:
|
|
232
|
+
chdir_project_root()
|
|
233
|
+
return load_project_at(Path.cwd())
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def load_project_at(root: Path) -> _Project:
|
|
237
|
+
"""The project whose root is ``root``, read without changing directory (``system`` reads
|
|
238
|
+
several); an invalid policy is an ``ApiCommandError`` (exit 3)."""
|
|
239
|
+
ensure_no_legacy_api_policy(root)
|
|
240
|
+
config = read_project_config(str(root))
|
|
241
|
+
text = read_text(root / POLICY_FILENAME)
|
|
242
|
+
document = None
|
|
243
|
+
if text is not None:
|
|
244
|
+
data, errors = parse_policy_yaml(text)
|
|
245
|
+
errors = errors or policy_errors(data)
|
|
246
|
+
if errors:
|
|
247
|
+
lines = "\n".join(f" - {error}" for error in errors)
|
|
248
|
+
raise ch.ApiCommandError(
|
|
249
|
+
f"{POLICY_FILENAME} is invalid; fix it first (graph-agents-cli api check):\n{lines}"
|
|
250
|
+
)
|
|
251
|
+
document = dict(data)
|
|
252
|
+
return _Project(root, config, text, document)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _validate(project: _Project, document: dict[str, Any]) -> None:
|
|
256
|
+
errors = policy_errors(document)
|
|
257
|
+
if not errors:
|
|
258
|
+
problem = forward_runtime_problem(summarize(document), project.config.runtime)
|
|
259
|
+
if problem:
|
|
260
|
+
errors.append(problem)
|
|
261
|
+
if errors:
|
|
262
|
+
lines = "\n".join(f" - {error}" for error in errors)
|
|
263
|
+
raise ch.ApiCommandError(
|
|
264
|
+
f"The change would make {POLICY_FILENAME} invalid; nothing was written:\n{lines}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# The order of an API's keys in the schema: a key an edit adds goes after the
|
|
269
|
+
# last key before it in this order, so files stay laid out the same way.
|
|
270
|
+
_API_KEY_ORDER = (
|
|
271
|
+
DESCRIPTION_KEY,
|
|
272
|
+
PROTOCOL_KEY,
|
|
273
|
+
A2A_KEY,
|
|
274
|
+
"base_url_env",
|
|
275
|
+
"auth",
|
|
276
|
+
"token_env",
|
|
277
|
+
"forward_header",
|
|
278
|
+
"forward_audience",
|
|
279
|
+
EXCHANGE_KEY,
|
|
280
|
+
"allowed_methods",
|
|
281
|
+
ch.ALLOWED,
|
|
282
|
+
ch.DENIED,
|
|
283
|
+
"openapi",
|
|
284
|
+
"timeouts_ms",
|
|
285
|
+
"pagination",
|
|
286
|
+
"limits",
|
|
287
|
+
"approval",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _after(api: dict[str, Any], key: str) -> str | None:
|
|
292
|
+
"""The existing key of ``api`` a new ``key`` goes after (None: at the end)."""
|
|
293
|
+
index = _API_KEY_ORDER.index(key)
|
|
294
|
+
return next((k for k in reversed(_API_KEY_ORDER[:index]) if k in api), None)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _edit(action: Callable[[], None], what: str) -> None:
|
|
298
|
+
try:
|
|
299
|
+
action()
|
|
300
|
+
except EditError as exc:
|
|
301
|
+
raise ch.ApiCommandError(
|
|
302
|
+
f"{POLICY_FILENAME} cannot be edited safely ({exc}); nothing was written. "
|
|
303
|
+
f"Make the change by hand: {what}."
|
|
304
|
+
) from exc
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _specs(root: Path, document: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
|
|
308
|
+
specs: dict[str, dict[str, Any]] = {}
|
|
309
|
+
for name, api in ((document or {}).get("apis") or {}).items():
|
|
310
|
+
if api.get("openapi"):
|
|
311
|
+
try:
|
|
312
|
+
specs[name] = ch.load_spec(root, str(api["openapi"]))
|
|
313
|
+
except ch.ApiCommandError:
|
|
314
|
+
continue
|
|
315
|
+
return specs
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _call_effects(
|
|
319
|
+
project: _Project, before: dict[str, Any] | None, after: dict[str, Any] | None, api: str
|
|
320
|
+
) -> list[str]:
|
|
321
|
+
"""How the change affects the calls the project's tools declare for ``api``."""
|
|
322
|
+
tools = project.root / project.config.agent_directory / policy_check.TOOLS_SUBDIR
|
|
323
|
+
calls, _problems = policy_check.collect_declared_calls(tools)
|
|
324
|
+
specs_before, specs_after = _specs(project.root, before), _specs(project.root, after)
|
|
325
|
+
lines = []
|
|
326
|
+
for call in calls:
|
|
327
|
+
if call.api != api:
|
|
328
|
+
continue
|
|
329
|
+
was_result = policy_check.check_call(call, before, specs_before)
|
|
330
|
+
now_result = policy_check.check_call(call, after, specs_after)
|
|
331
|
+
was, now = was_result.status, now_result.status
|
|
332
|
+
label = f"{call.tool}: {call.method} {call.operation}"
|
|
333
|
+
gate, old_gate = now_result.gate, was_result.gate
|
|
334
|
+
waits = f" (approvers: {', '.join(gate.approvers)}{_rule_of(gate)})" if gate else ""
|
|
335
|
+
if was == policy_check.STATUS_ALLOWED and now != policy_check.STATUS_ALLOWED:
|
|
336
|
+
lines.append(f"now refused: {label}")
|
|
337
|
+
elif was != policy_check.STATUS_ALLOWED and now == policy_check.STATUS_ALLOWED:
|
|
338
|
+
lines.append(f"now allowed: {label}{waits}")
|
|
339
|
+
elif now == policy_check.STATUS_ALLOWED and gate is not None and old_gate is None:
|
|
340
|
+
lines.append(f"now gated: {label}{waits}")
|
|
341
|
+
elif now == policy_check.STATUS_ALLOWED and gate is None and old_gate is not None:
|
|
342
|
+
lines.append(f"no longer gated: {label} (sent without an approval)")
|
|
343
|
+
elif (
|
|
344
|
+
now == policy_check.STATUS_ALLOWED
|
|
345
|
+
and gate is not None
|
|
346
|
+
and old_gate is not None
|
|
347
|
+
and set(gate.approvers) != set(old_gate.approvers)
|
|
348
|
+
):
|
|
349
|
+
gained = [a for a in gate.approvers if a not in old_gate.approvers]
|
|
350
|
+
kind = f"new approver(s) {', '.join(gained)}" if gained else "fewer approvers"
|
|
351
|
+
lines.append(
|
|
352
|
+
f"approvers change ({kind}): {label}: now {', '.join(gate.approvers)}"
|
|
353
|
+
f"{_rule_of(gate)}, was {', '.join(old_gate.approvers)}{_rule_of(old_gate)}"
|
|
354
|
+
)
|
|
355
|
+
elif (
|
|
356
|
+
now == policy_check.STATUS_ALLOWED
|
|
357
|
+
and gate is not None
|
|
358
|
+
and old_gate is not None
|
|
359
|
+
and gate.deciders() != old_gate.deciders()
|
|
360
|
+
):
|
|
361
|
+
relayed = gate.decide_with == DECIDE_RELAYED
|
|
362
|
+
was_relayed = old_gate.decide_with == DECIDE_RELAYED
|
|
363
|
+
gained = [r for r in gate.relayers if not was_relayed or r not in old_gate.relayers]
|
|
364
|
+
kind = (
|
|
365
|
+
f"new relayer(s) {', '.join(gained)}"
|
|
366
|
+
if relayed and gained
|
|
367
|
+
else "fewer relayers"
|
|
368
|
+
if relayed
|
|
369
|
+
else "now decided directly"
|
|
370
|
+
)
|
|
371
|
+
lines.append(
|
|
372
|
+
f"relaying changes ({kind}): {label}: now "
|
|
373
|
+
f"{_deciders_of(gate)}{_rule_of(gate)}, was "
|
|
374
|
+
f"{_deciders_of(old_gate)}{_rule_of(old_gate)}"
|
|
375
|
+
)
|
|
376
|
+
return lines
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _deciders_of(gate: Any) -> str:
|
|
380
|
+
"""``requester; relayed by concierge`` for a gate (its approvers, and relayers when any)."""
|
|
381
|
+
return describe_deciders(
|
|
382
|
+
{"approvers": gate.approvers, "decide_with": gate.decide_with, "relayers": gate.relayers}
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _rule_of(gate: Any) -> str:
|
|
387
|
+
"""``; rule approval[1]`` when the gate comes from a list of approval rules, else ``""``."""
|
|
388
|
+
return f"; rule {APPROVAL_KEY}[{gate.index}]" if gate.index is not None else ""
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
_EFFECT_STYLES = {
|
|
392
|
+
"now refused": "red",
|
|
393
|
+
"no longer gated": "yellow",
|
|
394
|
+
"approvers change (new": "yellow",
|
|
395
|
+
"relaying changes (new": "yellow",
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _finish(
|
|
400
|
+
project: _Project,
|
|
401
|
+
plan: Plan,
|
|
402
|
+
*,
|
|
403
|
+
dry_run: bool,
|
|
404
|
+
api: str,
|
|
405
|
+
before: dict[str, Any] | None,
|
|
406
|
+
after: dict[str, Any] | None,
|
|
407
|
+
widens: bool | None,
|
|
408
|
+
notes: list[str] | None = None,
|
|
409
|
+
next_steps: bool = True,
|
|
410
|
+
verdict: tuple[str, str] | None = None,
|
|
411
|
+
effects: list[str] | None = None,
|
|
412
|
+
) -> None:
|
|
413
|
+
"""Show the change and write it. ``widens=None``: the change allows nothing yet.
|
|
414
|
+
|
|
415
|
+
``verdict`` (text, style) replaces the line saying whether access widens:
|
|
416
|
+
``api approval`` changes who waits for a human, not what is allowed.
|
|
417
|
+
``effects``: the change's effect on the declared calls, when already computed.
|
|
418
|
+
"""
|
|
419
|
+
console = Console()
|
|
420
|
+
if not plan.effective:
|
|
421
|
+
console.print("Nothing to change.")
|
|
422
|
+
return
|
|
423
|
+
print_diff(plan.diff())
|
|
424
|
+
for line in plan.summaries():
|
|
425
|
+
console.print(escape(line), style="bold", highlight=False)
|
|
426
|
+
click.echo()
|
|
427
|
+
for note in notes or []:
|
|
428
|
+
console.print(f"Note: {escape(note)}", style="yellow", highlight=False)
|
|
429
|
+
if effects is None:
|
|
430
|
+
effects = _call_effects(project, before, after, api)
|
|
431
|
+
if effects:
|
|
432
|
+
console.print("Effect on the calls your tools declare:")
|
|
433
|
+
for line in effects:
|
|
434
|
+
style = next((v for k, v in _EFFECT_STYLES.items() if line.startswith(k)), "green")
|
|
435
|
+
console.print(f" {escape(line)}", style=style, highlight=False)
|
|
436
|
+
if verdict is not None:
|
|
437
|
+
console.print(escape(verdict[0]), style=verdict[1], highlight=False)
|
|
438
|
+
elif widens is None:
|
|
439
|
+
console.print(
|
|
440
|
+
f"No effect on access to {api} yet (see the notes above); what would make it "
|
|
441
|
+
f"take effect widens access. {REVIEW_NOTE}",
|
|
442
|
+
style="yellow",
|
|
443
|
+
)
|
|
444
|
+
elif widens:
|
|
445
|
+
console.print(f"This widens access to {api}. {REVIEW_NOTE}", style="yellow")
|
|
446
|
+
else:
|
|
447
|
+
console.print(f"This narrows or keeps access to {api} (always safe).", style="dim")
|
|
448
|
+
if dry_run:
|
|
449
|
+
console.print("Dry run: nothing was written.", style="yellow")
|
|
450
|
+
else:
|
|
451
|
+
plan.write()
|
|
452
|
+
written = ", ".join(change.path for change in plan.effective)
|
|
453
|
+
console.print(f"Wrote {escape(written)}.", style="green")
|
|
454
|
+
if plan.left_for_you:
|
|
455
|
+
console.print("Left for you:", style="bold")
|
|
456
|
+
for item in plan.left_for_you:
|
|
457
|
+
console.print(f" - {escape(item)}", highlight=False)
|
|
458
|
+
if next_steps and not dry_run:
|
|
459
|
+
console.print(NEXT_STEPS, style="dim")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ---------------------------------------------------------------------------
|
|
463
|
+
# add / remove
|
|
464
|
+
# ---------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _policy_header(config: ProjectConfig) -> list[str]:
|
|
468
|
+
name = config.project_name or "this project"
|
|
469
|
+
agent = config.agent_directory or "app"
|
|
470
|
+
return [
|
|
471
|
+
f"# Outbound API access policy for {name}.",
|
|
472
|
+
"#",
|
|
473
|
+
"# Every external API the agent's tools may call, and how. Owned by the project;",
|
|
474
|
+
"# change it with `graph-agents-cli api ...` or by hand, in a reviewed pull request",
|
|
475
|
+
"# (CODEOWNERS covers this file). There is no default access: each API lists its",
|
|
476
|
+
"# methods (and optionally its operations) explicitly.",
|
|
477
|
+
f"# {agent}/app_utils/api_client.py refuses, before sending, any call outside this",
|
|
478
|
+
"# file; `graph-agents-cli lint` checks every tool's API_CALLS against it.",
|
|
479
|
+
]
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _openapi_reference(
|
|
483
|
+
project: _Project, plan: Plan, name: str, source: Path
|
|
484
|
+
) -> tuple[str, dict[str, Any]]:
|
|
485
|
+
"""Where the project keeps the spec (copied under openapi/<api>/ unless already inside)."""
|
|
486
|
+
from graph_agents_cli.dev.policy_check import load_openapi
|
|
487
|
+
from graph_agents_cli.scaffold.utils.openapi_seed import FALLBACK_DIR, _keepable
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
spec = load_openapi(source)
|
|
491
|
+
except (OSError, ValueError, yaml.YAMLError) as exc:
|
|
492
|
+
raise ch.ApiCommandError(
|
|
493
|
+
f"--openapi {source}: not a readable OpenAPI spec ({exc})"
|
|
494
|
+
) from exc
|
|
495
|
+
try:
|
|
496
|
+
relative = source.resolve().relative_to(project.root.resolve())
|
|
497
|
+
except ValueError:
|
|
498
|
+
relative = None
|
|
499
|
+
kept = _keepable(relative) if relative is not None else None
|
|
500
|
+
if kept is not None:
|
|
501
|
+
return kept, spec
|
|
502
|
+
target = f"{FALLBACK_DIR}/{name}/{source.name}"
|
|
503
|
+
text = read_text(source) or "" # copied as stored, line endings included
|
|
504
|
+
existing = read_text(project.root / target)
|
|
505
|
+
if existing is not None and existing != text:
|
|
506
|
+
raise ch.ApiCommandError(
|
|
507
|
+
f"{target} already exists with other content; move it, or pass a spec inside "
|
|
508
|
+
"the project"
|
|
509
|
+
)
|
|
510
|
+
lines = len(text.splitlines())
|
|
511
|
+
plan.set_text(
|
|
512
|
+
target,
|
|
513
|
+
existing,
|
|
514
|
+
text,
|
|
515
|
+
summary=f"new file, a copy of {source} ({lines} lines; not shown)",
|
|
516
|
+
)
|
|
517
|
+
return target, spec
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@api_group.command("add")
|
|
521
|
+
@click.argument("name")
|
|
522
|
+
@click.option(
|
|
523
|
+
"--base-url-env",
|
|
524
|
+
"base_url_env",
|
|
525
|
+
required=True,
|
|
526
|
+
help="Environment variable holding the API's base URL (e.g. ORDERS_API_BASE_URL).",
|
|
527
|
+
)
|
|
528
|
+
@click.option(
|
|
529
|
+
"--auth",
|
|
530
|
+
"auth",
|
|
531
|
+
type=click.Choice(list(AUTH_MODES)),
|
|
532
|
+
required=True,
|
|
533
|
+
help=(
|
|
534
|
+
"none, bearer (a token from --token-env), forward (the caller's own credential) or "
|
|
535
|
+
"exchange (a token the issuer mints for --audience in exchange for the caller's, "
|
|
536
|
+
"RFC 8693)."
|
|
537
|
+
),
|
|
538
|
+
)
|
|
539
|
+
@click.option(
|
|
540
|
+
"--token-env", "token_env", default=None, help="--auth bearer: variable holding the token."
|
|
541
|
+
)
|
|
542
|
+
@click.option(
|
|
543
|
+
"--forward-header",
|
|
544
|
+
"forward_header",
|
|
545
|
+
default=None,
|
|
546
|
+
help=(
|
|
547
|
+
"--auth forward or exchange: header the credential is sent in (default "
|
|
548
|
+
f"{DEFAULT_FORWARD_HEADER})."
|
|
549
|
+
),
|
|
550
|
+
)
|
|
551
|
+
@click.option(
|
|
552
|
+
"--audience",
|
|
553
|
+
"audience",
|
|
554
|
+
default=None,
|
|
555
|
+
help=(
|
|
556
|
+
"--auth exchange (required): the audience the issuer mints the token for (the target's "
|
|
557
|
+
"AUTH_JWT_AUDIENCE). --auth forward: forward_audience, the audience the caller's own "
|
|
558
|
+
"token must name to be forwarded (jwt)."
|
|
559
|
+
),
|
|
560
|
+
)
|
|
561
|
+
@click.option(
|
|
562
|
+
"--scope",
|
|
563
|
+
"scope",
|
|
564
|
+
default=None,
|
|
565
|
+
help="--auth exchange: the scopes to ask for, space separated (least privilege).",
|
|
566
|
+
)
|
|
567
|
+
@click.option(
|
|
568
|
+
"--resource",
|
|
569
|
+
"resource",
|
|
570
|
+
default=None,
|
|
571
|
+
help="--auth exchange: the target's resource indicator, an absolute URI (RFC 8707).",
|
|
572
|
+
)
|
|
573
|
+
@click.option(
|
|
574
|
+
"--allow-actorless",
|
|
575
|
+
"allow_actorless",
|
|
576
|
+
is_flag=True,
|
|
577
|
+
default=False,
|
|
578
|
+
help=(
|
|
579
|
+
"--auth exchange: accept exchanged tokens that name no actor (exchange.allow_actorless; "
|
|
580
|
+
"refused by default). Only when the agent behind the API sets AUTH_JWT_DIRECT_CLIENTS "
|
|
581
|
+
"and lists this agent as client:<its client id> in AUTH_ALLOWED_ACTORS."
|
|
582
|
+
),
|
|
583
|
+
)
|
|
584
|
+
@click.option(
|
|
585
|
+
"--protocol",
|
|
586
|
+
"protocol",
|
|
587
|
+
type=click.Choice(list(PROTOCOLS)),
|
|
588
|
+
default=None,
|
|
589
|
+
help=(
|
|
590
|
+
"http (the default), jsonrpc (a JSON-RPC 2.0 API) or a2a (another agent over A2A 1.0 "
|
|
591
|
+
"JSON-RPC, with --a2a-path): jsonrpc and a2a judge each POST by the JSON-RPC request "
|
|
592
|
+
"it sends, read from its body."
|
|
593
|
+
),
|
|
594
|
+
)
|
|
595
|
+
@click.option(
|
|
596
|
+
"--a2a-path",
|
|
597
|
+
"a2a_path",
|
|
598
|
+
default=None,
|
|
599
|
+
metavar="PATH",
|
|
600
|
+
help="--protocol a2a (required): the agent's A2A endpoint, a literal path such as /a2a/orders.",
|
|
601
|
+
)
|
|
602
|
+
@click.option(
|
|
603
|
+
"--description",
|
|
604
|
+
"description",
|
|
605
|
+
default=None,
|
|
606
|
+
metavar="TEXT",
|
|
607
|
+
help=f"What the API is for (1-{DESCRIPTION_MAX_CHARS} characters).",
|
|
608
|
+
)
|
|
609
|
+
@click.option(
|
|
610
|
+
"--access",
|
|
611
|
+
"access",
|
|
612
|
+
type=click.Choice(list(ch.ACCESS_CHOICES)),
|
|
613
|
+
required=True,
|
|
614
|
+
help=(
|
|
615
|
+
"Required, no default: read-only (GET, HEAD), read-write (GET, HEAD, POST, PUT, PATCH, "
|
|
616
|
+
"DELETE) or custom (--methods)."
|
|
617
|
+
),
|
|
618
|
+
)
|
|
619
|
+
@click.option("--methods", "methods", default=None, help='--access custom: e.g. GET,POST (or "*").')
|
|
620
|
+
@click.option(
|
|
621
|
+
"--openapi",
|
|
622
|
+
"openapi",
|
|
623
|
+
type=click.Path(exists=True, dir_okay=False, resolve_path=True, path_type=Path),
|
|
624
|
+
default=None,
|
|
625
|
+
help="The API's OpenAPI spec (copied under openapi/<name>/ unless inside the project).",
|
|
626
|
+
)
|
|
627
|
+
@click.option(
|
|
628
|
+
"--max-calls-per-run",
|
|
629
|
+
type=click.IntRange(min=1),
|
|
630
|
+
default=None,
|
|
631
|
+
metavar="N",
|
|
632
|
+
help="Limit: calls to this API within one agent run.",
|
|
633
|
+
)
|
|
634
|
+
@click.option(
|
|
635
|
+
"--rate-per-minute",
|
|
636
|
+
type=click.IntRange(min=1),
|
|
637
|
+
default=None,
|
|
638
|
+
metavar="N",
|
|
639
|
+
help="Limit: calls per minute, per process (replica).",
|
|
640
|
+
)
|
|
641
|
+
@click.option(
|
|
642
|
+
"--max-response-bytes",
|
|
643
|
+
type=click.IntRange(min=1, max=MAX_RESPONSE_BYTES_LIMIT),
|
|
644
|
+
default=None,
|
|
645
|
+
metavar="N",
|
|
646
|
+
help="Limit: the most an answer may hold, in bytes (larger answers are discarded).",
|
|
647
|
+
)
|
|
648
|
+
@click.option(
|
|
649
|
+
"--connect-timeout-ms",
|
|
650
|
+
type=click.IntRange(min=1),
|
|
651
|
+
default=None,
|
|
652
|
+
metavar="N",
|
|
653
|
+
help="Connect timeout (default 2000).",
|
|
654
|
+
)
|
|
655
|
+
@click.option(
|
|
656
|
+
"--read-timeout-ms",
|
|
657
|
+
type=click.IntRange(min=1),
|
|
658
|
+
default=None,
|
|
659
|
+
metavar="N",
|
|
660
|
+
help="Read timeout (default 5000).",
|
|
661
|
+
)
|
|
662
|
+
@_dry_run_option
|
|
663
|
+
def cmd_add(
|
|
664
|
+
name: str,
|
|
665
|
+
base_url_env: str,
|
|
666
|
+
auth: str,
|
|
667
|
+
token_env: str | None,
|
|
668
|
+
forward_header: str | None,
|
|
669
|
+
audience: str | None,
|
|
670
|
+
scope: str | None,
|
|
671
|
+
resource: str | None,
|
|
672
|
+
allow_actorless: bool,
|
|
673
|
+
protocol: str | None,
|
|
674
|
+
a2a_path: str | None,
|
|
675
|
+
description: str | None,
|
|
676
|
+
access: str,
|
|
677
|
+
methods: str | None,
|
|
678
|
+
openapi: Path | None,
|
|
679
|
+
max_calls_per_run: int | None,
|
|
680
|
+
rate_per_minute: int | None,
|
|
681
|
+
max_response_bytes: int | None,
|
|
682
|
+
connect_timeout_ms: int | None,
|
|
683
|
+
read_timeout_ms: int | None,
|
|
684
|
+
dry_run: bool,
|
|
685
|
+
) -> None:
|
|
686
|
+
"""Declare an API with an explicit access choice (creates api-policy.yaml when absent).
|
|
687
|
+
|
|
688
|
+
With --protocol a2a (another agent), every message that approves one of that agent's
|
|
689
|
+
pending approvals is denied (denied_operations: a2a_operation: approve) when POST is
|
|
690
|
+
allowed: an agent never decides a person's approvals on its own. To relay the person's
|
|
691
|
+
decision instead, gate it (api approval NAME --a2a-operations approve --approvers
|
|
692
|
+
requester), then lift the denial (api revoke NAME --a2a-operation approve --from denied).
|
|
693
|
+
"""
|
|
694
|
+
allowed_methods = ch.access_methods(access, ch.parse_methods(methods))
|
|
695
|
+
if protocol == PROTOCOL_A2A and not a2a_path:
|
|
696
|
+
raise click.UsageError(
|
|
697
|
+
"--protocol a2a needs --a2a-path (the agent's A2A endpoint, such as /a2a/orders)"
|
|
698
|
+
)
|
|
699
|
+
if protocol != PROTOCOL_A2A and a2a_path:
|
|
700
|
+
raise click.UsageError("--a2a-path goes with --protocol a2a only")
|
|
701
|
+
if protocol == PROTOCOL_A2A and auth == "none":
|
|
702
|
+
raise click.UsageError(
|
|
703
|
+
"--protocol a2a needs a credential: --auth bearer, forward or exchange (an agent's "
|
|
704
|
+
"A2A endpoint authenticates its callers)"
|
|
705
|
+
)
|
|
706
|
+
if auth == "bearer" and not token_env:
|
|
707
|
+
raise click.UsageError("--auth bearer needs --token-env (the variable holding the token)")
|
|
708
|
+
if auth != "bearer" and token_env:
|
|
709
|
+
raise click.UsageError("--token-env goes with --auth bearer only")
|
|
710
|
+
if auth not in HEADER_AUTH_MODES and forward_header:
|
|
711
|
+
raise click.UsageError("--forward-header goes with --auth forward or exchange only")
|
|
712
|
+
if auth == "exchange" and not audience:
|
|
713
|
+
raise click.UsageError(
|
|
714
|
+
"--auth exchange needs --audience (the audience the issuer mints the token for: "
|
|
715
|
+
"the target's AUTH_JWT_AUDIENCE)"
|
|
716
|
+
)
|
|
717
|
+
if auth not in HEADER_AUTH_MODES and audience:
|
|
718
|
+
raise click.UsageError("--audience goes with --auth exchange or forward only")
|
|
719
|
+
if auth != "exchange" and (scope or resource or allow_actorless):
|
|
720
|
+
raise click.UsageError(
|
|
721
|
+
"--scope, --resource and --allow-actorless go with --auth exchange only"
|
|
722
|
+
)
|
|
723
|
+
project = _load_project()
|
|
724
|
+
if project.document is not None and name in project.document["apis"]:
|
|
725
|
+
raise ch.ApiCommandError(
|
|
726
|
+
f"API {name!r} is already declared; change it with `graph-agents-cli api access`, "
|
|
727
|
+
"`allow`, `deny`, `revoke` or `limits`"
|
|
728
|
+
)
|
|
729
|
+
plan = Plan(project.root)
|
|
730
|
+
api: dict[str, Any] = {}
|
|
731
|
+
if description is not None:
|
|
732
|
+
api[DESCRIPTION_KEY] = description
|
|
733
|
+
if protocol not in (None, PROTOCOL_HTTP):
|
|
734
|
+
api[PROTOCOL_KEY] = protocol
|
|
735
|
+
if a2a_path:
|
|
736
|
+
api[A2A_KEY] = {"path": a2a_path}
|
|
737
|
+
api.update({"base_url_env": base_url_env, "auth": auth})
|
|
738
|
+
if token_env:
|
|
739
|
+
api["token_env"] = token_env
|
|
740
|
+
if forward_header:
|
|
741
|
+
api["forward_header"] = forward_header
|
|
742
|
+
if auth == "forward" and audience:
|
|
743
|
+
api["forward_audience"] = audience
|
|
744
|
+
if auth == "exchange":
|
|
745
|
+
exchange: dict[str, Any] = {"audience": audience}
|
|
746
|
+
if scope:
|
|
747
|
+
exchange["scope"] = scope
|
|
748
|
+
if resource:
|
|
749
|
+
exchange["resource"] = resource
|
|
750
|
+
if allow_actorless:
|
|
751
|
+
exchange[ALLOW_ACTORLESS_KEY] = True
|
|
752
|
+
api[EXCHANGE_KEY] = exchange
|
|
753
|
+
api["allowed_methods"] = allowed_methods
|
|
754
|
+
holds_approve = protocol == PROTOCOL_A2A and "POST" in ch.effective_methods(allowed_methods)
|
|
755
|
+
if holds_approve:
|
|
756
|
+
# Fail closed: this agent must not decide the other agent's approvals on its own.
|
|
757
|
+
api[ch.DENIED] = [{A2A_OPERATION_KEY: A2A_APPROVE}]
|
|
758
|
+
if openapi is not None:
|
|
759
|
+
api["openapi"], _spec = _openapi_reference(project, plan, name, openapi)
|
|
760
|
+
if connect_timeout_ms or read_timeout_ms:
|
|
761
|
+
defaults = dict(DEFAULT_TIMEOUTS_MS)
|
|
762
|
+
api["timeouts_ms"] = {
|
|
763
|
+
"connect": connect_timeout_ms or defaults["connect"],
|
|
764
|
+
"read": read_timeout_ms or defaults["read"],
|
|
765
|
+
}
|
|
766
|
+
limits = ch.new_limits(
|
|
767
|
+
None, max_calls_per_run or "keep", rate_per_minute or "keep", max_response_bytes or "keep"
|
|
768
|
+
)
|
|
769
|
+
if limits:
|
|
770
|
+
api["limits"] = limits
|
|
771
|
+
document = ch.with_api(project.document, name, api)
|
|
772
|
+
_validate(project, document)
|
|
773
|
+
matrix_errors, matrix_notes = auth_policy_findings(
|
|
774
|
+
{"apis": {name: api}}, project.config.auth_policy
|
|
775
|
+
)
|
|
776
|
+
if matrix_errors:
|
|
777
|
+
lines = "\n".join(f" - {error}" for error in matrix_errors)
|
|
778
|
+
raise ch.ApiCommandError(
|
|
779
|
+
f"{name} cannot work in this project; nothing was written:\n{lines}"
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
if project.text is None:
|
|
783
|
+
lines = [*_policy_header(project.config), "apis:", *block_lines({name: api}, 2)]
|
|
784
|
+
text = "\n".join(lines) + "\n"
|
|
785
|
+
if YamlText(text).data != document:
|
|
786
|
+
raise ch.ApiCommandError("could not write a new api-policy.yaml (internal check)")
|
|
787
|
+
else:
|
|
788
|
+
editor = project.editor()
|
|
789
|
+
_edit(lambda: editor.set(("apis", name), api), f"add apis.{name}")
|
|
790
|
+
text = editor.text
|
|
791
|
+
plan.set_text(POLICY_FILENAME, project.text, text)
|
|
792
|
+
sync_manifest(plan, project.config, document=document, previous=project.document)
|
|
793
|
+
env_example_add(plan, name, api)
|
|
794
|
+
values_add(plan, project.config, document, name)
|
|
795
|
+
values_exchange_add(plan, project.config, document, name)
|
|
796
|
+
_add_todos(plan, project.config, name, api, first_exchange=not _exchanges(project.document))
|
|
797
|
+
notes = [
|
|
798
|
+
f"{name} allows {ch.describe_methods(allowed_methods)}, every operation.",
|
|
799
|
+
*matrix_notes,
|
|
800
|
+
]
|
|
801
|
+
if holds_approve:
|
|
802
|
+
notes.append(
|
|
803
|
+
f"messages to {name} that approve one of its pending approvals are denied "
|
|
804
|
+
"(denied_operations: a2a_operation: approve): this agent never decides them on its "
|
|
805
|
+
"own. To relay the person's decision instead, gate them, then lift the denial: "
|
|
806
|
+
f"graph-agents-cli api approval {name} --a2a-operations approve --approvers "
|
|
807
|
+
f"requester; graph-agents-cli api revoke {name} --a2a-operation approve --from denied"
|
|
808
|
+
)
|
|
809
|
+
_finish(
|
|
810
|
+
project,
|
|
811
|
+
plan,
|
|
812
|
+
dry_run=dry_run,
|
|
813
|
+
api=name,
|
|
814
|
+
before=project.document,
|
|
815
|
+
after=document,
|
|
816
|
+
widens=True,
|
|
817
|
+
notes=notes,
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _exchanges(document: dict[str, Any] | None) -> bool:
|
|
822
|
+
"""Whether the policy has an ``auth: exchange`` API."""
|
|
823
|
+
return document is not None and uses_exchange(summarize(document))
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _add_todos(
|
|
827
|
+
plan: Plan,
|
|
828
|
+
config: ProjectConfig,
|
|
829
|
+
name: str,
|
|
830
|
+
api: dict[str, Any],
|
|
831
|
+
*,
|
|
832
|
+
first_exchange: bool = True,
|
|
833
|
+
) -> None:
|
|
834
|
+
variable = api["base_url_env"]
|
|
835
|
+
if api_protocol(api) in RPC_PROTOCOLS:
|
|
836
|
+
plan.left_for_you.append(
|
|
837
|
+
f'declare each POST to {name} in API_CALLS with its "rpc_method" (the JSON-RPC '
|
|
838
|
+
"method it sends: the client reads it from the body and judges the call by it)"
|
|
839
|
+
)
|
|
840
|
+
where = "in .env (local runs)"
|
|
841
|
+
if config.deployment_target == "kubernetes":
|
|
842
|
+
where += (
|
|
843
|
+
" and per environment in the chart's values-<env>.yaml env: (values.yaml holds "
|
|
844
|
+
"a placeholder); only base URLs and tokens differ between environments"
|
|
845
|
+
)
|
|
846
|
+
plan.left_for_you.append(f"set {variable} {where}")
|
|
847
|
+
if api["auth"] == "bearer" and config.deployment_target == "kubernetes":
|
|
848
|
+
plan.left_for_you.append(
|
|
849
|
+
f"put {api['token_env']} in .env and in .env.<env>, then run "
|
|
850
|
+
"`graph-agents-cli secrets apply --env <env>`"
|
|
851
|
+
)
|
|
852
|
+
elif api["auth"] == "bearer":
|
|
853
|
+
plan.left_for_you.append(
|
|
854
|
+
f"put {api['token_env']} in .env (local runs) and in the environment the agent runs in"
|
|
855
|
+
)
|
|
856
|
+
elif api["auth"] == "forward" and api.get("forward_audience"):
|
|
857
|
+
plan.left_for_you.append(
|
|
858
|
+
f"have the issuer mint the users' tokens for audience {api['forward_audience']} too "
|
|
859
|
+
"(the caller's own token is forwarded only when its aud names it), or make the auth "
|
|
860
|
+
f"policy give callers attributes['credentials']['{name}']"
|
|
861
|
+
)
|
|
862
|
+
elif api["auth"] == "forward":
|
|
863
|
+
plan.left_for_you.append(
|
|
864
|
+
f"make the auth policy give callers attributes['credentials']['{name}'] "
|
|
865
|
+
"(the credential forwarded to the API)"
|
|
866
|
+
)
|
|
867
|
+
elif api["auth"] == EXCHANGE_KEY:
|
|
868
|
+
audience = api[EXCHANGE_KEY]["audience"]
|
|
869
|
+
if first_exchange:
|
|
870
|
+
where = "in .env" + (
|
|
871
|
+
" and in the chart's values-<env>.yaml env:"
|
|
872
|
+
if config.deployment_target == "kubernetes"
|
|
873
|
+
else ""
|
|
874
|
+
)
|
|
875
|
+
plan.left_for_you.append(
|
|
876
|
+
f"set TOKEN_EXCHANGE_URL (the issuer's token endpoint) and TOKEN_EXCHANGE_CLIENT_ID "
|
|
877
|
+
f"(this agent's client there) {where}; put TOKEN_EXCHANGE_CLIENT_SECRET in .env"
|
|
878
|
+
+ (
|
|
879
|
+
" and in .env.<env>, then run `graph-agents-cli secrets apply --env <env>`"
|
|
880
|
+
if config.deployment_target == "kubernetes"
|
|
881
|
+
else " and in the environment the agent runs in"
|
|
882
|
+
)
|
|
883
|
+
)
|
|
884
|
+
plan.left_for_you.append(
|
|
885
|
+
f"at the issuer, let this agent's client exchange users' tokens for audience "
|
|
886
|
+
f"{audience} (and name it in the exchanged token's act claim)"
|
|
887
|
+
)
|
|
888
|
+
if api[EXCHANGE_KEY].get(ALLOW_ACTORLESS_KEY) is True:
|
|
889
|
+
plan.left_for_you.append(
|
|
890
|
+
f"on the agent behind {name}: AUTH_JWT_AUDIENCE includes {audience}; set "
|
|
891
|
+
"AUTH_JWT_DIRECT_CLIENTS=<the clients people sign in with>, and list this agent "
|
|
892
|
+
"in AUTH_ALLOWED_ACTORS as client:<its client id> (and as its act id, if the "
|
|
893
|
+
"issuer names one). exchange.allow_actorless lets this agent send tokens that "
|
|
894
|
+
"name no actor: without those settings, that agent reads this agent's calls as "
|
|
895
|
+
"the person's own, and this agent could decide the person's approvals there"
|
|
896
|
+
)
|
|
897
|
+
else:
|
|
898
|
+
plan.left_for_you.append(
|
|
899
|
+
f"on the agent behind {name}: AUTH_JWT_AUDIENCE includes {audience}, and "
|
|
900
|
+
"AUTH_ALLOWED_ACTORS includes this agent's client id"
|
|
901
|
+
)
|
|
902
|
+
plan.left_for_you.append(
|
|
903
|
+
"check that the issuer names this agent in the exchanged token's act claim "
|
|
904
|
+
"(some issuers, Keycloak among them, add none; decode one to see): this agent "
|
|
905
|
+
f"refuses a token that names no actor, so calls to {name} fail. The opt-in "
|
|
906
|
+
f"exchange.allow_actorless: true (api add --allow-actorless) needs, on the agent "
|
|
907
|
+
f"behind {name}, AUTH_JWT_DIRECT_CLIENTS=<the clients people sign in with> and "
|
|
908
|
+
"this agent in AUTH_ALLOWED_ACTORS as client:<its client id>"
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
@api_group.command("remove")
|
|
913
|
+
@click.argument("name")
|
|
914
|
+
@_dry_run_option
|
|
915
|
+
def cmd_remove(name: str, dry_run: bool) -> None:
|
|
916
|
+
"""Remove an API (the last one removes api-policy.yaml)."""
|
|
917
|
+
project = _load_project()
|
|
918
|
+
api = project.api(name)
|
|
919
|
+
assert project.document is not None
|
|
920
|
+
document = copy.deepcopy(project.document)
|
|
921
|
+
del document["apis"][name]
|
|
922
|
+
plan = Plan(project.root)
|
|
923
|
+
after: dict[str, Any] | None = document
|
|
924
|
+
if not document["apis"]:
|
|
925
|
+
after = None
|
|
926
|
+
plan.set_text(POLICY_FILENAME, project.text, None)
|
|
927
|
+
else:
|
|
928
|
+
editor = project.editor()
|
|
929
|
+
_edit(lambda: editor.delete(("apis", name)), f"delete apis.{name}")
|
|
930
|
+
plan.set_text(POLICY_FILENAME, project.text, editor.text)
|
|
931
|
+
sync_manifest(plan, project.config, document=after, previous=project.document)
|
|
932
|
+
others = list(document["apis"].values())
|
|
933
|
+
keep = {str(o["base_url_env"]) for o in others} | {
|
|
934
|
+
str(o["token_env"]) for o in others if o.get("token_env")
|
|
935
|
+
}
|
|
936
|
+
env_example_remove(plan, name, api, keep, document["apis"])
|
|
937
|
+
if api["base_url_env"] not in keep:
|
|
938
|
+
values_remove(
|
|
939
|
+
plan, project.config, api["base_url_env"], {str(o["base_url_env"]) for o in others}
|
|
940
|
+
)
|
|
941
|
+
if api["auth"] == EXCHANGE_KEY and not _exchanges(document if others else None):
|
|
942
|
+
values_exchange_remove(plan, project.config)
|
|
943
|
+
if api.get("openapi"):
|
|
944
|
+
plan.left_for_you.append(f"delete {api['openapi']} if nothing else uses it")
|
|
945
|
+
notes = []
|
|
946
|
+
if after is None:
|
|
947
|
+
notes.append(
|
|
948
|
+
f"{name} was the only API: {POLICY_FILENAME} goes, so every outbound call is refused"
|
|
949
|
+
)
|
|
950
|
+
_finish(
|
|
951
|
+
project,
|
|
952
|
+
plan,
|
|
953
|
+
dry_run=dry_run,
|
|
954
|
+
api=name,
|
|
955
|
+
before=project.document,
|
|
956
|
+
after=after,
|
|
957
|
+
widens=False,
|
|
958
|
+
notes=notes,
|
|
959
|
+
next_steps=False,
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
# ---------------------------------------------------------------------------
|
|
964
|
+
# access / allow / deny / revoke / limits
|
|
965
|
+
# ---------------------------------------------------------------------------
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
@api_group.command("access")
|
|
969
|
+
@click.argument("name")
|
|
970
|
+
@click.argument("access", type=click.Choice(list(ch.ACCESS_CHOICES)))
|
|
971
|
+
@click.option("--methods", "methods", default=None, help='custom: e.g. GET,POST (or "*").')
|
|
972
|
+
@_dry_run_option
|
|
973
|
+
def cmd_access(name: str, access: str, methods: str | None, dry_run: bool) -> None:
|
|
974
|
+
"""Set the HTTP methods an API allows (read-only, read-write, or custom --methods)."""
|
|
975
|
+
new_methods = ch.access_methods(access, ch.parse_methods(methods))
|
|
976
|
+
project = _load_project()
|
|
977
|
+
api = project.api(name)
|
|
978
|
+
old_methods = [str(m).upper() for m in api["allowed_methods"]]
|
|
979
|
+
if sorted(old_methods) == sorted(new_methods):
|
|
980
|
+
Console().print(f"{name} already allows {ch.describe_methods(old_methods)}.")
|
|
981
|
+
return
|
|
982
|
+
document = copy.deepcopy(project.document)
|
|
983
|
+
assert document is not None
|
|
984
|
+
document["apis"][name]["allowed_methods"] = new_methods
|
|
985
|
+
_validate(project, document)
|
|
986
|
+
editor = project.editor()
|
|
987
|
+
_edit(
|
|
988
|
+
lambda: editor.set(("apis", name, "allowed_methods"), new_methods),
|
|
989
|
+
f"set apis.{name}.allowed_methods to {new_methods}",
|
|
990
|
+
)
|
|
991
|
+
plan = Plan(project.root)
|
|
992
|
+
plan.set_text(POLICY_FILENAME, project.text, editor.text)
|
|
993
|
+
before_set = set(ch.effective_methods(old_methods))
|
|
994
|
+
after_set = set(ch.effective_methods(new_methods))
|
|
995
|
+
notes = [f"{name}: {ch.describe_methods(old_methods)} -> {ch.describe_methods(new_methods)}"]
|
|
996
|
+
for entry in api.get(ch.ALLOWED) or []:
|
|
997
|
+
pinned = {str(m).upper() for m in entry.get("methods") or []}
|
|
998
|
+
if pinned and not pinned & after_set:
|
|
999
|
+
notes.append(
|
|
1000
|
+
f"allowed_operations entry {ch.describe_entry(entry)} has no effect while "
|
|
1001
|
+
f"{', '.join(sorted(pinned))} is not allowed"
|
|
1002
|
+
)
|
|
1003
|
+
_finish(
|
|
1004
|
+
project,
|
|
1005
|
+
plan,
|
|
1006
|
+
dry_run=dry_run,
|
|
1007
|
+
api=name,
|
|
1008
|
+
before=project.document,
|
|
1009
|
+
after=document,
|
|
1010
|
+
widens=bool(after_set - before_set),
|
|
1011
|
+
notes=notes,
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def _entry_for(
|
|
1016
|
+
project: _Project, api: dict[str, Any], ref: ch.OperationRef, methods: list[str]
|
|
1017
|
+
) -> tuple[dict[str, Any], list[str]]:
|
|
1018
|
+
spec = ch.load_spec(project.root, str(api["openapi"])) if api.get("openapi") else None
|
|
1019
|
+
return ch.build_entry(ref, methods, spec, str(api.get("openapi") or ""))
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
@api_group.command("allow")
|
|
1023
|
+
@click.argument("name")
|
|
1024
|
+
@_entry_options
|
|
1025
|
+
@click.option(
|
|
1026
|
+
"--methods", "methods", default=None, help="Limit the entry to these methods (e.g. GET,PUT)."
|
|
1027
|
+
)
|
|
1028
|
+
@click.option(
|
|
1029
|
+
"--rpc-method",
|
|
1030
|
+
"rpc_method",
|
|
1031
|
+
default=None,
|
|
1032
|
+
metavar="M",
|
|
1033
|
+
help=(
|
|
1034
|
+
"A JSON-RPC API (protocol jsonrpc or a2a): the JSON-RPC method of the request, with "
|
|
1035
|
+
"--method POST --path P (OPERATION_ID optional); A2A 1.0 names under a2a."
|
|
1036
|
+
),
|
|
1037
|
+
)
|
|
1038
|
+
@_dry_run_option
|
|
1039
|
+
def cmd_allow(
|
|
1040
|
+
name: str,
|
|
1041
|
+
operation_id: str | None,
|
|
1042
|
+
method: str | None,
|
|
1043
|
+
path: str | None,
|
|
1044
|
+
methods: str | None,
|
|
1045
|
+
rpc_method: str | None,
|
|
1046
|
+
dry_run: bool,
|
|
1047
|
+
) -> None:
|
|
1048
|
+
"""Allow one operation (an allowed_operations entry, by OPERATION_ID and/or --method/--path).
|
|
1049
|
+
|
|
1050
|
+
An entry pins every field given, and all of them must match a call: pin
|
|
1051
|
+
the method (--methods, or --method with --path) and, without an OpenAPI
|
|
1052
|
+
spec, the path, so the entry allows exactly the declared call. With the
|
|
1053
|
+
API's openapi spec recorded, OPERATION_ID must exist there and its
|
|
1054
|
+
method and path are filled in. On a JSON-RPC API, --rpc-method M --method
|
|
1055
|
+
POST --path P allows the requests with that JSON-RPC method (read from the
|
|
1056
|
+
body) at that endpoint.
|
|
1057
|
+
"""
|
|
1058
|
+
if rpc_method is not None and methods:
|
|
1059
|
+
raise click.UsageError("--rpc-method takes --method POST --path P, not --methods")
|
|
1060
|
+
ref = ch.OperationRef.from_options(
|
|
1061
|
+
operation_id, method, path, combine=True, rpc_method=rpc_method, endpoint=True
|
|
1062
|
+
)
|
|
1063
|
+
extra = ch.parse_methods(methods)
|
|
1064
|
+
project = _load_project()
|
|
1065
|
+
api = project.api(name)
|
|
1066
|
+
entry, warnings = _entry_for(project, api, ref, extra)
|
|
1067
|
+
entries = api.get(ch.ALLOWED)
|
|
1068
|
+
if entries is not None and any(ch.same_entry(e, entry) for e in entries):
|
|
1069
|
+
Console().print(f"{name} already allows {ch.describe_entry(entry)}.")
|
|
1070
|
+
return
|
|
1071
|
+
document = copy.deepcopy(project.document)
|
|
1072
|
+
assert document is not None
|
|
1073
|
+
document["apis"][name][ch.ALLOWED] = [*(entries or []), entry]
|
|
1074
|
+
_validate(project, document)
|
|
1075
|
+
editor = project.editor()
|
|
1076
|
+
_edit(
|
|
1077
|
+
lambda: editor.append(("apis", name, ch.ALLOWED), entry, after=_after(api, ch.ALLOWED)),
|
|
1078
|
+
f"add {entry} to apis.{name}.allowed_operations",
|
|
1079
|
+
)
|
|
1080
|
+
plan = Plan(project.root)
|
|
1081
|
+
plan.set_text(POLICY_FILENAME, project.text, editor.text)
|
|
1082
|
+
notes = list(warnings)
|
|
1083
|
+
creating = entries is None
|
|
1084
|
+
allowed = ch.effective_methods(api["allowed_methods"])
|
|
1085
|
+
if creating:
|
|
1086
|
+
notes.append(
|
|
1087
|
+
f"{name} had no allowed_operations, so every operation with "
|
|
1088
|
+
f"{', '.join(allowed)} was allowed. This creates the list: from now on only the "
|
|
1089
|
+
f"listed operations are allowed (before: any operation; after: only "
|
|
1090
|
+
f"{ch.describe_entry(entry)}). This narrows access."
|
|
1091
|
+
)
|
|
1092
|
+
entry_methods = [str(m).upper() for m in entry.get("methods") or []] or allowed
|
|
1093
|
+
outside = [m for m in entry_methods if m not in allowed]
|
|
1094
|
+
denied_for: set[str] = set()
|
|
1095
|
+
if outside:
|
|
1096
|
+
wanted = ",".join(m for m in HTTP_METHODS if m in {*allowed, *outside})
|
|
1097
|
+
notes.append(
|
|
1098
|
+
f"{', '.join(outside)} is not in {name}'s allowed_methods: the entry has no effect "
|
|
1099
|
+
f"for it until `graph-agents-cli api access {name} custom --methods {wanted}`"
|
|
1100
|
+
)
|
|
1101
|
+
for denial in api.get(ch.DENIED) or []:
|
|
1102
|
+
refused = {
|
|
1103
|
+
m
|
|
1104
|
+
for m in entry_methods
|
|
1105
|
+
if denial_match(
|
|
1106
|
+
denial,
|
|
1107
|
+
m,
|
|
1108
|
+
entry.get("operationId"),
|
|
1109
|
+
entry.get("path"),
|
|
1110
|
+
rpc_method=entry.get(RPC_METHOD_KEY),
|
|
1111
|
+
)
|
|
1112
|
+
== ""
|
|
1113
|
+
}
|
|
1114
|
+
if refused:
|
|
1115
|
+
denied_for |= refused
|
|
1116
|
+
notes.append(
|
|
1117
|
+
f"denied_operations entry {ch.describe_entry(denial)} still refuses it "
|
|
1118
|
+
"(denials win)"
|
|
1119
|
+
)
|
|
1120
|
+
if entry.get("path") is None and not ref.rpc:
|
|
1121
|
+
# An allow by label alone: the tool chooses the label, the entry does not say where.
|
|
1122
|
+
pinned = "no path" if entry.get("methods") else "no path and no method"
|
|
1123
|
+
notes.append(
|
|
1124
|
+
f"the entry pins {pinned}: a call labelled {entry['operationId']} is allowed on any "
|
|
1125
|
+
f"path with {', '.join(entry_methods)}; to allow exactly one call, pin its endpoint "
|
|
1126
|
+
f"too (graph-agents-cli api allow {name} {entry['operationId']} --method M --path P)"
|
|
1127
|
+
)
|
|
1128
|
+
# The entry allows a call only with a method the API allows that no denial refuses.
|
|
1129
|
+
effective = [m for m in entry_methods if m in allowed and m not in denied_for]
|
|
1130
|
+
_finish(
|
|
1131
|
+
project,
|
|
1132
|
+
plan,
|
|
1133
|
+
dry_run=dry_run,
|
|
1134
|
+
api=name,
|
|
1135
|
+
before=project.document,
|
|
1136
|
+
after=document,
|
|
1137
|
+
widens=False if creating else (True if effective else None),
|
|
1138
|
+
notes=notes,
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def _rpc_options(function: Callable[..., Any]) -> Callable[..., Any]:
|
|
1143
|
+
"""deny / revoke: ``--rpc-method M`` and ``--a2a-operation approve|reject``."""
|
|
1144
|
+
function = click.option(
|
|
1145
|
+
"--a2a-operation",
|
|
1146
|
+
"a2a_operation",
|
|
1147
|
+
type=click.Choice(list(A2A_OPERATIONS)),
|
|
1148
|
+
default=None,
|
|
1149
|
+
help=(
|
|
1150
|
+
"protocol a2a: the messages that approve (or reject) a pending approval of the "
|
|
1151
|
+
"agent behind the API, whatever their path, label or spelling."
|
|
1152
|
+
),
|
|
1153
|
+
)(function)
|
|
1154
|
+
return click.option(
|
|
1155
|
+
"--rpc-method",
|
|
1156
|
+
"rpc_method",
|
|
1157
|
+
default=None,
|
|
1158
|
+
metavar="M",
|
|
1159
|
+
help=(
|
|
1160
|
+
"A JSON-RPC API (protocol jsonrpc or a2a): the requests with this JSON-RPC method "
|
|
1161
|
+
"(read from the body; letter case ignored), whatever their path or label."
|
|
1162
|
+
),
|
|
1163
|
+
)(function)
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
@api_group.command("deny")
|
|
1167
|
+
@click.argument("name")
|
|
1168
|
+
@_entry_options
|
|
1169
|
+
@_rpc_options
|
|
1170
|
+
@_dry_run_option
|
|
1171
|
+
def cmd_deny(
|
|
1172
|
+
name: str,
|
|
1173
|
+
operation_id: str | None,
|
|
1174
|
+
method: str | None,
|
|
1175
|
+
path: str | None,
|
|
1176
|
+
rpc_method: str | None,
|
|
1177
|
+
a2a_operation: str | None,
|
|
1178
|
+
dry_run: bool,
|
|
1179
|
+
) -> None:
|
|
1180
|
+
"""Deny one operation (a denied_operations entry, by OPERATION_ID and/or --method/--path).
|
|
1181
|
+
|
|
1182
|
+
A denial with a path refuses every call to that path (with its method),
|
|
1183
|
+
whatever operation id the call names. A denial by OPERATION_ID alone
|
|
1184
|
+
knows only that label: with the API's openapi spec recorded, the id's
|
|
1185
|
+
method and path are filled in; without one, give --method M --path P
|
|
1186
|
+
too so the denial holds whatever a call is labelled. On a JSON-RPC API,
|
|
1187
|
+
--rpc-method M refuses every request with that method, and
|
|
1188
|
+
--a2a-operation approve every message that approves a pending approval
|
|
1189
|
+
of the agent behind the API (read from the body, whatever the label).
|
|
1190
|
+
"""
|
|
1191
|
+
ref = ch.OperationRef.from_options(
|
|
1192
|
+
operation_id,
|
|
1193
|
+
method,
|
|
1194
|
+
path,
|
|
1195
|
+
combine=True,
|
|
1196
|
+
rpc_method=rpc_method,
|
|
1197
|
+
a2a_operation=a2a_operation,
|
|
1198
|
+
)
|
|
1199
|
+
project = _load_project()
|
|
1200
|
+
api = project.api(name)
|
|
1201
|
+
entry, warnings = _entry_for(project, api, ref, [])
|
|
1202
|
+
entries = api.get(ch.DENIED) or []
|
|
1203
|
+
if any(ch.same_entry(e, entry) for e in entries):
|
|
1204
|
+
Console().print(f"{name} already denies {ch.describe_entry(entry)}.")
|
|
1205
|
+
return
|
|
1206
|
+
document = copy.deepcopy(project.document)
|
|
1207
|
+
assert document is not None
|
|
1208
|
+
document["apis"][name][ch.DENIED] = [*entries, entry]
|
|
1209
|
+
_validate(project, document)
|
|
1210
|
+
editor = project.editor()
|
|
1211
|
+
_edit(
|
|
1212
|
+
lambda: editor.append(("apis", name, ch.DENIED), entry, after=_after(api, ch.DENIED)),
|
|
1213
|
+
f"add {entry} to apis.{name}.denied_operations",
|
|
1214
|
+
)
|
|
1215
|
+
plan = Plan(project.root)
|
|
1216
|
+
plan.set_text(POLICY_FILENAME, project.text, editor.text)
|
|
1217
|
+
notes = list(warnings)
|
|
1218
|
+
if entry.get("operationId") is not None and entry.get("path") is None and not ref.rpc:
|
|
1219
|
+
notes.append(
|
|
1220
|
+
"a denial by operationId alone knows only that label: it refuses the calls that "
|
|
1221
|
+
"name it, and also refuses every call that names no operation_id (fail closed), "
|
|
1222
|
+
"but a call to the same endpoint under another operation_id gets past it. Pin "
|
|
1223
|
+
f"the endpoint too (graph-agents-cli api deny {name} {entry['operationId']} "
|
|
1224
|
+
"--method M --path P, or record the API's openapi spec, which fills them in)"
|
|
1225
|
+
)
|
|
1226
|
+
_finish(
|
|
1227
|
+
project,
|
|
1228
|
+
plan,
|
|
1229
|
+
dry_run=dry_run,
|
|
1230
|
+
api=name,
|
|
1231
|
+
before=project.document,
|
|
1232
|
+
after=document,
|
|
1233
|
+
widens=False,
|
|
1234
|
+
notes=notes,
|
|
1235
|
+
)
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
@api_group.command("revoke")
|
|
1239
|
+
@click.argument("name")
|
|
1240
|
+
@_operation_options
|
|
1241
|
+
@_rpc_options
|
|
1242
|
+
@click.option(
|
|
1243
|
+
"--from",
|
|
1244
|
+
"from_list",
|
|
1245
|
+
type=click.Choice(["allowed", "denied"]),
|
|
1246
|
+
default=None,
|
|
1247
|
+
help="The list to remove the entry from (needed when both have a match).",
|
|
1248
|
+
)
|
|
1249
|
+
@_dry_run_option
|
|
1250
|
+
def cmd_revoke(
|
|
1251
|
+
name: str,
|
|
1252
|
+
operation_id: str | None,
|
|
1253
|
+
method: str | None,
|
|
1254
|
+
path: str | None,
|
|
1255
|
+
rpc_method: str | None,
|
|
1256
|
+
a2a_operation: str | None,
|
|
1257
|
+
from_list: str | None,
|
|
1258
|
+
dry_run: bool,
|
|
1259
|
+
) -> None:
|
|
1260
|
+
"""Remove the allowed or denied entries naming an operation (OPERATION_ID or --method/--path).
|
|
1261
|
+
|
|
1262
|
+
On a JSON-RPC API, --rpc-method M and/or --a2a-operation approve|reject name
|
|
1263
|
+
the entries that pin them (with OPERATION_ID, the one also naming it).
|
|
1264
|
+
"""
|
|
1265
|
+
if (rpc_method is not None or a2a_operation is not None) and (method or path):
|
|
1266
|
+
raise click.UsageError(
|
|
1267
|
+
"--rpc-method and --a2a-operation name entries by what they pin: give no --method "
|
|
1268
|
+
"or --path with them"
|
|
1269
|
+
)
|
|
1270
|
+
ref = ch.OperationRef.from_options(
|
|
1271
|
+
operation_id, method, path, rpc_method=rpc_method, a2a_operation=a2a_operation
|
|
1272
|
+
)
|
|
1273
|
+
project = _load_project()
|
|
1274
|
+
api = project.api(name)
|
|
1275
|
+
keys = {"allowed": ch.ALLOWED, "denied": ch.DENIED}
|
|
1276
|
+
# What an entry without `methods` covers: an allowed one, the API's methods;
|
|
1277
|
+
# a denial, every method (it keeps denying a method allowed later).
|
|
1278
|
+
every = {
|
|
1279
|
+
"allowed": ch.effective_methods(api["allowed_methods"]),
|
|
1280
|
+
"denied": list(HTTP_METHODS),
|
|
1281
|
+
}
|
|
1282
|
+
matches = {
|
|
1283
|
+
label: ch.plan_revocations(api.get(key) or [], ref, every[label])
|
|
1284
|
+
for label, key in keys.items()
|
|
1285
|
+
if from_list in (None, label)
|
|
1286
|
+
}
|
|
1287
|
+
found = [label for label, plans in matches.items() if plans]
|
|
1288
|
+
if not found:
|
|
1289
|
+
where = f"{from_list} operations" if from_list else "allowed or denied operations"
|
|
1290
|
+
raise ch.ApiCommandError(f"no {where} entry of {name} names {ref.describe()}")
|
|
1291
|
+
if len(found) > 1:
|
|
1292
|
+
raise ch.ApiCommandError(
|
|
1293
|
+
f"{ref.describe()} is named in both allowed_operations and denied_operations of "
|
|
1294
|
+
f"{name}: pass --from allowed or --from denied"
|
|
1295
|
+
)
|
|
1296
|
+
label = found[0]
|
|
1297
|
+
key = keys[label]
|
|
1298
|
+
revocations = matches[label]
|
|
1299
|
+
entries = [dict(e) for e in api.get(key) or []]
|
|
1300
|
+
for revocation in revocations: # last index first
|
|
1301
|
+
if revocation.remaining_methods is None:
|
|
1302
|
+
del entries[revocation.index]
|
|
1303
|
+
else:
|
|
1304
|
+
entries[revocation.index]["methods"] = revocation.remaining_methods
|
|
1305
|
+
if key == ch.ALLOWED and not entries:
|
|
1306
|
+
raise ch.ApiCommandError(
|
|
1307
|
+
f"that would remove the last allowed_operations entry of {name}; without the list "
|
|
1308
|
+
"every operation within allowed_methods is allowed, which widens access. Narrow "
|
|
1309
|
+
f"with `graph-agents-cli api access {name} ...`, or remove the API with "
|
|
1310
|
+
f"`graph-agents-cli api remove {name}`"
|
|
1311
|
+
)
|
|
1312
|
+
document = copy.deepcopy(project.document)
|
|
1313
|
+
assert document is not None
|
|
1314
|
+
# The last denial takes its key with it (an empty list says nothing).
|
|
1315
|
+
drop_key = key == ch.DENIED and not entries
|
|
1316
|
+
if drop_key:
|
|
1317
|
+
del document["apis"][name][key]
|
|
1318
|
+
else:
|
|
1319
|
+
document["apis"][name][key] = entries
|
|
1320
|
+
_validate(project, document)
|
|
1321
|
+
editor = project.editor()
|
|
1322
|
+
|
|
1323
|
+
def apply() -> None:
|
|
1324
|
+
if drop_key:
|
|
1325
|
+
editor.delete(("apis", name, key))
|
|
1326
|
+
return
|
|
1327
|
+
for revocation in revocations:
|
|
1328
|
+
if revocation.remaining_methods is None:
|
|
1329
|
+
editor.remove_item(("apis", name, key), revocation.index)
|
|
1330
|
+
else:
|
|
1331
|
+
editor.set(
|
|
1332
|
+
("apis", name, key, revocation.index, "methods"), revocation.remaining_methods
|
|
1333
|
+
)
|
|
1334
|
+
|
|
1335
|
+
_edit(apply, f"in apis.{name}.{key}: " + "; ".join(r.describe() for r in revocations))
|
|
1336
|
+
plan = Plan(project.root)
|
|
1337
|
+
plan.set_text(POLICY_FILENAME, project.text, editor.text)
|
|
1338
|
+
notes = [f"{key}: {r.describe()}" for r in reversed(revocations)]
|
|
1339
|
+
if any(r.remaining_methods and not r.entry.get("methods") for r in revocations):
|
|
1340
|
+
notes.append(
|
|
1341
|
+
f"an entry without methods covered every method; it now lists the others, so "
|
|
1342
|
+
f"only {ref.method} is revoked"
|
|
1343
|
+
)
|
|
1344
|
+
if key == ch.DENIED:
|
|
1345
|
+
notes.append("lifting a denial widens access: make sure the operation should be allowed")
|
|
1346
|
+
_finish(
|
|
1347
|
+
project,
|
|
1348
|
+
plan,
|
|
1349
|
+
dry_run=dry_run,
|
|
1350
|
+
api=name,
|
|
1351
|
+
before=project.document,
|
|
1352
|
+
after=document,
|
|
1353
|
+
widens=key == ch.DENIED,
|
|
1354
|
+
notes=notes,
|
|
1355
|
+
)
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
@api_group.command("limits")
|
|
1359
|
+
@click.argument("name")
|
|
1360
|
+
@click.option(
|
|
1361
|
+
"--max-calls-per-run",
|
|
1362
|
+
"max_calls",
|
|
1363
|
+
default=None,
|
|
1364
|
+
metavar="N|none",
|
|
1365
|
+
help="Calls to this API within one agent run (none removes the limit).",
|
|
1366
|
+
)
|
|
1367
|
+
@click.option(
|
|
1368
|
+
"--rate-per-minute",
|
|
1369
|
+
"rate",
|
|
1370
|
+
default=None,
|
|
1371
|
+
metavar="N|none",
|
|
1372
|
+
help="Calls per minute, per process (replica); none removes the limit.",
|
|
1373
|
+
)
|
|
1374
|
+
@click.option(
|
|
1375
|
+
"--max-response-bytes",
|
|
1376
|
+
"max_bytes",
|
|
1377
|
+
default=None,
|
|
1378
|
+
metavar="N|none",
|
|
1379
|
+
help=(
|
|
1380
|
+
f"The most an answer may hold, in bytes (1-{MAX_RESPONSE_BYTES_LIMIT}; larger answers "
|
|
1381
|
+
"are discarded); none removes the cap."
|
|
1382
|
+
),
|
|
1383
|
+
)
|
|
1384
|
+
@_dry_run_option
|
|
1385
|
+
def cmd_limits(
|
|
1386
|
+
name: str, max_calls: str | None, rate: str | None, max_bytes: str | None, dry_run: bool
|
|
1387
|
+
) -> None:
|
|
1388
|
+
"""Set or clear an API's limits (max calls per run, rate per minute, answer size)."""
|
|
1389
|
+
if max_calls is None and rate is None and max_bytes is None:
|
|
1390
|
+
raise click.UsageError(
|
|
1391
|
+
"give --max-calls-per-run, --rate-per-minute and/or --max-response-bytes (N or none)"
|
|
1392
|
+
)
|
|
1393
|
+
max_calls_value = ch.parse_limit(max_calls, "--max-calls-per-run")
|
|
1394
|
+
rate_value = ch.parse_limit(rate, "--rate-per-minute")
|
|
1395
|
+
max_bytes_value = ch.parse_limit(max_bytes, "--max-response-bytes")
|
|
1396
|
+
project = _load_project()
|
|
1397
|
+
api = project.api(name)
|
|
1398
|
+
current = api.get("limits")
|
|
1399
|
+
new = ch.new_limits(current, max_calls_value, rate_value, max_bytes_value)
|
|
1400
|
+
if new == (dict(current) if current else None):
|
|
1401
|
+
Console().print(f"{name}'s limits are already {ch.describe_limits(current)}.")
|
|
1402
|
+
return
|
|
1403
|
+
document = copy.deepcopy(project.document)
|
|
1404
|
+
assert document is not None
|
|
1405
|
+
if new is None:
|
|
1406
|
+
document["apis"][name].pop("limits", None)
|
|
1407
|
+
else:
|
|
1408
|
+
document["apis"][name]["limits"] = new
|
|
1409
|
+
_validate(project, document)
|
|
1410
|
+
editor = project.editor()
|
|
1411
|
+
if new is None:
|
|
1412
|
+
_edit(lambda: editor.delete(("apis", name, "limits")), f"delete apis.{name}.limits")
|
|
1413
|
+
else:
|
|
1414
|
+
_edit(
|
|
1415
|
+
lambda: editor.set(("apis", name, "limits"), new, after=_after(api, "limits")),
|
|
1416
|
+
f"set apis.{name}.limits: {new}",
|
|
1417
|
+
)
|
|
1418
|
+
plan = Plan(project.root)
|
|
1419
|
+
plan.set_text(POLICY_FILENAME, project.text, editor.text)
|
|
1420
|
+
notes = [f"{name} limits: {ch.describe_limits(current)} -> {ch.describe_limits(new)}"]
|
|
1421
|
+
_finish(
|
|
1422
|
+
project,
|
|
1423
|
+
plan,
|
|
1424
|
+
dry_run=dry_run,
|
|
1425
|
+
api=name,
|
|
1426
|
+
before=project.document,
|
|
1427
|
+
after=document,
|
|
1428
|
+
widens=ch.limits_widen(current, new),
|
|
1429
|
+
notes=notes,
|
|
1430
|
+
next_steps=False,
|
|
1431
|
+
)
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
# ---------------------------------------------------------------------------
|
|
1435
|
+
# approval
|
|
1436
|
+
# ---------------------------------------------------------------------------
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def _parse_approvers(value: str) -> list[str]:
|
|
1440
|
+
"""``requester, role:ops`` -> ``["requester", "role:ops"]`` (repeats dropped, order kept)."""
|
|
1441
|
+
items = [item.strip() for item in value.split(",") if item.strip()]
|
|
1442
|
+
if not items:
|
|
1443
|
+
raise click.UsageError("--approvers needs requester and/or role:<name>")
|
|
1444
|
+
for item in items:
|
|
1445
|
+
role = item[len(ROLE_APPROVER_PREFIX) :] if item.startswith(ROLE_APPROVER_PREFIX) else None
|
|
1446
|
+
if item != REQUESTER_APPROVER and not (
|
|
1447
|
+
role and len(role) <= 256 and not any(c.isspace() or c == "," for c in role)
|
|
1448
|
+
):
|
|
1449
|
+
raise click.UsageError(
|
|
1450
|
+
f"--approvers: {item!r} is not an approver (requester, or role:<name> with a "
|
|
1451
|
+
"role name of 1-256 characters without spaces or commas)"
|
|
1452
|
+
)
|
|
1453
|
+
return list(dict.fromkeys(items))
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
def _parse_relayers(value: str) -> list[str]:
|
|
1457
|
+
"""``concierge, billing`` -> ``["concierge", "billing"]``: the agents' actor ids."""
|
|
1458
|
+
items = [item.strip() for item in value.split(",") if item.strip()]
|
|
1459
|
+
if not items:
|
|
1460
|
+
raise click.UsageError("--relayers needs at least one agent id")
|
|
1461
|
+
for item in items:
|
|
1462
|
+
if len(item) > 256 or any(c.isspace() or ord(c) < 32 or ord(c) == 127 for c in item):
|
|
1463
|
+
raise click.UsageError(
|
|
1464
|
+
f"--relayers: {item!r} is not an agent id (1-256 characters without spaces, "
|
|
1465
|
+
"commas or control characters)"
|
|
1466
|
+
)
|
|
1467
|
+
return list(dict.fromkeys(items))
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
def _parse_a2a_operations(value: str) -> list[str]:
|
|
1471
|
+
"""``approve, reject`` -> ``["approve", "reject"]`` (repeats dropped, order kept)."""
|
|
1472
|
+
items = [item.strip() for item in value.split(",") if item.strip()]
|
|
1473
|
+
if not items:
|
|
1474
|
+
raise click.UsageError("--a2a-operations needs approve and/or reject (or none)")
|
|
1475
|
+
for item in items:
|
|
1476
|
+
if item not in A2A_OPERATIONS:
|
|
1477
|
+
raise click.UsageError(f"--a2a-operations: {item!r} is not approve or reject")
|
|
1478
|
+
return list(dict.fromkeys(items))
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
def _a2a_gate_entries(current: list[Any], operations: list[str]) -> list[dict[str, Any]]:
|
|
1482
|
+
"""``required_for.operations`` entries for the A2A decisions ``operations``: the rule's own
|
|
1483
|
+
entry for each, kept as written, else ``{a2a_operation: <it>}``."""
|
|
1484
|
+
entries = []
|
|
1485
|
+
for operation in operations:
|
|
1486
|
+
existing = next(
|
|
1487
|
+
(e for e in current if isinstance(e, dict) and e.get(A2A_OPERATION_KEY) == operation),
|
|
1488
|
+
None,
|
|
1489
|
+
)
|
|
1490
|
+
entries.append(dict(existing) if existing is not None else {A2A_OPERATION_KEY: operation})
|
|
1491
|
+
return entries
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
def _parse_operation_ids(value: str) -> list[str]:
|
|
1495
|
+
items = [item.strip() for item in value.split(",") if item.strip()]
|
|
1496
|
+
if not items:
|
|
1497
|
+
raise click.UsageError("--operations needs at least one operationId (or none)")
|
|
1498
|
+
for item in items:
|
|
1499
|
+
if any(c.isspace() for c in item):
|
|
1500
|
+
raise click.UsageError(f"--operations: {item!r} contains whitespace")
|
|
1501
|
+
return list(dict.fromkeys(items))
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
def _gate_entries(
|
|
1505
|
+
project: _Project, api: dict[str, Any], operation_ids: list[str], current: list[Any]
|
|
1506
|
+
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
1507
|
+
"""``required_for.operations`` entries for ``operation_ids``, and notes.
|
|
1508
|
+
|
|
1509
|
+
An entry the block already has for an id is kept as it is (with any path
|
|
1510
|
+
or methods pinned by hand). With the API's openapi spec a new entry also
|
|
1511
|
+
pins the operation's method and path, so the gate holds for every call to
|
|
1512
|
+
that endpoint whatever label a tool gives it; an entry that knew only the
|
|
1513
|
+
operationId (written before the spec was recorded) gets the path too,
|
|
1514
|
+
keeping its methods (none: every method), so it gates at least what it did.
|
|
1515
|
+
"""
|
|
1516
|
+
spec_name = str(api.get("openapi") or "")
|
|
1517
|
+
spec = ch.load_spec(project.root, spec_name) if spec_name else None
|
|
1518
|
+
entries: list[dict[str, Any]] = []
|
|
1519
|
+
notes: list[str] = []
|
|
1520
|
+
labels_only: list[str] = []
|
|
1521
|
+
for op_id in operation_ids:
|
|
1522
|
+
existing = next(
|
|
1523
|
+
(e for e in current if isinstance(e, dict) and e.get("operationId") == op_id), None
|
|
1524
|
+
)
|
|
1525
|
+
if existing is not None:
|
|
1526
|
+
if existing.get("path") is None and spec is not None:
|
|
1527
|
+
pinned, warnings = ch.build_entry(
|
|
1528
|
+
ch.OperationRef(operation_id=op_id), [], spec, spec_name
|
|
1529
|
+
)
|
|
1530
|
+
if pinned.get("path") is not None:
|
|
1531
|
+
pinned.pop("methods", None)
|
|
1532
|
+
if existing.get("methods") is not None:
|
|
1533
|
+
pinned["methods"] = list(existing["methods"])
|
|
1534
|
+
entries.append(pinned)
|
|
1535
|
+
notes.extend(warnings)
|
|
1536
|
+
notes.append(
|
|
1537
|
+
f"the gate on {op_id} is now pinned to its endpoint from the openapi spec "
|
|
1538
|
+
f"({ch.describe_entry(pinned)}): a call to it under another label waits "
|
|
1539
|
+
"for approval too"
|
|
1540
|
+
)
|
|
1541
|
+
continue
|
|
1542
|
+
entries.append(dict(existing))
|
|
1543
|
+
if existing.get("path") is None:
|
|
1544
|
+
labels_only.append(op_id)
|
|
1545
|
+
continue
|
|
1546
|
+
entry, warnings = ch.build_entry(
|
|
1547
|
+
ch.OperationRef(operation_id=op_id), [], spec, str(api.get("openapi") or "")
|
|
1548
|
+
)
|
|
1549
|
+
entries.append(entry)
|
|
1550
|
+
notes.extend(warnings)
|
|
1551
|
+
if entry.get("path") is None:
|
|
1552
|
+
labels_only.append(op_id)
|
|
1553
|
+
if labels_only:
|
|
1554
|
+
notes.append(
|
|
1555
|
+
f"the gate on {', '.join(labels_only)} knows only the operationId: it holds for "
|
|
1556
|
+
"the calls that name it and for calls that name no operation_id (fail closed), but "
|
|
1557
|
+
"not for a call to the same endpoint under another label. Record the API's openapi "
|
|
1558
|
+
"spec (which pins the endpoint), or add path and methods to the entry by hand"
|
|
1559
|
+
)
|
|
1560
|
+
return entries, notes
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def _gated_methods(block: Mapping[str, Any] | None) -> set[str]:
|
|
1564
|
+
if not block:
|
|
1565
|
+
return set()
|
|
1566
|
+
methods = [str(m).upper() for m in block["required_for"].get("methods") or []]
|
|
1567
|
+
return set(HTTP_METHODS) if ANY_METHOD in methods else set(methods)
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def _gate_entry_covers(new: Mapping[str, Any], old: Mapping[str, Any]) -> bool:
|
|
1571
|
+
"""Whether gate entry ``new`` gates every call ``old`` did.
|
|
1572
|
+
|
|
1573
|
+
The same entry, or ``old`` (an operationId alone) pinned to its endpoint
|
|
1574
|
+
with no fewer methods: a gate entry fails closed like a denial, so adding
|
|
1575
|
+
a path only gates more calls.
|
|
1576
|
+
"""
|
|
1577
|
+
if ch.same_entry(old, new):
|
|
1578
|
+
return True
|
|
1579
|
+
if old.get("path") is not None or old.get("operationId") is None:
|
|
1580
|
+
return False
|
|
1581
|
+
if new.get("operationId") != old.get("operationId"):
|
|
1582
|
+
return False
|
|
1583
|
+
new_methods = new.get("methods")
|
|
1584
|
+
if new_methods is None:
|
|
1585
|
+
return True
|
|
1586
|
+
old_methods = old.get("methods")
|
|
1587
|
+
return old_methods is not None and {str(m).upper() for m in new_methods} >= {
|
|
1588
|
+
str(m).upper() for m in old_methods
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
def _gate_loosening(before: Mapping[str, Any] | None, after: Mapping[str, Any] | None) -> list[str]:
|
|
1593
|
+
"""How the change loosens one rule (effective rules): empty when it tightens or keeps it.
|
|
1594
|
+
|
|
1595
|
+
Loosening: a call that waited may go out without a human (a method or an
|
|
1596
|
+
operation no longer gated, the block removed), someone new may approve, an
|
|
1597
|
+
agent may now relay the requester's decision (``decide_with: relayed``, or
|
|
1598
|
+
a new relayer), or a pending approval stays open longer. Moving back to
|
|
1599
|
+
``direct`` narrows the gate.
|
|
1600
|
+
"""
|
|
1601
|
+
if before is None:
|
|
1602
|
+
return []
|
|
1603
|
+
if after is None:
|
|
1604
|
+
return ["no call waits for an approval any more"]
|
|
1605
|
+
reasons = []
|
|
1606
|
+
ungated = [m for m in HTTP_METHODS if m in _gated_methods(before) - _gated_methods(after)]
|
|
1607
|
+
if ungated:
|
|
1608
|
+
reasons.append(f"{', '.join(ungated)} calls no longer wait as such")
|
|
1609
|
+
old_ops = before["required_for"].get("operations") or []
|
|
1610
|
+
new_ops = after["required_for"].get("operations") or []
|
|
1611
|
+
dropped = [o for o in old_ops if not any(_gate_entry_covers(n, o) for n in new_ops)]
|
|
1612
|
+
if dropped:
|
|
1613
|
+
reasons.append(
|
|
1614
|
+
"no longer gated as written: " + "; ".join(ch.describe_entry(e) for e in dropped)
|
|
1615
|
+
)
|
|
1616
|
+
added = [a for a in after["approvers"] if a not in before["approvers"]]
|
|
1617
|
+
if added:
|
|
1618
|
+
reasons.append(f"new approver(s) {', '.join(added)}")
|
|
1619
|
+
if after.get("decide_with") == DECIDE_RELAYED:
|
|
1620
|
+
if before.get("decide_with") != DECIDE_RELAYED:
|
|
1621
|
+
reasons.append(
|
|
1622
|
+
"agents may now relay the requester's decision (decide_with: relayed, by "
|
|
1623
|
+
f"{', '.join(after.get('relayers') or [])})"
|
|
1624
|
+
)
|
|
1625
|
+
else:
|
|
1626
|
+
relayers = [r for r in after.get("relayers") or [] if r not in before["relayers"]]
|
|
1627
|
+
if relayers:
|
|
1628
|
+
reasons.append(f"new relayer(s) {', '.join(relayers)}")
|
|
1629
|
+
if after["timeout_s"] > before["timeout_s"]:
|
|
1630
|
+
reasons.append(
|
|
1631
|
+
f"pending approvals wait longer ({before['timeout_s']} -> {after['timeout_s']} s)"
|
|
1632
|
+
)
|
|
1633
|
+
return reasons
|
|
1634
|
+
|
|
1635
|
+
|
|
1636
|
+
def _entry_methods(entry: Mapping[str, Any]) -> set[str]:
|
|
1637
|
+
"""The methods a gate entry covers (none pinned: every method)."""
|
|
1638
|
+
return {str(m).upper() for m in entry.get("methods") or []} or set(HTTP_METHODS)
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
def _newly_covered(
|
|
1642
|
+
before: Mapping[str, Any] | None, after: Mapping[str, Any]
|
|
1643
|
+
) -> tuple[set[str], list[Mapping[str, Any]]]:
|
|
1644
|
+
"""What rule ``after`` gates that ``before`` did not: methods, and operation entries."""
|
|
1645
|
+
methods = _gated_methods(after) - _gated_methods(before)
|
|
1646
|
+
old_ops = (before or {}).get("required_for", {}).get("operations") or []
|
|
1647
|
+
entries = [
|
|
1648
|
+
entry
|
|
1649
|
+
for entry in after["required_for"].get("operations") or []
|
|
1650
|
+
if not _entry_methods(entry) <= _gated_methods(before)
|
|
1651
|
+
and not any(_gate_entry_covers(old, entry) for old in old_ops)
|
|
1652
|
+
]
|
|
1653
|
+
return methods, entries
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
def _may_overlap(
|
|
1657
|
+
methods: set[str], entries: list[Mapping[str, Any]], rule: Mapping[str, Any]
|
|
1658
|
+
) -> bool:
|
|
1659
|
+
"""Whether a call gated by ``methods`` or ``entries`` may also be one ``rule`` covers.
|
|
1660
|
+
|
|
1661
|
+
Conservative: entries overlap unless their methods are disjoint.
|
|
1662
|
+
"""
|
|
1663
|
+
rule_methods = _gated_methods(rule)
|
|
1664
|
+
rule_entries = rule["required_for"].get("operations") or []
|
|
1665
|
+
covered = set(methods) | {m for e in entries for m in _entry_methods(e)}
|
|
1666
|
+
if covered & rule_methods:
|
|
1667
|
+
return True
|
|
1668
|
+
return any(covered & _entry_methods(e) for e in rule_entries)
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
def _order_loosening(
|
|
1672
|
+
rules: list[dict[str, Any]], index: int, before: Mapping[str, Any] | None
|
|
1673
|
+
) -> list[str]:
|
|
1674
|
+
"""Calls rule ``index`` now covers first that a later rule, with other approvers, gated.
|
|
1675
|
+
|
|
1676
|
+
The first rule in file order that covers a call gates it, so a rule that
|
|
1677
|
+
starts covering more takes those calls from the rules after it: whoever it
|
|
1678
|
+
names and they do not may then decide them.
|
|
1679
|
+
"""
|
|
1680
|
+
after = rules[index]
|
|
1681
|
+
methods, entries = _newly_covered(before, after)
|
|
1682
|
+
if not methods and not entries:
|
|
1683
|
+
return []
|
|
1684
|
+
reasons = []
|
|
1685
|
+
for later in rules[index + 1 :]:
|
|
1686
|
+
gained = [a for a in after["approvers"] if a not in later["approvers"]]
|
|
1687
|
+
if after.get("decide_with") == DECIDE_RELAYED:
|
|
1688
|
+
later_relayers = (
|
|
1689
|
+
later.get("relayers") or [] if later.get("decide_with") == DECIDE_RELAYED else []
|
|
1690
|
+
)
|
|
1691
|
+
gained += [f"relayer {r}" for r in after["relayers"] if r not in later_relayers]
|
|
1692
|
+
if gained and _may_overlap(methods, entries, later):
|
|
1693
|
+
reasons.append(
|
|
1694
|
+
f"{after['rule']} now comes first for calls {later['rule']} may have gated "
|
|
1695
|
+
f"(approved by {describe_deciders(later)}): {', '.join(gained)} may decide "
|
|
1696
|
+
"them"
|
|
1697
|
+
)
|
|
1698
|
+
return reasons
|
|
1699
|
+
|
|
1700
|
+
|
|
1701
|
+
def _conflicts(api: Mapping[str, Any]) -> set[tuple[str, Any, Any]]:
|
|
1702
|
+
"""The rule conflicts of ``api`` (``rule_conflicts``) as comparable facts.
|
|
1703
|
+
|
|
1704
|
+
Each is an operationId named without a path, the deciders of its rule
|
|
1705
|
+
(``rule_deciders``) and those of a later rule that may also cover a call
|
|
1706
|
+
naming no operation id.
|
|
1707
|
+
"""
|
|
1708
|
+
rules = approval_rules(api)
|
|
1709
|
+
return {
|
|
1710
|
+
(str(entry["operationId"]), rule_deciders(rules[index]), rule_deciders(rules[j]))
|
|
1711
|
+
for index, entries, later in rule_conflicts(api)
|
|
1712
|
+
for entry in entries
|
|
1713
|
+
for j in later
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
def _approver_notes(
|
|
1718
|
+
project: _Project, approvers: list[str], rule: Mapping[str, Any] | None = None
|
|
1719
|
+
) -> list[str]:
|
|
1720
|
+
roles = [a for a in approvers if a.startswith(ROLE_APPROVER_PREFIX)]
|
|
1721
|
+
notes = []
|
|
1722
|
+
if rule is not None and rule.get("decide_with") == DECIDE_RELAYED:
|
|
1723
|
+
relayers = ", ".join(str(r) for r in rule.get("relayers") or [])
|
|
1724
|
+
notes.append(
|
|
1725
|
+
f"relayed: the agents {relayers} may deliver the requester's decision from another "
|
|
1726
|
+
"agent (with the requester's own token, only on threads they started, naming the "
|
|
1727
|
+
"approval's digest); list them in AUTH_ALLOWED_ACTORS too. Keep decide_with: "
|
|
1728
|
+
"direct for calls the person should confirm at this agent themselves"
|
|
1729
|
+
)
|
|
1730
|
+
if project.config.auth_policy == "shared-bearer":
|
|
1731
|
+
notes.append(
|
|
1732
|
+
"under the shared-bearer auth policy no caller is an agent acting for a user, so "
|
|
1733
|
+
"no decision is ever relayed; use jwt or custom"
|
|
1734
|
+
)
|
|
1735
|
+
if REQUESTER_APPROVER in approvers and not roles:
|
|
1736
|
+
notes.append(
|
|
1737
|
+
"requester confirms their own calls: the person whose run it is sees each gated "
|
|
1738
|
+
"call before it is sent. For a second person's review (four-eyes), list "
|
|
1739
|
+
"role:<name> approvers without requester"
|
|
1740
|
+
)
|
|
1741
|
+
elif REQUESTER_APPROVER not in approvers:
|
|
1742
|
+
notes.append(
|
|
1743
|
+
"requester is not an approver: another principal than the one who started the run, "
|
|
1744
|
+
f"holding {' or '.join(roles)}, must decide each gated call (four-eyes)"
|
|
1745
|
+
)
|
|
1746
|
+
if roles and project.config.auth_policy == "shared-bearer":
|
|
1747
|
+
notes.append(
|
|
1748
|
+
"under the shared-bearer auth policy every caller is the one principal 'shared', so "
|
|
1749
|
+
"only requester approvals can be decided (a role: approver must be another "
|
|
1750
|
+
"principal); use the jwt or custom policy for per-user principals and roles"
|
|
1751
|
+
)
|
|
1752
|
+
return notes
|
|
1753
|
+
|
|
1754
|
+
|
|
1755
|
+
def _rules_listing(api: Mapping[str, Any]) -> str:
|
|
1756
|
+
"""Each approval rule of ``api`` on its own line: `` approval[1]: ...; approved by ...``."""
|
|
1757
|
+
return "\n".join(
|
|
1758
|
+
f" {rule['rule']}: {_describe_rule(rule)}" for rule in effective_approval_rules(api)
|
|
1759
|
+
)
|
|
1760
|
+
|
|
1761
|
+
|
|
1762
|
+
def _add_rule_command(
|
|
1763
|
+
name: str,
|
|
1764
|
+
methods: str | None,
|
|
1765
|
+
operations: str | None,
|
|
1766
|
+
approvers: list[str],
|
|
1767
|
+
timeout_s: int | None,
|
|
1768
|
+
a2a_operations: str | None = None,
|
|
1769
|
+
) -> str:
|
|
1770
|
+
"""The ``api approval --add-rule`` command that gates the given calls for ``approvers``."""
|
|
1771
|
+
parts = ["graph-agents-cli", "api", "approval", name, "--add-rule"]
|
|
1772
|
+
if methods is not None and methods.strip().lower() != "none":
|
|
1773
|
+
parts += ["--methods", methods]
|
|
1774
|
+
if operations is not None and operations.strip().lower() != "none":
|
|
1775
|
+
parts += ["--operations", operations]
|
|
1776
|
+
if a2a_operations is not None and a2a_operations.strip().lower() != "none":
|
|
1777
|
+
parts += ["--a2a-operations", a2a_operations]
|
|
1778
|
+
parts += ["--approvers", ",".join(approvers)]
|
|
1779
|
+
if timeout_s is not None:
|
|
1780
|
+
parts += ["--timeout-s", str(timeout_s)]
|
|
1781
|
+
return " ".join(parts)
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
def _set_decide_with(
|
|
1785
|
+
new_rule: dict[str, Any],
|
|
1786
|
+
old_rule: Mapping[str, Any] | None,
|
|
1787
|
+
decide_with: str | None,
|
|
1788
|
+
relayer_list: list[str] | None,
|
|
1789
|
+
) -> None:
|
|
1790
|
+
"""Put the rule's ``decide_with`` and ``relayers`` in ``new_rule``: the old rule's, with
|
|
1791
|
+
the options given applied (``direct`` drops both keys: absent means direct)."""
|
|
1792
|
+
old = old_rule or {}
|
|
1793
|
+
for key in ("decide_with", "relayers"):
|
|
1794
|
+
if key in old:
|
|
1795
|
+
new_rule[key] = copy.deepcopy(old[key])
|
|
1796
|
+
if decide_with == DECIDE_DIRECT:
|
|
1797
|
+
new_rule.pop("relayers", None)
|
|
1798
|
+
if old.get("decide_with") != DECIDE_DIRECT:
|
|
1799
|
+
new_rule.pop("decide_with", None)
|
|
1800
|
+
elif decide_with == DECIDE_RELAYED:
|
|
1801
|
+
new_rule["decide_with"] = DECIDE_RELAYED
|
|
1802
|
+
if relayer_list is not None:
|
|
1803
|
+
if new_rule.get("decide_with") != DECIDE_RELAYED:
|
|
1804
|
+
raise click.UsageError(
|
|
1805
|
+
"--relayers needs --decide-with relayed (the rule decides directly)"
|
|
1806
|
+
)
|
|
1807
|
+
new_rule["relayers"] = relayer_list
|
|
1808
|
+
if new_rule.get("decide_with") == DECIDE_RELAYED and not new_rule.get("relayers"):
|
|
1809
|
+
raise click.UsageError(
|
|
1810
|
+
"--decide-with relayed needs --relayers: the agents, by actor id, that may relay "
|
|
1811
|
+
"the requester's decision"
|
|
1812
|
+
)
|
|
1813
|
+
|
|
1814
|
+
|
|
1815
|
+
def _edit_rule(
|
|
1816
|
+
editor: YamlText, base: tuple[Any, ...], old: Mapping[str, Any], new: Mapping[str, Any]
|
|
1817
|
+
) -> None:
|
|
1818
|
+
"""Rewrite approval rule ``old`` at ``base`` into ``new``, key by key (comments stay)."""
|
|
1819
|
+
old_rf, new_rf = old["required_for"], new["required_for"]
|
|
1820
|
+
# New keys first: a mapping must keep a key while another is deleted.
|
|
1821
|
+
for key in ("methods", "operations"):
|
|
1822
|
+
if key in new_rf and old_rf.get(key) != new_rf[key]:
|
|
1823
|
+
editor.set((*base, "required_for", key), new_rf[key])
|
|
1824
|
+
for key in ("methods", "operations"):
|
|
1825
|
+
if key not in new_rf and key in old_rf:
|
|
1826
|
+
editor.delete((*base, "required_for", key))
|
|
1827
|
+
for key in ("approvers", "timeout_s", "decide_with", "relayers"):
|
|
1828
|
+
if key in new and old.get(key) != new[key]:
|
|
1829
|
+
editor.set((*base, key), new[key])
|
|
1830
|
+
for key in ("relayers", "decide_with"):
|
|
1831
|
+
if key not in new and key in old:
|
|
1832
|
+
editor.delete((*base, key))
|
|
1833
|
+
|
|
1834
|
+
|
|
1835
|
+
@api_group.command("approval")
|
|
1836
|
+
@click.argument("name")
|
|
1837
|
+
@click.option(
|
|
1838
|
+
"--methods",
|
|
1839
|
+
"methods",
|
|
1840
|
+
default=None,
|
|
1841
|
+
metavar="M,...|none",
|
|
1842
|
+
help='Gate every call with these methods (e.g. POST,PATCH,DELETE, or "*"); none clears it.',
|
|
1843
|
+
)
|
|
1844
|
+
@click.option(
|
|
1845
|
+
"--operations",
|
|
1846
|
+
"operations",
|
|
1847
|
+
default=None,
|
|
1848
|
+
metavar="OP,...|none",
|
|
1849
|
+
help=(
|
|
1850
|
+
"Gate these operations by operationId (the API's openapi spec pins their method and "
|
|
1851
|
+
"path); none clears it."
|
|
1852
|
+
),
|
|
1853
|
+
)
|
|
1854
|
+
@click.option(
|
|
1855
|
+
"--a2a-operations",
|
|
1856
|
+
"a2a_operations",
|
|
1857
|
+
default=None,
|
|
1858
|
+
metavar="approve[,reject]|none",
|
|
1859
|
+
help=(
|
|
1860
|
+
"protocol a2a: gate the messages that approve (or reject) a pending approval of the "
|
|
1861
|
+
"agent behind the API, whatever their path, label or spelling; none clears it."
|
|
1862
|
+
),
|
|
1863
|
+
)
|
|
1864
|
+
@click.option(
|
|
1865
|
+
"--approvers",
|
|
1866
|
+
"approvers",
|
|
1867
|
+
default=None,
|
|
1868
|
+
metavar="A,...",
|
|
1869
|
+
help="Who decides: requester and/or role:<name> (e.g. requester, or role:ops for four-eyes).",
|
|
1870
|
+
)
|
|
1871
|
+
@click.option(
|
|
1872
|
+
"--timeout-s",
|
|
1873
|
+
"timeout_s",
|
|
1874
|
+
type=click.IntRange(MIN_APPROVAL_TIMEOUT_S, MAX_APPROVAL_TIMEOUT_S),
|
|
1875
|
+
default=None,
|
|
1876
|
+
metavar="N",
|
|
1877
|
+
help=(
|
|
1878
|
+
f"Seconds a pending approval waits before it expires, which rejects the call "
|
|
1879
|
+
f"({MIN_APPROVAL_TIMEOUT_S}-{MAX_APPROVAL_TIMEOUT_S}; default "
|
|
1880
|
+
f"{DEFAULT_APPROVAL_TIMEOUT_S})."
|
|
1881
|
+
),
|
|
1882
|
+
)
|
|
1883
|
+
@click.option(
|
|
1884
|
+
"--decide-with",
|
|
1885
|
+
"decide_with",
|
|
1886
|
+
type=click.Choice([DECIDE_DIRECT, DECIDE_RELAYED]),
|
|
1887
|
+
default=None,
|
|
1888
|
+
help=(
|
|
1889
|
+
"How the requester decides: direct (the default: with their own credentials, at this "
|
|
1890
|
+
"agent) or relayed (the agents --relayers names may deliver the requester's decision "
|
|
1891
|
+
"from another agent; a loosening, reviewed like one)."
|
|
1892
|
+
),
|
|
1893
|
+
)
|
|
1894
|
+
@click.option(
|
|
1895
|
+
"--relayers",
|
|
1896
|
+
"relayers",
|
|
1897
|
+
default=None,
|
|
1898
|
+
metavar="ID,...",
|
|
1899
|
+
help=(
|
|
1900
|
+
"With --decide-with relayed: the agents, by actor id (the act.sub of their exchanged "
|
|
1901
|
+
"tokens, usually their client ids), that may relay the requester's decision."
|
|
1902
|
+
),
|
|
1903
|
+
)
|
|
1904
|
+
@click.option(
|
|
1905
|
+
"--add-rule",
|
|
1906
|
+
"add_rule",
|
|
1907
|
+
is_flag=True,
|
|
1908
|
+
default=False,
|
|
1909
|
+
help=(
|
|
1910
|
+
"Add a rule after the existing ones, for calls other approvers decide (needs "
|
|
1911
|
+
"--approvers, and --methods and/or --operations)."
|
|
1912
|
+
),
|
|
1913
|
+
)
|
|
1914
|
+
@click.option(
|
|
1915
|
+
"--rule",
|
|
1916
|
+
"rule_index",
|
|
1917
|
+
type=click.IntRange(min=0),
|
|
1918
|
+
default=None,
|
|
1919
|
+
metavar="N",
|
|
1920
|
+
help=(
|
|
1921
|
+
"Change, or --remove, rule N of a list of rules: approval[N], numbered from 0 in file "
|
|
1922
|
+
"order as `api show` prints them (0 is also a single approval block)."
|
|
1923
|
+
),
|
|
1924
|
+
)
|
|
1925
|
+
@click.option(
|
|
1926
|
+
"--remove",
|
|
1927
|
+
"remove",
|
|
1928
|
+
is_flag=True,
|
|
1929
|
+
default=False,
|
|
1930
|
+
help=(
|
|
1931
|
+
"Remove the approval block, every rule of it (the API's calls are sent without waiting "
|
|
1932
|
+
"for a human), or with --rule N that one rule."
|
|
1933
|
+
),
|
|
1934
|
+
)
|
|
1935
|
+
@_dry_run_option
|
|
1936
|
+
def cmd_approval(
|
|
1937
|
+
name: str,
|
|
1938
|
+
methods: str | None,
|
|
1939
|
+
operations: str | None,
|
|
1940
|
+
a2a_operations: str | None,
|
|
1941
|
+
approvers: str | None,
|
|
1942
|
+
timeout_s: int | None,
|
|
1943
|
+
decide_with: str | None,
|
|
1944
|
+
relayers: str | None,
|
|
1945
|
+
add_rule: bool,
|
|
1946
|
+
rule_index: int | None,
|
|
1947
|
+
remove: bool,
|
|
1948
|
+
dry_run: bool,
|
|
1949
|
+
) -> None:
|
|
1950
|
+
"""Require a human approval before some of an API's calls are sent (or --remove it).
|
|
1951
|
+
|
|
1952
|
+
A gated call is still allowed by the rest of the policy first (approval
|
|
1953
|
+
never widens access; denials still win). The run pauses before sending it
|
|
1954
|
+
and an approver decides: requester (the principal who started the run
|
|
1955
|
+
confirms) and/or role:<name> (a principal holding that role; list roles
|
|
1956
|
+
without requester for a second person's review). Approving sends exactly
|
|
1957
|
+
the call shown, once; rejecting or expiry sends nothing.
|
|
1958
|
+
|
|
1959
|
+
\b
|
|
1960
|
+
Each option given replaces that part of the rule and keeps the rest:
|
|
1961
|
+
--methods POST,DELETE every call with those methods
|
|
1962
|
+
--operations cancelOrder calls to those operations
|
|
1963
|
+
--a2a-operations approve (protocol a2a) messages that approve one
|
|
1964
|
+
of the called agent's pending approvals
|
|
1965
|
+
--approvers requester who decides (required for a new rule)
|
|
1966
|
+
--timeout-s 900 how long a pending approval waits
|
|
1967
|
+
--decide-with relayed --relayers concierge
|
|
1968
|
+
let the agent concierge deliver the
|
|
1969
|
+
requester's decision (default: direct)
|
|
1970
|
+
|
|
1971
|
+
\b
|
|
1972
|
+
Different approvers for different calls: add a rule, and name one to
|
|
1973
|
+
change or remove by its number (api show lists them):
|
|
1974
|
+
--add-rule --operations createOrder --approvers role:admin
|
|
1975
|
+
--rule 1 --approvers role:admin,role:ops
|
|
1976
|
+
--rule 1 --remove
|
|
1977
|
+
The approval block then holds a list of rules, and a call is gated by the
|
|
1978
|
+
FIRST rule in file order that covers it, with that rule's approvers; a
|
|
1979
|
+
later rule that also covers it does not apply to it (lint and api show
|
|
1980
|
+
name the rule each declared call waits for).
|
|
1981
|
+
"""
|
|
1982
|
+
changing = (methods, operations, a2a_operations, approvers, timeout_s, decide_with, relayers)
|
|
1983
|
+
if add_rule and rule_index is not None:
|
|
1984
|
+
raise click.UsageError(
|
|
1985
|
+
"--add-rule adds a new rule and --rule N changes an existing one: give one of them"
|
|
1986
|
+
)
|
|
1987
|
+
if remove and add_rule:
|
|
1988
|
+
raise click.UsageError("--remove and --add-rule: give one of them")
|
|
1989
|
+
if remove and any(option is not None for option in changing):
|
|
1990
|
+
raise click.UsageError("--remove takes no other option (but --rule N)")
|
|
1991
|
+
if not remove and all(option is None for option in changing):
|
|
1992
|
+
raise click.UsageError(
|
|
1993
|
+
"give --methods, --operations and/or --a2a-operations, --approvers, --timeout-s, "
|
|
1994
|
+
"--decide-with, --relayers, or --remove"
|
|
1995
|
+
)
|
|
1996
|
+
new_methods = None
|
|
1997
|
+
if methods is not None and methods.strip().lower() != "none":
|
|
1998
|
+
new_methods = ch.parse_methods(methods)
|
|
1999
|
+
operation_ids = None
|
|
2000
|
+
if operations is not None and operations.strip().lower() != "none":
|
|
2001
|
+
operation_ids = _parse_operation_ids(operations)
|
|
2002
|
+
a2a_list = None
|
|
2003
|
+
if a2a_operations is not None and a2a_operations.strip().lower() != "none":
|
|
2004
|
+
a2a_list = _parse_a2a_operations(a2a_operations)
|
|
2005
|
+
approver_list = _parse_approvers(approvers) if approvers is not None else None
|
|
2006
|
+
relayer_list = _parse_relayers(relayers) if relayers is not None else None
|
|
2007
|
+
if relayer_list is not None and decide_with == DECIDE_DIRECT:
|
|
2008
|
+
raise click.UsageError(
|
|
2009
|
+
"--relayers is only for --decide-with relayed (direct: nobody relays)"
|
|
2010
|
+
)
|
|
2011
|
+
if add_rule:
|
|
2012
|
+
if approver_list is None:
|
|
2013
|
+
raise click.UsageError("--add-rule needs --approvers (requester and/or role:<name>)")
|
|
2014
|
+
if new_methods is None and operation_ids is None and a2a_list is None:
|
|
2015
|
+
raise click.UsageError(
|
|
2016
|
+
"--add-rule needs --methods, --operations and/or --a2a-operations (the calls the "
|
|
2017
|
+
"new rule gates)"
|
|
2018
|
+
)
|
|
2019
|
+
|
|
2020
|
+
project = _load_project()
|
|
2021
|
+
api = project.api(name)
|
|
2022
|
+
raw = api.get(APPROVAL_KEY)
|
|
2023
|
+
rules = approval_rules(api)
|
|
2024
|
+
listed = isinstance(raw, list)
|
|
2025
|
+
before_rules = effective_approval_rules(api)
|
|
2026
|
+
if remove and raw is None:
|
|
2027
|
+
Console().print(f"{name} has no approval block: no call waits for an approval.")
|
|
2028
|
+
return
|
|
2029
|
+
if add_rule:
|
|
2030
|
+
index = len(rules)
|
|
2031
|
+
elif rule_index is not None:
|
|
2032
|
+
if not rules:
|
|
2033
|
+
raise ch.ApiCommandError(
|
|
2034
|
+
f"{name} has no approval rules: add one without --rule (the first rule is the "
|
|
2035
|
+
"approval block itself)"
|
|
2036
|
+
)
|
|
2037
|
+
if rule_index >= len(rules):
|
|
2038
|
+
raise ch.ApiCommandError(
|
|
2039
|
+
f"{name} has {len(rules)} approval rule(s), so --rule takes 0 to "
|
|
2040
|
+
f"{len(rules) - 1}:\n{_rules_listing(api)}"
|
|
2041
|
+
)
|
|
2042
|
+
index = rule_index
|
|
2043
|
+
elif listed and len(rules) > 1 and not remove:
|
|
2044
|
+
raise click.UsageError(
|
|
2045
|
+
f"{name}'s approval is a list of {len(rules)} rules: name the one to change with "
|
|
2046
|
+
"--rule N, add one with --add-rule, or remove them all with --remove:\n"
|
|
2047
|
+
f"{_rules_listing(api)}"
|
|
2048
|
+
)
|
|
2049
|
+
else:
|
|
2050
|
+
index = 0
|
|
2051
|
+
whole = remove and (rule_index is None or len(rules) == 1)
|
|
2052
|
+
becomes_list = listed or (add_rule and raw is not None)
|
|
2053
|
+
label = f"{APPROVAL_KEY}[{index}]" if becomes_list else APPROVAL_KEY
|
|
2054
|
+
rule_flag = f" --rule {index}" if listed else ""
|
|
2055
|
+
old_rule = rules[index] if index < len(rules) else None
|
|
2056
|
+
notes: list[str] = []
|
|
2057
|
+
new_rule: dict[str, Any] | None
|
|
2058
|
+
if remove:
|
|
2059
|
+
new_rule = None
|
|
2060
|
+
else:
|
|
2061
|
+
required_for = dict(old_rule["required_for"]) if old_rule is not None else {}
|
|
2062
|
+
if methods is not None:
|
|
2063
|
+
if new_methods is None:
|
|
2064
|
+
required_for.pop("methods", None)
|
|
2065
|
+
else:
|
|
2066
|
+
required_for["methods"] = new_methods
|
|
2067
|
+
# --operations sets the rule's other entries and --a2a-operations its entries for
|
|
2068
|
+
# A2A decisions; each keeps the other's.
|
|
2069
|
+
current = list(required_for.get("operations") or [])
|
|
2070
|
+
decisions = [e for e in current if isinstance(e, dict) and A2A_OPERATION_KEY in e]
|
|
2071
|
+
others = [e for e in current if not (isinstance(e, dict) and A2A_OPERATION_KEY in e)]
|
|
2072
|
+
if operations is not None:
|
|
2073
|
+
if operation_ids is None:
|
|
2074
|
+
others = []
|
|
2075
|
+
else:
|
|
2076
|
+
others, entry_notes = _gate_entries(project, api, operation_ids, others)
|
|
2077
|
+
notes.extend(entry_notes)
|
|
2078
|
+
if a2a_operations is not None:
|
|
2079
|
+
decisions = [] if a2a_list is None else _a2a_gate_entries(decisions, a2a_list)
|
|
2080
|
+
if operations is not None or a2a_operations is not None:
|
|
2081
|
+
if others or decisions:
|
|
2082
|
+
required_for["operations"] = [*others, *decisions]
|
|
2083
|
+
else:
|
|
2084
|
+
required_for.pop("operations", None)
|
|
2085
|
+
required_for = {k: required_for[k] for k in ("methods", "operations") if k in required_for}
|
|
2086
|
+
what = "approval block" if label == APPROVAL_KEY else f"approval rule {label}"
|
|
2087
|
+
if not required_for:
|
|
2088
|
+
raise ch.ApiCommandError(
|
|
2089
|
+
f"the {what} of {name} would gate nothing (it needs --methods, --operations "
|
|
2090
|
+
f"and/or --a2a-operations); remove it with `graph-agents-cli api approval "
|
|
2091
|
+
f"{name}{rule_flag} --remove`"
|
|
2092
|
+
)
|
|
2093
|
+
if approver_list is None and old_rule is None:
|
|
2094
|
+
raise click.UsageError(
|
|
2095
|
+
"--approvers is required for a new approval block (requester and/or role:<name>)"
|
|
2096
|
+
)
|
|
2097
|
+
new_rule = {
|
|
2098
|
+
"required_for": required_for,
|
|
2099
|
+
"approvers": (
|
|
2100
|
+
approver_list if approver_list is not None else list(old_rule["approvers"])
|
|
2101
|
+
),
|
|
2102
|
+
}
|
|
2103
|
+
if timeout_s is not None:
|
|
2104
|
+
new_rule["timeout_s"] = timeout_s
|
|
2105
|
+
elif old_rule is not None and "timeout_s" in old_rule:
|
|
2106
|
+
new_rule["timeout_s"] = old_rule["timeout_s"]
|
|
2107
|
+
_set_decide_with(new_rule, old_rule, decide_with, relayer_list)
|
|
2108
|
+
if old_rule is not None and new_rule == dict(old_rule):
|
|
2109
|
+
Console().print(f"{name}'s {what} already says that.")
|
|
2110
|
+
return
|
|
2111
|
+
same = next((i for i, rule in enumerate(rules) if dict(rule) == new_rule), None)
|
|
2112
|
+
if add_rule and same is not None:
|
|
2113
|
+
Console().print(
|
|
2114
|
+
f"{name} already has that approval rule ({approval_rule_label(api, same)})."
|
|
2115
|
+
)
|
|
2116
|
+
return
|
|
2117
|
+
|
|
2118
|
+
document = copy.deepcopy(project.document)
|
|
2119
|
+
assert document is not None
|
|
2120
|
+
target = document["apis"][name]
|
|
2121
|
+
if new_rule is None:
|
|
2122
|
+
if whole:
|
|
2123
|
+
target.pop(APPROVAL_KEY)
|
|
2124
|
+
else:
|
|
2125
|
+
del target[APPROVAL_KEY][index]
|
|
2126
|
+
elif add_rule and raw is not None:
|
|
2127
|
+
target[APPROVAL_KEY] = [*rules, new_rule] if listed else [target[APPROVAL_KEY], new_rule]
|
|
2128
|
+
elif listed:
|
|
2129
|
+
target[APPROVAL_KEY][index] = new_rule
|
|
2130
|
+
else:
|
|
2131
|
+
target[APPROVAL_KEY] = new_rule
|
|
2132
|
+
_validate(project, document)
|
|
2133
|
+
after_rules = effective_approval_rules(target)
|
|
2134
|
+
editor = project.editor()
|
|
2135
|
+
base = ("apis", name, APPROVAL_KEY)
|
|
2136
|
+
|
|
2137
|
+
def apply() -> None:
|
|
2138
|
+
if new_rule is None:
|
|
2139
|
+
if whole:
|
|
2140
|
+
editor.delete(base)
|
|
2141
|
+
else:
|
|
2142
|
+
editor.remove_item(base, index)
|
|
2143
|
+
elif raw is None:
|
|
2144
|
+
editor.set(base, new_rule, after=_after(api, APPROVAL_KEY), block=True)
|
|
2145
|
+
elif add_rule:
|
|
2146
|
+
if not listed:
|
|
2147
|
+
editor.wrap_in_list(base)
|
|
2148
|
+
editor.append(base, new_rule)
|
|
2149
|
+
else:
|
|
2150
|
+
assert old_rule is not None
|
|
2151
|
+
_edit_rule(editor, (*base, index) if listed else base, old_rule, new_rule)
|
|
2152
|
+
|
|
2153
|
+
_edit(apply, f"set apis.{name}.{label} to {new_rule}")
|
|
2154
|
+
plan = Plan(project.root)
|
|
2155
|
+
plan.set_text(POLICY_FILENAME, project.text, editor.text)
|
|
2156
|
+
|
|
2157
|
+
if new_rule is None and whole:
|
|
2158
|
+
loosening = _gate_loosening(before_rules[0], None)
|
|
2159
|
+
notes.append(f"calls to {name} are sent without waiting for a human approval")
|
|
2160
|
+
elif new_rule is None:
|
|
2161
|
+
loosening = (
|
|
2162
|
+
[]
|
|
2163
|
+
if rule_never_applies(api, index)
|
|
2164
|
+
else [
|
|
2165
|
+
f"{label} is removed: the calls it gated first now wait for a later rule's "
|
|
2166
|
+
"approvers, or for no one"
|
|
2167
|
+
]
|
|
2168
|
+
)
|
|
2169
|
+
notes.append(
|
|
2170
|
+
f"{label} is removed; the rules after it move up one place"
|
|
2171
|
+
if index < len(after_rules)
|
|
2172
|
+
else f"{label} is removed"
|
|
2173
|
+
)
|
|
2174
|
+
notes.extend(approval_notes(name, target))
|
|
2175
|
+
else:
|
|
2176
|
+
before = before_rules[index] if old_rule is not None else None
|
|
2177
|
+
loosening = [
|
|
2178
|
+
f"{label}: {reason}" if becomes_list else reason
|
|
2179
|
+
for reason in _gate_loosening(before, after_rules[index])
|
|
2180
|
+
]
|
|
2181
|
+
loosening.extend(_order_loosening(after_rules, index, before))
|
|
2182
|
+
notes.extend(approval_notes(name, target))
|
|
2183
|
+
notes.extend(
|
|
2184
|
+
f"{label}: {note}" if becomes_list else note
|
|
2185
|
+
for note in _approver_notes(project, new_rule["approvers"], new_rule)
|
|
2186
|
+
)
|
|
2187
|
+
if (
|
|
2188
|
+
not add_rule
|
|
2189
|
+
and before is not None
|
|
2190
|
+
and approver_list is not None
|
|
2191
|
+
and set(approver_list) != set(before["approvers"])
|
|
2192
|
+
# The edit also drops calls the rule gated (its approvers and expiry aside).
|
|
2193
|
+
and _gate_loosening(
|
|
2194
|
+
before,
|
|
2195
|
+
{
|
|
2196
|
+
**{k: v for k, v in after_rules[index].items() if k != "relayers"},
|
|
2197
|
+
"approvers": before["approvers"],
|
|
2198
|
+
"timeout_s": before["timeout_s"],
|
|
2199
|
+
"decide_with": before.get("decide_with", DECIDE_DIRECT),
|
|
2200
|
+
**({"relayers": before["relayers"]} if "relayers" in before else {}),
|
|
2201
|
+
},
|
|
2202
|
+
)
|
|
2203
|
+
):
|
|
2204
|
+
notes.append(
|
|
2205
|
+
f"this gives the {what} other approvers and drops calls it gated: to keep those "
|
|
2206
|
+
f"with {', '.join(before['approvers'])} and gate the calls you named for "
|
|
2207
|
+
f"{', '.join(approver_list)}, add a rule instead: "
|
|
2208
|
+
+ _add_rule_command(
|
|
2209
|
+
name, methods, operations, approver_list, timeout_s, a2a_operations
|
|
2210
|
+
)
|
|
2211
|
+
)
|
|
2212
|
+
effects = _call_effects(project, project.document, document, name)
|
|
2213
|
+
if any(line.startswith("approvers change (new") for line in effects):
|
|
2214
|
+
loosening.append("a declared call gets new approver(s) (see above)")
|
|
2215
|
+
if any(line.startswith("relaying changes (new") for line in effects):
|
|
2216
|
+
loosening.append("a declared call gets new relayer(s) (see above)")
|
|
2217
|
+
if any(line.startswith("now allowed") for line in effects):
|
|
2218
|
+
loosening.append("a declared call the rules refused is now sent after an approval")
|
|
2219
|
+
refuses = bool(_conflicts(target) - _conflicts(api)) or any(
|
|
2220
|
+
line.startswith("now refused") for line in effects
|
|
2221
|
+
)
|
|
2222
|
+
if loosening:
|
|
2223
|
+
verdict = (
|
|
2224
|
+
f"This loosens the approval gate on {name} ({'; '.join(loosening)}). "
|
|
2225
|
+
f"{GATE_REVIEW_NOTE}",
|
|
2226
|
+
"yellow",
|
|
2227
|
+
)
|
|
2228
|
+
elif refuses:
|
|
2229
|
+
verdict = (
|
|
2230
|
+
f"This tightens the approval gate on {name}, and the agent refuses the calls to "
|
|
2231
|
+
f"{name} that name no operation_id where rules with other approvers may both cover "
|
|
2232
|
+
"them (see the notes): name the operation_id on those calls, or pin path and "
|
|
2233
|
+
"methods in the rule.",
|
|
2234
|
+
"yellow",
|
|
2235
|
+
)
|
|
2236
|
+
else:
|
|
2237
|
+
verdict = (
|
|
2238
|
+
f"This tightens or keeps the approval gate on {name} (always safe); access to "
|
|
2239
|
+
f"{name} is unchanged.",
|
|
2240
|
+
"dim",
|
|
2241
|
+
)
|
|
2242
|
+
_finish(
|
|
2243
|
+
project,
|
|
2244
|
+
plan,
|
|
2245
|
+
dry_run=dry_run,
|
|
2246
|
+
api=name,
|
|
2247
|
+
before=project.document,
|
|
2248
|
+
after=document,
|
|
2249
|
+
widens=None,
|
|
2250
|
+
notes=notes,
|
|
2251
|
+
next_steps=False,
|
|
2252
|
+
verdict=verdict,
|
|
2253
|
+
effects=effects,
|
|
2254
|
+
)
|
|
2255
|
+
|
|
2256
|
+
|
|
2257
|
+
# ---------------------------------------------------------------------------
|
|
2258
|
+
# show / check
|
|
2259
|
+
# ---------------------------------------------------------------------------
|
|
2260
|
+
|
|
2261
|
+
|
|
2262
|
+
def _effective(name: str, api: dict[str, Any]) -> dict[str, Any]:
|
|
2263
|
+
methods = [str(m).upper() for m in api["allowed_methods"]]
|
|
2264
|
+
timeouts = dict(DEFAULT_TIMEOUTS_MS)
|
|
2265
|
+
timeouts.update(api.get("timeouts_ms") or {})
|
|
2266
|
+
return {
|
|
2267
|
+
"name": name,
|
|
2268
|
+
"description": api.get(DESCRIPTION_KEY),
|
|
2269
|
+
# http, jsonrpc or a2a; a2a: {path} for an agent's A2A endpoint.
|
|
2270
|
+
"protocol": api_protocol(api),
|
|
2271
|
+
A2A_KEY: dict(api[A2A_KEY]) if A2A_KEY in api else None,
|
|
2272
|
+
"base_url_env": api["base_url_env"],
|
|
2273
|
+
"auth": api["auth"],
|
|
2274
|
+
"token_env": api.get("token_env"),
|
|
2275
|
+
"forward_header": (
|
|
2276
|
+
(api.get("forward_header") or DEFAULT_FORWARD_HEADER)
|
|
2277
|
+
if api["auth"] in HEADER_AUTH_MODES
|
|
2278
|
+
else None
|
|
2279
|
+
),
|
|
2280
|
+
# auth: forward: the audience the caller's own token must name to be forwarded.
|
|
2281
|
+
"forward_audience": api.get("forward_audience"),
|
|
2282
|
+
# auth: exchange (RFC 8693): {audience, scope, resource} as the file sets them.
|
|
2283
|
+
EXCHANGE_KEY: dict(api[EXCHANGE_KEY]) if EXCHANGE_KEY in api else None,
|
|
2284
|
+
"allowed_methods": methods,
|
|
2285
|
+
"preset": ch.preset_name(methods),
|
|
2286
|
+
# None: every operation within allowed_methods.
|
|
2287
|
+
"allowed_operations": api.get(ch.ALLOWED),
|
|
2288
|
+
"denied_operations": api.get(ch.DENIED) or [],
|
|
2289
|
+
"limits": api.get("limits"),
|
|
2290
|
+
"openapi": api.get("openapi"),
|
|
2291
|
+
"timeouts_ms": timeouts,
|
|
2292
|
+
"pagination": api.get("pagination"),
|
|
2293
|
+
# None: no call waits for a human approval. One rule (a mapping), or a
|
|
2294
|
+
# list of rules as the file writes it.
|
|
2295
|
+
"approval": effective_approval(api),
|
|
2296
|
+
# Every rule in file order, with its name in messages ("approval", or
|
|
2297
|
+
# "approval[N]" in a list): the first that covers a call gates it.
|
|
2298
|
+
"approval_rules": effective_approval_rules(api),
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
|
|
2302
|
+
@api_group.command("show")
|
|
2303
|
+
@click.argument("name", required=False)
|
|
2304
|
+
@click.option("--json", "as_json", is_flag=True, default=False, help="Print JSON.")
|
|
2305
|
+
def cmd_show(name: str | None, as_json: bool) -> None:
|
|
2306
|
+
"""Show the effective policy per API and the calls every tool declares.
|
|
2307
|
+
|
|
2308
|
+
Each call's status says whether the policy allows it, and a gated call
|
|
2309
|
+
names who must approve it before it is sent (the API's approval block).
|
|
2310
|
+
"""
|
|
2311
|
+
project = _load_project()
|
|
2312
|
+
if name is not None:
|
|
2313
|
+
project.api(name)
|
|
2314
|
+
document = project.document
|
|
2315
|
+
apis = {
|
|
2316
|
+
api_name: _effective(api_name, api)
|
|
2317
|
+
for api_name, api in ((document or {}).get("apis") or {}).items()
|
|
2318
|
+
if name in (None, api_name)
|
|
2319
|
+
}
|
|
2320
|
+
report = policy_check.build_report(
|
|
2321
|
+
project.root,
|
|
2322
|
+
project.config.agent_directory,
|
|
2323
|
+
runtime=project.config.runtime,
|
|
2324
|
+
policy_declared=bool(project.config.api_policy_file),
|
|
2325
|
+
auth_policy=project.config.auth_policy,
|
|
2326
|
+
)
|
|
2327
|
+
results = [r for r in report.results if name in (None, r.call.api)]
|
|
2328
|
+
if as_json:
|
|
2329
|
+
payload = {
|
|
2330
|
+
"policy_file": POLICY_FILENAME if document is not None else None,
|
|
2331
|
+
"apis": apis,
|
|
2332
|
+
"calls": [
|
|
2333
|
+
{
|
|
2334
|
+
"tool": r.call.tool,
|
|
2335
|
+
"api": r.call.api or None,
|
|
2336
|
+
"method": r.call.method,
|
|
2337
|
+
"operation_id": r.call.operation_id,
|
|
2338
|
+
"path": r.call.path,
|
|
2339
|
+
# A call to a JSON-RPC API: its JSON-RPC method and A2A decision.
|
|
2340
|
+
"rpc_method": r.call.rpc_method,
|
|
2341
|
+
"a2a_operation": r.call.a2a_operation,
|
|
2342
|
+
"status": r.status,
|
|
2343
|
+
"reason": r.reason,
|
|
2344
|
+
"hint": r.hint or None,
|
|
2345
|
+
# {approvers, timeout_s, rule} for a gated call, else None.
|
|
2346
|
+
"approval": gate_payload(r.gate),
|
|
2347
|
+
}
|
|
2348
|
+
for r in results
|
|
2349
|
+
],
|
|
2350
|
+
"violations": sum(1 for r in results if r.is_violation),
|
|
2351
|
+
"gated": sum(1 for r in results if r.gate is not None),
|
|
2352
|
+
}
|
|
2353
|
+
click.echo(json.dumps(payload, indent=2))
|
|
2354
|
+
return
|
|
2355
|
+
console = Console()
|
|
2356
|
+
if document is None:
|
|
2357
|
+
console.print(
|
|
2358
|
+
f"No {POLICY_FILENAME}: every outbound API call is refused. Declare an API with "
|
|
2359
|
+
"`graph-agents-cli api add`."
|
|
2360
|
+
)
|
|
2361
|
+
for effective in apis.values():
|
|
2362
|
+
_print_api(console, effective)
|
|
2363
|
+
policy_check.print_report(
|
|
2364
|
+
policy_check.PolicyReport(results=results, notes=report.notes), console
|
|
2365
|
+
)
|
|
2366
|
+
|
|
2367
|
+
|
|
2368
|
+
def _print_api(console: Console, api: dict[str, Any]) -> None:
|
|
2369
|
+
auth = api["auth"]
|
|
2370
|
+
if auth == "bearer":
|
|
2371
|
+
auth += f" (token from {api['token_env']})"
|
|
2372
|
+
elif auth == "forward" and api["forward_audience"]:
|
|
2373
|
+
auth += (
|
|
2374
|
+
f" (the caller's credential in {api['forward_header']}, or the caller's own token "
|
|
2375
|
+
f"when it was minted for {api['forward_audience']})"
|
|
2376
|
+
)
|
|
2377
|
+
elif auth == "forward":
|
|
2378
|
+
auth += f" (the caller's credential in {api['forward_header']})"
|
|
2379
|
+
elif auth == EXCHANGE_KEY:
|
|
2380
|
+
exchange = api[EXCHANGE_KEY]
|
|
2381
|
+
extra = "".join(
|
|
2382
|
+
f"; {key} {exchange[key]}" for key in ("scope", "resource") if exchange.get(key)
|
|
2383
|
+
)
|
|
2384
|
+
actorless = (
|
|
2385
|
+
"; accepts tokens that name no actor (allow_actorless)"
|
|
2386
|
+
if exchange.get(ALLOW_ACTORLESS_KEY) is True
|
|
2387
|
+
else ""
|
|
2388
|
+
)
|
|
2389
|
+
auth += (
|
|
2390
|
+
f" (a token the issuer mints for audience {exchange['audience']}{extra}, in exchange "
|
|
2391
|
+
f"for the caller's, sent in {api['forward_header']}; RFC 8693{actorless})"
|
|
2392
|
+
)
|
|
2393
|
+
rows = []
|
|
2394
|
+
if api["description"]:
|
|
2395
|
+
rows.append(("description", api["description"]))
|
|
2396
|
+
if api["protocol"] == PROTOCOL_A2A:
|
|
2397
|
+
rows.append(
|
|
2398
|
+
(
|
|
2399
|
+
"protocol",
|
|
2400
|
+
f"a2a (another agent, endpoint {api[A2A_KEY]['path']}): each POST is judged by "
|
|
2401
|
+
"the JSON-RPC request it sends, and a message that approves by a2a_operation",
|
|
2402
|
+
)
|
|
2403
|
+
)
|
|
2404
|
+
elif api["protocol"] != PROTOCOL_HTTP:
|
|
2405
|
+
rows.append(
|
|
2406
|
+
(
|
|
2407
|
+
"protocol",
|
|
2408
|
+
f"{api['protocol']}: each POST is judged by the JSON-RPC request it sends",
|
|
2409
|
+
)
|
|
2410
|
+
)
|
|
2411
|
+
rows += [
|
|
2412
|
+
("base URL", f"from {api['base_url_env']}"),
|
|
2413
|
+
("auth", auth),
|
|
2414
|
+
("methods", ch.describe_methods(api["allowed_methods"])),
|
|
2415
|
+
(
|
|
2416
|
+
"allowed",
|
|
2417
|
+
"every operation within the methods"
|
|
2418
|
+
if api["allowed_operations"] is None
|
|
2419
|
+
else "; ".join(ch.describe_entry(e) for e in api["allowed_operations"]),
|
|
2420
|
+
),
|
|
2421
|
+
("denied", "; ".join(ch.describe_entry(e) for e in api["denied_operations"]) or "none"),
|
|
2422
|
+
("limits", ch.describe_limits(api["limits"])),
|
|
2423
|
+
("openapi", api["openapi"] or "none"),
|
|
2424
|
+
(
|
|
2425
|
+
"timeouts",
|
|
2426
|
+
f"connect {api['timeouts_ms']['connect']} ms, read {api['timeouts_ms']['read']} ms",
|
|
2427
|
+
),
|
|
2428
|
+
]
|
|
2429
|
+
if api["pagination"]:
|
|
2430
|
+
pagination = api["pagination"]
|
|
2431
|
+
rows.append(
|
|
2432
|
+
("pagination", f"{pagination['page_size_param']} <= {pagination['max_page_size']}")
|
|
2433
|
+
)
|
|
2434
|
+
rows.append(("approval", _describe_approval(api["approval_rules"])))
|
|
2435
|
+
table = Table(title=f"API {api['name']}", show_header=False, title_justify="left")
|
|
2436
|
+
table.add_column(style="bold", no_wrap=True)
|
|
2437
|
+
table.add_column(overflow="fold")
|
|
2438
|
+
for label, value in rows:
|
|
2439
|
+
table.add_row(label, escape(str(value)))
|
|
2440
|
+
print_table(console, table)
|
|
2441
|
+
|
|
2442
|
+
|
|
2443
|
+
def _describe_rule(rule: Mapping[str, Any]) -> str:
|
|
2444
|
+
"""``POST, DELETE and operations cancelOrder any method; approved by requester; ...``."""
|
|
2445
|
+
required_for = rule["required_for"]
|
|
2446
|
+
gates = []
|
|
2447
|
+
if "methods" in required_for:
|
|
2448
|
+
methods = required_for["methods"]
|
|
2449
|
+
gates.append('"*" (every method)' if "*" in methods else ", ".join(methods))
|
|
2450
|
+
if "operations" in required_for:
|
|
2451
|
+
gates.append(
|
|
2452
|
+
"operations " + "; ".join(ch.describe_entry(e) for e in required_for["operations"])
|
|
2453
|
+
)
|
|
2454
|
+
return (
|
|
2455
|
+
f"{' and '.join(gates)}; approved by {describe_deciders(rule)}; "
|
|
2456
|
+
f"expires after {rule['timeout_s']} s"
|
|
2457
|
+
)
|
|
2458
|
+
|
|
2459
|
+
|
|
2460
|
+
def _describe_approval(rules: list[dict[str, Any]]) -> str:
|
|
2461
|
+
"""One rule on a line, ``approval[N]: ...`` for each rule of a list, and how they apply."""
|
|
2462
|
+
if not rules:
|
|
2463
|
+
return "none (no call waits for a human approval)"
|
|
2464
|
+
if len(rules) == 1 and rules[0]["rule"] == APPROVAL_KEY:
|
|
2465
|
+
return f"{_describe_rule(rules[0])} (approval never widens access)"
|
|
2466
|
+
lines = [f"{rule['rule']}: {_describe_rule(rule)}" for rule in rules]
|
|
2467
|
+
lines.append(
|
|
2468
|
+
"a call waits for the first rule in file order that covers it (approval never widens "
|
|
2469
|
+
"access)"
|
|
2470
|
+
)
|
|
2471
|
+
return "\n".join(lines)
|
|
2472
|
+
|
|
2473
|
+
|
|
2474
|
+
@api_group.command("check")
|
|
2475
|
+
@click.pass_context
|
|
2476
|
+
def cmd_check(ctx: click.Context) -> None:
|
|
2477
|
+
"""Check every tool's API_CALLS against api-policy.yaml (same as lint --policy-only)."""
|
|
2478
|
+
from graph_agents_cli.dev.cmd_lint import cmd_lint
|
|
2479
|
+
|
|
2480
|
+
ctx.invoke(cmd_lint, fix=False, policy_only=True)
|