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,1214 @@
|
|
|
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
|
+
"""Unit tests for the policy-enforcing API client (`app_utils.api_client`).
|
|
16
|
+
|
|
17
|
+
No outside network: most requests go to an httpx MockTransport, the
|
|
18
|
+
every-method tests to an HTTP server on 127.0.0.1 started by the test, and
|
|
19
|
+
every refusal is checked to happen before anything is sent.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import importlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import pkgutil
|
|
28
|
+
import threading
|
|
29
|
+
from collections.abc import Iterator
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
import httpx
|
|
36
|
+
import pytest
|
|
37
|
+
import yaml
|
|
38
|
+
|
|
39
|
+
from {{cookiecutter.agent_directory}}.app_utils import api_client
|
|
40
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import (
|
|
41
|
+
ApiCallError,
|
|
42
|
+
ApiPolicy,
|
|
43
|
+
ApiPolicyError,
|
|
44
|
+
Caller,
|
|
45
|
+
current_caller,
|
|
46
|
+
current_context,
|
|
47
|
+
current_run_id,
|
|
48
|
+
end_run,
|
|
49
|
+
get_client,
|
|
50
|
+
render_path,
|
|
51
|
+
require_direct_caller,
|
|
52
|
+
require_owner,
|
|
53
|
+
require_user_mentioned,
|
|
54
|
+
reset_limits,
|
|
55
|
+
reset_policy_cache,
|
|
56
|
+
set_outbound_headers,
|
|
57
|
+
)
|
|
58
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import caller_note_enabled, delegated_mentions
|
|
59
|
+
from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
|
|
60
|
+
from {{cookiecutter.agent_directory}}.app_utils.telemetry import bind_log_context
|
|
61
|
+
|
|
62
|
+
POLICY = """
|
|
63
|
+
apis:
|
|
64
|
+
items:
|
|
65
|
+
base_url_env: ITEMS_API_BASE_URL
|
|
66
|
+
auth: bearer
|
|
67
|
+
token_env: ITEMS_API_TOKEN
|
|
68
|
+
allowed_methods: [GET]
|
|
69
|
+
allowed_operations:
|
|
70
|
+
- operationId: getItem
|
|
71
|
+
path: /items/{item_id}
|
|
72
|
+
- path: /sites/{site_id}/topology
|
|
73
|
+
methods: [GET]
|
|
74
|
+
- operationId: getSecret
|
|
75
|
+
- path: /listing
|
|
76
|
+
denied_operations:
|
|
77
|
+
- operationId: getSecret
|
|
78
|
+
path: /secret
|
|
79
|
+
- path: /items/admin
|
|
80
|
+
timeouts_ms: {connect: 1500, read: 2500}
|
|
81
|
+
pagination: {page_size_param: pageSize, max_page_size: 200}
|
|
82
|
+
directory:
|
|
83
|
+
base_url_env: DIRECTORY_API_BASE_URL
|
|
84
|
+
auth: forward
|
|
85
|
+
forward_header: X-User-Token
|
|
86
|
+
allowed_methods: ["*"]
|
|
87
|
+
public:
|
|
88
|
+
base_url_env: PUBLIC_API_BASE_URL
|
|
89
|
+
auth: none
|
|
90
|
+
allowed_methods: [GET, POST]
|
|
91
|
+
records:
|
|
92
|
+
base_url_env: RECORDS_API_BASE_URL
|
|
93
|
+
auth: none
|
|
94
|
+
allowed_methods: ["*"]
|
|
95
|
+
denied_operations:
|
|
96
|
+
- operationId: deleteRecord
|
|
97
|
+
methods: [DELETE]
|
|
98
|
+
- path: /admin/{section}
|
|
99
|
+
- operationId: dropTable
|
|
100
|
+
path: /tables/{table}
|
|
101
|
+
methods: [DELETE]
|
|
102
|
+
pagination: {page_size_param: limit, max_page_size: 50}
|
|
103
|
+
orders:
|
|
104
|
+
base_url_env: ORDERS_API_BASE_URL
|
|
105
|
+
auth: bearer
|
|
106
|
+
token_env: ORDERS_API_TOKEN
|
|
107
|
+
allowed_methods: [GET, HEAD, POST, PUT, PATCH, DELETE]
|
|
108
|
+
allowed_operations:
|
|
109
|
+
- {operationId: listOrders, path: /orders, methods: [GET, HEAD]}
|
|
110
|
+
- {operationId: createOrder, path: /orders, methods: [POST]}
|
|
111
|
+
- {operationId: replaceOrder, path: "/orders/{order_id}", methods: [PUT]}
|
|
112
|
+
- {operationId: updateOrder, path: "/orders/{order_id}", methods: [PATCH]}
|
|
113
|
+
- {operationId: cancelOrder, path: "/orders/{order_id}", methods: [DELETE]}
|
|
114
|
+
denied_operations:
|
|
115
|
+
- {operationId: purgeOrders, path: /orders, methods: [DELETE]}
|
|
116
|
+
limits: {max_calls_per_run: 3}
|
|
117
|
+
metered:
|
|
118
|
+
base_url_env: METERED_API_BASE_URL
|
|
119
|
+
auth: none
|
|
120
|
+
allowed_methods: [GET, POST]
|
|
121
|
+
limits: {rate_per_minute: 2}
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class _Context:
|
|
127
|
+
principal_id: str = "u1"
|
|
128
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@pytest.fixture
|
|
132
|
+
def policy_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
133
|
+
path = tmp_path / "api-policy.yaml"
|
|
134
|
+
path.write_text(POLICY, encoding="utf-8")
|
|
135
|
+
monkeypatch.setenv("API_POLICY_PATH", str(path))
|
|
136
|
+
monkeypatch.setenv("ITEMS_API_BASE_URL", "http://items.test")
|
|
137
|
+
monkeypatch.setenv("ITEMS_API_TOKEN", "tok")
|
|
138
|
+
monkeypatch.setenv("DIRECTORY_API_BASE_URL", "http://directory.test/api/v2/")
|
|
139
|
+
monkeypatch.setenv("PUBLIC_API_BASE_URL", "https://public.test")
|
|
140
|
+
monkeypatch.setenv("RECORDS_API_BASE_URL", "https://records.test")
|
|
141
|
+
monkeypatch.setenv("ORDERS_API_BASE_URL", "http://orders.test")
|
|
142
|
+
monkeypatch.setenv("ORDERS_API_TOKEN", "orders-token")
|
|
143
|
+
monkeypatch.setenv("METERED_API_BASE_URL", "http://metered.test")
|
|
144
|
+
reset_policy_cache()
|
|
145
|
+
reset_limits()
|
|
146
|
+
yield path
|
|
147
|
+
reset_policy_cache()
|
|
148
|
+
reset_limits()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _transport(calls: list[httpx.Request], status: int = 200, **extra: Any) -> httpx.MockTransport:
|
|
152
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
153
|
+
calls.append(request)
|
|
154
|
+
return httpx.Response(status, json={"ok": True, "path": request.url.path}, **extra)
|
|
155
|
+
|
|
156
|
+
return httpx.MockTransport(handler)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# --- fail closed: the policy file ----------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_no_policy_file_refuses_every_client(
|
|
163
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
164
|
+
) -> None:
|
|
165
|
+
monkeypatch.setenv("API_POLICY_PATH", str(tmp_path / "missing.yaml"))
|
|
166
|
+
reset_policy_cache()
|
|
167
|
+
with pytest.raises(ApiPolicyError, match="not found"):
|
|
168
|
+
get_client("anything")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_undeclared_api_is_refused(policy_file: Path) -> None:
|
|
172
|
+
with pytest.raises(ApiPolicyError, match="not declared"):
|
|
173
|
+
get_client("billing")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@pytest.mark.parametrize(
|
|
177
|
+
("document", "fragment"),
|
|
178
|
+
[
|
|
179
|
+
("product_api:\n base_url_env: X\n", "retired single-API format"),
|
|
180
|
+
("apis: {}\n", "non-empty mapping"),
|
|
181
|
+
("apis:\n a:\n base_url_env: A\n auth: none\n", "allowed_methods: required"),
|
|
182
|
+
(
|
|
183
|
+
"apis:\n a:\n base_url_env: A\n auth: none\n allowed_methods: [PUT]\n"
|
|
184
|
+
" allowed_method: [POST]\n",
|
|
185
|
+
"unknown key 'allowed_method'",
|
|
186
|
+
),
|
|
187
|
+
(
|
|
188
|
+
"apis:\n a:\n base_url_env: A\n auth: bearer\n allowed_methods: [POST]\n",
|
|
189
|
+
"token_env",
|
|
190
|
+
),
|
|
191
|
+
(
|
|
192
|
+
"apis:\n A:\n base_url_env: A\n auth: none\n allowed_methods: [DELETE]\n",
|
|
193
|
+
"invalid API name",
|
|
194
|
+
),
|
|
195
|
+
(
|
|
196
|
+
"apis:\n a:\n base_url_env: A\n auth: none\n allowed_methods: [PATCH]\n"
|
|
197
|
+
" allowed_methods: ['*']\n",
|
|
198
|
+
"found duplicate key 'allowed_methods'",
|
|
199
|
+
),
|
|
200
|
+
(
|
|
201
|
+
"apis:\n a:\n base_url_env: A\n auth: none\n allowed_methods: [POST]\n"
|
|
202
|
+
" approval: required\n",
|
|
203
|
+
"approval: must be a mapping with required_for and approvers",
|
|
204
|
+
),
|
|
205
|
+
(
|
|
206
|
+
"apis:\n a:\n base_url_env: A\n auth: none\n allowed_methods: [POST]\n"
|
|
207
|
+
" approval: {required_for: {methods: [POST]}, approvers: [anyone]}\n",
|
|
208
|
+
"approval.approvers[0]: 'anyone' is not an approver",
|
|
209
|
+
),
|
|
210
|
+
(
|
|
211
|
+
"apis:\n a:\n base_url_env: A\n auth: none\n allowed_methods: [POST]\n"
|
|
212
|
+
" approval: []\n",
|
|
213
|
+
"approval: must not be empty",
|
|
214
|
+
),
|
|
215
|
+
(
|
|
216
|
+
"apis:\n a:\n base_url_env: A\n auth: none\n allowed_methods: [POST]\n"
|
|
217
|
+
" approval:\n - {required_for: {methods: [POST]}, approvers: [requester]}\n"
|
|
218
|
+
" - {required_for: {operations: [{operationId: x}]}}\n",
|
|
219
|
+
'approval[1].approvers: required (a list of "requester" and/or "role:<name>")',
|
|
220
|
+
),
|
|
221
|
+
(
|
|
222
|
+
"apis:\n a:\n base_url_env: A\n auth: none\n allowed_methods: [POST]\n"
|
|
223
|
+
" approval: {required_for: {methods: [POST]}, approvers: [requester], "
|
|
224
|
+
"timeout_s: 5}\n",
|
|
225
|
+
"approval.timeout_s: must be an integer from 30 to 86400",
|
|
226
|
+
),
|
|
227
|
+
(
|
|
228
|
+
"apis:\n a:\n base_url_env: A\n auth: none\n allowed_methods: [POST]\n"
|
|
229
|
+
" allowed_operations:\n - operationId: createOrder\n approval: true\n",
|
|
230
|
+
"not valid on an operation entry; gate the operation with "
|
|
231
|
+
"apis.a.approval.required_for.operations",
|
|
232
|
+
),
|
|
233
|
+
(
|
|
234
|
+
"apis:\n a:\n base_url_env: A\n auth: none\n allowed_methods: [PUT]\n"
|
|
235
|
+
" limits: {max_calls_per_run: 0}\n",
|
|
236
|
+
"limits.max_calls_per_run: must be an integer >= 1",
|
|
237
|
+
),
|
|
238
|
+
],
|
|
239
|
+
)
|
|
240
|
+
def test_invalid_policy_is_refused_with_the_schema_error(
|
|
241
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, document: str, fragment: str
|
|
242
|
+
) -> None:
|
|
243
|
+
path = tmp_path / "api-policy.yaml"
|
|
244
|
+
path.write_text(document, encoding="utf-8")
|
|
245
|
+
monkeypatch.setenv("API_POLICY_PATH", str(path))
|
|
246
|
+
reset_policy_cache()
|
|
247
|
+
with pytest.raises(ApiPolicyError) as exc:
|
|
248
|
+
get_client("a")
|
|
249
|
+
assert fragment in str(exc.value)
|
|
250
|
+
assert exc.value.errors
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# --- the rules -------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
async def test_refusals_happen_before_sending(policy_file: Path) -> None:
|
|
257
|
+
calls: list[httpx.Request] = []
|
|
258
|
+
client = get_client("items", transport=_transport(calls))
|
|
259
|
+
with pytest.raises(ApiPolicyError, match="allowed_methods"):
|
|
260
|
+
await client.request("POST", "/items/1", operation_id="getItem")
|
|
261
|
+
with pytest.raises(ApiPolicyError, match="not in allowed_operations"):
|
|
262
|
+
await client.get("/users", operation_id="listUsers")
|
|
263
|
+
with pytest.raises(ApiPolicyError, match="denied"):
|
|
264
|
+
await client.get("/secret", operation_id="getSecret")
|
|
265
|
+
# AND: the operationId matches but the pinned path does not.
|
|
266
|
+
with pytest.raises(ApiPolicyError, match="not in allowed_operations"):
|
|
267
|
+
await client.get("/admin", operation_id="getItem")
|
|
268
|
+
# A denial on the rendered path is enforced even when the template is allowed.
|
|
269
|
+
with pytest.raises(ApiPolicyError, match="denied"):
|
|
270
|
+
await client.get(
|
|
271
|
+
"/items/{item_id}", operation_id="getItem", path_params={"item_id": "admin"}
|
|
272
|
+
)
|
|
273
|
+
assert calls == []
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def test_allowed_call_is_sent_with_the_bearer_token(policy_file: Path) -> None:
|
|
277
|
+
calls: list[httpx.Request] = []
|
|
278
|
+
client = get_client("items", transport=_transport(calls))
|
|
279
|
+
data = await client.get(
|
|
280
|
+
"/items/{item_id}", operation_id="getItem", path_params={"item_id": "42"}
|
|
281
|
+
)
|
|
282
|
+
assert data == {"ok": True, "path": "/items/42"}
|
|
283
|
+
await client.get("/sites/7/topology")
|
|
284
|
+
# The policy's credential wins over a caller-supplied header.
|
|
285
|
+
await client.get("/listing", headers={"Authorization": "Bearer forged"})
|
|
286
|
+
assert [c.headers["authorization"] for c in calls] == ["Bearer tok"] * 3
|
|
287
|
+
assert str(calls[0].url) == "http://items.test/items/42"
|
|
288
|
+
assert calls[0].extensions["timeout"]["connect"] == 1.5
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
TRACEPARENT = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
|
|
292
|
+
CORRELATION_HEADERS = ("x-request-id", "traceparent", "tracestate")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _forwarding_caller() -> _Context:
|
|
296
|
+
return _Context(attributes={"credentials": {"directory": "user-token-1"}})
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
async def test_correlation_headers_are_sent_but_not_bound_by_an_approval(
|
|
300
|
+
policy_file: Path,
|
|
301
|
+
) -> None:
|
|
302
|
+
calls: list[httpx.Request] = []
|
|
303
|
+
set_outbound_headers(
|
|
304
|
+
lambda: {"X-Request-ID": "req-1", "traceparent": TRACEPARENT, "Host": "elsewhere.test"}
|
|
305
|
+
)
|
|
306
|
+
try:
|
|
307
|
+
client = get_client("directory", context=_forwarding_caller(), transport=_transport(calls))
|
|
308
|
+
await client.get("/listing")
|
|
309
|
+
await client.get("/listing", headers={"X-Request-ID": "tool-own"})
|
|
310
|
+
prepared = client._prepare("GET", "/listing", None, None, None, None, {"X-Tool": "t"})
|
|
311
|
+
finally:
|
|
312
|
+
set_outbound_headers(None)
|
|
313
|
+
assert calls[0].headers["x-request-id"] == "req-1"
|
|
314
|
+
assert calls[0].headers["traceparent"] == TRACEPARENT
|
|
315
|
+
# A header a tool may not set is never added.
|
|
316
|
+
assert calls[0].headers["host"] == "directory.test"
|
|
317
|
+
assert calls[1].headers["x-request-id"] == "tool-own" # the tool's own header wins
|
|
318
|
+
# They differ per request, so the request an approval binds leaves them out.
|
|
319
|
+
assert prepared.tool_headers == [("x-tool", "t")]
|
|
320
|
+
assert prepared.headers["traceparent"] == TRACEPARENT
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
async def test_correlation_headers_go_only_to_forward_apis(policy_file: Path) -> None:
|
|
324
|
+
"""The request id and trace context reach the APIs that act for the caller (another
|
|
325
|
+
agent, reached with the caller's credential), never a third party's bearer or open API."""
|
|
326
|
+
calls: list[httpx.Request] = []
|
|
327
|
+
set_outbound_headers(
|
|
328
|
+
lambda: {"X-Request-ID": "req-1", "traceparent": TRACEPARENT, "tracestate": "k=v"}
|
|
329
|
+
)
|
|
330
|
+
try:
|
|
331
|
+
await get_client("items", transport=_transport(calls)).get("/listing")
|
|
332
|
+
await get_client("public", transport=_transport(calls)).post("/search", json_body={})
|
|
333
|
+
forward = get_client("directory", context=_forwarding_caller(), transport=_transport(calls))
|
|
334
|
+
await forward.get("/me")
|
|
335
|
+
# A tool may still send its own header to any API: that is the tool's choice.
|
|
336
|
+
await get_client("items", transport=_transport(calls)).get(
|
|
337
|
+
"/listing", headers={"X-Request-ID": "tool-own"}
|
|
338
|
+
)
|
|
339
|
+
finally:
|
|
340
|
+
set_outbound_headers(None)
|
|
341
|
+
bearer, open_api, peer, own = calls
|
|
342
|
+
for third_party in (bearer, open_api):
|
|
343
|
+
assert not [h for h in CORRELATION_HEADERS if h in third_party.headers]
|
|
344
|
+
assert bearer.headers["authorization"] == "Bearer tok"
|
|
345
|
+
assert peer.headers["x-user-token"] == "user-token-1"
|
|
346
|
+
assert (peer.headers["x-request-id"], peer.headers["traceparent"]) == ("req-1", TRACEPARENT)
|
|
347
|
+
assert peer.headers["tracestate"] == "k=v"
|
|
348
|
+
assert own.headers["x-request-id"] == "tool-own" and "traceparent" not in own.headers
|
|
349
|
+
modes = ("forward", "exchange", "bearer", "none")
|
|
350
|
+
assert [api_client.propagates({"auth": mode}) for mode in modes] == [True, True, False, False]
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
async def test_a_failing_correlation_provider_does_not_stop_the_call(policy_file: Path) -> None:
|
|
354
|
+
calls: list[httpx.Request] = []
|
|
355
|
+
|
|
356
|
+
def broken() -> dict[str, str]:
|
|
357
|
+
raise RuntimeError("no tracer")
|
|
358
|
+
|
|
359
|
+
set_outbound_headers(broken)
|
|
360
|
+
try:
|
|
361
|
+
client = get_client("directory", context=_forwarding_caller(), transport=_transport(calls))
|
|
362
|
+
await client.get("/me")
|
|
363
|
+
finally:
|
|
364
|
+
set_outbound_headers(None)
|
|
365
|
+
assert len(calls) == 1 and "x-request-id" not in calls[0].headers
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
async def test_bearer_without_a_token_sends_nothing(
|
|
369
|
+
policy_file: Path, monkeypatch: pytest.MonkeyPatch
|
|
370
|
+
) -> None:
|
|
371
|
+
monkeypatch.delenv("ITEMS_API_TOKEN")
|
|
372
|
+
calls: list[httpx.Request] = []
|
|
373
|
+
client = get_client("items", transport=_transport(calls))
|
|
374
|
+
with pytest.raises(ApiCallError, match="ITEMS_API_TOKEN"):
|
|
375
|
+
await client.get("/listing")
|
|
376
|
+
assert calls == []
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
async def test_forward_sends_the_callers_credential_under_a_path_prefix(policy_file: Path) -> None:
|
|
380
|
+
calls: list[httpx.Request] = []
|
|
381
|
+
context = _Context(attributes={"credentials": {"directory": "user-token-1"}, "tenant": "t"})
|
|
382
|
+
client = get_client("directory", context=context, transport=_transport(calls))
|
|
383
|
+
await client.request("DELETE", "/people/{person_id}", path_params={"person_id": "p 1"})
|
|
384
|
+
assert calls[0].headers["x-user-token"] == "user-token-1"
|
|
385
|
+
assert "authorization" not in calls[0].headers
|
|
386
|
+
# The base URL's /api/v2 prefix is kept: the path is joined under it.
|
|
387
|
+
assert calls[0].url.raw_path == b"/api/v2/people/p%201"
|
|
388
|
+
# A mapping context (what LangGraph Server passes) works too.
|
|
389
|
+
other = get_client(
|
|
390
|
+
"directory",
|
|
391
|
+
context={"attributes": {"credentials": {"directory": "user-token-2"}}},
|
|
392
|
+
transport=_transport(calls),
|
|
393
|
+
)
|
|
394
|
+
await other.get("/me")
|
|
395
|
+
assert calls[1].headers["x-user-token"] == "user-token-2"
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
async def test_forward_without_a_credential_sends_nothing(policy_file: Path) -> None:
|
|
399
|
+
calls: list[httpx.Request] = []
|
|
400
|
+
for context in (None, _Context(), _Context(attributes={"credentials": {"items": "x"}})):
|
|
401
|
+
client = get_client("directory", context=context, transport=_transport(calls))
|
|
402
|
+
with pytest.raises(ApiCallError, match="no credential"):
|
|
403
|
+
await client.get("/me")
|
|
404
|
+
assert calls == []
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
async def test_auth_none_sends_no_credential(policy_file: Path) -> None:
|
|
408
|
+
calls: list[httpx.Request] = []
|
|
409
|
+
client = get_client("public", transport=_transport(calls))
|
|
410
|
+
await client.request("POST", "/search", json_body={"q": "x"})
|
|
411
|
+
assert "authorization" not in calls[0].headers
|
|
412
|
+
assert str(calls[0].url) == "https://public.test/search"
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@pytest.mark.parametrize(
|
|
416
|
+
"item_id",
|
|
417
|
+
[
|
|
418
|
+
"1/../../admin",
|
|
419
|
+
"../admin",
|
|
420
|
+
"..",
|
|
421
|
+
".",
|
|
422
|
+
"1/extra",
|
|
423
|
+
"a/b",
|
|
424
|
+
"a\\b",
|
|
425
|
+
"",
|
|
426
|
+
" 1",
|
|
427
|
+
"1;x",
|
|
428
|
+
"1\x00", # servers that end a path at a NUL route it to /items/1
|
|
429
|
+
"1\n2",
|
|
430
|
+
],
|
|
431
|
+
)
|
|
432
|
+
async def test_path_params_refuse_traversal_before_sending(policy_file: Path, item_id: str) -> None:
|
|
433
|
+
calls: list[httpx.Request] = []
|
|
434
|
+
client = get_client("items", transport=_transport(calls))
|
|
435
|
+
with pytest.raises(ApiPolicyError):
|
|
436
|
+
await client.get(
|
|
437
|
+
"/items/{item_id}", operation_id="getItem", path_params={"item_id": item_id}
|
|
438
|
+
)
|
|
439
|
+
assert calls == []
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@pytest.mark.parametrize(
|
|
443
|
+
"path",
|
|
444
|
+
[
|
|
445
|
+
"/items/1/../../admin",
|
|
446
|
+
"/items/..",
|
|
447
|
+
"/items/%2e%2e",
|
|
448
|
+
"/items/1%2F..%2F..%2Fadmin",
|
|
449
|
+
"/items//1",
|
|
450
|
+
"/admin",
|
|
451
|
+
# Path parameters: servers that strip them route these to /items/1.
|
|
452
|
+
"/items/1;jsessionid=x",
|
|
453
|
+
"/items/1%3B",
|
|
454
|
+
"/items/1%3bx",
|
|
455
|
+
# Servers that trim segments, or end a path at a NUL, route these to /items/1.
|
|
456
|
+
"/items/1%20",
|
|
457
|
+
"/items/%201",
|
|
458
|
+
"/items/1%09",
|
|
459
|
+
"/items/1 ",
|
|
460
|
+
"/items/1%00",
|
|
461
|
+
"/items/1%00/x",
|
|
462
|
+
"/items/1%7F",
|
|
463
|
+
# Servers that trim the name before a format suffix, or strip trailing dots and
|
|
464
|
+
# spaces, route these to /items/1 as well.
|
|
465
|
+
"/items/1%20.json",
|
|
466
|
+
"/items/1%20%2e",
|
|
467
|
+
"/items/1.%20json",
|
|
468
|
+
],
|
|
469
|
+
)
|
|
470
|
+
async def test_concrete_paths_are_validated(policy_file: Path, path: str) -> None:
|
|
471
|
+
calls: list[httpx.Request] = []
|
|
472
|
+
client = get_client("items", transport=_transport(calls))
|
|
473
|
+
with pytest.raises(ApiPolicyError):
|
|
474
|
+
await client.get(path, operation_id="getItem")
|
|
475
|
+
assert calls == []
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
async def test_whitespace_inside_a_segment_is_sent(policy_file: Path) -> None:
|
|
479
|
+
calls: list[httpx.Request] = []
|
|
480
|
+
client = get_client("items", transport=_transport(calls))
|
|
481
|
+
await client.get("/items/red%20shirt", operation_id="getItem")
|
|
482
|
+
await client.get("/items/{item_id}", operation_id="getItem", path_params={"item_id": "a b"})
|
|
483
|
+
assert [c.url.raw_path for c in calls] == [b"/items/red%20shirt", b"/items/a%20b"]
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
async def test_an_operation_id_denial_refuses_calls_that_do_not_name_one(policy_file: Path) -> None:
|
|
487
|
+
calls: list[httpx.Request] = []
|
|
488
|
+
client = get_client("records", transport=_transport(calls))
|
|
489
|
+
# Without an operation id the call cannot be told apart from deleteRecord.
|
|
490
|
+
with pytest.raises(ApiPolicyError, match="names no operation_id"):
|
|
491
|
+
await client.request("DELETE", "/records/{record_id}", path_params={"record_id": "1"})
|
|
492
|
+
with pytest.raises(ApiPolicyError, match="denied"):
|
|
493
|
+
await client.request(
|
|
494
|
+
"DELETE",
|
|
495
|
+
"/records/{record_id}",
|
|
496
|
+
operation_id="deleteRecord",
|
|
497
|
+
path_params={"record_id": "1"},
|
|
498
|
+
)
|
|
499
|
+
assert calls == []
|
|
500
|
+
await client.request(
|
|
501
|
+
"DELETE",
|
|
502
|
+
"/records/{record_id}",
|
|
503
|
+
operation_id="archiveRecord",
|
|
504
|
+
path_params={"record_id": "1"},
|
|
505
|
+
)
|
|
506
|
+
await client.get("/records/1") # the denial pins DELETE
|
|
507
|
+
assert [(c.method, c.url.path) for c in calls] == [
|
|
508
|
+
("DELETE", "/records/1"),
|
|
509
|
+
("GET", "/records/1"),
|
|
510
|
+
]
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@pytest.mark.parametrize("label", ["dropTable", "archiveTable", "dropTabel", None])
|
|
514
|
+
async def test_a_denial_pinning_a_path_refuses_the_call_whatever_its_label(
|
|
515
|
+
policy_file: Path, label: str | None
|
|
516
|
+
) -> None:
|
|
517
|
+
"""The operation id is the tool's label: a relabelled call to a denied endpoint is refused."""
|
|
518
|
+
calls: list[httpx.Request] = []
|
|
519
|
+
client = get_client("records", transport=_transport(calls))
|
|
520
|
+
with pytest.raises(ApiPolicyError, match="denied"):
|
|
521
|
+
await client.request(
|
|
522
|
+
"DELETE", "/tables/{table}", operation_id=label, path_params={"table": "t1"}
|
|
523
|
+
)
|
|
524
|
+
with pytest.raises(ApiPolicyError, match="denied"):
|
|
525
|
+
await client.delete("/TABLES/t1/", operation_id=label)
|
|
526
|
+
assert calls == []
|
|
527
|
+
await client.get("/tables/t1", operation_id=label) # the denial pins DELETE
|
|
528
|
+
assert [(c.method, c.url.path) for c in calls] == [("GET", "/tables/t1")]
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@pytest.mark.parametrize("path", ["/admin/1", "/admin/1/", "/ADMIN/1", "/%61dmin/1", "/Admin/%31"])
|
|
532
|
+
async def test_path_denials_cover_equivalent_spellings(policy_file: Path, path: str) -> None:
|
|
533
|
+
calls: list[httpx.Request] = []
|
|
534
|
+
client = get_client("records", transport=_transport(calls))
|
|
535
|
+
with pytest.raises(ApiPolicyError, match="denied"):
|
|
536
|
+
await client.get(path, operation_id="getAdmin")
|
|
537
|
+
assert calls == []
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def test_path_rendering_encodes_each_value_as_one_segment() -> None:
|
|
541
|
+
assert render_path("/sites/{site_id}/topology", {"site_id": "x y"}) == "/sites/x%20y/topology"
|
|
542
|
+
assert render_path("/items/{item_id}", {"item_id": "a?b=1#f"}) == "/items/a%3Fb%3D1%23f"
|
|
543
|
+
with pytest.raises(ApiPolicyError, match="no parameter"):
|
|
544
|
+
render_path("/items/{item_id}", {"item_id": "1", "other": "2"})
|
|
545
|
+
with pytest.raises(ApiPolicyError, match="needs value"):
|
|
546
|
+
render_path("/items/{item_id}", {})
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
@pytest.mark.parametrize(
|
|
550
|
+
("value", "allowed"), [(50, True), ("200", True), (201, False), (0, False), ("many", False)]
|
|
551
|
+
)
|
|
552
|
+
async def test_page_size_is_capped(policy_file: Path, value: Any, allowed: bool) -> None:
|
|
553
|
+
calls: list[httpx.Request] = []
|
|
554
|
+
client = get_client("items", transport=_transport(calls))
|
|
555
|
+
if allowed:
|
|
556
|
+
await client.get("/listing", params={"pageSize": value})
|
|
557
|
+
assert calls[0].url.params["pageSize"] == str(value)
|
|
558
|
+
else:
|
|
559
|
+
with pytest.raises(ApiPolicyError, match="max_page_size"):
|
|
560
|
+
await client.get("/listing", params={"pageSize": value})
|
|
561
|
+
assert calls == []
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
@pytest.mark.parametrize(
|
|
565
|
+
"params",
|
|
566
|
+
[
|
|
567
|
+
[("limit", "5000")],
|
|
568
|
+
{"Limit": 5000},
|
|
569
|
+
"limit=5000",
|
|
570
|
+
[("limit", "10"), ("limit", "5000")],
|
|
571
|
+
{"LIMIT": "10", "limit": "51"},
|
|
572
|
+
{"limit": " 10"},
|
|
573
|
+
{"limit": "1e3"},
|
|
574
|
+
{"limit": "9" * 5000},
|
|
575
|
+
],
|
|
576
|
+
)
|
|
577
|
+
async def test_page_size_cap_covers_every_spelling(policy_file: Path, params: Any) -> None:
|
|
578
|
+
calls: list[httpx.Request] = []
|
|
579
|
+
client = get_client("records", transport=_transport(calls))
|
|
580
|
+
with pytest.raises(ApiPolicyError, match="max_page_size"):
|
|
581
|
+
await client.get("/records", params=params)
|
|
582
|
+
assert calls == []
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
async def test_the_checked_query_is_the_one_sent(policy_file: Path) -> None:
|
|
586
|
+
calls: list[httpx.Request] = []
|
|
587
|
+
client = get_client("records", transport=_transport(calls))
|
|
588
|
+
await client.get("/records", params=[("limit", "20"), ("tag", "a"), ("tag", "b")])
|
|
589
|
+
assert calls[0].url.params.multi_items() == [("limit", "20"), ("tag", "a"), ("tag", "b")]
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
async def test_redirects_and_errors_are_not_followed(policy_file: Path) -> None:
|
|
593
|
+
calls: list[httpx.Request] = []
|
|
594
|
+
moved = _transport(calls, status=302, headers={"location": "http://elsewhere.test/admin"})
|
|
595
|
+
with pytest.raises(ApiCallError, match="HTTP 302"):
|
|
596
|
+
await get_client("items", transport=moved).get("/listing")
|
|
597
|
+
with pytest.raises(ApiCallError, match="HTTP 500"):
|
|
598
|
+
await get_client("items", transport=_transport(calls, status=500)).get("/listing")
|
|
599
|
+
assert [c.url.host for c in calls] == ["items.test", "items.test"]
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
async def test_missing_base_url_is_a_call_error(
|
|
603
|
+
policy_file: Path, monkeypatch: pytest.MonkeyPatch
|
|
604
|
+
) -> None:
|
|
605
|
+
monkeypatch.delenv("PUBLIC_API_BASE_URL")
|
|
606
|
+
with pytest.raises(ApiCallError, match="PUBLIC_API_BASE_URL"):
|
|
607
|
+
await get_client("public", transport=_transport([])).get("/x")
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def test_current_context_outside_a_run_is_none() -> None:
|
|
611
|
+
assert current_context() is None
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
# --- approval gates -------------------------------------------------------------
|
|
615
|
+
|
|
616
|
+
GATED_POLICY = """
|
|
617
|
+
apis:
|
|
618
|
+
shop:
|
|
619
|
+
base_url_env: SHOP_API_BASE_URL
|
|
620
|
+
auth: none
|
|
621
|
+
allowed_methods: [GET, POST, PATCH]
|
|
622
|
+
denied_operations:
|
|
623
|
+
- {path: "/orders/{order_id}/purge", methods: [POST]}
|
|
624
|
+
approval:
|
|
625
|
+
required_for:
|
|
626
|
+
methods: [PATCH]
|
|
627
|
+
operations:
|
|
628
|
+
- {operationId: cancelOrder, path: "/orders/{order_id}/cancel", methods: [POST]}
|
|
629
|
+
- {path: "/orders/{order_id}/purge"}
|
|
630
|
+
approvers: [requester, "role:ops"]
|
|
631
|
+
timeout_s: 60
|
|
632
|
+
"""
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
@pytest.fixture
|
|
636
|
+
def gated_policy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
637
|
+
path = tmp_path / "api-policy.yaml"
|
|
638
|
+
path.write_text(GATED_POLICY, encoding="utf-8")
|
|
639
|
+
monkeypatch.setenv("API_POLICY_PATH", str(path))
|
|
640
|
+
monkeypatch.setenv("SHOP_API_BASE_URL", "http://shop.test")
|
|
641
|
+
reset_policy_cache()
|
|
642
|
+
yield path
|
|
643
|
+
reset_policy_cache()
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def test_the_policy_names_gated_calls_and_their_approvers(gated_policy: Path) -> None:
|
|
647
|
+
policy = ApiPolicy.load(gated_policy)
|
|
648
|
+
gate = policy.gate("shop", "post", "cancelOrder", "/orders/7/cancel")
|
|
649
|
+
assert gate is not None
|
|
650
|
+
assert gate.approvers == ("requester", "role:ops") and gate.timeout_s == 60
|
|
651
|
+
# A path gate holds whatever label the call gives it.
|
|
652
|
+
assert policy.gate("shop", "POST", "archiveOrder", "/ORDERS/7/cancel/") is not None
|
|
653
|
+
assert policy.gate("shop", "PATCH", "updateOrder", "/orders/7").rule == (
|
|
654
|
+
"approval.required_for.methods ['PATCH']"
|
|
655
|
+
)
|
|
656
|
+
assert policy.gate("shop", "GET", "getOrder", "/orders/7") is None
|
|
657
|
+
assert policy.gate("shop", "POST", "createOrder", "/orders") is None
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def test_with_a_list_of_rules_the_first_that_covers_a_call_gates_it(
|
|
661
|
+
gated_policy: Path,
|
|
662
|
+
) -> None:
|
|
663
|
+
"""Other approvers for other calls of one API: the first covering rule in file order."""
|
|
664
|
+
gated_policy.write_text(
|
|
665
|
+
GATED_POLICY.split(" approval:")[0]
|
|
666
|
+
+ " approval:\n"
|
|
667
|
+
+ " - required_for:\n"
|
|
668
|
+
+ " operations:\n"
|
|
669
|
+
+ ' - {operationId: cancelOrder, path: "/orders/{order_id}/cancel"}\n'
|
|
670
|
+
+ " approvers: [requester]\n"
|
|
671
|
+
+ " - required_for: {operations: [{operationId: createOrder, path: /orders}]}\n"
|
|
672
|
+
+ ' approvers: ["role:admin"]\n'
|
|
673
|
+
+ " timeout_s: 3600\n"
|
|
674
|
+
+ ' - {required_for: {methods: [POST, PATCH]}, approvers: ["role:ops"]}\n',
|
|
675
|
+
encoding="utf-8",
|
|
676
|
+
)
|
|
677
|
+
reset_policy_cache()
|
|
678
|
+
policy = ApiPolicy.load(gated_policy)
|
|
679
|
+
cancel = policy.gate("shop", "POST", "cancelOrder", "/orders/7/cancel")
|
|
680
|
+
assert (cancel.approvers, cancel.timeout_s, cancel.index, cancel.also) == (
|
|
681
|
+
("requester",),
|
|
682
|
+
900,
|
|
683
|
+
0,
|
|
684
|
+
(2,),
|
|
685
|
+
)
|
|
686
|
+
create = policy.gate("shop", "POST", "placeOrder", "/orders") # the path, whatever label
|
|
687
|
+
assert (create.approvers, create.timeout_s, create.index) == (("role:admin",), 3600, 1)
|
|
688
|
+
assert create.rule.startswith("approval[1].required_for.operations")
|
|
689
|
+
update = policy.gate("shop", "PATCH", "updateOrder", "/orders/7")
|
|
690
|
+
assert (update.approvers, update.index) == (("role:ops",), 2)
|
|
691
|
+
# The template it was rendered from counts too: the first rule covering either wins.
|
|
692
|
+
templated = policy.gate(
|
|
693
|
+
"shop", "POST", None, "/orders/7/x", template="/orders/{order_id}/cancel"
|
|
694
|
+
)
|
|
695
|
+
assert templated.index == 0
|
|
696
|
+
assert policy.gate("shop", "GET", "getOrder", "/orders/7") is None
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
async def test_a_call_either_of_two_rules_with_other_approvers_may_gate_is_refused(
|
|
700
|
+
gated_policy: Path,
|
|
701
|
+
) -> None:
|
|
702
|
+
"""A rule by operationId alone cannot rule out a call that names no operation id.
|
|
703
|
+
|
|
704
|
+
When a later rule with other approvers also covers that call, it could be either
|
|
705
|
+
rule's: it is refused before anything is sent, instead of going to the first rule's
|
|
706
|
+
approvers (a requester approving what the later rule gives to role:admin).
|
|
707
|
+
"""
|
|
708
|
+
gated_policy.write_text(
|
|
709
|
+
GATED_POLICY.split(" approval:")[0]
|
|
710
|
+
+ " approval:\n"
|
|
711
|
+
+ " - required_for: {operations: [{operationId: cancelOrder}]}\n"
|
|
712
|
+
+ " approvers: [requester]\n"
|
|
713
|
+
+ ' - {required_for: {methods: [POST]}, approvers: ["role:admin"]}\n',
|
|
714
|
+
encoding="utf-8",
|
|
715
|
+
)
|
|
716
|
+
reset_policy_cache()
|
|
717
|
+
calls: list[httpx.Request] = []
|
|
718
|
+
client = get_client("shop", transport=_transport(calls))
|
|
719
|
+
with pytest.raises(ApiPolicyError) as exc:
|
|
720
|
+
await client.post("/orders", json_body={"sku": "A-1"})
|
|
721
|
+
assert "refused by the API policy" in str(exc.value)
|
|
722
|
+
assert "it could be either rule's call" in str(exc.value)
|
|
723
|
+
assert "name the operation_id on the call" in str(exc.value)
|
|
724
|
+
# Named, each call goes to its own rule's approvers (outside a run: refused as gated).
|
|
725
|
+
with pytest.raises(ApiPolicyError, match=r"needs human approval \(role:admin\)"):
|
|
726
|
+
await client.post("/orders", operation_id="createOrder", json_body={"sku": "A-1"})
|
|
727
|
+
with pytest.raises(ApiPolicyError, match=r"needs human approval \(requester\)"):
|
|
728
|
+
await client.post("/orders/7/cancel", operation_id="cancelOrder")
|
|
729
|
+
# No later rule with other approvers covers an unnamed PATCH: rule 0 gates it.
|
|
730
|
+
with pytest.raises(ApiPolicyError, match=r"needs human approval \(requester\)"):
|
|
731
|
+
await client.patch("/orders/7", json_body={"note": "x"})
|
|
732
|
+
assert calls == []
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
async def test_a_gated_call_is_refused_before_sending(gated_policy: Path) -> None:
|
|
736
|
+
"""Outside an agent run nothing can pause for a decision: a gated call fails closed.
|
|
737
|
+
|
|
738
|
+
(Inside a run it pauses for approval: see `test_approval_ledger.py`.)
|
|
739
|
+
"""
|
|
740
|
+
calls: list[httpx.Request] = []
|
|
741
|
+
client = get_client("shop", transport=_transport(calls))
|
|
742
|
+
with pytest.raises(ApiPolicyError) as exc:
|
|
743
|
+
await client.post(
|
|
744
|
+
"/orders/{order_id}/cancel",
|
|
745
|
+
operation_id="cancelOrder",
|
|
746
|
+
path_params={"order_id": "7"},
|
|
747
|
+
json_body={"reason": "customer asked"},
|
|
748
|
+
)
|
|
749
|
+
assert "needs human approval (requester, role:ops)" in str(exc.value)
|
|
750
|
+
assert "nothing was sent" in str(exc.value)
|
|
751
|
+
with pytest.raises(ApiPolicyError, match="needs human approval"):
|
|
752
|
+
await client.patch("/orders/7", operation_id="updateOrder", json_body={"note": "x"})
|
|
753
|
+
# Relabelled: the gate on the path still holds.
|
|
754
|
+
with pytest.raises(ApiPolicyError, match="needs human approval"):
|
|
755
|
+
await client.post("/orders/7/cancel", operation_id="closeOrder")
|
|
756
|
+
assert calls == []
|
|
757
|
+
assert await client.get("/orders/7", operation_id="getOrder") == {
|
|
758
|
+
"ok": True,
|
|
759
|
+
"path": "/orders/7",
|
|
760
|
+
}
|
|
761
|
+
assert await client.post("/orders", operation_id="createOrder", json_body={}) == {
|
|
762
|
+
"ok": True,
|
|
763
|
+
"path": "/orders",
|
|
764
|
+
}
|
|
765
|
+
assert [(r.method, r.url.path) for r in calls] == [("GET", "/orders/7"), ("POST", "/orders")]
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
@pytest.mark.parametrize(
|
|
769
|
+
"path",
|
|
770
|
+
[
|
|
771
|
+
# Format suffixes and a trailing dot on the gated (or denied) segment: servers
|
|
772
|
+
# that route `.json` or drop a trailing dot send these to the same endpoint.
|
|
773
|
+
"/orders/7/cancel.json",
|
|
774
|
+
"/orders/7/cancel.",
|
|
775
|
+
"/orders/7/cancel%2e",
|
|
776
|
+
"/orders/7/Cancel.JSON",
|
|
777
|
+
],
|
|
778
|
+
)
|
|
779
|
+
async def test_a_dot_suffixed_spelling_of_a_gated_or_denied_path_is_covered(
|
|
780
|
+
gated_policy: Path, path: str
|
|
781
|
+
) -> None:
|
|
782
|
+
calls: list[httpx.Request] = []
|
|
783
|
+
client = get_client("shop", transport=_transport(calls))
|
|
784
|
+
with pytest.raises(ApiPolicyError, match="needs human approval"):
|
|
785
|
+
await client.post(path, operation_id="closeOrder")
|
|
786
|
+
denied = path.replace("cancel", "purge").replace("Cancel", "Purge")
|
|
787
|
+
with pytest.raises(ApiPolicyError, match="denied by denied_operations"):
|
|
788
|
+
await client.post(denied, operation_id="purgeOrder")
|
|
789
|
+
assert calls == []
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
@pytest.mark.parametrize(
|
|
793
|
+
"path",
|
|
794
|
+
[
|
|
795
|
+
# Whitespace next to a dot on the gated (or denied) segment: servers that trim the
|
|
796
|
+
# name before a format suffix, or strip trailing dots and spaces, send these to
|
|
797
|
+
# the bare segment, which the gate would not see in them: refused, not gated.
|
|
798
|
+
"/orders/7/cancel%20.json",
|
|
799
|
+
"/orders/7/cancel%20%2e",
|
|
800
|
+
"/orders/7/cancel%20%2E%2e",
|
|
801
|
+
"/orders/7/cancel.%20json",
|
|
802
|
+
"/orders/{order_id}/cancel%20.json",
|
|
803
|
+
],
|
|
804
|
+
)
|
|
805
|
+
async def test_whitespace_next_to_a_dot_is_refused_before_sending(
|
|
806
|
+
gated_policy: Path, path: str
|
|
807
|
+
) -> None:
|
|
808
|
+
calls: list[httpx.Request] = []
|
|
809
|
+
client = get_client("shop", transport=_transport(calls))
|
|
810
|
+
params = {"order_id": "7"} if "{" in path else None
|
|
811
|
+
for spelling in (path, path.replace("cancel", "purge")):
|
|
812
|
+
with pytest.raises(ApiPolicyError, match=r"whitespace next to a dot|not a valid"):
|
|
813
|
+
await client.post(spelling, operation_id="closeOrder", path_params=params)
|
|
814
|
+
# A path parameter value that would put it there is refused as well.
|
|
815
|
+
with pytest.raises(ApiPolicyError):
|
|
816
|
+
await client.post(
|
|
817
|
+
"/orders/{order_id}/cancel", operation_id="closeOrder", path_params={"order_id": "7 .x"}
|
|
818
|
+
)
|
|
819
|
+
assert calls == []
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
async def test_approval_never_widens_access(gated_policy: Path) -> None:
|
|
823
|
+
"""A gated call outside the policy is refused by the policy, not held for approval."""
|
|
824
|
+
calls: list[httpx.Request] = []
|
|
825
|
+
client = get_client("shop", transport=_transport(calls))
|
|
826
|
+
# Gated (the purge path) but denied: the denial wins.
|
|
827
|
+
with pytest.raises(ApiPolicyError) as denied:
|
|
828
|
+
await client.post("/orders/7/purge", operation_id="purgeOrder")
|
|
829
|
+
assert "denied by denied_operations" in str(denied.value)
|
|
830
|
+
assert "approval" not in str(denied.value)
|
|
831
|
+
# Gated (the purge path, every method) but DELETE is not allowed: allowed_methods refuses.
|
|
832
|
+
with pytest.raises(ApiPolicyError, match="not in allowed_methods"):
|
|
833
|
+
await client.delete("/orders/{order_id}/purge", path_params={"order_id": "7"})
|
|
834
|
+
assert calls == []
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
# --- every method against a real server ------------------------------------------
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
class _EchoHandler(BaseHTTPRequestHandler):
|
|
841
|
+
"""Answers every method with what it received (204 and no body for DELETE)."""
|
|
842
|
+
|
|
843
|
+
def _answer(self) -> None:
|
|
844
|
+
length = int(self.headers.get("content-length") or 0)
|
|
845
|
+
raw = self.rfile.read(length) if length else b""
|
|
846
|
+
if self.command == "DELETE":
|
|
847
|
+
self.send_response(204)
|
|
848
|
+
self.end_headers()
|
|
849
|
+
return
|
|
850
|
+
payload = {
|
|
851
|
+
"method": self.command,
|
|
852
|
+
"path": self.path,
|
|
853
|
+
"body": json.loads(raw) if raw else None,
|
|
854
|
+
"authorization": self.headers.get("authorization"),
|
|
855
|
+
"x_request_tag": self.headers.get("x-request-tag"),
|
|
856
|
+
"content_type": self.headers.get("content-type"),
|
|
857
|
+
}
|
|
858
|
+
data = json.dumps(payload).encode()
|
|
859
|
+
self.send_response(200)
|
|
860
|
+
self.send_header("content-type", "application/json")
|
|
861
|
+
self.send_header("content-length", str(len(data)))
|
|
862
|
+
self.end_headers()
|
|
863
|
+
if self.command != "HEAD":
|
|
864
|
+
self.wfile.write(data)
|
|
865
|
+
|
|
866
|
+
do_GET = do_HEAD = do_POST = do_PUT = do_PATCH = do_DELETE = do_OPTIONS = _answer
|
|
867
|
+
|
|
868
|
+
def log_message(self, format: str, *args: Any) -> None: # quiet test output
|
|
869
|
+
return
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
@pytest.fixture
|
|
873
|
+
def local_server(policy_file: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[str]:
|
|
874
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), _EchoHandler)
|
|
875
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
876
|
+
thread.start()
|
|
877
|
+
base = f"http://127.0.0.1:{server.server_address[1]}/v1"
|
|
878
|
+
monkeypatch.setenv("ORDERS_API_BASE_URL", base)
|
|
879
|
+
try:
|
|
880
|
+
yield base
|
|
881
|
+
finally:
|
|
882
|
+
server.shutdown()
|
|
883
|
+
server.server_close()
|
|
884
|
+
thread.join(timeout=5)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
async def test_every_allowed_method_reaches_a_real_server(local_server: str) -> None:
|
|
888
|
+
client = get_client("orders", run_id="methods-1")
|
|
889
|
+
created = await client.post(
|
|
890
|
+
"/orders",
|
|
891
|
+
operation_id="createOrder",
|
|
892
|
+
json_body={"sku": "a-1", "quantity": 2},
|
|
893
|
+
params={"dry": "false"},
|
|
894
|
+
headers={"X-Request-Tag": "t1", "Authorization": "Bearer forged"},
|
|
895
|
+
)
|
|
896
|
+
assert created["method"] == "POST"
|
|
897
|
+
assert created["path"] == "/v1/orders?dry=false"
|
|
898
|
+
assert created["body"] == {"sku": "a-1", "quantity": 2}
|
|
899
|
+
assert created["content_type"] == "application/json"
|
|
900
|
+
assert created["x_request_tag"] == "t1"
|
|
901
|
+
assert created["authorization"] == "Bearer orders-token" # the policy's token wins
|
|
902
|
+
replaced = await client.put(
|
|
903
|
+
"/orders/{order_id}",
|
|
904
|
+
operation_id="replaceOrder",
|
|
905
|
+
path_params={"order_id": "7"},
|
|
906
|
+
json_body={"sku": "b-2"},
|
|
907
|
+
)
|
|
908
|
+
assert (replaced["method"], replaced["path"], replaced["body"]) == (
|
|
909
|
+
"PUT",
|
|
910
|
+
"/v1/orders/7",
|
|
911
|
+
{"sku": "b-2"},
|
|
912
|
+
)
|
|
913
|
+
client = get_client("orders", run_id="methods-2") # max_calls_per_run is 3
|
|
914
|
+
updated = await client.patch(
|
|
915
|
+
"/orders/{order_id}",
|
|
916
|
+
operation_id="updateOrder",
|
|
917
|
+
path_params={"order_id": "7"},
|
|
918
|
+
json_body={"quantity": 3},
|
|
919
|
+
)
|
|
920
|
+
assert (updated["method"], updated["body"]) == ("PATCH", {"quantity": 3})
|
|
921
|
+
cancelled = await client.delete(
|
|
922
|
+
"/orders/{order_id}", operation_id="cancelOrder", path_params={"order_id": "7"}
|
|
923
|
+
)
|
|
924
|
+
assert cancelled == "" # 204, no body
|
|
925
|
+
assert await client.head("/orders", operation_id="listOrders") == ""
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
async def test_a_denied_method_never_reaches_the_server(local_server: str) -> None:
|
|
929
|
+
client = get_client("orders", run_id="denied")
|
|
930
|
+
with pytest.raises(ApiPolicyError, match="denied"):
|
|
931
|
+
await client.delete("/orders", operation_id="purgeOrders")
|
|
932
|
+
with pytest.raises(ApiPolicyError, match="allowed_methods"):
|
|
933
|
+
await client.options("/orders", operation_id="listOrders")
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
# --- limits ----------------------------------------------------------------------
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
class _Clock:
|
|
940
|
+
def __init__(self) -> None:
|
|
941
|
+
self.now = 1000.0
|
|
942
|
+
|
|
943
|
+
def __call__(self) -> float:
|
|
944
|
+
return self.now
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
async def test_max_calls_per_run_is_counted_per_run(policy_file: Path) -> None:
|
|
948
|
+
calls: list[httpx.Request] = []
|
|
949
|
+
run_a = get_client("orders", transport=_transport(calls), run_id="run-a")
|
|
950
|
+
for _ in range(3):
|
|
951
|
+
await run_a.get("/orders", operation_id="listOrders")
|
|
952
|
+
with pytest.raises(ApiPolicyError, match="max_calls_per_run"):
|
|
953
|
+
await run_a.get("/orders", operation_id="listOrders")
|
|
954
|
+
assert len(calls) == 3 # refused before sending
|
|
955
|
+
# Another run has its own count; a finished run's count is dropped.
|
|
956
|
+
run_b = get_client("orders", transport=_transport(calls), run_id="run-b")
|
|
957
|
+
await run_b.get("/orders", operation_id="listOrders")
|
|
958
|
+
end_run("run-a")
|
|
959
|
+
await run_a.get("/orders", operation_id="listOrders")
|
|
960
|
+
assert len(calls) == 5
|
|
961
|
+
# A refused call (outside the policy) does not use up the budget.
|
|
962
|
+
run_c = get_client("orders", transport=_transport(calls), run_id="run-c")
|
|
963
|
+
with pytest.raises(ApiPolicyError, match="not in allowed_operations"):
|
|
964
|
+
await run_c.get("/elsewhere", operation_id="other")
|
|
965
|
+
for _ in range(3):
|
|
966
|
+
await run_c.get("/orders", operation_id="listOrders")
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
async def test_rate_per_minute_is_a_token_bucket(policy_file: Path) -> None:
|
|
970
|
+
clock = _Clock()
|
|
971
|
+
reset_limits(clock)
|
|
972
|
+
calls: list[httpx.Request] = []
|
|
973
|
+
client = get_client("metered", transport=_transport(calls))
|
|
974
|
+
await client.get("/a")
|
|
975
|
+
await client.post("/b")
|
|
976
|
+
with pytest.raises(ApiPolicyError, match="rate_per_minute"):
|
|
977
|
+
await client.get("/a")
|
|
978
|
+
assert len(calls) == 2
|
|
979
|
+
clock.now += 30 # half a minute refills one of the two tokens
|
|
980
|
+
await client.get("/a")
|
|
981
|
+
with pytest.raises(ApiPolicyError, match="rate_per_minute"):
|
|
982
|
+
await client.get("/a")
|
|
983
|
+
assert len(calls) == 3
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def test_run_counters_are_evicted_by_ttl_and_size(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
987
|
+
clock = _Clock()
|
|
988
|
+
limiter = reset_limits(clock)
|
|
989
|
+
limits = {"max_calls_per_run": 1}
|
|
990
|
+
assert limiter.acquire("a", limits, "old") is None
|
|
991
|
+
clock.now += api_client.RUN_COUNTER_TTL_S + 1
|
|
992
|
+
assert limiter.acquire("a", limits, "new") is None
|
|
993
|
+
assert limiter.tracked_runs() == 1 # "old" went after the TTL
|
|
994
|
+
assert limiter.acquire("a", limits, "old") is None # a fresh count
|
|
995
|
+
monkeypatch.setattr(api_client, "MAX_TRACKED_RUNS", 2)
|
|
996
|
+
for run in ("r1", "r2", "r3"):
|
|
997
|
+
assert limiter.acquire("a", limits, run) is None
|
|
998
|
+
assert limiter.tracked_runs() == 2
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
async def test_the_run_id_comes_from_the_graph_run_then_the_request() -> None:
|
|
1002
|
+
from typing import TypedDict
|
|
1003
|
+
|
|
1004
|
+
from langgraph.graph import END, START, StateGraph
|
|
1005
|
+
|
|
1006
|
+
class State(TypedDict):
|
|
1007
|
+
seen: str
|
|
1008
|
+
|
|
1009
|
+
def node(state: State) -> dict[str, str]:
|
|
1010
|
+
return {"seen": current_run_id() or ""}
|
|
1011
|
+
|
|
1012
|
+
builder = StateGraph(State)
|
|
1013
|
+
builder.add_node("n", node)
|
|
1014
|
+
builder.add_edge(START, "n")
|
|
1015
|
+
builder.add_edge("n", END)
|
|
1016
|
+
graph = builder.compile()
|
|
1017
|
+
result = await graph.ainvoke({"seen": ""}, config={"metadata": {"run_id": "run-42"}})
|
|
1018
|
+
assert result["seen"] == "run-42"
|
|
1019
|
+
assert current_run_id() is None
|
|
1020
|
+
bind_log_context(request_id="req-1")
|
|
1021
|
+
try:
|
|
1022
|
+
assert current_run_id() == "req-1"
|
|
1023
|
+
bind_log_context(run_id="run-7")
|
|
1024
|
+
assert current_run_id() == "run-7"
|
|
1025
|
+
finally:
|
|
1026
|
+
bind_log_context(request_id=None, run_id=None)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
# --- the project's own tools and policy ----------------------------------------
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _project_policy() -> ApiPolicy | None:
|
|
1033
|
+
path = Path(api_client.__file__).resolve().parents[2] / "api-policy.yaml"
|
|
1034
|
+
return ApiPolicy.load(path) if path.is_file() else None
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _tool_modules(package: Any) -> list[str]:
|
|
1038
|
+
"""Every module under the tools package, subpackages included (what lint reads).
|
|
1039
|
+
|
|
1040
|
+
`*.py` files anywhere below the package (hidden and cache directories
|
|
1041
|
+
skipped, symlinked directories followed once), a subpackage's own
|
|
1042
|
+
`__init__.py` included; the package's top-level `__init__.py` is the registry.
|
|
1043
|
+
"""
|
|
1044
|
+
root = Path(package.__file__).resolve().parent
|
|
1045
|
+
names: list[str] = []
|
|
1046
|
+
seen: set[str] = set()
|
|
1047
|
+
for directory, dirs, files in os.walk(root, followlinks=True):
|
|
1048
|
+
real = os.path.realpath(directory)
|
|
1049
|
+
if real in seen:
|
|
1050
|
+
dirs[:] = []
|
|
1051
|
+
continue
|
|
1052
|
+
seen.add(real)
|
|
1053
|
+
dirs[:] = sorted(d for d in dirs if not d.startswith(".") and d != "__pycache__")
|
|
1054
|
+
relative = Path(directory).relative_to(root)
|
|
1055
|
+
for filename in sorted(files):
|
|
1056
|
+
if not filename.endswith(".py") or (not relative.parts and filename == "__init__.py"):
|
|
1057
|
+
continue
|
|
1058
|
+
parts = [*relative.parts] + ([] if filename == "__init__.py" else [filename[:-3]])
|
|
1059
|
+
names.append(".".join([package.__name__, *parts]))
|
|
1060
|
+
return sorted(names)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def test_every_tool_declares_calls_its_policy_allows() -> None:
|
|
1064
|
+
"""Every `API_CALLS` entry names a declared API and passes its rules (what lint checks).
|
|
1065
|
+
|
|
1066
|
+
Tool subpackages count: lint reads every module below `tools/`, and so does this test.
|
|
1067
|
+
"""
|
|
1068
|
+
import {{cookiecutter.agent_directory}}.tools as tools
|
|
1069
|
+
|
|
1070
|
+
policy = _project_policy()
|
|
1071
|
+
modules = _tool_modules(tools)
|
|
1072
|
+
# At least every module and subpackage `get_tools()` imports.
|
|
1073
|
+
depth = len(tools.__name__.split("."))
|
|
1074
|
+
walked = {name.split(".")[depth] for name in modules}
|
|
1075
|
+
assert {info.name for info in pkgutil.iter_modules(tools.__path__)} <= walked
|
|
1076
|
+
for name in modules:
|
|
1077
|
+
module = importlib.import_module(name)
|
|
1078
|
+
for call in getattr(module, "API_CALLS", []):
|
|
1079
|
+
assert policy is not None, f"{name} declares API calls but there is no api-policy.yaml"
|
|
1080
|
+
policy.check(call["api"], call["method"], call.get("operation_id"), call.get("path"))
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def test_the_tool_module_walk_covers_subpackages(
|
|
1084
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1085
|
+
) -> None:
|
|
1086
|
+
"""The walk above reaches nested modules and subpackage `__init__`s, like lint."""
|
|
1087
|
+
package = tmp_path / "walked_tools"
|
|
1088
|
+
(package / "nested" / "deeper").mkdir(parents=True)
|
|
1089
|
+
(package / "__pycache__").mkdir()
|
|
1090
|
+
(package / ".hidden").mkdir()
|
|
1091
|
+
for relative in (
|
|
1092
|
+
"__init__.py",
|
|
1093
|
+
"top.py",
|
|
1094
|
+
"nested/__init__.py",
|
|
1095
|
+
"nested/inner.py",
|
|
1096
|
+
"nested/deeper/leaf.py",
|
|
1097
|
+
"__pycache__/cached.py",
|
|
1098
|
+
".hidden/secret.py",
|
|
1099
|
+
):
|
|
1100
|
+
(package / relative).write_text("API_CALLS = []\n", encoding="utf-8")
|
|
1101
|
+
monkeypatch.syspath_prepend(str(tmp_path))
|
|
1102
|
+
walked = importlib.import_module("walked_tools")
|
|
1103
|
+
assert _tool_modules(walked) == [
|
|
1104
|
+
"walked_tools.nested",
|
|
1105
|
+
"walked_tools.nested.deeper.leaf",
|
|
1106
|
+
"walked_tools.nested.inner",
|
|
1107
|
+
"walked_tools.top",
|
|
1108
|
+
]
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def test_the_project_policy_is_valid() -> None:
|
|
1112
|
+
policy = _project_policy()
|
|
1113
|
+
if policy is None:
|
|
1114
|
+
pytest.skip("this project declares no api-policy.yaml")
|
|
1115
|
+
assert policy.apis
|
|
1116
|
+
assert yaml.safe_load(policy.source.read_text(encoding="utf-8"))["apis"]
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
# --- the caller when another agent asks for the user (0.3) ----------------------------------
|
|
1120
|
+
|
|
1121
|
+
_CONCIERGE = {"id": "concierge", "chain": ["concierge", "web-agent"], "client": "concierge"}
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
@dataclass
|
|
1125
|
+
class _AgentRun:
|
|
1126
|
+
"""A tool's runtime: the run context and the thread state."""
|
|
1127
|
+
|
|
1128
|
+
context: Any
|
|
1129
|
+
state: dict[str, Any]
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def _context(*, actor: dict[str, Any] | None = None, origin: str | None = None) -> dict[str, Any]:
|
|
1133
|
+
attributes: dict[str, Any] = {}
|
|
1134
|
+
if actor is not None:
|
|
1135
|
+
attributes["@actor"] = actor
|
|
1136
|
+
if origin is not None:
|
|
1137
|
+
attributes["credentials"] = {"@origin": {"text": origin, "truncated": False, "hops": 1}}
|
|
1138
|
+
return {"principal_id": "alice", "roles": [], "attributes": attributes}
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _asked(text: str, **context: Any) -> _AgentRun:
|
|
1142
|
+
return _AgentRun(
|
|
1143
|
+
context=_context(**context), state={"messages": [{"type": "human", "content": text}]}
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def test_caller_fields() -> None:
|
|
1148
|
+
direct = current_caller(_context())
|
|
1149
|
+
assert (direct.actor, direct.actor_chain, direct.delegated) == (None, (), False)
|
|
1150
|
+
agent = current_caller(_context(actor=_CONCIERGE))
|
|
1151
|
+
assert agent.principal_id == "alice" and agent.delegated
|
|
1152
|
+
assert (agent.actor, agent.actor_chain) == ("concierge", ("concierge", "web-agent"))
|
|
1153
|
+
assert current_caller(_context(actor={"id": "billing"})).actor_chain == ("billing",)
|
|
1154
|
+
assert Caller("bob", frozenset()).actor is None # the 0.2 constructor still works
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def test_require_direct_caller() -> None:
|
|
1158
|
+
assert require_direct_caller(_context()).principal_id == "alice"
|
|
1159
|
+
with pytest.raises(ApiPolicyError, match="not agent 'concierge' acting for them"):
|
|
1160
|
+
require_direct_caller(_context(actor=_CONCIERGE))
|
|
1161
|
+
# require_owner still compares the subject: the record is the user's.
|
|
1162
|
+
assert require_owner("alice", context=_context(actor=_CONCIERGE)).actor == "concierge"
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def test_require_user_mentioned_delegated_uses_origin(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
1166
|
+
monkeypatch.delenv("A2A_DELEGATED_MENTIONS", raising=False)
|
|
1167
|
+
# The user named it, and the agent's request names it: allowed.
|
|
1168
|
+
require_user_mentioned(
|
|
1169
|
+
"ORD-1002", _asked("Cancel ORD-1002", actor=_CONCIERGE, origin="cancel order ORD-1002")
|
|
1170
|
+
)
|
|
1171
|
+
# Only the agent's request names it (an instruction planted in data it read): refused.
|
|
1172
|
+
with pytest.raises(ApiPolicyError, match="not named in the user's own words"):
|
|
1173
|
+
require_user_mentioned(
|
|
1174
|
+
"ORD-1017", _asked("Cancel ORD-1017", actor=_CONCIERGE, origin="cancel ORD-1002")
|
|
1175
|
+
)
|
|
1176
|
+
# The user named it, the agent's request does not: refused as before.
|
|
1177
|
+
with pytest.raises(ApiPolicyError, match="not named in the user's latest message"):
|
|
1178
|
+
require_user_mentioned(
|
|
1179
|
+
"ORD-1002", _asked("Cancel it", actor=_CONCIERGE, origin="cancel ORD-1002")
|
|
1180
|
+
)
|
|
1181
|
+
# No forwarded words: refused, naming the agent.
|
|
1182
|
+
with pytest.raises(ApiPolicyError) as refused:
|
|
1183
|
+
require_user_mentioned("ORD-1002", _asked("Cancel ORD-1002", actor=_CONCIERGE))
|
|
1184
|
+
assert str(refused.value) == (
|
|
1185
|
+
"refused: 'ORD-1002' was asked for by agent 'concierge', which forwarded no user "
|
|
1186
|
+
"message to check it against; the user must name it."
|
|
1187
|
+
)
|
|
1188
|
+
# A direct caller: the user's latest message, as in 0.2 (forwarded words are not read).
|
|
1189
|
+
require_user_mentioned("ORD-1002", _asked("Cancel ORD-1002", origin="nothing"))
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def test_require_user_mentioned_refuse_and_request_modes(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
1193
|
+
run = _asked("Cancel ORD-1002", actor=_CONCIERGE, origin="cancel ORD-1002")
|
|
1194
|
+
monkeypatch.setenv("A2A_DELEGATED_MENTIONS", "refuse")
|
|
1195
|
+
with pytest.raises(ApiPolicyError, match="the user must ask this agent directly"):
|
|
1196
|
+
require_user_mentioned("ORD-1002", run)
|
|
1197
|
+
require_user_mentioned("ORD-1002", _asked("Cancel ORD-1002")) # a direct caller
|
|
1198
|
+
monkeypatch.setenv("A2A_DELEGATED_MENTIONS", "request")
|
|
1199
|
+
require_user_mentioned("ORD-1017", _asked("Cancel ORD-1017", actor=_CONCIERGE))
|
|
1200
|
+
# A bad value (the startup check refuses it) fails closed: refused.
|
|
1201
|
+
monkeypatch.setenv("A2A_DELEGATED_MENTIONS", "sometimes")
|
|
1202
|
+
with pytest.raises(ApiPolicyError, match="the user must ask this agent directly"):
|
|
1203
|
+
require_user_mentioned("ORD-1002", run)
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def test_the_delegated_prompt_settings_are_checked() -> None:
|
|
1207
|
+
assert delegated_mentions({}) == "origin"
|
|
1208
|
+
assert delegated_mentions({"A2A_DELEGATED_MENTIONS": "Request"}) == "request"
|
|
1209
|
+
assert caller_note_enabled({}) is True
|
|
1210
|
+
assert caller_note_enabled({"A2A_CALLER_NOTE": "off"}) is False
|
|
1211
|
+
with pytest.raises(SettingsError, match="A2A_DELEGATED_MENTIONS"):
|
|
1212
|
+
delegated_mentions({"A2A_DELEGATED_MENTIONS": "any"})
|
|
1213
|
+
with pytest.raises(SettingsError, match="A2A_CALLER_NOTE"):
|
|
1214
|
+
caller_note_enabled({"A2A_CALLER_NOTE": "false"})
|