diff --git a/.env.example b/.env.example index 07d27b8..28357c6 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,11 @@ # survive a rebuild regardless of what the shell that runs it has exported. # # cp .env.example .env +# cp .env.provider.example .env.provider # gateway only: ANTHROPIC/OPENAI key +# +# Compose reads these files directly rather than through ${VAR} interpolation, +# because interpolation gives an exported shell variable precedence over the +# file — which silently defeats a key rotation. # sed -i "s/^AETHERYA_CONSOLE_API_KEY=.*/AETHERYA_CONSOLE_API_KEY=$(openssl rand -hex 32)/" .env # # .env is gitignored. .env.example is not — keep real secrets out of it. @@ -13,10 +18,11 @@ AETHERYA_CONSOLE_API_KEY=replace-me # is self-consistent, not that this service produced it. AETHERYA_ATTESTATION_KEY=replace-me -# The gateway forwards to a real provider. Without a key the container starts, -# answers /health, and returns 502 on every completion. -ANTHROPIC_API_KEY= -OPENAI_API_KEY= +# Answering a held action in the approval queue. The first authorises the +# operator to answer at all; the second signs the proof that unblocks the agent. +# Both are needed or Approve fails at the click. +AETHERYA_APPROVALS_API_KEY=replace-me +AETHERYA_CONFIRMATION_HMAC_KEY=replace-me # Gateway target. The actor must exist in capability_matrix.actors, or phase 3 # refuses every call — the gateway refuses to start at phase 3 without it. diff --git a/.env.provider.example b/.env.provider.example new file mode 100644 index 0000000..055a18a --- /dev/null +++ b/.env.provider.example @@ -0,0 +1,6 @@ +# Upstream provider credentials, read only by the gateway service. +# Kept out of .env so the decision service never receives a credential it +# has no use for. Without one the gateway starts, answers /health, and +# returns 502 on every completion. +ANTHROPIC_API_KEY= +OPENAI_API_KEY= diff --git a/.gitignore b/.gitignore index 9a543ca..0ab4002 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ CLAUDE.md demo.cast # Local secrets for the compose stack. Never commit. .env +.env.provider +RESUMEN-SESION.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d099ee6..f709fa5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,8 +15,12 @@ ENV PATH="/opt/venv/bin:$PATH" COPY pyproject.toml README.md ./ COPY src/ ./src/ +# The provider SDKs are here because the same image runs the gateway, which +# cannot reach an upstream without them — installed, it answers; absent, every +# completion is a 502 that reads like a network fault. They are optional extras +# for a library consumer and mandatory for this image. RUN pip install --no-cache-dir -U pip \ - && pip install --no-cache-dir ".[redis]" + && pip install --no-cache-dir ".[redis,llm,anthropic]" # Pre-download the sentence-transformers model into the image. ENV HF_HOME=/opt/models diff --git a/config/policy.docker.yaml b/config/policy.docker.yaml index 230983b..da2d2ef 100644 --- a/config/policy.docker.yaml +++ b/config/policy.docker.yaml @@ -123,7 +123,12 @@ confirmation: token_pattern: "^ack:[a-z0-9_-]{8,}$" min_context_length: 12 signed_proof: - enabled: false + # On in the container profiles because they ship the approval queue. + # An approval that is not cryptographically bound to the exact action + # is a token anyone can type; with this on, a proof is scoped to one + # action, single-use, and useless anywhere else. The repo default + # stays off so `aetherya decide` needs no key management. + enabled: true proof_param: confirm_proof key_env: AETHERYA_CONFIRMATION_HMAC_KEY keyring_env: AETHERYA_CONFIRMATION_HMAC_KEYRING diff --git a/config/policy.slim.yaml b/config/policy.slim.yaml index 94c800e..3368a6f 100644 --- a/config/policy.slim.yaml +++ b/config/policy.slim.yaml @@ -122,7 +122,12 @@ confirmation: token_pattern: "^ack:[a-z0-9_-]{8,}$" min_context_length: 12 signed_proof: - enabled: false + # On in the container profiles because they ship the approval queue. + # An approval that is not cryptographically bound to the exact action + # is a token anyone can type; with this on, a proof is scoped to one + # action, single-use, and useless anywhere else. The repo default + # stays off so `aetherya decide` needs no key management. + enabled: true proof_param: confirm_proof key_env: AETHERYA_CONFIRMATION_HMAC_KEY keyring_env: AETHERYA_CONFIRMATION_HMAC_KEYRING diff --git a/docker-compose.yml b/docker-compose.yml index b62aacb..057c767 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,10 @@ services: image: redis:7-alpine container_name: aetherya-redis ports: - - "6379:6379" + # Loopback only. This holds the confirmation replay store, so network + # access to it means deleting replay records and reusing approval proofs + # — the single-use guarantee lives in here, not just in the signature. + - "127.0.0.1:6379:6379" command: ["redis-server", "--appendonly", "yes"] volumes: - redis-data:/data @@ -28,30 +31,27 @@ services: build: . container_name: aetherya-decision ports: - - "8080:8080" + # Bound to the loopback interface on the host, not 0.0.0.0. In a container + # the app-level "must come from 127.0.0.1" check can never pass — every + # client arrives as the bridge gateway — so the port publication is what + # actually keeps the admin routes off the network. + - "127.0.0.1:8080:8080" depends_on: redis: condition: service_healthy + # Secrets come from the file, never from `${VAR}` interpolation. Compose + # gives the invoking shell precedence over .env, so an exported variable + # silently wins over the file — which is how a rotated console key kept + # reverting to an older value and locked the console out with no + # indication that two different keys were in play. + env_file: + - .env environment: # Shared rate-limit state. With the in-process backend, N replicas mean # N x the configured limit — see docs/security-model.md. AETHERYA_RATE_LIMIT_REDIS_URL: redis://redis:6379/0 AETHERYA_CONFIRMATION_REPLAY_REDIS_URL: redis://redis:6379/1 - # Secrets come from .env (see .env.example), not from whatever the shell - # running `docker compose` happens to have exported — otherwise rotating a - # key silently reverts to the default on the next rebuild by someone else. - # - # Signs the audit chain. Replace before this is anything but a local demo: - # without a real secret the attestation proves the file is self-consistent, - # not that this service produced it. - AETHERYA_ATTESTATION_KEY: "${AETHERYA_ATTESTATION_KEY:-local-dev-attestation-key-replace-me}" - - # Gates the operator console. Recording a hard-deny review requires this - # to be set — the verdict is attributed to a named reviewer, so it has to - # be authenticated. Reads are gated by it too once it exists. - AETHERYA_CONSOLE_API_KEY: "${AETHERYA_CONSOLE_API_KEY:-local-dev-console-key-replace-me}" - # Pin the policy so a replica refuses to start under an unintended one. # Get the value with: aetherya policy fingerprint --json # Left unset locally so the stack comes up before you have run that. @@ -72,6 +72,8 @@ services: - --policy-path=config/policy.docker.yaml - --audit-path=audit/decisions.jsonl - --require-semantic-ready + # Safe only because the port above is published on loopback. + - --approval-trust-network-boundary restart: unless-stopped @@ -90,14 +92,17 @@ services: build: . container_name: aetherya-gateway ports: - - "8090:8090" + # Loopback only: this process holds your upstream API key and + # authenticates nobody. + - "127.0.0.1:8090:8090" depends_on: aetherya: condition: service_healthy - environment: - ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" - OPENAI_API_KEY: "${OPENAI_API_KEY:-}" - AETHERYA_ATTESTATION_KEY: "${AETHERYA_ATTESTATION_KEY:-local-dev-attestation-key-replace-me}" + # Same reasoning as above. The provider key lives in its own file so the + # decision service never receives a credential it has no use for. + env_file: + - .env + - .env.provider volumes: - audit-data:/app/audit - ./config:/app/config:ro @@ -112,6 +117,21 @@ services: - --actor=${AETHERYA_GATEWAY_ACTOR:-robert} - --policy-path=config/policy.docker.yaml - --audit-path=audit/decisions.jsonl + # The image's healthcheck probes 8080 and asserts fields only the decision + # service reports, so the gateway inherits a check it can never pass. + healthcheck: + test: + - CMD + - python + - -c + - | + import json, urllib.request + d = json.load(urllib.request.urlopen("http://127.0.0.1:8090/health", timeout=4)) + raise SystemExit(0 if d.get("ok") and d.get("upstream_key_present") else 1) + interval: 15s + timeout: 5s + start_period: 15s + retries: 3 restart: unless-stopped volumes: diff --git a/src/aetherya/api_server.py b/src/aetherya/api_server.py index 04e6656..cc6fae6 100644 --- a/src/aetherya/api_server.py +++ b/src/aetherya/api_server.py @@ -329,6 +329,7 @@ def serve_api( enable_approval_routes: bool = True, warmup_semantic: bool = True, require_semantic_ready: bool = False, + approval_sign_local_only: bool = True, ) -> None: warmup_semantic_layer( policy_path=policy_path, @@ -345,6 +346,7 @@ def serve_api( enable_decide_routes=enable_decide_routes, enable_audit_routes=enable_audit_routes, enable_approval_routes=enable_approval_routes, + approval_sign_local_only=approval_sign_local_only, ) api = AetheryaAPI(settings) server = build_server( @@ -368,6 +370,17 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--constitution-path", default=None) parser.add_argument("--default-actor", default="robert") parser.add_argument("--max-body-bytes", type=int, default=1_048_576) + parser.add_argument( + "--approval-trust-network-boundary", + action="store_true", + help=( + "Stop requiring that admin requests arrive from 127.0.0.1. Necessary " + "in a container, where every client appears as the bridge gateway and " + "the check can never pass — publish the port as 127.0.0.1:PORT:PORT " + "so the host is the boundary instead. Do not set this on a service " + "whose port is reachable from anywhere else." + ), + ) parser.add_argument( "--no-warmup-semantic", action="store_true", @@ -423,6 +436,7 @@ def main(argv: list[str] | None = None) -> int: enable_approval_routes=enable_approval_routes, warmup_semantic=not bool(args.no_warmup_semantic), require_semantic_ready=bool(args.require_semantic_ready), + approval_sign_local_only=not bool(args.approval_trust_network_boundary), ) return 0 except KeyboardInterrupt: diff --git a/src/aetherya/gateway.py b/src/aetherya/gateway.py index a42c2eb..3c7a3ff 100644 --- a/src/aetherya/gateway.py +++ b/src/aetherya/gateway.py @@ -211,13 +211,20 @@ def _call_anthropic(self, body: dict[str, Any]) -> dict[str, Any]: continue messages.append({"role": role, "content": str(content or "")}) + model = str(body.get("model") or self.settings.model) request: dict[str, Any] = { - "model": body.get("model") or self.settings.model, + "model": model, "max_tokens": int(body.get("max_tokens") or self.settings.max_tokens), "messages": messages, - "thinking": {"type": "adaptive"}, - "output_config": {"effort": self.settings.anthropic_effort}, } + # Adaptive thinking and the effort control exist on the 4.6+ families + # and are a 400 on anything older, which takes down every request + # rather than degrading. Sent only where the model is known to accept + # them, so pointing the gateway at an older or unfamiliar model works + # with less depth instead of not at all. + if _supports_adaptive_thinking(model): + request["thinking"] = {"type": "adaptive"} + request["output_config"] = {"effort": self.settings.anthropic_effort} if system_parts: request["system"] = "\n\n".join(system_parts) @@ -383,6 +390,27 @@ def stream(self, body: dict[str, Any]) -> list[dict[str, Any]]: return frames +# Families that accept `thinking: {"type": "adaptive"}` and `output_config.effort`. +# An allowlist rather than a denylist: an unrecognised model then loses depth, +# which is a worse answer, instead of returning 400 on every request, which is +# no answer at all. Add a family here when it is known to support them. +_ADAPTIVE_THINKING_FAMILIES: Final[tuple[str, ...]] = ( + "claude-opus-4-6", + "claude-opus-4-7", + "claude-opus-4-8", + "claude-opus-5", + "claude-sonnet-4-6", + "claude-sonnet-5", + "claude-fable-5", + "claude-mythos-5", +) + + +def _supports_adaptive_thinking(model: str) -> bool: + name = model.strip().lower() + return any(name.startswith(family) for family in _ADAPTIVE_THINKING_FAMILIES) + + def _parse_arguments(raw: Any) -> dict[str, Any]: if isinstance(raw, dict): return dict(raw) diff --git a/src/aetherya/gateway_server.py b/src/aetherya/gateway_server.py index 807975f..a81946b 100644 --- a/src/aetherya/gateway_server.py +++ b/src/aetherya/gateway_server.py @@ -10,7 +10,7 @@ from aetherya.audit import AuditLogger from aetherya.config import load_policy_config -from aetherya.gateway import AetheryaGateway, GatewaySettings, UpstreamError +from aetherya.gateway import _PROVIDERS, AetheryaGateway, GatewaySettings, UpstreamError DEFAULT_PORT = 8090 MAX_BODY_BYTES = 4_194_304 @@ -54,14 +54,23 @@ def do_GET(self) -> None: # noqa: N802 path = self.path.split("?", 1)[0].rstrip("/") or "/" if path == "/health": gateway = self.gateway + # A gateway with no upstream credential answers every completion + # with a 502. Reporting that as healthy is a lie a readiness probe + # would believe, so the key is part of the contract rather than + # something you discover on the first real request. + key_present = False + if gateway is not None: + key_env = _PROVIDERS.get(gateway.settings.provider.strip().lower()) + key_present = bool(key_env and os.getenv(key_env[0], "").strip()) self._send_json( 200, { - "ok": gateway is not None, + "ok": gateway is not None and key_present, "provider": gateway.settings.provider if gateway else None, "model": gateway.settings.model if gateway else None, "phase": gateway.phase.number if gateway else None, "phase_name": gateway.phase.name if gateway else None, + "upstream_key_present": key_present, }, ) return diff --git a/src/aetherya/pipeline.py b/src/aetherya/pipeline.py index fd47fbc..270f9fc 100644 --- a/src/aetherya/pipeline.py +++ b/src/aetherya/pipeline.py @@ -237,6 +237,30 @@ def _mode_thresholds(cfg: PolicyConfig | Any, mode: str) -> dict[str, int]: } +def _audit_action(action: ActionRequest) -> dict[str, Any]: + """ + The structured shape of what was requested, for the audit trail. + + `raw_input` is already recorded, but as prose: recovering which tool an + agent used means regexing a string, and the console's tool column read as + empty for every real decision because nothing ever wrote this. Phase 1 is + supposed to tell you what vocabulary your agent actually speaks, and that + needs structure rather than a rendering. + + Parameter *names* only. Values carry commands, file contents and — for a + confirmed action — `confirm_proof`, which is a single-use credential. The + audit trail is exported, mirrored and archived; a credential written there + cannot be taken back out. + """ + return { + "intent": action.intent, + "tool": action.tool, + "target": action.target, + "operation": action.parameters.get("operation"), + "parameter_names": sorted(str(name) for name in action.parameters), + } + + def _fail_closed( *, raw_input: str, @@ -923,6 +947,7 @@ def _shadow_call() -> Any: context: dict[str, Any] = { "mode": mode.value, "signals": [s.__dict__ for s in agg.breakdown], + "action": _audit_action(action), } if explainability: context["explainability"] = explainability diff --git a/tests/test_approval_api.py b/tests/test_approval_api.py index 09d5d50..6f8ece8 100644 --- a/tests/test_approval_api.py +++ b/tests/test_approval_api.py @@ -642,3 +642,56 @@ def test_a_rejection_returns_no_confirmation_parameters( **admin, )[1] assert "confirmation" not in body + + +def test_the_localhost_check_can_be_delegated_to_the_network_boundary( + tmp_path: Path, policy: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + In a container every client arrives as the bridge gateway, so the + "must come from 127.0.0.1" check can never pass and the approve button is + dead on arrival. Publishing the port as 127.0.0.1:PORT:PORT makes the host + the boundary instead; this flag says the deployment has done that. + """ + monkeypatch.setenv("AETHERYA_APPROVALS_API_KEY", "admin-key") + monkeypatch.setenv("AETHERYA_CONFIRMATION_HMAC_KEY", HMAC_KEY) + api = AetheryaAPI( + APISettings( + policy_path=policy, + audit_path=tmp_path / "decisions.jsonl", + approval_queue_path=tmp_path / "approvals.jsonl", + approval_sign_local_only=False, + ) + ) + request_id = _submit(api) + + code, body = api.resolve_approval( + {"request_id": request_id, "approved": True, "decided_by": "robert"}, + headers={"X-AETHERYA-Admin-Key": "admin-key"}, + client_ip="172.23.0.1", + ) + assert code == 200, body + assert body["confirmation"] + + +def test_delegating_the_boundary_does_not_drop_the_admin_key( + tmp_path: Path, policy: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Trusting the network for locality must not also trust it for identity.""" + monkeypatch.setenv("AETHERYA_APPROVALS_API_KEY", "admin-key") + api = AetheryaAPI( + APISettings( + policy_path=policy, + audit_path=tmp_path / "decisions.jsonl", + approval_queue_path=tmp_path / "approvals.jsonl", + approval_sign_local_only=False, + ) + ) + request_id = _submit(api) + + code, _ = api.resolve_approval( + {"request_id": request_id, "approved": True, "decided_by": "robert"}, + headers={"X-AETHERYA-Admin-Key": "wrong"}, + client_ip="172.23.0.1", + ) + assert code == 401 diff --git a/tests/test_deployment_contracts.py b/tests/test_deployment_contracts.py index 546936f..3a9b922 100644 --- a/tests/test_deployment_contracts.py +++ b/tests/test_deployment_contracts.py @@ -593,12 +593,18 @@ def test_the_slim_policy_differs_only_in_the_semantic_layer() -> None: assert replace(docker.constitution_config, use_semantic=False) == slim.constitution_config -def test_the_deployment_policy_differs_only_in_the_rate_limit_backend() -> None: +def test_the_deployment_policy_differs_only_where_it_is_meant_to() -> None: """ `config/policy.docker.yaml` exists so the container gets the distributed - limiter while the repo default keeps working with no infrastructure. Two - policy files is a drift hazard, so the difference is pinned to exactly one - field — anything else changing in one and not the other fails here. + limiter and cryptographically bound approvals, while the repo default keeps + working with no infrastructure at all. Two policy files is a drift hazard, + so the differences are pinned to exactly these two — anything else changing + in one and not the other fails here. + + Signed proofs are on in the container because it ships the approval queue. + An approval that is not bound to the exact action is a token anyone can + type; the repo default stays off so `aetherya decide` needs no key + management to be useful. """ from dataclasses import asdict @@ -607,10 +613,107 @@ def test_the_deployment_policy_differs_only_in_the_rate_limit_backend() -> None: assert repo.rate_limit.backend == "memory" assert docker.rate_limit.backend == "redis" + assert repo.confirmation.evidence.signed_proof.enabled is False + assert docker.confirmation.evidence.signed_proof.enabled is True - ignored = {"policy_fingerprint", "effective_fingerprint", "rate_limit"} + ignored = {"policy_fingerprint", "effective_fingerprint", "rate_limit", "confirmation"} repo_fields = {k: v for k, v in asdict(repo).items() if k not in ignored} docker_fields = {k: v for k, v in asdict(docker).items() if k not in ignored} assert repo_fields == docker_fields assert replace(repo.rate_limit, backend="redis") == docker.rate_limit + # Confirmation must be identical apart from that one flag. + repo_evidence = repo.confirmation.evidence + docker_evidence = docker.confirmation.evidence + assert replace( + repo.confirmation, + evidence=replace( + repo_evidence, + signed_proof=replace(repo_evidence.signed_proof, enabled=True), + ), + ) == replace( + docker.confirmation, + evidence=replace( + docker_evidence, + signed_proof=replace(docker_evidence.signed_proof, enabled=True), + ), + ) + + +def test_the_image_installs_the_provider_sdks_the_gateway_needs() -> None: + """ + Regression: the gateway runs from the same image as the decision service, + and that image installed only `[redis]`. With a valid API key in place every + completion came back 502 `anthropic is not installed` — a wiring gap that + reads like a network fault. + + Optional extras for a library consumer; mandatory for this image, because + the gateway cannot reach an upstream without them. + """ + dockerfile = Path("Dockerfile").read_text(encoding="utf-8") + install = next( + line for line in dockerfile.splitlines() if "pip install" in line and ".[" in line + ) + for extra in ("redis", "llm", "anthropic"): + assert extra in install, f"{extra} missing from the image install: {install.strip()}" + + +def test_the_gateway_service_gets_its_own_healthcheck() -> None: + """ + The image healthcheck probes 8080 and asserts fields only the decision + service returns, so the gateway inherits a check it can never pass and sits + permanently unhealthy for a reason unrelated to its state. + """ + compose = Path("docker-compose.yml").read_text(encoding="utf-8") + gateway_block = compose.split(" gateway:")[1] + assert "healthcheck:" in gateway_block + assert "8090/health" in gateway_block + assert "upstream_key_present" in gateway_block + + +def test_compose_secrets_come_from_files_not_interpolation() -> None: + """ + Compose gives an exported shell variable precedence over .env when the value + arrives as `${VAR}`, which silently defeated a key rotation three times. + `env_file` reads the file and ignores the shell. + """ + compose = Path("docker-compose.yml").read_text(encoding="utf-8") + assert "env_file:" in compose + for secret in ( + "AETHERYA_CONSOLE_API_KEY", + "AETHERYA_APPROVALS_API_KEY", + "AETHERYA_CONFIRMATION_HMAC_KEY", + "ANTHROPIC_API_KEY", + ): + assert f"{secret}: " not in compose, f"{secret} still arrives by interpolation" + + +def test_the_published_ports_are_loopback_only() -> None: + """ + In a container the app-level localhost check can never pass, so the port + publication is what actually keeps the admin routes and the upstream key + off the network. + """ + compose = Path("docker-compose.yml").read_text(encoding="utf-8") + assert '"127.0.0.1:8080:8080"' in compose + assert '"127.0.0.1:8090:8090"' in compose + + +def test_redis_is_not_published_to_the_network() -> None: + """ + Regression, found by an agent reading its own deployment: redis was on + 0.0.0.0 with no auth. It holds the confirmation replay store, so reachability + means an attacker can delete replay records and reuse an approval proof — + the single-use guarantee lives there, not only in the signature. + """ + compose = Path("docker-compose.yml").read_text(encoding="utf-8") + assert '"127.0.0.1:6379:6379"' in compose + assert '"6379:6379"' not in compose + + +def test_no_service_publishes_on_all_interfaces() -> None: + """One rule for the whole stack rather than three ports to remember.""" + compose = yaml.safe_load(Path("docker-compose.yml").read_text(encoding="utf-8")) + for name, service in compose["services"].items(): + for mapping in service.get("ports", []): + assert str(mapping).startswith("127.0.0.1:"), f"{name} publishes {mapping} publicly" diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 97bcd35..d0188c9 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -536,7 +536,12 @@ def test_anthropic_requests_use_adaptive_thinking_and_no_sampling_params() -> No """ upstream = _FakeAnthropic(_AnthropicResponse([_Block(type="text", text="ok")])) _gateway(upstream, provider="anthropic").complete( - {"messages": [{"role": "user", "content": "hi"}], "temperature": 0.7, "top_p": 0.9} + { + "messages": [{"role": "user", "content": "hi"}], + "model": "claude-opus-5", + "temperature": 0.7, + "top_p": 0.9, + } ) request = upstream.requests[0] @@ -770,3 +775,64 @@ def test_the_trace_names_what_was_refused() -> None: gated = result["aetherya"]["gated"][0] assert gated["arguments"] == {"command": "rm -rf /"} assert gated["executed"] is False + + +# --------------------------------------------------------------------------- +# Model capability differences +# --------------------------------------------------------------------------- + + +def test_adaptive_thinking_is_omitted_on_a_model_that_rejects_it() -> None: + """ + Regression, hit live: Haiku 4.5 answers `adaptive thinking is not supported + on this model` with a 400, so sending it unconditionally took down every + request rather than degrading the answer. + """ + upstream = _FakeAnthropic(_AnthropicResponse([_Block(type="text", text="ok")])) + _gateway(upstream, provider="anthropic").complete( + {"messages": [{"role": "user", "content": "hi"}], "model": "claude-haiku-4-5"} + ) + + request = upstream.requests[0] + assert "thinking" not in request + assert "output_config" not in request + + +def test_adaptive_thinking_is_sent_on_a_model_that_supports_it() -> None: + upstream = _FakeAnthropic(_AnthropicResponse([_Block(type="text", text="ok")])) + _gateway(upstream, provider="anthropic").complete( + {"messages": [{"role": "user", "content": "hi"}], "model": "claude-opus-5"} + ) + + request = upstream.requests[0] + assert request["thinking"] == {"type": "adaptive"} + assert request["output_config"] == {"effort": "high"} + + +def test_an_unrecognised_model_loses_depth_rather_than_failing() -> None: + """ + An allowlist, not a denylist: a model nobody has added yet should answer + with less depth instead of 400-ing every request. + """ + upstream = _FakeAnthropic(_AnthropicResponse([_Block(type="text", text="ok")])) + _gateway(upstream, provider="anthropic").complete( + {"messages": [], "model": "claude-something-not-released-yet"} + ) + assert "thinking" not in upstream.requests[0] + + +@pytest.mark.parametrize( + ("model", "expected"), + [ + ("claude-opus-5", True), + ("CLAUDE-OPUS-5", True), + (" claude-sonnet-5 ", True), + ("claude-haiku-4-5", False), + ("claude-sonnet-3-7", False), + ("", False), + ], +) +def test_the_capability_check_is_case_and_whitespace_tolerant(model: str, expected: bool) -> None: + from aetherya.gateway import _supports_adaptive_thinking + + assert _supports_adaptive_thinking(model) is expected diff --git a/tests/test_gateway_server.py b/tests/test_gateway_server.py index 6312dc1..01ab4c1 100644 --- a/tests/test_gateway_server.py +++ b/tests/test_gateway_server.py @@ -464,3 +464,60 @@ def server_close(self) -> None: def test_the_upstream_error_type_is_exported() -> None: assert issubclass(UpstreamError, RuntimeError) + + +def test_health_is_not_ok_without_an_upstream_key(monkeypatch: pytest.MonkeyPatch) -> None: + """ + A gateway with no credential answers every completion with a 502. Reporting + it healthy is a lie a readiness probe believes, and the container sits in + rotation serving nothing. + """ + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + gateway = AetheryaGateway( + GatewaySettings(provider="anthropic", model="claude-opus-5", actor="robert"), + constitution=Constitution([], use_semantic=False), + cfg=load_policy_config(POLICY), + ) + srv = build_gateway_server("127.0.0.1", 0, gateway=gateway) + threading.Thread(target=srv.serve_forever, daemon=True).start() + try: + payload = json.loads(_request(srv, "/health")[1]) + assert payload["ok"] is False + assert payload["upstream_key_present"] is False + finally: + srv.shutdown() + srv.server_close() + + +def test_health_is_ok_once_the_key_is_present(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + gateway = AetheryaGateway( + GatewaySettings(provider="anthropic", model="claude-opus-5", actor="robert"), + constitution=Constitution([], use_semantic=False), + cfg=load_policy_config(POLICY), + ) + srv = build_gateway_server("127.0.0.1", 0, gateway=gateway) + threading.Thread(target=srv.serve_forever, daemon=True).start() + try: + payload = json.loads(_request(srv, "/health")[1]) + assert payload["ok"] is True + assert payload["upstream_key_present"] is True + finally: + srv.shutdown() + srv.server_close() + + +def test_an_unknown_provider_reports_no_key(monkeypatch: pytest.MonkeyPatch) -> None: + """Defensive: an unsupported provider has no key env to look up.""" + gateway = AetheryaGateway( + GatewaySettings(provider="cohere", model="x", actor="robert"), + constitution=Constitution([], use_semantic=False), + cfg=load_policy_config(POLICY), + ) + srv = build_gateway_server("127.0.0.1", 0, gateway=gateway) + threading.Thread(target=srv.serve_forever, daemon=True).start() + try: + assert json.loads(_request(srv, "/health")[1])["upstream_key_present"] is False + finally: + srv.shutdown() + srv.server_close() diff --git a/tests/test_pipeline_audit_paths.py b/tests/test_pipeline_audit_paths.py index 3296e05..091ec30 100644 --- a/tests/test_pipeline_audit_paths.py +++ b/tests/test_pipeline_audit_paths.py @@ -204,3 +204,81 @@ def aggregate(self, _signals, mode: str): # noqa: ANN001 ) assert d.allowed is True assert len(audit.events) == 1 + + +# --------------------------------------------------------------------------- +# What the trail has to carry for phase-1 vocabulary analysis +# --------------------------------------------------------------------------- + + +def test_the_audit_records_the_structured_action(tmp_path) -> None: # noqa: ANN001 + """ + Regression: `context.action` was never written, so every real decision had + `tool: null` and recovering an agent's vocabulary meant regexing prose. + Phase 1 exists to tell you what your agent actually calls, and that needs + structure. The console test passed only because it seeded the field by hand. + """ + import json + from pathlib import Path + + from aetherya.actions import ActionRequest + from aetherya.audit import AuditLogger + from aetherya.config import load_policy_config + from aetherya.constitution import Constitution + from aetherya.pipeline import run_pipeline_structured + + path = tmp_path / "decisions.jsonl" + run_pipeline_structured( + ActionRequest( + raw_input="exec ls -la", + intent="operate", + tool="shell", + target="/srv/app", + parameters={"command": "ls -la", "operation": "read"}, + ), + constitution=Constitution([], use_semantic=False), + actor="robert", + cfg=load_policy_config(Path("config/policy.yaml")), + audit=AuditLogger(str(path)), + ) + + recorded = json.loads(path.read_text(encoding="utf-8").splitlines()[-1])["context"]["action"] + assert recorded["tool"] == "shell" + assert recorded["intent"] == "operate" + assert recorded["target"] == "/srv/app" + assert recorded["operation"] == "read" + assert recorded["parameter_names"] == ["command", "operation"] + + +def test_the_audit_records_parameter_names_but_never_their_values(tmp_path) -> None: # noqa: ANN001 + """ + A confirmed action carries `confirm_proof`, a single-use credential. The + trail is exported, mirrored and archived — anything written there cannot be + taken back out. + """ + import json + from pathlib import Path + + from aetherya.actions import ActionRequest + from aetherya.audit import AuditLogger + from aetherya.config import load_policy_config + from aetherya.constitution import Constitution + from aetherya.pipeline import run_pipeline_structured + + path = tmp_path / "decisions.jsonl" + run_pipeline_structured( + ActionRequest( + raw_input="write config", + intent="operate", + tool="filesystem", + parameters={"operation": "write", "confirm_proof": "ap1.k1.SECRET-PROOF-VALUE"}, + ), + constitution=Constitution([], use_semantic=False), + actor="robert", + cfg=load_policy_config(Path("config/policy.yaml")), + audit=AuditLogger(str(path)), + ) + + line = path.read_text(encoding="utf-8").splitlines()[-1] + assert "SECRET-PROOF-VALUE" not in line + assert "confirm_proof" in json.loads(line)["context"]["action"]["parameter_names"]