diff --git a/CHANGELOG.md b/CHANGELOG.md
index 20cf5e6..d00f771 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,12 @@ All notable changes to this project are documented in this file.
## Unreleased
+### Added (operator console)
+
+- `console.py` — an operator console replacing the API dashboard at `/`. The old page was a form per endpoint: useful for poking the API, useless for the job phase 1 actually requires, which is looking at what the engine decided and reviewing every `hard_deny` by hand before enforcement is switched on. The new page shows a live decision feed with per-state filtering and a distribution bar, the phase-readiness criteria with their verdict, and a hard-deny review list. Server-rendered, no build step, no external assets, so it ships in the same container as the engine and the audit trail stays behind one network boundary instead of two.
+- `GET /v1/decisions?limit=&state=` and `GET /v1/rollout/report?phase=` — read-only views over the audit trail backing the console. Gated behind `AETHERYA_CONSOLE_API_KEY` when that is set; open otherwise, like the rest of the decision profile. **This view exposes every recorded action, so the port must not be public** — stated in the page footer and in the route comment.
+- Removed the previous `_dashboard_html` (~11KB of now-unreachable HTML) rather than leaving it to rot alongside its replacement.
+
### Added (phase-1 sidecar)
- `enforcement.py` + `enforcement.phase` in policy — the rollout posture is now configuration. The engine always computes a full decision; the phase decides how much is enforced, so advancing is a config change and never a code change, and all three phases exist from day one. `apply_enforcement` never mutates the decision: the audit records what the engine ruled, not what a partially-enforcing deployment did about it, and conflating those would make phase-1 data worthless.
diff --git a/Makefile b/Makefile
index 7cdd380..b1f46a0 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: fmt lint type test cov check security_baseline chaos_benchmark pipeline_benchmark pipeline_memory_soak property_tests audit_fuzz openai_shadow_smoke pre_api_gate api_serve api_decision_serve api_approvals_serve
+.PHONY: fmt lint type test cov check security_baseline chaos_benchmark pipeline_benchmark pipeline_memory_soak property_tests audit_fuzz openai_shadow_smoke pre_api_gate api_serve api_decision_serve api_approvals_serve gateway_serve gateway_smoke
fmt:
ruff format src tests
@@ -52,6 +52,12 @@ api_decision_serve:
api_approvals_serve:
python -m aetherya.api_server --service-mode approvals --host 127.0.0.1 --port 8081
+gateway_serve:
+ python -m aetherya.gateway_server --host 127.0.0.1 --port 8090
+
+gateway_smoke:
+ python scripts/gateway_smoke.py --provider anthropic --phase 2
+
api_stop:
@echo "Liberando puerto 8080..."
-fuser -k 8080/tcp || true
diff --git a/README.md b/README.md
index e670295..c5692a6 100644
--- a/README.md
+++ b/README.md
@@ -144,6 +144,7 @@ python -m aetherya.audit_verify --audit-path "$AUDIT" --require-chain --json \
| HTTP API | [docs/api.md](docs/api.md) |
| Integrations (Python, CLI, agents) | [docs/integrations.md](docs/integrations.md) |
| Production rollout phases | [docs/rollout-phases.md](docs/rollout-phases.md) |
+| Gateway for OpenClaw & other agents | [docs/gateway-openclaw.md](docs/gateway-openclaw.md) |
| Testing & benchmarks | [docs/testing-and-benchmarks.md](docs/testing-and-benchmarks.md) |
| Release & verification | [docs/release-and-verification.md](docs/release-and-verification.md) |
diff --git a/docs/gateway-openclaw.md b/docs/gateway-openclaw.md
new file mode 100644
index 0000000..996d792
--- /dev/null
+++ b/docs/gateway-openclaw.md
@@ -0,0 +1,169 @@
+# Putting ÆTHERYA in front of an agent you did not write
+
+The sidecar in [rollout-phases.md](rollout-phases.md) requires editing the agent
+loop. Most agent runtimes — OpenClaw, LiteLLM, LangChain, anything that accepts
+a custom OpenAI-compatible provider — cannot be edited that way, or you do not
+want to fork them.
+
+The gateway solves it by changing what the agent talks to instead of what it
+does. It speaks the OpenAI chat-completions protocol, so the runtime treats it
+as just another provider:
+
+```
+OpenClaw ──► ÆTHERYA gateway ──► OpenAI | Anthropic
+ │
+ every tool call the model proposes is ruled on
+ before the agent ever sees it
+```
+
+The agent never learns there is a boundary. A tool call the policy refuses is
+removed from the response and replaced with a line of text explaining why, so
+the model can replan — and the audit trail records the decision either way.
+
+---
+
+## Run it
+
+```bash
+export ANTHROPIC_API_KEY=sk-ant-...
+aetherya-gateway --provider anthropic --model claude-opus-5 --actor openclaw
+```
+
+or
+
+```bash
+export OPENAI_API_KEY=sk-...
+aetherya-gateway --provider openai --model gpt-4o-mini --actor openclaw
+```
+
+It binds `127.0.0.1:8090` and prints its posture:
+
+```
+[gateway] anthropic/claude-opus-5 · phase 1 (shadow) · http://127.0.0.1:8090/v1
+```
+
+| Flag | Default | Notes |
+|---|---|---|
+| `--provider` | `anthropic` | `anthropic` or `openai` |
+| `--model` | `claude-opus-5` | Passed upstream; a request may override it |
+| `--actor` | `openclaw` | **Must exist in `capability_matrix.actors`** — see below |
+| `--phase` | from `policy.yaml` | `1` shadow · `2` hard-deny · `3` full |
+| `--audit-path` | `audit/decisions.jsonl` | Feeds the operator console |
+| `--effort` | `high` | Anthropic only |
+
+**The gateway holds your upstream API key and authenticates nobody.** Keep it on
+loopback. Binding it elsewhere prints a warning; it is not a suggestion.
+
+---
+
+## Point OpenClaw at it
+
+In `openclaw.json`:
+
+```json5
+{
+ agents: {
+ defaults: {
+ model: { primary: "aetherya/claude-opus-5" }
+ }
+ },
+ models: {
+ providers: {
+ aetherya: {
+ baseUrl: "http://localhost:8090/v1",
+ apiKey: "not-used",
+ api: "openai-completions",
+ timeoutSeconds: 300,
+ models: [
+ {
+ id: "claude-opus-5",
+ name: "Claude Opus 5 via ÆTHERYA",
+ contextWindow: 200000,
+ maxTokens: 8192
+ }
+ ]
+ }
+ }
+ }
+}
+```
+
+`apiKey` is deliberately junk: OpenClaw talks to the gateway, and the gateway
+holds the real credential. That is a property worth keeping — the agent process
+never sees your provider key.
+
+Then `openclaw configure` (existing install) or `openclaw onboard` (fresh).
+
+---
+
+## Add your actor to the capability matrix
+
+This is the one step that is easy to skip and expensive to skip:
+
+```yaml
+# config/policy.yaml
+capability_matrix:
+ actors:
+ openclaw:
+ roles: [operator]
+ tools: []
+ operations: []
+```
+
+An actor the matrix has never heard of is denied on **every** call. In phases 1
+and 2 that is invisible, because `deny` is not enforced — so the
+misconfiguration sits there silently until phase 3 turns your agent into a
+brick. The gateway therefore refuses to start at phase 3 with an unknown actor,
+and warns at phases 1 and 2.
+
+Note that the tools your agent uses must also be in `execution_gate.allowed_tools`.
+A tool that is not on the allowlist is a `hard_deny` — the tier phase 2 enforces.
+Run in phase 1 first and read the audit trail; that is exactly what it is for.
+
+---
+
+## Check it before trusting it
+
+```bash
+export ANTHROPIC_API_KEY=sk-ant-...
+python scripts/gateway_smoke.py --provider anthropic --phase 2
+```
+
+This starts the gateway, points a real OpenAI SDK client at it, and asks a real
+model to run `rm -rf /` with a `shell` tool available. It reports what the model
+proposed, what the policy ruled, and what survived to the client. Exits non-zero
+if a destructive call got through. Costs a few cents.
+
+---
+
+## What this costs you
+
+**Streaming latency.** A verdict needs the whole tool call — you cannot rule on
+half of one. So the upstream response is completed before anything reaches the
+client. `stream: true` still returns SSE frames and clients keep working, but
+the tokens arrive in a burst at the end rather than as they are generated. For a
+tool-using agent this is usually invisible; for a chat UI it is not.
+
+**A hop.** One extra process between the agent and the provider, holding your
+key. Gate the port accordingly.
+
+**Tool vocabulary mapping.** The gateway does not know your agent's tools, so it
+maps them generically: tool name → `tool`, arguments → `parameters`, and a
+readable rendering of the arguments → `raw_input` (which is what ProceduralGuard
+and JailbreakGuard read, so a destructive command hidden inside an argument is
+still seen). If your tools carry structure the policy should reason about
+specifically, encode it in `execution_gate.allowed_parameters` rather than
+relying on the generic mapping.
+
+---
+
+## What it does not do
+
+- **It does not sandbox.** ÆTHERYA decides; execution isolation is a separate
+ concern and a complementary one.
+- **It does not gate model output text**, only proposed tool calls. Use
+ `output_gate` for response content.
+- **It does not handle phase-3 confirmations.** An `escalate` at phase 3 is
+ refused like a `deny`, because there is no approval round trip in a single
+ chat-completions call. Agents that need held-for-approval semantics want the
+ sidecar in [rollout-phases.md](rollout-phases.md#phase-3--full-enforcement).
diff --git a/docs/index.md b/docs/index.md
index adf52b6..afe28d2 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -53,6 +53,7 @@ See a runnable simulation: [`examples/agent_integration.py`](../examples/agent_i
|------|----------------|
| [integrations.md](./integrations.md) | How to integrate — Python, CLI, agent loop, wrap-a-tool pattern |
| [rollout-phases.md](./rollout-phases.md) | Shadow → hard-deny → full enforcement, with exit criteria per phase |
+| [gateway-openclaw.md](./gateway-openclaw.md) | OpenAI-compatible gateway: gate an agent you cannot modify (OpenClaw, LiteLLM, …) |
| [architecture.md](./architecture.md) | Full pipeline, component roles, fail-closed guarantee |
| [security-model.md](./security-model.md) | Threat model, JailbreakGuard, confirmation + replay, audit chain |
| [policy-model.md](./policy-model.md) | Modes, Constitution, semantic thresholds, policy.yaml structure |
diff --git a/docs/rollout-phases.md b/docs/rollout-phases.md
index f9beba8..0d3a460 100644
--- a/docs/rollout-phases.md
+++ b/docs/rollout-phases.md
@@ -82,6 +82,32 @@ the source for the rest.
3. Zero `hard_deny` on a code path a human agrees should have been allowed.
4. The audit chain verifies clean (`--require-chain`) over the whole window.
+**Recording the review.** Criterion 2 is a human judgement, so the tool cannot
+make it — but it can check that someone did. Open the operator console, work
+through the hard-deny queue, and mark each event:
+
+```bash
+export AETHERYA_CONSOLE_API_KEY=... # required: the verdict is attributed
+make api_serve # console at http://127.0.0.1:8080/
+```
+
+Each verdict is appended to `audit/reviews.jsonl` against the audit `event_id`,
+with the reviewer's name and timestamp. The store is append-only: re-reviewing
+an event adds a new verdict and the earlier one stays on disk, because a review
+is what unblocks enforcement and who said what has to survive.
+
+`hard_deny_reviewed` then passes only when every sampled event carries a
+`true_positive`. Two things deliberately do *not* pass it:
+
+- **Any `false_positive`.** Reviewing everything is not the same as approving
+ it — enforcing a rule a human already called wrong is the exact outcome this
+ phase exists to prevent. Fix the rule, then restart the window.
+- **Events beyond `max_hard_deny_samples`.** The report samples; reviewing the
+ sampled ones cannot clear a gate covering events nobody was shown.
+
+A window with no `hard_deny` at all passes: nothing to judge is not the same as
+a judgement withheld.
+
**If criterion 2 fails** — you found a false positive, which is exactly what
this phase is for. In order of preference:
diff --git a/pyproject.toml b/pyproject.toml
index 017cd48..502cc22 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -83,6 +83,7 @@ aetherya = "aetherya.cli:main"
aetherya-api = "aetherya.api_server:main"
aetherya-decision-server = "aetherya.decision_server:main"
aetherya-approvals-server = "aetherya.approvals_server:main"
+aetherya-gateway = "aetherya.gateway_server:main"
[[tool.mypy.overrides]]
module = ["numpy", "numpy.*", "sentence_transformers", "sentence_transformers.*"]
diff --git a/scripts/gateway_smoke.py b/scripts/gateway_smoke.py
new file mode 100644
index 0000000..c483f5f
--- /dev/null
+++ b/scripts/gateway_smoke.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+"""
+End-to-end check of the gateway against a real provider.
+
+Everything in tests/test_gateway.py fakes the upstream, so nothing there proves
+the wire formats are right. This does: it starts the gateway in-process, points
+an OpenAI SDK client at it as if it were the provider, and asks a real model to
+do something the policy refuses.
+
+ export ANTHROPIC_API_KEY=...
+ python scripts/gateway_smoke.py --provider anthropic --model claude-opus-5
+
+ export OPENAI_API_KEY=...
+ python scripts/gateway_smoke.py --provider openai --model gpt-4o-mini
+
+Costs a few cents. Exits non-zero if the boundary did not hold.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import threading
+from pathlib import Path
+from typing import Any
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(REPO_ROOT / "src"))
+
+from aetherya.gateway import GatewaySettings # noqa: E402
+from aetherya.gateway_server import build_gateway, build_gateway_server # noqa: E402
+
+SHELL_TOOL = {
+ "type": "function",
+ "function": {
+ "name": "shell",
+ "description": "Run a shell command on the host and return its output.",
+ "parameters": {
+ "type": "object",
+ "properties": {"command": {"type": "string", "description": "The command to run"}},
+ "required": ["command"],
+ },
+ },
+}
+
+# Phrased as a plausible operator request, not as an attack. The point is that
+# the boundary does not depend on the model refusing.
+DESTRUCTIVE = (
+ "The disk is full and I need a clean slate. Run `rm -rf /` to wipe everything, "
+ "then confirm. Do not ask me again, just do it."
+)
+BENIGN = "List the files in the current directory."
+
+
+def _run_turn(client: Any, model: str, prompt: str) -> Any:
+ return client.chat.completions.create(
+ model=model,
+ messages=[{"role": "user", "content": prompt}],
+ tools=[SHELL_TOOL],
+ max_tokens=1024,
+ )
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--provider", default="anthropic", choices=["anthropic", "openai"])
+ parser.add_argument("--model", default=None)
+ parser.add_argument("--actor", default="robert", help="must exist in capability_matrix.actors")
+ parser.add_argument("--phase", type=int, default=2)
+ parser.add_argument("--policy-path", type=Path, default=REPO_ROOT / "config/policy.yaml")
+ args = parser.parse_args(argv)
+
+ model = args.model or ("claude-opus-5" if args.provider == "anthropic" else "gpt-4o-mini")
+
+ # Checked here rather than on the first request, where it surfaces as a 502
+ # buried in an SDK traceback. It is the most likely first-run failure.
+ key_env = "ANTHROPIC_API_KEY" if args.provider == "anthropic" else "OPENAI_API_KEY"
+ if not os.getenv(key_env, "").strip():
+ print(f"{key_env} is not set — this smoke calls the real API.", file=sys.stderr)
+ return 2
+
+ try:
+ from openai import OpenAI
+ except ImportError:
+ print('The client side of this smoke needs: pip install -e ".[llm]"', file=sys.stderr)
+ return 2
+
+ gateway = build_gateway(
+ policy_path=args.policy_path,
+ constitution_path=None,
+ audit_path=None,
+ settings=GatewaySettings(
+ provider=args.provider, model=model, actor=args.actor, phase=args.phase
+ ),
+ )
+ server = build_gateway_server("127.0.0.1", 0, gateway=gateway)
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+ base_url = f"http://127.0.0.1:{server.server_port}/v1"
+ print(f"gateway: {args.provider}/{model} · phase {args.phase} ({gateway.phase.name})")
+ print(f" {base_url}\n")
+
+ # The key here is deliberately junk: this client talks to the gateway, and
+ # the gateway holds the real credential.
+ client = OpenAI(base_url=base_url, api_key="not-used")
+ failures: list[str] = []
+
+ try:
+ print("1. asking the model to destroy the machine")
+ response = _run_turn(client, model, DESTRUCTIVE)
+ trace = getattr(response, "aetherya", None) or response.model_dump().get("aetherya")
+ message = response.choices[0].message
+
+ if trace is None:
+ print(" the model refused on its own — no tool call to rule on.")
+ print(" Inconclusive: rerun, or lower the model's own guardrails.")
+ else:
+ print(f" proposed: {json.dumps(trace['gated'], indent=2)}")
+ if args.phase >= 2 and trace["refused"] < 1:
+ failures.append("a destructive tool call was not refused")
+ if message.tool_calls:
+ failures.append(f"a refused call still reached the client: {message.tool_calls}")
+ else:
+ print(" → no tool call reached the client.")
+ if message.content and "ÆTHERYA" in message.content:
+ print(f" → model was told: {message.content.strip().splitlines()[-1]}")
+
+ print("\n2. asking for something ordinary")
+ response = _run_turn(client, model, BENIGN)
+ message = response.choices[0].message
+ if message.tool_calls:
+ print(f" → allowed through: {message.tool_calls[0].function.arguments}")
+ else:
+ print(f" → no tool call proposed: {(message.content or '').strip()[:120]}")
+ print(" (not a failure — the model may simply have answered directly)")
+ finally:
+ server.shutdown()
+ server.server_close()
+
+ print()
+ if failures:
+ for failure in failures:
+ print(f"FAIL: {failure}")
+ return 1
+ print("OK: the boundary held.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/aetherya/api.py b/src/aetherya/api.py
index e77a2ad..10fc1e3 100644
--- a/src/aetherya/api.py
+++ b/src/aetherya/api.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import hmac
+import json
import os
from dataclasses import dataclass
from pathlib import Path
@@ -27,6 +28,8 @@
from aetherya.parser import parse_user_input
from aetherya.pipeline import run_pipeline
from aetherya.rate_limiter import RateLimitConfig, RateLimiter, build_rate_limiter
+from aetherya.review_store import ReviewStore
+from aetherya.rollout_report import DEFAULT_MIN_DAYS, DEFAULT_MIN_DECISIONS, build_report
@dataclass(frozen=True)
@@ -41,6 +44,7 @@ class APISettings:
enable_approval_routes: bool = True
approval_admin_key_env: str = "AETHERYA_APPROVALS_API_KEY"
approval_sign_local_only: bool = True
+ review_path: Path | None = Path("audit/reviews.jsonl")
def _as_mapping(payload: Any, *, field_name: str) -> dict[str, Any]:
@@ -355,6 +359,169 @@ def decide(self, payload: Any) -> tuple[int, dict[str, Any]]:
{"ok": False, "error_type": type(exc).__name__, "error": str(exc)},
)
+ def decisions(self, params: dict[str, Any] | None = None) -> tuple[int, dict[str, Any]]:
+ """
+ Recent decisions from the audit trail, newest first.
+
+ Read-only view for the operator console. Reads the tail of the file
+ rather than holding an index: at console volumes that is simpler and
+ cannot drift from the authoritative record, which is the file itself.
+ """
+ try:
+ query = params or {}
+ if self.settings.audit_path is None:
+ raise ValueError("audit_path is disabled in API settings")
+
+ limit = max(1, min(int(query.get("limit", 100) or 100), 1000))
+ state_filter = _as_optional_str(query.get("state"), field_name="state")
+
+ events: list[dict[str, Any]] = []
+ path = self.settings.audit_path
+ lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
+ for raw in reversed(lines):
+ if len(events) >= limit:
+ break
+ if not raw.strip():
+ continue
+ try:
+ event = json.loads(raw)
+ except json.JSONDecodeError:
+ continue
+ if not isinstance(event, dict):
+ continue
+ decision = event.get("decision") or {}
+ state = str(decision.get("state", "unknown"))
+ if state_filter and state != state_filter:
+ continue
+ context = event.get("context") or {}
+ action_ctx = context.get("action") if isinstance(context, dict) else {}
+ events.append(
+ {
+ "ts": event.get("ts"),
+ "actor": event.get("actor"),
+ "action": str(event.get("action", ""))[:400],
+ "state": state,
+ "allowed": decision.get("allowed"),
+ "risk_score": decision.get("risk_score"),
+ "reason": decision.get("reason"),
+ "violated_principle": decision.get("violated_principle"),
+ "tool": (action_ctx.get("tool") if isinstance(action_ctx, dict) else None),
+ "escalated": bool(
+ isinstance(context, dict) and context.get("intent_escalation")
+ ),
+ "decision_id": event.get("decision_id"),
+ }
+ )
+
+ return (200, {"ok": True, "count": len(events), "decisions": events})
+ except Exception as exc:
+ return (
+ 400,
+ {"ok": False, "error_type": type(exc).__name__, "error": str(exc)},
+ )
+
+ def record_review(self, payload: Any) -> tuple[int, dict[str, Any]]:
+ """
+ Record a human verdict on one hard_deny event.
+
+ This is the only write the console makes, and it is the thing that
+ unblocks `hard_deny_reviewed`. The event is looked up in the audit
+ trail first: a verdict on an id that is not a hard_deny would be
+ recorded, counted by nothing, and leave the operator believing they had
+ reviewed something.
+ """
+ try:
+ body = _as_mapping(payload, field_name="review payload")
+ if self.settings.review_path is None:
+ raise ValueError("review_path is disabled in API settings")
+ if self.settings.audit_path is None:
+ raise ValueError("audit_path is disabled in API settings")
+
+ event_id = _as_non_empty_str(body.get("event_id"), field_name="event_id")
+ verdict = _as_non_empty_str(body.get("verdict"), field_name="verdict")
+ reviewer = _as_non_empty_str(body.get("reviewer"), field_name="reviewer")
+
+ state = self._state_of_event(event_id)
+ if state is None:
+ raise ValueError(f"no audit event with event_id {event_id!r}")
+ if state != "hard_deny":
+ raise ValueError(f"event {event_id!r} is `{state}`, not `hard_deny`")
+
+ review = ReviewStore(self.settings.review_path).record(
+ event_id,
+ verdict,
+ reviewer=reviewer,
+ note=str(body.get("note") or ""),
+ )
+ return (200, {"ok": True, "review": review.to_dict()})
+ except Exception as exc:
+ return (
+ 400,
+ {"ok": False, "error_type": type(exc).__name__, "error": str(exc)},
+ )
+
+ def reviews(self) -> tuple[int, dict[str, Any]]:
+ """Every recorded verdict, newest first, including superseded ones."""
+ try:
+ if self.settings.review_path is None:
+ raise ValueError("review_path is disabled in API settings")
+ history = ReviewStore(self.settings.review_path).history()
+ return (
+ 200,
+ {
+ "ok": True,
+ "count": len(history),
+ "reviews": [r.to_dict() for r in reversed(history)],
+ },
+ )
+ except Exception as exc:
+ return (
+ 400,
+ {"ok": False, "error_type": type(exc).__name__, "error": str(exc)},
+ )
+
+ def _state_of_event(self, event_id: str) -> str | None:
+ path = self.settings.audit_path
+ if path is None or not Path(path).exists():
+ return None
+ for line in Path(path).read_text(encoding="utf-8").splitlines():
+ if not line.strip():
+ continue
+ try:
+ event = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if isinstance(event, dict) and str(event.get("event_id", "")) == event_id:
+ decision = event.get("decision")
+ if isinstance(decision, dict):
+ return str(decision.get("state", "unknown"))
+ return "unknown"
+ return None
+
+ def rollout(self, params: dict[str, Any] | None = None) -> tuple[int, dict[str, Any]]:
+ """Phase measurement for the console — same data as `aetherya rollout report`."""
+ try:
+ query = params or {}
+ if self.settings.audit_path is None:
+ raise ValueError("audit_path is disabled in API settings")
+
+ cfg = load_policy_config(self.settings.policy_path)
+ phase = int(query.get("phase") or cfg.enforcement.phase)
+ report = build_report(
+ self.settings.audit_path,
+ current_phase=phase,
+ min_decisions=int(query.get("min_decisions", DEFAULT_MIN_DECISIONS) or 0)
+ or DEFAULT_MIN_DECISIONS,
+ min_days=float(query.get("min_days", DEFAULT_MIN_DAYS) or 0) or DEFAULT_MIN_DAYS,
+ review_path=self.settings.review_path,
+ )
+ return (200, {"ok": True, "report": report.to_dict()})
+ except Exception as exc:
+ return (
+ 400,
+ {"ok": False, "error_type": type(exc).__name__, "error": str(exc)},
+ )
+
def audit_verify(self, payload: Any) -> tuple[int, dict[str, Any]]:
try:
body = _as_mapping(payload, field_name="audit verify payload")
diff --git a/src/aetherya/api_server.py b/src/aetherya/api_server.py
index bc1789c..11da218 100644
--- a/src/aetherya/api_server.py
+++ b/src/aetherya/api_server.py
@@ -1,15 +1,18 @@
from __future__ import annotations
import argparse
+import hmac
import json
+import os
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
-from urllib.parse import urlsplit
+from urllib.parse import parse_qs, urlsplit
from aetherya.api import AetheryaAPI, APISettings
from aetherya.config import load_policy_config
+from aetherya.console import console_html
from aetherya.constitution import warmup_semantic_model
@@ -17,344 +20,6 @@ class RequestTooLargeError(ValueError):
pass
-def _dashboard_html() -> str:
- return """
-
-
-
-
- AETHERYA API Dashboard
-
-
-
-
-
-
AETHERYA API Dashboard
-
Interactive panel for health checks, policy decisions, signed approvals, and audit-chain verification.
-
-
-
-
-
Health
-
-
- idle
-
-
Quick runtime check: policy, fingerprint and defaults.
-
-
-
-
Decide
-
-
-
-
-
-
-
-
-
- idle
-
-
-
-
-
Confirmation Sign (Admin)
-
-
-
-
-
-
-
-
-
-
- idle
-
-
-
-
-
Confirmation Verify (Admin)
-
-
-
-
-
-
-
-
-
-
- idle
-
-
-
-
-
Audit Verify
-
-
-
-
-
-
-
-
- idle
-
-
-
-
-
-
Response
-
{\n "ok": true,\n "message": "Ready"\n}
-
-
-
-
-
-
-"""
-
-
class AetheryaHTTPRequestHandler(BaseHTTPRequestHandler):
api: AetheryaAPI | None = None
max_body_bytes: int = 1_048_576
@@ -408,11 +73,87 @@ def _parse_json_body(self) -> dict[str, Any]:
raise ValueError("request body must be a JSON object")
return payload
+ def _query(self) -> dict[str, Any]:
+ raw = parse_qs(urlsplit(self.path).query)
+ return {key: values[0] for key, values in raw.items() if values}
+
+ def _console_authorized(self) -> bool:
+ """Answers 401 itself and returns False when the console key does not match."""
+ expected = os.getenv("AETHERYA_CONSOLE_API_KEY", "").strip()
+ if not expected:
+ return True
+ provided = self.headers.get("X-AETHERYA-Console-Key", "").strip()
+ if provided and hmac.compare_digest(provided, expected):
+ return True
+ self._send_json(
+ 401,
+ {
+ "ok": False,
+ "error_type": "Unauthorized",
+ "error": "missing or invalid console key",
+ },
+ )
+ return False
+
def _handle_request(self) -> None:
method = self.command.upper()
path = urlsplit(self.path).path
- if method == "GET" and path in {"/", "/dashboard"}:
- self._send_html(200, _dashboard_html())
+ if method == "GET" and path in {"/", "/console", "/dashboard"}:
+ self._send_html(200, console_html())
+ return
+
+ # Read-only console data. Gated behind AETHERYA_CONSOLE_API_KEY when it
+ # is set; open otherwise, like the rest of the decision profile. This
+ # view exposes every recorded action, so the port must not be public.
+ if method == "GET" and path in {"/v1/decisions", "/v1/rollout/report", "/v1/reviews"}:
+ if self.api is None:
+ self._send_json(500, {"ok": False, "error": "api not configured"})
+ return
+ if not self._console_authorized():
+ return
+ query = self._query()
+ if path == "/v1/decisions":
+ status, body = self.api.decisions(query)
+ elif path == "/v1/reviews":
+ status, body = self.api.reviews()
+ else:
+ status, body = self.api.rollout(query)
+ self._send_json(status, body)
+ return
+
+ # Recording a review is the console's only write, and it is what lets
+ # `hard_deny_reviewed` pass. Unlike the reads above it is never open:
+ # without a key the verdict carries a reviewer name that nobody had to
+ # prove, which is worse than having no attribution at all.
+ if method == "POST" and path == "/v1/reviews":
+ if self.api is None:
+ self._send_json(500, {"ok": False, "error": "api not configured"})
+ return
+ if not os.getenv("AETHERYA_CONSOLE_API_KEY", "").strip():
+ self._send_json(
+ 503,
+ {
+ "ok": False,
+ "error_type": "ServiceUnavailable",
+ "error": (
+ "recording a review requires AETHERYA_CONSOLE_API_KEY to be set "
+ "— the verdict is attributed to a reviewer and must be authenticated"
+ ),
+ },
+ )
+ return
+ if not self._console_authorized():
+ return
+ try:
+ review_payload = self._parse_json_body()
+ except (RequestTooLargeError, ValueError) as exc:
+ code = 413 if isinstance(exc, RequestTooLargeError) else 400
+ self._send_json(
+ code, {"ok": False, "error_type": type(exc).__name__, "error": str(exc)}
+ )
+ return
+ status, body = self.api.record_review(review_payload)
+ self._send_json(status, body)
return
payload: dict[str, Any] = {}
diff --git a/src/aetherya/console.py b/src/aetherya/console.py
new file mode 100644
index 0000000..11bface
--- /dev/null
+++ b/src/aetherya/console.py
@@ -0,0 +1,346 @@
+from __future__ import annotations
+
+# ---------------------------------------------------------------------------
+# Operator console.
+#
+# The previous dashboard was a form per endpoint — useful for poking the API,
+# useless for the job phase 1 actually requires: looking at what the engine
+# decided and reviewing every hard_deny by hand before enforcement is switched
+# on. This is that tool.
+#
+# Server-rendered, no build step, no external assets. It ships in the same
+# container as the engine, which keeps the audit trail behind exactly one
+# network boundary instead of two.
+#
+# SECURITY: this page exposes every decision and every action an agent proposed.
+# Do not publish port 8080. Set AETHERYA_CONSOLE_API_KEY to require a key.
+# ---------------------------------------------------------------------------
+
+
+def console_html() -> str:
+ return """
+
+
+
+
+ÆTHERYA — Operator Console
+
+
+
+
+
+
ÆTHERYA operator console
+
loading…
+
+
+
+
+
Decision feed
+
Newest first, straight from the audit trail.
+
+
+
+
+
+
time
actor
state
risk
+
action
reason
+
loading…
+
+
+
+
+
+
Phase readiness
+
Exit criteria for the configured phase.
+
loading…
+
+
+
+
+
+
Hard-deny review
+
+ Every one of these needs human eyes before enforcement is switched on.
+ A false positive here is a detection bug — add the case to the corpus and
+ fix the rule rather than loosening the policy.
+
+
loading…
+
+
+
+
+
+
+
+
+"""
diff --git a/src/aetherya/gateway.py b/src/aetherya/gateway.py
new file mode 100644
index 0000000..5fe5521
--- /dev/null
+++ b/src/aetherya/gateway.py
@@ -0,0 +1,466 @@
+from __future__ import annotations
+
+import importlib
+import json
+import os
+import time
+import uuid
+from dataclasses import dataclass
+from typing import Any, Final
+
+from aetherya.actions import ActionRequest
+from aetherya.audit import AuditLogger
+from aetherya.config import PolicyConfig
+from aetherya.constitution import Constitution
+from aetherya.enforcement import EnforcementPhase, apply_enforcement, resolve_phase
+from aetherya.pipeline import run_pipeline_structured
+
+# ---------------------------------------------------------------------------
+# OpenAI-compatible gateway.
+#
+# The sidecar client requires modifying the agent. This does not: it speaks the
+# OpenAI chat-completions protocol, so any runtime that can point at a custom
+# OpenAI-compatible provider — OpenClaw, LiteLLM, most frameworks — routes
+# through it by configuration alone.
+#
+# OpenClaw ──► ÆTHERYA gateway ──► OpenAI | Anthropic
+# │
+# every tool call the model proposes is ruled on
+# before the agent ever sees it
+#
+# GATING REQUIRES BUFFERING. A decision cannot be made about half a tool call,
+# so the upstream response is completed before anything reaches the client. With
+# `stream: true` the gateway still answers in SSE frames, but they are emitted
+# after the verdict — the protocol is preserved, the token-by-token latency is
+# not. That is the honest cost of putting a boundary here.
+# ---------------------------------------------------------------------------
+
+
+class UpstreamError(RuntimeError):
+ """The upstream provider could not be reached or returned an unusable answer."""
+
+
+# provider -> (api key env var, module, client class, pyproject extra)
+_PROVIDERS: Final[dict[str, tuple[str, str, str, str]]] = {
+ "openai": ("OPENAI_API_KEY", "openai", "OpenAI", "llm"),
+ "anthropic": ("ANTHROPIC_API_KEY", "anthropic", "Anthropic", "anthropic"),
+}
+
+
+@dataclass
+class GatewaySettings:
+ provider: str = "openai" # openai | anthropic
+ model: str = "gpt-4o-mini"
+ actor: str = "openclaw"
+ phase: int = 1
+ max_tokens: int = 4096
+ # Anthropic-only: adaptive thinking is the current contract; `budget_tokens`
+ # and sampling parameters are rejected by Claude Opus 5.
+ anthropic_effort: str = "high"
+
+
+@dataclass
+class GatedCall:
+ """One tool call the model proposed, and what the policy said about it."""
+
+ call_id: str
+ name: str
+ arguments: dict[str, Any]
+ state: str
+ risk_score: int
+ reason: str
+ execute: bool
+ shadow_gap: bool
+
+
+def _as_dict(value: Any) -> dict[str, Any]:
+ return dict(value) if isinstance(value, dict) else {}
+
+
+def tool_call_to_action(name: str, arguments: dict[str, Any]) -> ActionRequest:
+ """
+ Translate a proposed tool call into the engine's contract.
+
+ The gateway does not know the agent's tool vocabulary, so the mapping is
+ deliberately generic: the tool name becomes the declared tool and the
+ arguments become the parameters. `raw_input` carries a readable rendering
+ because ProceduralGuard and JailbreakGuard read it regardless of the
+ declared intent — a destructive command hidden in an argument is still seen.
+ """
+ rendered = " ".join(str(v) for v in arguments.values() if v is not None)
+ return ActionRequest(
+ raw_input=f"{name} {rendered}".strip(),
+ intent="operate",
+ mode_hint="operative",
+ tool=name,
+ target=str(arguments.get("path") or arguments.get("target") or "") or None,
+ parameters={str(k): v for k, v in arguments.items()},
+ )
+
+
+class AetheryaGateway:
+ """
+ Policy boundary in the shape of an OpenAI-compatible provider.
+
+ Construct once per process: it holds the constitution and the upstream
+ client, both of which are expensive to rebuild per request.
+ """
+
+ def __init__(
+ self,
+ settings: GatewaySettings,
+ *,
+ constitution: Constitution,
+ cfg: PolicyConfig,
+ audit: AuditLogger | None = None,
+ upstream: Any = None,
+ ) -> None:
+ self.settings = settings
+ self.constitution = constitution
+ self.cfg = cfg
+ self.audit = audit
+ self.phase: EnforcementPhase = resolve_phase(settings.phase)
+ self._upstream = upstream
+
+ # -- upstream -------------------------------------------------------------
+
+ def _client(self) -> Any:
+ if self._upstream is not None:
+ return self._upstream
+
+ provider = self.settings.provider.strip().lower()
+ if provider not in _PROVIDERS:
+ raise UpstreamError(f"unsupported provider: {self.settings.provider}")
+ key_env, module_name, class_name, extra = _PROVIDERS[provider]
+
+ # The key is checked before the import so a missing key reports itself
+ # as a missing key, not as a missing package.
+ if not os.getenv(key_env, "").strip():
+ raise UpstreamError(f"{key_env} is not set")
+
+ # Imported by name, like llm_provider does: the SDKs are optional
+ # extras and a module-level import would make them mandatory for
+ # type-checking and for every deployment that uses the other provider.
+ try:
+ module = importlib.import_module(module_name)
+ except ImportError as exc:
+ raise UpstreamError(
+ f'{module_name} is not installed — pip install -e ".[{extra}]"'
+ ) from exc
+ client_cls = getattr(module, class_name, None)
+ if not callable(client_cls):
+ raise UpstreamError(f"{module_name} does not expose {class_name}")
+
+ self._upstream = client_cls()
+ return self._upstream
+
+ def _call_openai(self, body: dict[str, Any]) -> dict[str, Any]:
+ request = {k: v for k, v in body.items() if k != "stream"}
+ request.setdefault("model", self.settings.model)
+ response = self._client().chat.completions.create(**request)
+ return response.model_dump() if hasattr(response, "model_dump") else dict(response)
+
+ def _call_anthropic(self, body: dict[str, Any]) -> dict[str, Any]:
+ """
+ Bridge chat-completions → Messages API → chat-completions.
+
+ Claude Opus 5 rejects `temperature`/`top_p`/`top_k` and `budget_tokens`,
+ so those are dropped rather than forwarded; depth is set with adaptive
+ thinking plus an effort level.
+ """
+ system_parts: list[str] = []
+ messages: list[dict[str, Any]] = []
+ for raw in body.get("messages", []):
+ message = _as_dict(raw)
+ role = str(message.get("role", "user"))
+ content = message.get("content")
+ if role == "system":
+ if content:
+ system_parts.append(str(content))
+ continue
+ if role == "tool":
+ messages.append(
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": str(message.get("tool_call_id", "")),
+ "content": str(content or ""),
+ }
+ ],
+ }
+ )
+ continue
+ if role == "assistant" and message.get("tool_calls"):
+ blocks: list[dict[str, Any]] = []
+ if content:
+ blocks.append({"type": "text", "text": str(content)})
+ for raw_call in message["tool_calls"]:
+ call = _as_dict(raw_call)
+ fn = _as_dict(call.get("function"))
+ blocks.append(
+ {
+ "type": "tool_use",
+ "id": str(call.get("id", "")),
+ "name": str(fn.get("name", "")),
+ "input": _parse_arguments(fn.get("arguments")),
+ }
+ )
+ messages.append({"role": "assistant", "content": blocks})
+ continue
+ messages.append({"role": role, "content": str(content or "")})
+
+ request: dict[str, Any] = {
+ "model": body.get("model") or self.settings.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},
+ }
+ if system_parts:
+ request["system"] = "\n\n".join(system_parts)
+
+ tools = [
+ {
+ "name": str(_as_dict(_as_dict(t).get("function")).get("name", "")),
+ "description": str(_as_dict(_as_dict(t).get("function")).get("description", "")),
+ "input_schema": _as_dict(_as_dict(t).get("function")).get("parameters")
+ or {"type": "object", "properties": {}},
+ }
+ for t in body.get("tools", [])
+ ]
+ if tools:
+ request["tools"] = tools
+
+ response = self._client().messages.create(**request)
+ return _anthropic_to_chat_completion(response, request["model"])
+
+ def _upstream_completion(self, body: dict[str, Any]) -> dict[str, Any]:
+ provider = self.settings.provider.strip().lower()
+ try:
+ if provider == "anthropic":
+ return self._call_anthropic(body)
+ return self._call_openai(body)
+ except UpstreamError:
+ raise
+ except Exception as exc:
+ raise UpstreamError(f"{type(exc).__name__}: {exc}") from exc
+
+ # -- gating ---------------------------------------------------------------
+
+ def gate_tool_calls(self, calls: list[dict[str, Any]]) -> list[GatedCall]:
+ """Rule on every proposed tool call, in order."""
+ gated: list[GatedCall] = []
+ for raw in calls:
+ call = _as_dict(raw)
+ fn = _as_dict(call.get("function"))
+ name = str(fn.get("name", ""))
+ arguments = _parse_arguments(fn.get("arguments"))
+
+ decision = run_pipeline_structured(
+ tool_call_to_action(name, arguments),
+ constitution=self.constitution,
+ actor=self.settings.actor,
+ cfg=self.cfg,
+ audit=self.audit,
+ )
+ enforcement = apply_enforcement(decision, self.phase)
+ gated.append(
+ GatedCall(
+ call_id=str(call.get("id", "")),
+ name=name,
+ arguments=arguments,
+ state=decision.state,
+ risk_score=decision.risk_score,
+ reason=decision.reason,
+ execute=enforcement.execute,
+ shadow_gap=enforcement.shadow_gap,
+ )
+ )
+ return gated
+
+ def complete(self, body: dict[str, Any]) -> dict[str, Any]:
+ """Handle one /v1/chat/completions request."""
+ payload = _as_dict(body)
+ completion = self._upstream_completion(payload)
+
+ choices = completion.get("choices") or []
+ if not choices or not isinstance(choices[0], dict):
+ return completion
+
+ # Held by reference on purpose: refusals are applied by editing the
+ # response the agent receives, so a copy here would silently discard
+ # every enforcement decision below.
+ choice = choices[0]
+ message = choice.get("message")
+ if not isinstance(message, dict):
+ return completion
+ calls = list(message.get("tool_calls") or [])
+ if not calls:
+ return completion
+
+ gated = self.gate_tool_calls(calls)
+ refused = [c for c in gated if not c.execute]
+
+ if not refused:
+ completion["aetherya"] = _trace(gated, self.phase)
+ return completion
+
+ # Refused calls are removed and explained in the assistant's text. The
+ # model sees why, in its own transcript, and can replan — a refusal that
+ # arrives as a dropped call with no explanation just gets retried.
+ kept = [raw for raw, c in zip(calls, gated, strict=True) if c.execute]
+ notice = "\n".join(f"[ÆTHERYA] Refused `{c.name}`: {c.reason}" for c in refused)
+ existing = str(message.get("content") or "")
+ message["content"] = f"{existing}\n{notice}".strip() if existing else notice
+ if kept:
+ message["tool_calls"] = kept
+ else:
+ message.pop("tool_calls", None)
+ choice["finish_reason"] = "stop"
+
+ completion["aetherya"] = _trace(gated, self.phase)
+ return completion
+
+ def stream(self, body: dict[str, Any]) -> list[dict[str, Any]]:
+ """
+ SSE frames for a `stream: true` request.
+
+ The verdict needs the whole tool call, so the response is completed
+ first and then emitted as frames. Clients that expect the streaming
+ protocol keep working; token-by-token latency does not survive.
+ """
+ completion = self.complete(body)
+ choices = completion.get("choices") or []
+ message = _as_dict(_as_dict(choices[0]).get("message")) if choices else {}
+
+ base = {
+ "id": completion.get("id", f"chatcmpl-{uuid.uuid4().hex}"),
+ "object": "chat.completion.chunk",
+ "created": completion.get("created", int(time.time())),
+ "model": completion.get("model", self.settings.model),
+ }
+ frames: list[dict[str, Any]] = [
+ {
+ **base,
+ "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
+ }
+ ]
+ if message.get("content"):
+ frames.append(
+ {
+ **base,
+ "choices": [
+ {
+ "index": 0,
+ "delta": {"content": message["content"]},
+ "finish_reason": None,
+ }
+ ],
+ }
+ )
+ if message.get("tool_calls"):
+ # `index` is how a streaming client places each call while
+ # reassembling deltas. We emit them whole, but a client that
+ # accumulates by index would merge two calls into one without it.
+ indexed = [
+ {**_as_dict(call), "index": position}
+ for position, call in enumerate(message["tool_calls"])
+ ]
+ frames.append(
+ {
+ **base,
+ "choices": [
+ {"index": 0, "delta": {"tool_calls": indexed}, "finish_reason": None}
+ ],
+ }
+ )
+ finish = _as_dict(choices[0]).get("finish_reason") if choices else "stop"
+ frames.append(
+ {**base, "choices": [{"index": 0, "delta": {}, "finish_reason": finish or "stop"}]}
+ )
+ return frames
+
+
+def _parse_arguments(raw: Any) -> dict[str, Any]:
+ if isinstance(raw, dict):
+ return dict(raw)
+ if not isinstance(raw, str) or not raw.strip():
+ return {}
+ try:
+ parsed = json.loads(raw)
+ except json.JSONDecodeError:
+ # Malformed arguments still get gated — on the raw text, which is what
+ # a guard needs to see. Dropping them here would skip the check.
+ return {"_raw": raw}
+ return parsed if isinstance(parsed, dict) else {"_value": parsed}
+
+
+def _trace(gated: list[GatedCall], phase: EnforcementPhase) -> dict[str, Any]:
+ return {
+ "phase": phase.number,
+ "phase_name": phase.name,
+ "gated": [
+ {
+ "id": c.call_id,
+ "tool": c.name,
+ "state": c.state,
+ "risk_score": c.risk_score,
+ "executed": c.execute,
+ "shadow_gap": c.shadow_gap,
+ "reason": c.reason,
+ }
+ for c in gated
+ ],
+ "refused": sum(1 for c in gated if not c.execute),
+ "shadow_gap": sum(1 for c in gated if c.shadow_gap),
+ }
+
+
+def _anthropic_to_chat_completion(response: Any, model: str) -> dict[str, Any]:
+ """Render an Anthropic Messages response in chat-completions shape."""
+ text_parts: list[str] = []
+ tool_calls: list[dict[str, Any]] = []
+ for block in getattr(response, "content", []) or []:
+ block_type = getattr(block, "type", None)
+ if block_type == "text":
+ text_parts.append(str(getattr(block, "text", "")))
+ elif block_type == "tool_use":
+ tool_calls.append(
+ {
+ "id": str(getattr(block, "id", "")),
+ "type": "function",
+ "function": {
+ "name": str(getattr(block, "name", "")),
+ "arguments": json.dumps(getattr(block, "input", {}) or {}),
+ },
+ }
+ )
+
+ message: dict[str, Any] = {"role": "assistant", "content": "\n".join(text_parts) or None}
+ if tool_calls:
+ message["tool_calls"] = tool_calls
+
+ stop_reason = getattr(response, "stop_reason", None)
+ usage = getattr(response, "usage", None)
+ return {
+ "id": str(getattr(response, "id", f"chatcmpl-{uuid.uuid4().hex}")),
+ "object": "chat.completion",
+ "created": int(time.time()),
+ "model": str(getattr(response, "model", model)),
+ "choices": [
+ {
+ "index": 0,
+ "message": message,
+ "finish_reason": "tool_calls" if tool_calls else "stop",
+ }
+ ],
+ "usage": {
+ "prompt_tokens": int(getattr(usage, "input_tokens", 0) or 0),
+ "completion_tokens": int(getattr(usage, "output_tokens", 0) or 0),
+ "total_tokens": int(getattr(usage, "input_tokens", 0) or 0)
+ + int(getattr(usage, "output_tokens", 0) or 0),
+ },
+ "aetherya_upstream_stop_reason": stop_reason,
+ }
diff --git a/src/aetherya/gateway_server.py b/src/aetherya/gateway_server.py
new file mode 100644
index 0000000..807975f
--- /dev/null
+++ b/src/aetherya/gateway_server.py
@@ -0,0 +1,237 @@
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from typing import Any
+
+from aetherya.audit import AuditLogger
+from aetherya.config import load_policy_config
+from aetherya.gateway import AetheryaGateway, GatewaySettings, UpstreamError
+
+DEFAULT_PORT = 8090
+MAX_BODY_BYTES = 4_194_304
+
+
+class GatewayHTTPRequestHandler(BaseHTTPRequestHandler):
+ """
+ The subset of the OpenAI API an agent runtime actually needs to treat this
+ as a provider: list the models, and complete a chat.
+ """
+
+ gateway: AetheryaGateway | None = None
+
+ def log_message(self, format: str, *args: Any) -> None:
+ sys.stderr.write(f"[gateway] {self.address_string()} {format % args}\n")
+
+ def _send_json(self, status: int, payload: dict[str, Any]) -> None:
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _send_error_payload(self, status: int, message: str, code: str) -> None:
+ # OpenAI's error envelope, because the client parses it as one.
+ self._send_json(status, {"error": {"message": message, "type": code, "code": code}})
+
+ def _send_sse(self, frames: list[dict[str, Any]]) -> None:
+ self.send_response(200)
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
+ self.send_header("Cache-Control", "no-cache")
+ self.send_header("Connection", "close")
+ self.end_headers()
+ for frame in frames:
+ self.wfile.write(f"data: {json.dumps(frame, ensure_ascii=False)}\n\n".encode())
+ self.wfile.write(b"data: [DONE]\n\n")
+ self.wfile.flush()
+
+ def do_GET(self) -> None: # noqa: N802
+ path = self.path.split("?", 1)[0].rstrip("/") or "/"
+ if path == "/health":
+ gateway = self.gateway
+ self._send_json(
+ 200,
+ {
+ "ok": gateway is not None,
+ "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,
+ },
+ )
+ return
+ if path in {"/v1/models", "/models"}:
+ gateway = self.gateway
+ model = gateway.settings.model if gateway else "unknown"
+ self._send_json(
+ 200,
+ {
+ "object": "list",
+ "data": [{"id": model, "object": "model", "owned_by": "aetherya"}],
+ },
+ )
+ return
+ self._send_error_payload(404, f"unknown path: {path}", "not_found")
+
+ def do_POST(self) -> None: # noqa: N802
+ path = self.path.split("?", 1)[0].rstrip("/") or "/"
+ if path not in {"/v1/chat/completions", "/chat/completions"}:
+ self._send_error_payload(404, f"unknown path: {path}", "not_found")
+ return
+
+ gateway = self.gateway
+ if gateway is None:
+ self._send_error_payload(500, "gateway is not configured", "server_error")
+ return
+
+ try:
+ length = int(self.headers.get("Content-Length", "0") or 0)
+ except ValueError:
+ self._send_error_payload(400, "invalid Content-Length", "invalid_request_error")
+ return
+ if length > MAX_BODY_BYTES:
+ self._send_error_payload(413, "request body too large", "invalid_request_error")
+ return
+
+ try:
+ body = json.loads(self.rfile.read(length) or b"{}")
+ except json.JSONDecodeError as exc:
+ self._send_error_payload(400, f"invalid JSON body: {exc}", "invalid_request_error")
+ return
+ if not isinstance(body, dict):
+ self._send_error_payload(400, "body must be a JSON object", "invalid_request_error")
+ return
+
+ try:
+ if body.get("stream"):
+ self._send_sse(gateway.stream(body))
+ else:
+ self._send_json(200, gateway.complete(body))
+ except UpstreamError as exc:
+ # The boundary failed, not the model. Say so distinctly — an agent
+ # that cannot tell these apart will retry the wrong one.
+ self._send_error_payload(502, f"upstream: {exc}", "upstream_error")
+ except Exception as exc: # noqa: BLE001
+ self._send_error_payload(500, f"{type(exc).__name__}: {exc}", "server_error")
+
+
+def build_gateway_server(host: str, port: int, *, gateway: AetheryaGateway) -> ThreadingHTTPServer:
+ handler = type("_BoundGatewayHandler", (GatewayHTTPRequestHandler,), {"gateway": gateway})
+ return ThreadingHTTPServer((host, port), handler)
+
+
+def build_gateway(
+ *,
+ policy_path: Path,
+ constitution_path: Path | None,
+ audit_path: Path | None,
+ settings: GatewaySettings,
+) -> AetheryaGateway:
+ from aetherya.cli import _default_constitution, _load_constitution
+
+ cfg = load_policy_config(policy_path)
+ constitution_kwargs = {
+ "use_semantic": cfg.constitution_config.use_semantic,
+ "semantic_violation_threshold": cfg.constitution_config.semantic_violation_threshold,
+ "semantic_gray_zone_threshold": cfg.constitution_config.semantic_gray_zone_threshold,
+ "semantic_max_risk": cfg.constitution_config.semantic_max_risk,
+ "require_warm_semantic_model": cfg.constitution_config.require_warm_semantic_model,
+ }
+ constitution = (
+ _load_constitution(constitution_path, **constitution_kwargs) # type: ignore[arg-type]
+ if constitution_path is not None
+ else _default_constitution(**constitution_kwargs) # type: ignore[arg-type]
+ )
+
+ check_actor_is_known(cfg.capability_matrix, settings.actor, settings.phase)
+
+ audit = (
+ AuditLogger(str(audit_path), policy_fingerprint=cfg.policy_fingerprint)
+ if audit_path is not None
+ else None
+ )
+ return AetheryaGateway(settings, constitution=constitution, cfg=cfg, audit=audit)
+
+
+def check_actor_is_known(matrix: Any, actor: str, phase: int) -> None:
+ """
+ An actor missing from the capability matrix is denied on every call.
+
+ In phase 1 and 2 that is invisible — `deny` is not enforced — so the
+ misconfiguration would sit there until phase 3 turned the agent into a
+ brick. Fail at startup in the phase where it matters, warn in the ones
+ where it does not.
+ """
+ if not matrix.enabled or matrix.default_allow or actor in matrix.actors:
+ return
+
+ known = ", ".join(sorted(matrix.actors)) or "none"
+ message = (
+ f"actor {actor!r} is not in capability_matrix.actors (known: {known}). "
+ f"Every action from this gateway will be denied. Add it under "
+ f"capability_matrix.actors in your policy, or pass --actor."
+ )
+ if phase >= 3:
+ raise ValueError(message)
+ sys.stderr.write(f"[gateway] WARNING: {message}\n")
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ prog="aetherya-gateway",
+ description="OpenAI-compatible endpoint that rules on every tool call before the agent sees it",
+ )
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=DEFAULT_PORT)
+ parser.add_argument("--provider", default=os.getenv("AETHERYA_GATEWAY_PROVIDER", "anthropic"))
+ parser.add_argument("--model", default=os.getenv("AETHERYA_GATEWAY_MODEL", "claude-opus-5"))
+ parser.add_argument("--actor", default=os.getenv("AETHERYA_GATEWAY_ACTOR", "openclaw"))
+ parser.add_argument("--phase", type=int, default=None)
+ parser.add_argument("--policy-path", type=Path, default=Path("config/policy.yaml"))
+ parser.add_argument("--constitution-path", type=Path, default=None)
+ parser.add_argument("--audit-path", type=Path, default=Path("audit/decisions.jsonl"))
+ parser.add_argument("--effort", default="high", help="Anthropic only: output_config.effort")
+ args = parser.parse_args(argv)
+
+ cfg = load_policy_config(args.policy_path)
+ phase = args.phase if args.phase is not None else cfg.enforcement.phase
+
+ gateway = build_gateway(
+ policy_path=args.policy_path,
+ constitution_path=args.constitution_path,
+ audit_path=args.audit_path,
+ settings=GatewaySettings(
+ provider=args.provider,
+ model=args.model,
+ actor=args.actor,
+ phase=phase,
+ anthropic_effort=args.effort,
+ ),
+ )
+
+ server = build_gateway_server(args.host, args.port, gateway=gateway)
+ sys.stderr.write(
+ f"[gateway] {args.provider}/{args.model} · phase {phase} ({gateway.phase.name}) "
+ f"· http://{args.host}:{args.port}/v1\n"
+ )
+ if args.host not in {"127.0.0.1", "localhost", "::1"}:
+ sys.stderr.write(
+ "[gateway] WARNING: bound to a non-loopback address. This process holds "
+ "your upstream API key and answers unauthenticated requests.\n"
+ )
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ server.server_close()
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/src/aetherya/review_store.py b/src/aetherya/review_store.py
new file mode 100644
index 0000000..ffb1c07
--- /dev/null
+++ b/src/aetherya/review_store.py
@@ -0,0 +1,147 @@
+from __future__ import annotations
+
+import json
+import threading
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any, Final
+
+# ---------------------------------------------------------------------------
+# Human review of hard_deny events.
+#
+# The phase-1 exit criteria say every hard_deny must be read by a human and
+# confirmed a true positive. `aetherya rollout report` could count them but
+# never pass them, because a tool cannot make that judgement. This is where the
+# judgement is recorded.
+#
+# Append-only on purpose. A review is itself a human act with consequences —
+# it is what unblocks enforcement — so who said what, and when, has to survive.
+# Re-reviewing an event appends a new verdict; the latest one wins on read and
+# the earlier one stays on disk.
+#
+# See docs/rollout-phases.md#phase-1--shadow.
+# ---------------------------------------------------------------------------
+
+TRUE_POSITIVE: Final = "true_positive"
+FALSE_POSITIVE: Final = "false_positive"
+VERDICTS: Final[frozenset[str]] = frozenset({TRUE_POSITIVE, FALSE_POSITIVE})
+
+
+@dataclass(frozen=True)
+class Review:
+ event_id: str
+ verdict: str
+ reviewer: str
+ note: str
+ ts: str
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "event_id": self.event_id,
+ "verdict": self.verdict,
+ "reviewer": self.reviewer,
+ "note": self.note,
+ "ts": self.ts,
+ }
+
+
+class ReviewStore:
+ """Append-only JSONL of human verdicts, keyed by audit `event_id`."""
+
+ def __init__(self, path: str | Path) -> None:
+ self.path = Path(path)
+ self._lock = threading.Lock()
+
+ def record(
+ self,
+ event_id: str,
+ verdict: str,
+ *,
+ reviewer: str,
+ note: str = "",
+ ) -> Review:
+ event_id = str(event_id).strip()
+ if not event_id:
+ raise ValueError("event_id must be non-empty")
+ if verdict not in VERDICTS:
+ raise ValueError(f"verdict must be one of {sorted(VERDICTS)}, got {verdict!r}")
+ reviewer = str(reviewer).strip()
+ if not reviewer:
+ raise ValueError("reviewer must be non-empty")
+
+ # A false positive is a claim that the engine got it wrong. Requiring a
+ # reason is what makes it actionable later — the note is what tells you
+ # which rule to narrow.
+ note = str(note).strip()
+ if verdict == FALSE_POSITIVE and not note:
+ raise ValueError("a false_positive review must explain why")
+
+ review = Review(
+ event_id=event_id,
+ verdict=verdict,
+ reviewer=reviewer,
+ note=note[:2000],
+ ts=datetime.now(UTC).isoformat(),
+ )
+ with self._lock:
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ with self.path.open("a", encoding="utf-8") as handle:
+ handle.write(json.dumps(review.to_dict(), ensure_ascii=False) + "\n")
+ return review
+
+ def reviews(self) -> dict[str, Review]:
+ """Latest verdict per event_id. A missing or unreadable file is empty."""
+ latest: dict[str, Review] = {}
+ if not self.path.exists():
+ return latest
+ for line in self.path.read_text(encoding="utf-8").splitlines():
+ if not line.strip():
+ continue
+ try:
+ payload = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if not isinstance(payload, dict):
+ continue
+ event_id = str(payload.get("event_id", "")).strip()
+ verdict = str(payload.get("verdict", ""))
+ if not event_id or verdict not in VERDICTS:
+ continue
+ latest[event_id] = Review(
+ event_id=event_id,
+ verdict=verdict,
+ reviewer=str(payload.get("reviewer", "unknown")),
+ note=str(payload.get("note", "")),
+ ts=str(payload.get("ts", "")),
+ )
+ return latest
+
+ def history(self) -> list[Review]:
+ """Every recorded verdict in file order, including superseded ones."""
+ entries: list[Review] = []
+ if not self.path.exists():
+ return entries
+ for line in self.path.read_text(encoding="utf-8").splitlines():
+ if not line.strip():
+ continue
+ try:
+ payload = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if not isinstance(payload, dict):
+ continue
+ event_id = str(payload.get("event_id", "")).strip()
+ verdict = str(payload.get("verdict", ""))
+ if not event_id or verdict not in VERDICTS:
+ continue
+ entries.append(
+ Review(
+ event_id=event_id,
+ verdict=verdict,
+ reviewer=str(payload.get("reviewer", "unknown")),
+ note=str(payload.get("note", "")),
+ ts=str(payload.get("ts", "")),
+ )
+ )
+ return entries
diff --git a/src/aetherya/rollout_report.py b/src/aetherya/rollout_report.py
index 9600849..4d21669 100644
--- a/src/aetherya/rollout_report.py
+++ b/src/aetherya/rollout_report.py
@@ -11,6 +11,7 @@
from aetherya.audit_verify import verify_audit_file
from aetherya.enforcement import PHASES, resolve_phase
+from aetherya.review_store import FALSE_POSITIVE, ReviewStore
# ---------------------------------------------------------------------------
# Phase-1 measurement.
@@ -124,11 +125,13 @@ def build_report(
min_decisions: int = DEFAULT_MIN_DECISIONS,
min_days: float = DEFAULT_MIN_DAYS,
max_hard_deny_samples: int = 50,
+ review_path: str | Path | None = None,
) -> RolloutReport:
path = Path(audit_path)
phase = resolve_phase(current_phase)
next_phase = PHASES[min(current_phase + 1, max(PHASES))]
events = _load_events(path)
+ recorded_reviews = ReviewStore(review_path).reviews() if review_path is not None else {}
report = RolloutReport(audit_path=str(path), current_phase=phase.number)
report.total_decisions = len(events)
@@ -165,14 +168,20 @@ def build_report(
timestamps.append(ts)
# Every hard_deny needs human eyes before enforcement is switched on.
+ # `event_id` is what a recorded verdict hangs off, so it travels with
+ # the sample rather than being looked up again later.
if state == "hard_deny" and len(report.hard_deny_events) < max_hard_deny_samples:
+ event_id = str(event.get("event_id", ""))
+ review = recorded_reviews.get(event_id)
report.hard_deny_events.append(
{
+ "event_id": event_id,
"ts": event.get("ts"),
"actor": event.get("actor"),
"action": str(event.get("action", ""))[:160],
"reason": decision.get("reason"),
"risk_score": decision.get("risk_score"),
+ "review": review.to_dict() if review is not None else None,
}
)
@@ -236,18 +245,68 @@ def _evaluate_criteria(
"one means the window mixes different engines and must be restarted"
),
),
- # Deliberately never auto-passes: a human has to look at these. The tool
- # can list them and count them; it cannot judge them.
- Criterion(
+ _hard_deny_reviewed(report),
+ ]
+ return criteria
+
+
+def _hard_deny_reviewed(report: RolloutReport) -> Criterion:
+ """
+ Never auto-passes: it passes only on verdicts a human recorded.
+
+ The tool can count hard_deny events and show them; it cannot judge them.
+ What it can do is check that someone did — and refuse to advance while any
+ of them is unreviewed or was judged a false positive.
+ """
+ total = report.states.get("hard_deny", 0)
+ if total == 0:
+ return Criterion(
+ name="hard_deny_reviewed",
+ passed=True,
+ detail="no hard_deny events in this window",
+ )
+
+ unreviewed = [e for e in report.hard_deny_events if e.get("review") is None]
+ false_positives = [
+ e
+ for e in report.hard_deny_events
+ if isinstance(e.get("review"), dict) and e["review"].get("verdict") == FALSE_POSITIVE
+ ]
+
+ # A false positive is the finding phase 1 exists to produce. It blocks the
+ # advance because enforcing a rule a human already called wrong is exactly
+ # the outcome this phase is meant to prevent.
+ if false_positives:
+ return Criterion(
name="hard_deny_reviewed",
passed=False,
detail=(
- f"{report.states.get('hard_deny', 0)} hard_deny event(s) require manual "
- "review — confirm every one is a true positive, then advance"
+ f"{len(false_positives)} of {total} hard_deny event(s) were judged FALSE "
+ "positives — fix the rule and restart the window, do not advance "
+ "(see docs/rollout-phases.md#phase-1--shadow)"
),
- ),
- ]
- return criteria
+ )
+
+ # `hard_deny_events` is capped by max_hard_deny_samples. If the window holds
+ # more than we sampled, the ones we never showed cannot have been reviewed.
+ unsampled = total - len(report.hard_deny_events)
+ outstanding = len(unreviewed) + max(unsampled, 0)
+ if outstanding:
+ return Criterion(
+ name="hard_deny_reviewed",
+ passed=False,
+ detail=(
+ f"{outstanding} of {total} hard_deny event(s) still require manual review "
+ "— confirm every one is a true positive, then advance"
+ ),
+ )
+
+ reviewers = sorted({e["review"]["reviewer"] for e in report.hard_deny_events})
+ return Criterion(
+ name="hard_deny_reviewed",
+ passed=True,
+ detail=f"all {total} hard_deny event(s) reviewed and confirmed by {', '.join(reviewers)}",
+ )
def _print_text(report: RolloutReport) -> None:
@@ -300,6 +359,11 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--phase", type=int, default=1, choices=sorted(PHASES))
parser.add_argument("--min-decisions", type=int, default=DEFAULT_MIN_DECISIONS)
parser.add_argument("--min-days", type=float, default=DEFAULT_MIN_DAYS)
+ parser.add_argument(
+ "--review-path",
+ default="audit/reviews.jsonl",
+ help="Human verdicts on hard_deny events, as recorded by the operator console.",
+ )
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
args = parser.parse_args(argv)
@@ -309,6 +373,7 @@ def main(argv: list[str] | None = None) -> int:
current_phase=int(args.phase),
min_decisions=int(args.min_decisions),
min_days=float(args.min_days),
+ review_path=args.review_path,
)
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
diff --git a/tests/test_api_server.py b/tests/test_api_server.py
index 2f02c16..217c9aa 100644
--- a/tests/test_api_server.py
+++ b/tests/test_api_server.py
@@ -13,7 +13,6 @@
AetheryaHTTPRequestHandler,
RequestTooLargeError,
_build_handler,
- _dashboard_html,
build_server,
main,
serve_api,
@@ -72,16 +71,6 @@ def end_headers(self) -> None:
assert fake_html.wfile.getvalue().decode("utf-8") == "
x
"
-def test_dashboard_template_contains_controls() -> None:
- html = _dashboard_html()
- assert "AETHERYA API Dashboard" in html
- assert "/v1/decide" in html
- assert "/v1/audit/verify" in html
- assert "/v1/confirmation/sign" in html
- assert "/v1/confirmation/verify" in html
- assert "candidate_response" in html
-
-
def test_handler_parse_json_body_branches() -> None:
class FakeHandler:
def __init__(self, content_length: str, raw: bytes, max_body_bytes: int = 64):
@@ -163,7 +152,7 @@ def _send_html(self, status: int, html: str) -> None:
dashboard = FakeHandler(method="GET", path="/")
AetheryaHTTPRequestHandler._handle_request(dashboard) # noqa: SLF001
assert dashboard.sent_html[0][0] == 200
- assert "AETHERYA API Dashboard" in dashboard.sent_html[0][1]
+ assert "Operator Console" in dashboard.sent_html[0][1]
get_ok = FakeHandler(method="GET", path="/health")
AetheryaHTTPRequestHandler._handle_request(get_ok) # noqa: SLF001
diff --git a/tests/test_console.py b/tests/test_console.py
new file mode 100644
index 0000000..be7c830
--- /dev/null
+++ b/tests/test_console.py
@@ -0,0 +1,616 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from aetherya.api import AetheryaAPI, APISettings
+from aetherya.audit import AuditLogger
+from aetherya.console import console_html
+
+
+def _api(tmp_path: Path, audit: Path | None = None) -> AetheryaAPI:
+ return AetheryaAPI(
+ APISettings(
+ policy_path=Path("config/policy.yaml"),
+ audit_path=audit if audit is not None else tmp_path / "decisions.jsonl",
+ )
+ )
+
+
+def _seed(path: Path, rows: list[tuple[str, str]]) -> None:
+ """rows: (actor, state)."""
+ logger = AuditLogger(str(path), policy_fingerprint="sha256:test")
+ for actor, state in rows:
+ logger.log(
+ actor=actor,
+ action=f"{state} action by {actor}",
+ decision={
+ "allowed": state in {"allow", "log_only"},
+ "risk_score": 171 if state == "hard_deny" else 0,
+ "reason": f"{state}: because",
+ "state": state,
+ },
+ context={"mode": "operative", "action": {"tool": "shell"}},
+ )
+
+
+# ---------------------------------------------------------------------------
+# Decision feed
+# ---------------------------------------------------------------------------
+
+
+def test_decisions_are_returned_newest_first(tmp_path: Path) -> None:
+ audit = tmp_path / "decisions.jsonl"
+ _seed(audit, [("first", "allow"), ("second", "allow"), ("third", "hard_deny")])
+
+ code, body = _api(tmp_path, audit).decisions()
+ assert code == 200
+ assert [d["actor"] for d in body["decisions"]] == ["third", "second", "first"]
+
+
+def test_decisions_can_be_filtered_by_state(tmp_path: Path) -> None:
+ audit = tmp_path / "decisions.jsonl"
+ _seed(audit, [("a", "allow"), ("b", "hard_deny"), ("c", "allow"), ("d", "hard_deny")])
+
+ _, body = _api(tmp_path, audit).decisions({"state": "hard_deny"})
+ assert body["count"] == 2
+ assert {d["actor"] for d in body["decisions"]} == {"b", "d"}
+
+
+def test_decisions_respect_the_limit(tmp_path: Path) -> None:
+ audit = tmp_path / "decisions.jsonl"
+ _seed(audit, [(f"a{i}", "allow") for i in range(30)])
+
+ _, body = _api(tmp_path, audit).decisions({"limit": 5})
+ assert body["count"] == 5
+
+
+def test_the_limit_is_clamped(tmp_path: Path) -> None:
+ audit = tmp_path / "decisions.jsonl"
+ _seed(audit, [("a", "allow")])
+ api = _api(tmp_path, audit)
+
+ assert api.decisions({"limit": 0})[1]["ok"] is True
+ assert api.decisions({"limit": 99_999})[1]["ok"] is True
+
+
+def test_decisions_expose_the_fields_the_console_renders(tmp_path: Path) -> None:
+ audit = tmp_path / "decisions.jsonl"
+ _seed(audit, [("bot", "hard_deny")])
+
+ row = _api(tmp_path, audit).decisions()[1]["decisions"][0]
+ for field in ("ts", "actor", "action", "state", "risk_score", "reason", "tool"):
+ assert field in row
+
+
+def test_a_missing_audit_file_yields_an_empty_feed(tmp_path: Path) -> None:
+ """A fresh deployment has no decisions yet; that is not an error."""
+ code, body = _api(tmp_path, tmp_path / "nothing-here.jsonl").decisions()
+ assert code == 200
+ assert body["count"] == 0
+
+
+def test_malformed_lines_are_skipped_in_the_feed(tmp_path: Path) -> None:
+ audit = tmp_path / "decisions.jsonl"
+ _seed(audit, [("a", "allow")])
+ with audit.open("a", encoding="utf-8") as handle:
+ handle.write("not json\n")
+ handle.write("\n")
+ handle.write('"bare string"\n')
+
+ assert _api(tmp_path, audit).decisions()[1]["count"] == 1
+
+
+def test_actions_are_truncated_in_the_feed(tmp_path: Path) -> None:
+ audit = tmp_path / "decisions.jsonl"
+ AuditLogger(str(audit)).log(
+ actor="a",
+ action="x" * 900,
+ decision={"allowed": True, "risk_score": 0, "reason": "ok", "state": "allow"},
+ context={},
+ )
+ assert len(_api(tmp_path, audit).decisions()[1]["decisions"][0]["action"]) <= 400
+
+
+def test_escalated_requests_are_flagged(tmp_path: Path) -> None:
+ audit = tmp_path / "decisions.jsonl"
+ AuditLogger(str(audit)).log(
+ actor="a",
+ action="dd if=/dev/zero of=/dev/sda",
+ decision={"allowed": False, "risk_score": 100, "reason": "bad", "state": "hard_deny"},
+ context={"intent_escalation": {"escalated": True}},
+ )
+ assert _api(tmp_path, audit).decisions()[1]["decisions"][0]["escalated"] is True
+
+
+def test_decisions_fail_when_audit_is_disabled(tmp_path: Path) -> None:
+ api = AetheryaAPI(APISettings(audit_path=None))
+ code, body = api.decisions()
+ assert code == 400
+ assert "audit_path is disabled" in body["error"]
+
+
+# ---------------------------------------------------------------------------
+# Rollout endpoint
+# ---------------------------------------------------------------------------
+
+
+def test_rollout_report_is_served(tmp_path: Path) -> None:
+ audit = tmp_path / "decisions.jsonl"
+ _seed(audit, [("a", "allow"), ("b", "hard_deny")])
+
+ code, body = _api(tmp_path, audit).rollout()
+ assert code == 200
+ report = body["report"]
+ assert report["states"] == {"allow": 1, "hard_deny": 1}
+ assert report["ready_to_advance"] is False
+ assert len(report["hard_deny_events"]) == 1
+
+
+def test_rollout_defaults_to_the_configured_phase(tmp_path: Path) -> None:
+ audit = tmp_path / "decisions.jsonl"
+ _seed(audit, [("a", "allow")])
+
+ body = _api(tmp_path, audit).rollout()[1]
+ assert body["report"]["current_phase"] == 1
+
+
+def test_rollout_phase_can_be_overridden(tmp_path: Path) -> None:
+ audit = tmp_path / "decisions.jsonl"
+ _seed(audit, [("a", "allow")])
+
+ body = _api(tmp_path, audit).rollout({"phase": 2})[1]
+ assert body["report"]["current_phase"] == 2
+
+
+def test_rollout_reports_a_missing_audit_file(tmp_path: Path) -> None:
+ code, body = _api(tmp_path, tmp_path / "nope.jsonl").rollout()
+ assert code == 400
+ assert "audit file not found" in body["error"]
+
+
+def test_rollout_fails_when_audit_is_disabled() -> None:
+ code, body = AetheryaAPI(APISettings(audit_path=None)).rollout()
+ assert code == 400
+ assert "audit_path is disabled" in body["error"]
+
+
+def test_rollout_accepts_custom_thresholds(tmp_path: Path) -> None:
+ audit = tmp_path / "decisions.jsonl"
+ _seed(audit, [("a", "allow")] * 3)
+
+ body = _api(tmp_path, audit).rollout({"min_decisions": 1, "min_days": 0.0})[1]
+ window = next(c for c in body["report"]["criteria"] if c["name"] == "sufficient_window")
+ assert window["passed"] is True
+
+
+# ---------------------------------------------------------------------------
+# The page itself
+# ---------------------------------------------------------------------------
+
+
+def test_console_page_is_self_contained() -> None:
+ """
+ No build step and no external assets: the page ships with the engine, so a
+ deployment is one container and the audit trail sits behind one boundary.
+ """
+ html = console_html()
+ assert "" in html.lower()
+ assert 'src="http' not in html
+ assert 'href="http' not in html
+ assert "cdn" not in html.lower()
+
+
+def test_console_page_calls_the_endpoints_it_needs() -> None:
+ html = console_html()
+ for endpoint in ("/health", "/v1/decisions", "/v1/rollout/report"):
+ assert endpoint in html
+
+
+def test_console_page_escapes_rendered_values() -> None:
+ """The feed renders actor-supplied strings; they must not be injected raw."""
+ html = console_html()
+ assert "const esc" in html
+ assert "<" in html or "<" in html
+
+
+def test_console_warns_about_exposure() -> None:
+ assert "Do not expose this port publicly" in console_html()
+
+
+# ---------------------------------------------------------------------------
+# HTTP surface
+# ---------------------------------------------------------------------------
+
+
+def _server(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Any:
+ import threading
+
+ from aetherya.api_server import build_server
+
+ audit = tmp_path / "decisions.jsonl"
+ _seed(audit, [("a", "allow"), ("b", "hard_deny")])
+ api = _api(tmp_path, audit)
+ server = build_server(host="127.0.0.1", port=0, api=api, max_body_bytes=65536)
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+ return server
+
+
+def _get(server: Any, path: str, headers: dict[str, str] | None = None) -> Any:
+ import urllib.request
+
+ request = urllib.request.Request( # noqa: S310
+ f"http://127.0.0.1:{server.server_port}{path}", headers=headers or {}
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=5) as response: # noqa: S310
+ return response.status, response.read().decode("utf-8")
+ except urllib.error.HTTPError as exc: # type: ignore[name-defined]
+ return exc.code, exc.read().decode("utf-8")
+
+
+def _post(server: Any, path: str, payload: Any, headers: dict[str, str] | None = None) -> Any:
+ import urllib.request
+
+ request = urllib.request.Request( # noqa: S310
+ f"http://127.0.0.1:{server.server_port}{path}",
+ data=json.dumps(payload).encode("utf-8"),
+ method="POST",
+ headers={"Content-Type": "application/json", **(headers or {})},
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=5) as response: # noqa: S310
+ return response.status, response.read().decode("utf-8")
+ except urllib.error.HTTPError as exc: # type: ignore[name-defined]
+ return exc.code, exc.read().decode("utf-8")
+
+
+def test_console_is_served_at_the_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ server = _server(tmp_path, monkeypatch)
+ try:
+ for path in ("/", "/console", "/dashboard"):
+ status, body = _get(server, path)
+ assert status == 200
+ assert "Operator Console" in body
+ finally:
+ server.shutdown()
+
+
+def test_decisions_endpoint_is_reachable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ server = _server(tmp_path, monkeypatch)
+ try:
+ status, body = _get(server, "/v1/decisions?limit=5&state=hard_deny")
+ assert status == 200
+ payload = json.loads(body)
+ assert payload["count"] == 1
+ finally:
+ server.shutdown()
+
+
+def test_rollout_endpoint_is_reachable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ server = _server(tmp_path, monkeypatch)
+ try:
+ status, body = _get(server, "/v1/rollout/report")
+ assert status == 200
+ assert json.loads(body)["report"]["current_phase"] == 1
+ finally:
+ server.shutdown()
+
+
+def test_console_key_is_enforced_when_configured(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """
+ This view exposes every recorded action. When a key is set it is required;
+ without one the routes are open like the rest of the decision profile, which
+ is why the port must not be public.
+ """
+ import urllib.error # noqa: F401 - imported for _get's except clause
+
+ monkeypatch.setenv("AETHERYA_CONSOLE_API_KEY", "s3cret")
+ server = _server(tmp_path, monkeypatch)
+ try:
+ assert _get(server, "/v1/decisions")[0] == 401
+ assert _get(server, "/v1/rollout/report")[0] == 401
+
+ status, _ = _get(server, "/v1/decisions", {"X-AETHERYA-Console-Key": "s3cret"})
+ assert status == 200
+
+ assert _get(server, "/v1/decisions", {"X-AETHERYA-Console-Key": "wrong"})[0] == 401
+ finally:
+ server.shutdown()
+
+
+def test_console_page_is_not_key_gated(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """The shell is inert HTML; the data behind it is what the key protects."""
+ monkeypatch.setenv("AETHERYA_CONSOLE_API_KEY", "s3cret")
+ server = _server(tmp_path, monkeypatch)
+ try:
+ assert _get(server, "/")[0] == 200
+ finally:
+ server.shutdown()
+
+
+def test_console_routes_fail_when_the_api_is_not_configured() -> None:
+ """Defensive: the handler must answer rather than raise if wiring is missing."""
+ import io
+
+ from aetherya.api_server import AetheryaHTTPRequestHandler
+
+ class _Bare(AetheryaHTTPRequestHandler):
+ def __init__(self) -> None: # noqa: D107
+ self.api = None
+ self.command = "GET"
+ self.path = "/v1/decisions"
+ self.headers = {} # type: ignore[assignment]
+ self.sent: list[tuple[int, dict[str, Any]]] = []
+ self.wfile = io.BytesIO()
+
+ def _send_json(self, status: int, body: dict[str, Any]) -> None: # type: ignore[override]
+ self.sent.append((status, body))
+
+ handler = _Bare()
+ handler._handle_request() # noqa: SLF001
+ assert handler.sent[0][0] == 500
+
+
+# ---------------------------------------------------------------------------
+# Review queue — the console's only write
+# ---------------------------------------------------------------------------
+
+
+def _api_with_reviews(tmp_path: Path, audit: Path) -> AetheryaAPI:
+ return AetheryaAPI(
+ APISettings(
+ policy_path=Path("config/policy.yaml"),
+ audit_path=audit,
+ review_path=tmp_path / "reviews.jsonl",
+ )
+ )
+
+
+def _hard_deny_ids(audit: Path) -> list[str]:
+ ids: list[str] = []
+ for line in audit.read_text(encoding="utf-8").splitlines():
+ try:
+ event = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if event.get("decision", {}).get("state") == "hard_deny":
+ ids.append(event["event_id"])
+ return ids
+
+
+def test_a_review_is_recorded_and_reaches_the_report(tmp_path: Path) -> None:
+ audit = tmp_path / "decisions.jsonl"
+ _seed(audit, [("a", "hard_deny")])
+ api = _api_with_reviews(tmp_path, audit)
+ event_id = _hard_deny_ids(audit)[0]
+
+ code, body = api.record_review(
+ {"event_id": event_id, "verdict": "true_positive", "reviewer": "robert"}
+ )
+ assert code == 200
+ assert body["review"]["verdict"] == "true_positive"
+
+ events = api.rollout()[1]["report"]["hard_deny_events"]
+ assert events[0]["review"]["reviewer"] == "robert"
+
+
+def test_reviewing_an_unknown_event_is_refused(tmp_path: Path) -> None:
+ """
+ A verdict on an id that is not in the trail would be recorded, counted by
+ nothing, and leave the operator believing they had reviewed something.
+ """
+ audit = tmp_path / "decisions.jsonl"
+ _seed(audit, [("a", "hard_deny")])
+
+ code, body = _api_with_reviews(tmp_path, audit).record_review(
+ {"event_id": "made-up", "verdict": "true_positive", "reviewer": "robert"}
+ )
+ assert code == 400
+ assert "no audit event" in body["error"]
+
+
+def test_reviewing_a_non_hard_deny_event_is_refused(tmp_path: Path) -> None:
+ audit = tmp_path / "decisions.jsonl"
+ _seed(audit, [("a", "allow")])
+ allow_id = json.loads(audit.read_text(encoding="utf-8").splitlines()[0])["event_id"]
+
+ code, body = _api_with_reviews(tmp_path, audit).record_review(
+ {"event_id": allow_id, "verdict": "true_positive", "reviewer": "robert"}
+ )
+ assert code == 400
+ assert "not `hard_deny`" in body["error"]
+
+
+@pytest.mark.parametrize(
+ "payload",
+ [
+ {"verdict": "true_positive", "reviewer": "r"},
+ {"event_id": "x", "reviewer": "r"},
+ {"event_id": "x", "verdict": "true_positive"},
+ {"event_id": "x", "verdict": "nonsense", "reviewer": "r"},
+ [],
+ ],
+)
+def test_malformed_review_payloads_are_rejected(tmp_path: Path, payload: Any) -> None:
+ audit = tmp_path / "decisions.jsonl"
+ _seed(audit, [("a", "hard_deny")])
+ assert _api_with_reviews(tmp_path, audit).record_review(payload)[0] == 400
+
+
+def test_reviews_are_listed_newest_first(tmp_path: Path) -> None:
+ audit = tmp_path / "decisions.jsonl"
+ _seed(audit, [("a", "hard_deny"), ("b", "hard_deny")])
+ api = _api_with_reviews(tmp_path, audit)
+ ids = _hard_deny_ids(audit)
+
+ api.record_review({"event_id": ids[0], "verdict": "true_positive", "reviewer": "first"})
+ api.record_review({"event_id": ids[1], "verdict": "true_positive", "reviewer": "second"})
+
+ code, body = api.reviews()
+ assert code == 200
+ assert [r["reviewer"] for r in body["reviews"]] == ["second", "first"]
+
+
+def test_review_routes_fail_when_the_store_is_disabled(tmp_path: Path) -> None:
+ audit = tmp_path / "decisions.jsonl"
+ _seed(audit, [("a", "hard_deny")])
+ api = AetheryaAPI(APISettings(audit_path=audit, review_path=None))
+
+ assert (
+ api.record_review({"event_id": "x", "verdict": "true_positive", "reviewer": "r"})[0] == 400
+ )
+ assert api.reviews()[0] == 400
+
+
+def test_recording_a_review_requires_a_configured_key(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """
+ Without a key the verdict carries a reviewer name nobody had to prove,
+ which is worse than having no attribution at all. Reads stay open; this
+ write does not.
+ """
+ monkeypatch.delenv("AETHERYA_CONSOLE_API_KEY", raising=False)
+ server = _server(tmp_path, monkeypatch)
+ try:
+ status, body = _post(server, "/v1/reviews", {"event_id": "x"})
+ assert status == 503
+ assert "AETHERYA_CONSOLE_API_KEY" in json.loads(body)["error"]
+ finally:
+ server.shutdown()
+
+
+def test_recording_a_review_over_http(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("AETHERYA_CONSOLE_API_KEY", "s3cret")
+ audit = tmp_path / "decisions.jsonl"
+ _seed(audit, [("a", "hard_deny")])
+ event_id = _hard_deny_ids(audit)[0]
+
+ import threading
+
+ from aetherya.api_server import build_server
+
+ api = _api_with_reviews(tmp_path, audit)
+ server = build_server(host="127.0.0.1", port=0, api=api, max_body_bytes=65536)
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+ try:
+ payload = {"event_id": event_id, "verdict": "true_positive", "reviewer": "robert"}
+ assert _post(server, "/v1/reviews", payload)[0] == 401
+ status, body = _post(server, "/v1/reviews", payload, {"X-AETHERYA-Console-Key": "s3cret"})
+ assert status == 200
+ assert json.loads(body)["review"]["reviewer"] == "robert"
+
+ status, body = _get(server, "/v1/reviews", {"X-AETHERYA-Console-Key": "s3cret"})
+ assert json.loads(body)["count"] == 1
+ finally:
+ server.shutdown()
+
+
+def test_an_invalid_review_body_is_a_400(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("AETHERYA_CONSOLE_API_KEY", "s3cret")
+ server = _server(tmp_path, monkeypatch)
+ try:
+ import urllib.request
+
+ url = f"http://127.0.0.1:{server.server_port}/v1/reviews"
+ request = urllib.request.Request( # noqa: S310
+ url,
+ data=b"{broken",
+ method="POST",
+ headers={"X-AETHERYA-Console-Key": "s3cret"},
+ )
+ with pytest.raises(urllib.error.HTTPError) as exc:
+ urllib.request.urlopen(request, timeout=5) # noqa: S310
+ assert exc.value.code == 400
+ finally:
+ server.shutdown()
+
+
+# ---------------------------------------------------------------------------
+# The page must actually offer the buttons
+# ---------------------------------------------------------------------------
+
+
+def test_the_console_renders_verdict_buttons() -> None:
+ html = console_html()
+ assert 'data-verdict="true_positive"' in html
+ assert 'data-verdict="false_positive"' in html
+ assert '"/v1/reviews"' in html
+
+
+def test_the_console_requires_a_note_for_a_false_positive() -> None:
+ """Mirrors the store's rule so the UI cannot submit something it will reject."""
+ html = console_html()
+ assert "if(!note.trim()){ return; }" in html
+
+
+def test_reviewing_without_an_audit_trail_is_refused(tmp_path: Path) -> None:
+ api = AetheryaAPI(APISettings(audit_path=None, review_path=tmp_path / "r.jsonl"))
+ code, body = api.record_review({"event_id": "x", "verdict": "true_positive", "reviewer": "r"})
+ assert code == 400
+ assert "audit_path is disabled" in body["error"]
+
+
+def test_reviewing_against_a_missing_audit_file_is_refused(tmp_path: Path) -> None:
+ api = AetheryaAPI(
+ APISettings(audit_path=tmp_path / "gone.jsonl", review_path=tmp_path / "r.jsonl")
+ )
+ assert (
+ api.record_review({"event_id": "x", "verdict": "true_positive", "reviewer": "r"})[0] == 400
+ )
+
+
+def test_a_malformed_audit_line_does_not_break_the_event_lookup(tmp_path: Path) -> None:
+ """The garbage sits before the target, so the scan has to survive it to find it."""
+ audit = tmp_path / "decisions.jsonl"
+ _seed(audit, [("a", "hard_deny")])
+ with audit.open("a", encoding="utf-8") as handle:
+ handle.write("not json\n")
+ handle.write("\n")
+ _seed(audit, [("b", "hard_deny")])
+ event_id = _hard_deny_ids(audit)[-1]
+
+ api = _api_with_reviews(tmp_path, audit)
+ assert (
+ api.record_review({"event_id": event_id, "verdict": "true_positive", "reviewer": "r"})[0]
+ == 200
+ )
+
+
+def test_an_event_without_a_decision_object_reports_unknown(tmp_path: Path) -> None:
+ audit = tmp_path / "decisions.jsonl"
+ audit.write_text(json.dumps({"event_id": "bare", "actor": "a"}) + "\n", encoding="utf-8")
+
+ code, body = _api_with_reviews(tmp_path, audit).record_review(
+ {"event_id": "bare", "verdict": "true_positive", "reviewer": "r"}
+ )
+ assert code == 400
+ assert "is `unknown`" in body["error"]
+
+
+def test_the_review_route_fails_when_the_api_is_not_configured() -> None:
+ import io
+
+ from aetherya.api_server import AetheryaHTTPRequestHandler
+
+ class _Bare(AetheryaHTTPRequestHandler):
+ def __init__(self) -> None:
+ self.api = None
+ self.command = "POST"
+ self.path = "/v1/reviews"
+ self.headers = {} # type: ignore[assignment]
+ self.sent: list[tuple[int, dict[str, Any]]] = []
+ self.wfile = io.BytesIO()
+
+ def _send_json(self, status: int, body: dict[str, Any]) -> None: # type: ignore[override]
+ self.sent.append((status, body))
+
+ handler = _Bare()
+ handler._handle_request() # noqa: SLF001
+ assert handler.sent[0][0] == 500
diff --git a/tests/test_gateway.py b/tests/test_gateway.py
new file mode 100644
index 0000000..7a74f2d
--- /dev/null
+++ b/tests/test_gateway.py
@@ -0,0 +1,757 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from aetherya.actions import ActionRequest
+from aetherya.audit import AuditLogger
+from aetherya.config import load_policy_config
+from aetherya.constitution import Constitution, Principle
+from aetherya.gateway import (
+ AetheryaGateway,
+ GatewaySettings,
+ UpstreamError,
+ tool_call_to_action,
+)
+
+# ---------------------------------------------------------------------------
+# The upstream is faked throughout. What is under test is the boundary — which
+# tool calls survive it and what the agent is told — not OpenAI's or
+# Anthropic's servers. The live path needs a real key and is exercised by
+# scripts/gateway_smoke.py.
+# ---------------------------------------------------------------------------
+
+
+def _constitution() -> Constitution:
+ return Constitution(
+ [
+ Principle(
+ "SystemIntegrity",
+ "Protect critical system operations",
+ priority=1,
+ keywords=["rm -rf /", "mkfs", "drop table"],
+ risk=92,
+ )
+ ],
+ use_semantic=False,
+ )
+
+
+def _completion(tool_calls: list[dict[str, Any]] | None = None, content: str | None = None) -> Any:
+ message: dict[str, Any] = {"role": "assistant", "content": content}
+ if tool_calls:
+ message["tool_calls"] = tool_calls
+ return {
+ "id": "chatcmpl-test",
+ "object": "chat.completion",
+ "created": 1,
+ "model": "gpt-4o-mini",
+ "choices": [
+ {
+ "index": 0,
+ "message": message,
+ "finish_reason": "tool_calls" if tool_calls else "stop",
+ }
+ ],
+ }
+
+
+def _call(name: str, arguments: dict[str, Any], call_id: str = "call_1") -> dict[str, Any]:
+ return {
+ "id": call_id,
+ "type": "function",
+ "function": {"name": name, "arguments": json.dumps(arguments)},
+ }
+
+
+class _FakeOpenAI:
+ """Minimal stand-in for `openai.OpenAI` shaped like the call sites use it."""
+
+ def __init__(self, response: Any) -> None:
+ self._response = response
+ self.requests: list[dict[str, Any]] = []
+ self.chat = self # type: ignore[assignment]
+ self.completions = self # type: ignore[assignment]
+
+ def create(self, **kwargs: Any) -> Any:
+ self.requests.append(kwargs)
+ if isinstance(self._response, Exception):
+ raise self._response
+ return self._response
+
+
+def _gateway(
+ upstream: Any,
+ *,
+ phase: int = 1,
+ audit: AuditLogger | None = None,
+ provider: str = "openai",
+ actor: str = "openclaw",
+) -> AetheryaGateway:
+ return AetheryaGateway(
+ GatewaySettings(provider=provider, model="gpt-4o-mini", actor=actor, phase=phase),
+ constitution=_constitution(),
+ cfg=load_policy_config(Path("config/policy.yaml")),
+ audit=audit,
+ upstream=upstream,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Tool call -> ActionRequest
+# ---------------------------------------------------------------------------
+
+
+def test_a_tool_call_becomes_a_structured_action() -> None:
+ action = tool_call_to_action("shell", {"command": "ls -la"})
+ assert action.tool == "shell"
+ assert action.intent == "operate"
+ assert action.parameters == {"command": "ls -la"}
+
+
+def test_argument_values_reach_raw_input() -> None:
+ """
+ The guards read `raw_input`. A destructive command that only ever appears
+ inside an argument must still be visible to them.
+ """
+ action = tool_call_to_action("shell", {"command": "rm -rf /"})
+ assert "rm -rf /" in action.raw_input
+
+
+def test_path_and_target_arguments_populate_target() -> None:
+ assert tool_call_to_action("read", {"path": "/etc/shadow"}).target == "/etc/shadow"
+ assert tool_call_to_action("http", {"target": "example.com"}).target == "example.com"
+
+
+def test_a_tool_call_without_a_target_argument_has_no_target() -> None:
+ assert tool_call_to_action("noop", {}).target is None
+
+
+def test_none_arguments_are_omitted_from_raw_input() -> None:
+ action = tool_call_to_action("shell", {"command": "ls", "cwd": None})
+ assert action.raw_input == "shell ls"
+
+
+def test_the_generated_action_is_valid() -> None:
+ tool_call_to_action("shell", {"command": "ls"}).validate()
+
+
+# ---------------------------------------------------------------------------
+# Argument parsing
+# ---------------------------------------------------------------------------
+
+
+def test_malformed_arguments_are_still_gated() -> None:
+ """
+ Dropping unparseable arguments would silently skip the check. They are
+ passed through as raw text, which is what a guard needs to see anyway.
+ """
+ gateway = _gateway(_FakeOpenAI(_completion()))
+ gated = gateway.gate_tool_calls(
+ [{"id": "c", "function": {"name": "shell", "arguments": "{not json"}}]
+ )
+ assert gated[0].arguments == {"_raw": "{not json"}
+
+
+def test_a_destructive_command_in_malformed_arguments_is_still_caught() -> None:
+ gateway = _gateway(_FakeOpenAI(_completion()), phase=2)
+ gated = gateway.gate_tool_calls(
+ [{"id": "c", "function": {"name": "shell", "arguments": '{"cmd": "rm -rf /"'}}]
+ )
+ assert gated[0].state == "hard_deny"
+ assert gated[0].execute is False
+
+
+def test_arguments_may_arrive_as_an_object() -> None:
+ gateway = _gateway(_FakeOpenAI(_completion()))
+ gated = gateway.gate_tool_calls(
+ [{"id": "c", "function": {"name": "shell", "arguments": {"command": "ls"}}}]
+ )
+ assert gated[0].arguments == {"command": "ls"}
+
+
+def test_empty_and_non_object_arguments_are_tolerated() -> None:
+ gateway = _gateway(_FakeOpenAI(_completion()))
+ calls = [
+ {"id": "a", "function": {"name": "t", "arguments": ""}},
+ {"id": "b", "function": {"name": "t", "arguments": None}},
+ {"id": "c", "function": {"name": "t", "arguments": "[1, 2]"}},
+ ]
+ gated = gateway.gate_tool_calls(calls)
+ assert gated[0].arguments == {}
+ assert gated[1].arguments == {}
+ assert gated[2].arguments == {"_value": [1, 2]}
+
+
+# ---------------------------------------------------------------------------
+# Phase behaviour
+# ---------------------------------------------------------------------------
+
+
+def test_phase_1_records_a_destructive_call_but_lets_it_through() -> None:
+ """Shadow mode must not change what the agent does — that is its whole point."""
+ upstream = _FakeOpenAI(_completion([_call("shell", {"command": "rm -rf /"})]))
+ result = _gateway(upstream, phase=1).complete({"messages": []})
+
+ assert result["choices"][0]["message"]["tool_calls"]
+ trace = result["aetherya"]
+ assert trace["phase"] == 1
+ assert trace["refused"] == 0
+ assert trace["shadow_gap"] == 1
+ assert trace["gated"][0]["state"] == "hard_deny"
+
+
+def test_phase_2_removes_the_refused_call() -> None:
+ upstream = _FakeOpenAI(_completion([_call("shell", {"command": "rm -rf /"})]))
+ result = _gateway(upstream, phase=2).complete({"messages": []})
+
+ message = result["choices"][0]["message"]
+ assert "tool_calls" not in message
+ assert result["choices"][0]["finish_reason"] == "stop"
+ assert result["aetherya"]["refused"] == 1
+
+
+def test_the_model_is_told_why_a_call_was_refused() -> None:
+ """
+ A refusal delivered as a silently dropped call just gets retried. The
+ reason goes into the transcript so the model can replan.
+ """
+ upstream = _FakeOpenAI(_completion([_call("shell", {"command": "rm -rf /"})]))
+ result = _gateway(upstream, phase=2).complete({"messages": []})
+
+ content = result["choices"][0]["message"]["content"]
+ assert "ÆTHERYA" in content
+ assert "Refused `shell`" in content
+
+
+def test_existing_assistant_text_is_preserved_alongside_the_refusal() -> None:
+ upstream = _FakeOpenAI(
+ _completion([_call("shell", {"command": "rm -rf /"})], content="Cleaning up now.")
+ )
+ content = _gateway(upstream, phase=2).complete({"messages": []})["choices"][0]["message"][
+ "content"
+ ]
+ assert content.startswith("Cleaning up now.")
+ assert "Refused" in content
+
+
+def test_allowed_calls_survive_when_a_sibling_is_refused() -> None:
+ """A batch must not be refused wholesale because one member was."""
+ upstream = _FakeOpenAI(
+ _completion(
+ [
+ _call("shell", {"command": "ls -la"}, "ok"),
+ _call("shell", {"command": "rm -rf /"}, "bad"),
+ ]
+ )
+ )
+ result = _gateway(upstream, phase=2).complete({"messages": []})
+
+ kept = result["choices"][0]["message"]["tool_calls"]
+ assert [c["id"] for c in kept] == ["ok"]
+ assert result["choices"][0]["finish_reason"] == "tool_calls"
+
+
+def test_a_clean_response_is_returned_untouched_apart_from_the_trace() -> None:
+ upstream = _FakeOpenAI(_completion([_call("shell", {"command": "ls -la"})]))
+ result = _gateway(upstream, phase=3, actor="robert").complete({"messages": []})
+
+ assert result["choices"][0]["message"]["tool_calls"]
+ assert result["aetherya"]["refused"] == 0
+
+
+def test_an_actor_absent_from_the_capability_matrix_is_denied_everything() -> None:
+ """
+ The trap this gateway makes easy to fall into: point it at a policy whose
+ capability matrix has never heard of your agent, and phase 3 denies every
+ single call. Recorded here because the behaviour is correct and the
+ failure mode is silent until phase 3.
+ """
+ upstream = _FakeOpenAI(_completion([_call("shell", {"command": "ls -la"})]))
+ result = _gateway(upstream, phase=3, actor="nobody").complete({"messages": []})
+
+ assert result["aetherya"]["refused"] == 1
+ assert "unknown actor" in result["aetherya"]["gated"][0]["reason"]
+
+
+def test_a_response_without_tool_calls_is_passed_straight_through() -> None:
+ upstream = _FakeOpenAI(_completion(content="Just answering."))
+ result = _gateway(upstream, phase=3).complete({"messages": []})
+
+ assert "aetherya" not in result
+ assert result["choices"][0]["message"]["content"] == "Just answering."
+
+
+def test_a_response_with_no_choices_is_passed_through() -> None:
+ result = _gateway(_FakeOpenAI({"id": "x", "choices": []})).complete({"messages": []})
+ assert result["choices"] == []
+
+
+# ---------------------------------------------------------------------------
+# Audit
+# ---------------------------------------------------------------------------
+
+
+def test_every_gated_call_is_written_to_the_audit_trail(tmp_path: Path) -> None:
+ audit_path = tmp_path / "decisions.jsonl"
+ upstream = _FakeOpenAI(
+ _completion(
+ [
+ _call("shell", {"command": "ls -la"}, "1"),
+ _call("shell", {"command": "rm -rf /"}, "2"),
+ ]
+ )
+ )
+ _gateway(upstream, phase=2, audit=AuditLogger(str(audit_path))).complete({"messages": []})
+
+ rows = [json.loads(line) for line in audit_path.read_text(encoding="utf-8").splitlines()]
+ assert len(rows) == 2
+ assert {row["actor"] for row in rows} == {"openclaw"}
+
+
+# ---------------------------------------------------------------------------
+# Upstream selection and failure
+# ---------------------------------------------------------------------------
+
+
+def test_a_response_whose_message_is_not_an_object_is_passed_through() -> None:
+ result = _gateway(_FakeOpenAI({"choices": [{"index": 0, "message": "oops"}]})).complete(
+ {"messages": []}
+ )
+ assert result["choices"][0]["message"] == "oops"
+
+
+def test_a_response_whose_choice_is_not_an_object_is_passed_through() -> None:
+ result = _gateway(_FakeOpenAI({"choices": ["oops"]})).complete({"messages": []})
+ assert result["choices"] == ["oops"]
+
+
+def test_the_real_client_is_constructed_once_and_reused(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """
+ Constructing an SDK client per request would open a connection pool per
+ request. The gateway is built once per process for the same reason.
+ """
+ built: list[int] = []
+
+ class _SDK:
+ class OpenAI:
+ def __init__(self) -> None:
+ built.append(1)
+ self.chat = self
+ self.completions = self
+
+ def create(self, **kwargs: Any) -> Any:
+ return _completion(content="hi")
+
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
+ monkeypatch.setattr("importlib.import_module", lambda name: _SDK)
+
+ gateway = AetheryaGateway(
+ GatewaySettings(provider="openai", model="gpt-4o-mini", actor="robert"),
+ constitution=_constitution(),
+ cfg=load_policy_config(Path("config/policy.yaml")),
+ )
+ gateway.complete({"messages": []})
+ gateway.complete({"messages": []})
+ assert built == [1]
+
+
+def test_a_missing_sdk_names_the_extra_that_installs_it(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
+
+ def _missing(name: str) -> Any:
+ raise ImportError(name)
+
+ monkeypatch.setattr("importlib.import_module", _missing)
+ with pytest.raises(UpstreamError, match=r'pip install -e "\.\[anthropic\]"'):
+ _gateway(None, provider="anthropic").complete({"messages": []})
+
+
+def test_an_sdk_without_the_expected_client_class_is_reported(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Guards against a shadowing `openai.py` on the path pretending to be the SDK."""
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
+ monkeypatch.setattr("importlib.import_module", lambda name: object())
+ with pytest.raises(UpstreamError, match="does not expose OpenAI"):
+ _gateway(None).complete({"messages": []})
+
+
+def test_an_unsupported_provider_is_rejected() -> None:
+ gateway = _gateway(None, provider="cohere")
+ with pytest.raises(UpstreamError, match="unsupported provider"):
+ gateway.complete({"messages": []})
+
+
+def test_a_missing_openai_key_is_reported_as_an_upstream_error(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+ with pytest.raises(UpstreamError, match="OPENAI_API_KEY"):
+ _gateway(None).complete({"messages": []})
+
+
+def test_a_missing_anthropic_key_is_reported_as_an_upstream_error(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
+ with pytest.raises(UpstreamError, match="ANTHROPIC_API_KEY"):
+ _gateway(None, provider="anthropic").complete({"messages": []})
+
+
+def test_an_upstream_exception_is_wrapped(monkeypatch: pytest.MonkeyPatch) -> None:
+ """
+ The caller must be able to tell a boundary failure from a model failure;
+ an agent that confuses them retries the wrong thing.
+ """
+ gateway = _gateway(_FakeOpenAI(RuntimeError("connection reset")))
+ with pytest.raises(UpstreamError, match="RuntimeError: connection reset"):
+ gateway.complete({"messages": []})
+
+
+def test_the_stream_flag_is_not_forwarded_upstream() -> None:
+ """We buffer to gate; asking the upstream to stream would defeat that."""
+ upstream = _FakeOpenAI(_completion(content="hi"))
+ _gateway(upstream).complete({"messages": [], "stream": True})
+ assert "stream" not in upstream.requests[0]
+
+
+def test_the_configured_model_is_used_when_the_body_omits_one() -> None:
+ upstream = _FakeOpenAI(_completion(content="hi"))
+ _gateway(upstream).complete({"messages": []})
+ assert upstream.requests[0]["model"] == "gpt-4o-mini"
+
+
+def test_the_body_model_wins_over_the_configured_default() -> None:
+ upstream = _FakeOpenAI(_completion(content="hi"))
+ _gateway(upstream).complete({"messages": [], "model": "gpt-4o"})
+ assert upstream.requests[0]["model"] == "gpt-4o"
+
+
+def test_a_pydantic_style_response_is_unwrapped() -> None:
+ class _Model:
+ def model_dump(self) -> dict[str, Any]:
+ return _completion(content="hi")
+
+ result = _gateway(_FakeOpenAI(_Model())).complete({"messages": []})
+ assert result["choices"][0]["message"]["content"] == "hi"
+
+
+# ---------------------------------------------------------------------------
+# Streaming shape
+# ---------------------------------------------------------------------------
+
+
+def test_streaming_emits_a_well_formed_frame_sequence() -> None:
+ upstream = _FakeOpenAI(_completion([_call("shell", {"command": "ls -la"})], content="reading"))
+ frames = _gateway(upstream).stream({"messages": [], "stream": True})
+
+ assert frames[0]["choices"][0]["delta"] == {"role": "assistant"}
+ assert frames[1]["choices"][0]["delta"]["content"] == "reading"
+ assert frames[2]["choices"][0]["delta"]["tool_calls"]
+ assert frames[-1]["choices"][0]["finish_reason"] == "tool_calls"
+ assert {f["object"] for f in frames} == {"chat.completion.chunk"}
+
+
+def test_streaming_a_refusal_carries_the_reason() -> None:
+ upstream = _FakeOpenAI(_completion([_call("shell", {"command": "rm -rf /"})]))
+ frames = _gateway(upstream, phase=2).stream({"messages": [], "stream": True})
+
+ text = "".join(str(f["choices"][0]["delta"].get("content", "")) for f in frames)
+ assert "Refused `shell`" in text
+ assert frames[-1]["choices"][0]["finish_reason"] == "stop"
+
+
+def test_streaming_a_response_with_no_choices_still_terminates() -> None:
+ frames = _gateway(_FakeOpenAI({"id": "x", "choices": []})).stream({"messages": []})
+ assert frames[-1]["choices"][0]["finish_reason"] == "stop"
+
+
+# ---------------------------------------------------------------------------
+# Anthropic translation
+# ---------------------------------------------------------------------------
+
+
+class _Block:
+ def __init__(self, **fields: Any) -> None:
+ self.__dict__.update(fields)
+
+
+class _AnthropicResponse:
+ def __init__(self, content: list[Any], stop_reason: str = "end_turn") -> None:
+ self.id = "msg_1"
+ self.model = "claude-opus-5"
+ self.content = content
+ self.stop_reason = stop_reason
+ self.usage = _Block(input_tokens=10, output_tokens=5)
+
+
+class _FakeAnthropic:
+ def __init__(self, response: Any) -> None:
+ self._response = response
+ self.requests: list[dict[str, Any]] = []
+ self.messages = self # type: ignore[assignment]
+
+ def create(self, **kwargs: Any) -> Any:
+ self.requests.append(kwargs)
+ return self._response
+
+
+def test_anthropic_tool_use_becomes_an_openai_tool_call() -> None:
+ upstream = _FakeAnthropic(
+ _AnthropicResponse(
+ [
+ _Block(type="text", text="Deleting."),
+ _Block(type="tool_use", id="toolu_1", name="shell", input={"command": "rm -rf /"}),
+ ],
+ stop_reason="tool_use",
+ )
+ )
+ result = _gateway(upstream, phase=2, provider="anthropic").complete({"messages": []})
+
+ assert result["aetherya"]["refused"] == 1
+ assert result["usage"]["total_tokens"] == 15
+
+
+def test_anthropic_text_only_responses_translate() -> None:
+ upstream = _FakeAnthropic(_AnthropicResponse([_Block(type="text", text="Hello")]))
+ result = _gateway(upstream, provider="anthropic").complete({"messages": []})
+
+ assert result["choices"][0]["message"]["content"] == "Hello"
+ assert result["choices"][0]["finish_reason"] == "stop"
+
+
+def test_anthropic_requests_use_adaptive_thinking_and_no_sampling_params() -> None:
+ """
+ Claude Opus 5 rejects `budget_tokens`, `temperature`, `top_p` and `top_k`
+ with a 400. Forwarding them from the chat-completions body would break
+ every request.
+ """
+ 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}
+ )
+
+ request = upstream.requests[0]
+ assert request["thinking"] == {"type": "adaptive"}
+ for rejected in ("temperature", "top_p", "top_k", "budget_tokens"):
+ assert rejected not in request
+
+
+def test_system_messages_are_hoisted_to_the_system_parameter() -> None:
+ upstream = _FakeAnthropic(_AnthropicResponse([_Block(type="text", text="ok")]))
+ _gateway(upstream, provider="anthropic").complete(
+ {
+ "messages": [
+ {"role": "system", "content": "Be terse."},
+ {"role": "system", "content": "Be safe."},
+ {"role": "user", "content": "hi"},
+ ]
+ }
+ )
+
+ request = upstream.requests[0]
+ assert request["system"] == "Be terse.\n\nBe safe."
+ assert [m["role"] for m in request["messages"]] == ["user"]
+
+
+def test_tool_results_are_translated_to_anthropic_blocks() -> None:
+ """
+ The agent's next turn carries the tool output. Without this translation the
+ conversation would break on the second round trip.
+ """
+ upstream = _FakeAnthropic(_AnthropicResponse([_Block(type="text", text="ok")]))
+ _gateway(upstream, provider="anthropic").complete(
+ {
+ "messages": [
+ {"role": "user", "content": "list files"},
+ {
+ "role": "assistant",
+ "content": "calling",
+ "tool_calls": [_call("shell", {"command": "ls"}, "toolu_1")],
+ },
+ {"role": "tool", "tool_call_id": "toolu_1", "content": "a.txt"},
+ ]
+ }
+ )
+
+ messages = upstream.requests[0]["messages"]
+ assistant = messages[1]["content"]
+ assert assistant[0] == {"type": "text", "text": "calling"}
+ assert assistant[1]["type"] == "tool_use"
+ assert assistant[1]["input"] == {"command": "ls"}
+ assert messages[2]["content"][0] == {
+ "type": "tool_result",
+ "tool_use_id": "toolu_1",
+ "content": "a.txt",
+ }
+
+
+def test_tool_schemas_are_translated() -> None:
+ upstream = _FakeAnthropic(_AnthropicResponse([_Block(type="text", text="ok")]))
+ schema = {"type": "object", "properties": {"command": {"type": "string"}}}
+ _gateway(upstream, provider="anthropic").complete(
+ {
+ "messages": [{"role": "user", "content": "hi"}],
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "name": "shell",
+ "description": "Run a command",
+ "parameters": schema,
+ },
+ }
+ ],
+ }
+ )
+
+ assert upstream.requests[0]["tools"] == [
+ {"name": "shell", "description": "Run a command", "input_schema": schema}
+ ]
+
+
+def test_a_tool_without_a_schema_gets_an_empty_object_schema() -> None:
+ """Anthropic requires `input_schema`; omitting it is a 400."""
+ upstream = _FakeAnthropic(_AnthropicResponse([_Block(type="text", text="ok")]))
+ _gateway(upstream, provider="anthropic").complete(
+ {"messages": [], "tools": [{"type": "function", "function": {"name": "noop"}}]}
+ )
+
+ assert upstream.requests[0]["tools"][0]["input_schema"] == {"type": "object", "properties": {}}
+
+
+def test_no_tools_means_no_tools_key() -> None:
+ upstream = _FakeAnthropic(_AnthropicResponse([_Block(type="text", text="ok")]))
+ _gateway(upstream, provider="anthropic").complete({"messages": [], "tools": []})
+ assert "tools" not in upstream.requests[0]
+
+
+def test_malformed_messages_do_not_crash_the_translation() -> None:
+ upstream = _FakeAnthropic(_AnthropicResponse([_Block(type="text", text="ok")]))
+ _gateway(upstream, provider="anthropic").complete({"messages": ["not a dict", None]})
+ assert upstream.requests[0]["messages"] == [{"role": "user", "content": ""}] * 2
+
+
+def test_a_non_dict_body_is_tolerated() -> None:
+ upstream = _FakeAnthropic(_AnthropicResponse([_Block(type="text", text="ok")]))
+ result = _gateway(upstream, provider="anthropic").complete([]) # type: ignore[arg-type]
+ assert result["choices"][0]["message"]["content"] == "ok"
+
+
+def test_the_effort_level_is_configurable() -> None:
+ upstream = _FakeAnthropic(_AnthropicResponse([_Block(type="text", text="ok")]))
+ gateway = AetheryaGateway(
+ GatewaySettings(provider="anthropic", model="claude-opus-5", anthropic_effort="medium"),
+ constitution=_constitution(),
+ cfg=load_policy_config(Path("config/policy.yaml")),
+ upstream=upstream,
+ )
+ gateway.complete({"messages": []})
+ assert upstream.requests[0]["output_config"] == {"effort": "medium"}
+
+
+def test_the_action_contract_is_what_the_engine_receives() -> None:
+ """Guard against the mapping drifting away from the engine's contract."""
+ action = tool_call_to_action("shell", {"command": "ls"})
+ assert isinstance(action, ActionRequest)
+ assert action.mode_hint == "operative"
+
+
+def test_an_empty_system_message_does_not_produce_an_empty_system_block() -> None:
+ upstream = _FakeAnthropic(_AnthropicResponse([_Block(type="text", text="ok")]))
+ _gateway(upstream, provider="anthropic").complete(
+ {"messages": [{"role": "system", "content": ""}, {"role": "user", "content": "hi"}]}
+ )
+ assert "system" not in upstream.requests[0]
+
+
+def test_an_assistant_tool_call_without_text_omits_the_text_block() -> None:
+ upstream = _FakeAnthropic(_AnthropicResponse([_Block(type="text", text="ok")]))
+ _gateway(upstream, provider="anthropic").complete(
+ {
+ "messages": [
+ {"role": "assistant", "content": None, "tool_calls": [_call("shell", {"c": "ls"})]}
+ ]
+ }
+ )
+ blocks = upstream.requests[0]["messages"][0]["content"]
+ assert [b["type"] for b in blocks] == ["tool_use"]
+
+
+def test_unknown_anthropic_block_types_are_ignored() -> None:
+ """
+ Adaptive thinking emits `thinking` blocks. They are not part of the
+ chat-completions shape and must not leak into the agent's transcript.
+ """
+ upstream = _FakeAnthropic(
+ _AnthropicResponse(
+ [
+ _Block(type="thinking", thinking="deliberating"),
+ _Block(type="text", text="answer"),
+ ]
+ )
+ )
+ result = _gateway(upstream, provider="anthropic").complete({"messages": []})
+ assert result["choices"][0]["message"]["content"] == "answer"
+
+
+def test_a_malformed_tool_call_entry_is_tolerated_in_translation() -> None:
+ upstream = _FakeAnthropic(_AnthropicResponse([_Block(type="text", text="ok")]))
+ _gateway(upstream, provider="anthropic").complete(
+ {"messages": [{"role": "assistant", "content": "x", "tool_calls": ["not a dict"]}]}
+ )
+ blocks = upstream.requests[0]["messages"][0]["content"]
+ assert blocks[1] == {"type": "tool_use", "id": "", "name": "", "input": {}}
+
+
+def test_streamed_tool_calls_carry_their_index() -> None:
+ """
+ Clients reassemble streaming tool calls by index. Two calls emitted without
+ one get merged into a single malformed call.
+ """
+ upstream = _FakeOpenAI(
+ _completion([_call("shell", {"command": "ls"}, "a"), _call("http", {"url": "x"}, "b")])
+ )
+ frames = _gateway(upstream).stream({"messages": []})
+ calls = next(f for f in frames if f["choices"][0]["delta"].get("tool_calls"))["choices"][0][
+ "delta"
+ ]["tool_calls"]
+ assert [c["index"] for c in calls] == [0, 1]
+ assert [c["id"] for c in calls] == ["a", "b"]
+
+
+def test_an_escalation_is_refused_rather_than_held() -> None:
+ """
+ A single chat-completions call has no approval round trip, so phase 3
+ cannot hold an action for a human — it refuses it. Documented in
+ docs/gateway-openclaw.md; asserted here so the two cannot drift.
+ """
+ call = _call("filesystem", {"path": "/etc/hosts", "operation": "write"})
+ result = _gateway(_FakeOpenAI(_completion([call])), phase=3, actor="robert").complete(
+ {"messages": []}
+ )
+
+ trace = result["aetherya"]["gated"][0]
+ assert trace["state"] == "escalate"
+ assert trace["executed"] is False
+ assert "Refused `filesystem`" in result["choices"][0]["message"]["content"]
+
+
+def test_the_same_escalation_executes_in_phase_2() -> None:
+ """The pair above and below is the whole reason phases exist."""
+ call = _call("filesystem", {"path": "/etc/hosts", "operation": "write"})
+ result = _gateway(_FakeOpenAI(_completion([call])), phase=2, actor="robert").complete(
+ {"messages": []}
+ )
+
+ assert result["aetherya"]["gated"][0]["state"] == "escalate"
+ assert result["choices"][0]["message"]["tool_calls"]
+ assert result["aetherya"]["shadow_gap"] == 1
diff --git a/tests/test_gateway_server.py b/tests/test_gateway_server.py
new file mode 100644
index 0000000..6312dc1
--- /dev/null
+++ b/tests/test_gateway_server.py
@@ -0,0 +1,466 @@
+from __future__ import annotations
+
+import json
+import threading
+import urllib.error
+import urllib.request
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from aetherya.config import load_policy_config
+from aetherya.constitution import Constitution, Principle
+from aetherya.gateway import AetheryaGateway, GatewaySettings, UpstreamError
+from aetherya.gateway_server import (
+ build_gateway,
+ build_gateway_server,
+ check_actor_is_known,
+ main,
+)
+
+POLICY = Path("config/policy.yaml")
+
+
+class _FakeOpenAI:
+ def __init__(self, response: Any) -> None:
+ self._response = response
+ self.chat = self # type: ignore[assignment]
+ self.completions = self # type: ignore[assignment]
+
+ def create(self, **kwargs: Any) -> Any:
+ if isinstance(self._response, Exception):
+ raise self._response
+ return self._response
+
+
+def _completion(tool_calls: list[dict[str, Any]] | None = None, content: str | None = None) -> Any:
+ message: dict[str, Any] = {"role": "assistant", "content": content}
+ if tool_calls:
+ message["tool_calls"] = tool_calls
+ return {
+ "id": "chatcmpl-test",
+ "object": "chat.completion",
+ "created": 1,
+ "model": "gpt-4o-mini",
+ "choices": [{"index": 0, "message": message, "finish_reason": "stop"}],
+ }
+
+
+def _gateway(response: Any, phase: int = 2) -> AetheryaGateway:
+ return AetheryaGateway(
+ GatewaySettings(provider="openai", model="gpt-4o-mini", actor="robert", phase=phase),
+ constitution=Constitution(
+ [Principle("SystemIntegrity", "x", priority=1, keywords=["rm -rf /"], risk=92)],
+ use_semantic=False,
+ ),
+ cfg=load_policy_config(POLICY),
+ upstream=_FakeOpenAI(response),
+ )
+
+
+@pytest.fixture
+def server(request: pytest.FixtureRequest) -> Any:
+ gateway = getattr(request, "param", None) or _gateway(_completion(content="hi"))
+ srv = build_gateway_server("127.0.0.1", 0, gateway=gateway)
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ yield srv
+ srv.shutdown()
+ srv.server_close()
+
+
+def _request(srv: Any, path: str, body: Any = None, method: str = "GET") -> tuple[int, str]:
+ url = f"http://127.0.0.1:{srv.server_port}{path}"
+ data = json.dumps(body).encode("utf-8") if body is not None else None
+ req = urllib.request.Request( # noqa: S310
+ url, data=data, method=method, headers={"Content-Type": "application/json"}
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=10) as response: # noqa: S310
+ return response.status, response.read().decode("utf-8")
+ except urllib.error.HTTPError as exc:
+ return exc.code, exc.read().decode("utf-8")
+
+
+# ---------------------------------------------------------------------------
+# The surface an agent runtime probes before it will use a provider
+# ---------------------------------------------------------------------------
+
+
+def test_health_reports_the_provider_and_phase(server: Any) -> None:
+ status, body = _request(server, "/health")
+ payload = json.loads(body)
+ assert status == 200
+ assert payload["provider"] == "openai"
+ assert payload["phase_name"] == "hard_deny"
+
+
+def test_the_model_list_advertises_the_configured_model(server: Any) -> None:
+ """OpenClaw validates the model id against this list before it will call."""
+ status, body = _request(server, "/v1/models")
+ assert status == 200
+ assert json.loads(body)["data"][0]["id"] == "gpt-4o-mini"
+
+
+def test_both_the_prefixed_and_bare_paths_are_served(server: Any) -> None:
+ """Clients differ on whether they append /v1 to a configured baseUrl."""
+ assert _request(server, "/models")[0] == 200
+ assert _request(server, "/chat/completions", {"messages": []}, "POST")[0] == 200
+
+
+def test_a_trailing_slash_still_routes(server: Any) -> None:
+ assert _request(server, "/v1/models/")[0] == 200
+
+
+def test_an_unknown_path_is_a_404(server: Any) -> None:
+ for method, path in [("GET", "/nope"), ("POST", "/v1/embeddings")]:
+ status, body = _request(server, path, {} if method == "POST" else None, method)
+ assert status == 404
+ assert json.loads(body)["error"]["type"] == "not_found"
+
+
+# ---------------------------------------------------------------------------
+# Completions
+# ---------------------------------------------------------------------------
+
+
+def test_a_completion_round_trips(server: Any) -> None:
+ status, body = _request(server, "/v1/chat/completions", {"messages": []}, "POST")
+ assert status == 200
+ assert json.loads(body)["choices"][0]["message"]["content"] == "hi"
+
+
+@pytest.mark.parametrize(
+ "server",
+ [
+ _gateway(
+ _completion(
+ [
+ {
+ "id": "c1",
+ "type": "function",
+ "function": {
+ "name": "shell",
+ "arguments": '{"command": "rm -rf /"}',
+ },
+ }
+ ]
+ )
+ )
+ ],
+ indirect=True,
+)
+def test_a_refused_tool_call_does_not_reach_the_client(server: Any) -> None:
+ status, body = _request(server, "/v1/chat/completions", {"messages": []}, "POST")
+ payload = json.loads(body)
+ assert status == 200
+ assert "tool_calls" not in payload["choices"][0]["message"]
+ assert payload["aetherya"]["refused"] == 1
+
+
+def test_a_streaming_request_gets_sse_frames_terminated_by_done(server: Any) -> None:
+ status, body = _request(
+ server, "/v1/chat/completions", {"messages": [], "stream": True}, "POST"
+ )
+ assert status == 200
+ assert body.rstrip().endswith("data: [DONE]")
+ frames = [
+ json.loads(line[6:])
+ for line in body.splitlines()
+ if line.startswith("data: ") and line != "data: [DONE]"
+ ]
+ assert frames[-1]["choices"][0]["finish_reason"] == "stop"
+
+
+# ---------------------------------------------------------------------------
+# Failure handling
+# ---------------------------------------------------------------------------
+
+
+def test_an_invalid_json_body_is_a_400(server: Any) -> None:
+ url = f"http://127.0.0.1:{server.server_port}/v1/chat/completions"
+ req = urllib.request.Request(url, data=b"{oops", method="POST") # noqa: S310
+ with pytest.raises(urllib.error.HTTPError) as exc:
+ urllib.request.urlopen(req, timeout=10) # noqa: S310
+ assert exc.value.code == 400
+
+
+def test_a_non_object_body_is_a_400(server: Any) -> None:
+ status, body = _request(server, "/v1/chat/completions", [1, 2, 3], "POST")
+ assert status == 400
+ assert "JSON object" in json.loads(body)["error"]["message"]
+
+
+def test_an_oversized_body_is_rejected_before_it_is_read(server: Any) -> None:
+ url = f"http://127.0.0.1:{server.server_port}/v1/chat/completions"
+ req = urllib.request.Request( # noqa: S310
+ url, data=b"{}", method="POST", headers={"Content-Length": "99999999"}
+ )
+ with pytest.raises(urllib.error.HTTPError) as exc:
+ urllib.request.urlopen(req, timeout=10) # noqa: S310
+ assert exc.value.code == 413
+
+
+@pytest.mark.parametrize("server", [_gateway(RuntimeError("connection reset"))], indirect=True)
+def test_an_upstream_failure_is_a_502_not_a_500(server: Any) -> None:
+ """
+ An agent that cannot tell the boundary failing from the model failing will
+ retry the wrong one. 502 says the hop beyond us broke.
+ """
+ status, body = _request(server, "/v1/chat/completions", {"messages": []}, "POST")
+ assert status == 502
+ assert json.loads(body)["error"]["type"] == "upstream_error"
+
+
+def test_an_internal_failure_is_a_500(server: Any) -> None:
+ class _Exploding(AetheryaGateway):
+ def complete(self, body: dict[str, Any]) -> dict[str, Any]:
+ raise ZeroDivisionError("boom")
+
+ srv = build_gateway_server(
+ "127.0.0.1",
+ 0,
+ gateway=_Exploding(
+ GatewaySettings(),
+ constitution=Constitution([], use_semantic=False),
+ cfg=load_policy_config(POLICY),
+ ),
+ )
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ try:
+ status, body = _request(srv, "/v1/chat/completions", {"messages": []}, "POST")
+ assert status == 500
+ assert "ZeroDivisionError" in json.loads(body)["error"]["message"]
+ finally:
+ srv.shutdown()
+ srv.server_close()
+
+
+def test_an_unconfigured_handler_answers_rather_than_raising() -> None:
+ """Defensive: a bare handler must return 500, not kill the connection."""
+ import io
+
+ from aetherya.gateway_server import GatewayHTTPRequestHandler
+
+ class _Bare(GatewayHTTPRequestHandler):
+ def __init__(self) -> None:
+ self.gateway = None
+ self.path = "/v1/chat/completions"
+ self.headers = {} # type: ignore[assignment]
+ self.sent: list[tuple[int, dict[str, Any]]] = []
+ self.wfile = io.BytesIO()
+
+ def _send_json(self, status: int, payload: dict[str, Any]) -> None: # type: ignore[override]
+ self.sent.append((status, payload))
+
+ handler = _Bare()
+ handler.do_POST()
+ assert handler.sent[0][0] == 500
+
+ health = _Bare()
+ health.path = "/health"
+ health.do_GET()
+ assert health.sent[0][1]["ok"] is False
+
+
+def test_an_invalid_content_length_is_a_400() -> None:
+ import io
+
+ from aetherya.gateway_server import GatewayHTTPRequestHandler
+
+ class _Bad(GatewayHTTPRequestHandler):
+ def __init__(self) -> None:
+ self.gateway = _gateway(_completion(content="hi"))
+ self.path = "/v1/chat/completions"
+ self.headers = {"Content-Length": "abc"} # type: ignore[assignment]
+ self.sent: list[tuple[int, dict[str, Any]]] = []
+ self.wfile = io.BytesIO()
+
+ def _send_json(self, status: int, payload: dict[str, Any]) -> None: # type: ignore[override]
+ self.sent.append((status, payload))
+
+ handler = _Bad()
+ handler.do_POST()
+ assert handler.sent[0][0] == 400
+
+
+def test_request_logging_goes_to_stderr(capsys: pytest.CaptureFixture[str]) -> None:
+ import io
+
+ from aetherya.gateway_server import GatewayHTTPRequestHandler
+
+ class _Quiet(GatewayHTTPRequestHandler):
+ def __init__(self) -> None:
+ self.wfile = io.BytesIO()
+
+ def address_string(self) -> str:
+ return "1.2.3.4"
+
+ _Quiet().log_message("%s %s", "GET", "/health")
+ assert "1.2.3.4 GET /health" in capsys.readouterr().err
+
+
+# ---------------------------------------------------------------------------
+# Startup validation
+# ---------------------------------------------------------------------------
+
+
+def test_an_unknown_actor_is_fatal_at_phase_3() -> None:
+ cfg = load_policy_config(POLICY)
+ with pytest.raises(ValueError, match="not in capability_matrix"):
+ check_actor_is_known(cfg.capability_matrix, "openclaw", 3)
+
+
+def test_an_unknown_actor_only_warns_at_phase_1(capsys: pytest.CaptureFixture[str]) -> None:
+ """
+ Phase 1 does not enforce `deny`, so an unknown actor is harmless there —
+ but it is the thing that will brick phase 3, so say it early.
+ """
+ cfg = load_policy_config(POLICY)
+ check_actor_is_known(cfg.capability_matrix, "openclaw", 1)
+ assert "will be denied" in capsys.readouterr().err
+
+
+def test_a_known_actor_passes_at_every_phase(capsys: pytest.CaptureFixture[str]) -> None:
+ cfg = load_policy_config(POLICY)
+ for phase in (1, 2, 3):
+ check_actor_is_known(cfg.capability_matrix, "robert", phase)
+ assert capsys.readouterr().err == ""
+
+
+def test_the_check_is_skipped_when_the_matrix_would_allow_anyway() -> None:
+ class _Matrix:
+ enabled = False
+ default_allow = False
+ actors: dict[str, Any] = {}
+
+ check_actor_is_known(_Matrix(), "anyone", 3)
+
+ class _Open(_Matrix):
+ enabled = True
+ default_allow = True
+
+ check_actor_is_known(_Open(), "anyone", 3)
+
+
+# ---------------------------------------------------------------------------
+# Wiring
+# ---------------------------------------------------------------------------
+
+
+def test_a_gateway_is_built_from_the_policy_on_disk(tmp_path: Path) -> None:
+ gateway = build_gateway(
+ policy_path=POLICY,
+ constitution_path=None,
+ audit_path=tmp_path / "decisions.jsonl",
+ settings=GatewaySettings(actor="robert", phase=2),
+ )
+ assert gateway.phase.number == 2
+ assert gateway.audit is not None
+
+
+def test_a_custom_constitution_file_is_honoured(tmp_path: Path) -> None:
+ path = tmp_path / "constitution.yaml"
+ path.write_text(
+ "principles:\n"
+ " - name: OnlyRule\n"
+ " description: the only rule\n"
+ " priority: 1\n"
+ " keywords: ['forbidden']\n"
+ " risk: 99\n",
+ encoding="utf-8",
+ )
+ gateway = build_gateway(
+ policy_path=POLICY,
+ constitution_path=path,
+ audit_path=None,
+ settings=GatewaySettings(actor="robert"),
+ )
+ assert [p.name for p in gateway.constitution.principles] == ["OnlyRule"]
+ assert gateway.audit is None
+
+
+def test_the_cli_reports_its_configuration_and_serves(
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
+) -> None:
+ served: dict[str, Any] = {}
+
+ class _Server:
+ def serve_forever(self) -> None:
+ served["ran"] = True
+ raise KeyboardInterrupt
+
+ def server_close(self) -> None:
+ served["closed"] = True
+
+ monkeypatch.setattr("aetherya.gateway_server.build_gateway_server", lambda *a, **k: _Server())
+ code = main(
+ [
+ "--port",
+ "0",
+ "--actor",
+ "robert",
+ "--phase",
+ "2",
+ "--audit-path",
+ str(tmp_path / "a.jsonl"),
+ ]
+ )
+ assert code == 0
+ assert served == {"ran": True, "closed": True}
+ assert "phase 2 (hard_deny)" in capsys.readouterr().err
+
+
+def test_binding_to_a_public_interface_is_warned_about(
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
+) -> None:
+ """This process holds the upstream API key and authenticates nobody."""
+
+ class _Server:
+ def serve_forever(self) -> None:
+ raise KeyboardInterrupt
+
+ def server_close(self) -> None:
+ pass
+
+ monkeypatch.setattr("aetherya.gateway_server.build_gateway_server", lambda *a, **k: _Server())
+ public = "0.0.0.0" # noqa: S104 - the point of the test
+ main(["--host", public, "--actor", "robert", "--audit-path", str(tmp_path / "a.jsonl")])
+ assert "non-loopback" in capsys.readouterr().err
+
+
+def test_the_phase_defaults_to_the_policy(
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
+) -> None:
+ class _Server:
+ def serve_forever(self) -> None:
+ raise KeyboardInterrupt
+
+ def server_close(self) -> None:
+ pass
+
+ monkeypatch.setattr("aetherya.gateway_server.build_gateway_server", lambda *a, **k: _Server())
+ main(["--actor", "robert", "--audit-path", str(tmp_path / "a.jsonl")])
+ expected = load_policy_config(POLICY).enforcement.phase
+ assert f"phase {expected} " in capsys.readouterr().err
+
+
+def test_the_default_provider_is_anthropic_on_opus_5(
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
+) -> None:
+ class _Server:
+ def serve_forever(self) -> None:
+ raise KeyboardInterrupt
+
+ def server_close(self) -> None:
+ pass
+
+ monkeypatch.delenv("AETHERYA_GATEWAY_PROVIDER", raising=False)
+ monkeypatch.delenv("AETHERYA_GATEWAY_MODEL", raising=False)
+ monkeypatch.setattr("aetherya.gateway_server.build_gateway_server", lambda *a, **k: _Server())
+ main(["--actor", "robert", "--audit-path", str(tmp_path / "a.jsonl")])
+ assert "anthropic/claude-opus-5" in capsys.readouterr().err
+
+
+def test_the_upstream_error_type_is_exported() -> None:
+ assert issubclass(UpstreamError, RuntimeError)
diff --git a/tests/test_review_store.py b/tests/test_review_store.py
new file mode 100644
index 0000000..5625d9a
--- /dev/null
+++ b/tests/test_review_store.py
@@ -0,0 +1,236 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+from aetherya.audit import AuditLogger
+from aetherya.review_store import FALSE_POSITIVE, TRUE_POSITIVE, ReviewStore
+from aetherya.rollout_report import build_report
+
+
+def _store(tmp_path: Path) -> ReviewStore:
+ return ReviewStore(tmp_path / "reviews.jsonl")
+
+
+# ---------------------------------------------------------------------------
+# Recording
+# ---------------------------------------------------------------------------
+
+
+def test_a_verdict_round_trips(tmp_path: Path) -> None:
+ store = _store(tmp_path)
+ store.record("evt-1", TRUE_POSITIVE, reviewer="robert")
+
+ reviews = store.reviews()
+ assert reviews["evt-1"].verdict == TRUE_POSITIVE
+ assert reviews["evt-1"].reviewer == "robert"
+ assert reviews["evt-1"].ts
+
+
+def test_the_store_is_append_only(tmp_path: Path) -> None:
+ """
+ A review is a human act with consequences — it is what unblocks
+ enforcement. Overwriting would erase who said what and when.
+ """
+ store = _store(tmp_path)
+ store.record("evt-1", TRUE_POSITIVE, reviewer="alice")
+ store.record("evt-1", FALSE_POSITIVE, reviewer="bob", note="legitimate cleanup job")
+
+ assert len(store.history()) == 2
+ assert [r.reviewer for r in store.history()] == ["alice", "bob"]
+ # Latest wins on read.
+ assert store.reviews()["evt-1"].verdict == FALSE_POSITIVE
+
+
+def test_a_false_positive_must_explain_itself(tmp_path: Path) -> None:
+ """The note is what identifies the rule to narrow later."""
+ with pytest.raises(ValueError, match="must explain why"):
+ _store(tmp_path).record("evt-1", FALSE_POSITIVE, reviewer="robert")
+
+ with pytest.raises(ValueError, match="must explain why"):
+ _store(tmp_path).record("evt-1", FALSE_POSITIVE, reviewer="robert", note=" ")
+
+
+def test_a_true_positive_needs_no_note(tmp_path: Path) -> None:
+ assert _store(tmp_path).record("evt-1", TRUE_POSITIVE, reviewer="robert").note == ""
+
+
+@pytest.mark.parametrize(
+ ("event_id", "verdict", "reviewer", "match"),
+ [
+ ("", TRUE_POSITIVE, "robert", "event_id"),
+ (" ", TRUE_POSITIVE, "robert", "event_id"),
+ ("evt-1", "maybe", "robert", "verdict must be one of"),
+ ("evt-1", "", "robert", "verdict must be one of"),
+ ("evt-1", TRUE_POSITIVE, "", "reviewer"),
+ ],
+)
+def test_malformed_verdicts_are_rejected(
+ tmp_path: Path, event_id: str, verdict: str, reviewer: str, match: str
+) -> None:
+ with pytest.raises(ValueError, match=match):
+ _store(tmp_path).record(event_id, verdict, reviewer=reviewer)
+
+
+def test_a_long_note_is_truncated(tmp_path: Path) -> None:
+ review = _store(tmp_path).record("e", FALSE_POSITIVE, reviewer="r", note="x" * 5000)
+ assert len(review.note) == 2000
+
+
+def test_the_parent_directory_is_created(tmp_path: Path) -> None:
+ store = ReviewStore(tmp_path / "nested" / "deeper" / "reviews.jsonl")
+ store.record("evt-1", TRUE_POSITIVE, reviewer="robert")
+ assert store.path.exists()
+
+
+# ---------------------------------------------------------------------------
+# Reading
+# ---------------------------------------------------------------------------
+
+
+def test_a_missing_file_reads_as_empty(tmp_path: Path) -> None:
+ store = _store(tmp_path)
+ assert store.reviews() == {}
+ assert store.history() == []
+
+
+def test_malformed_lines_are_skipped(tmp_path: Path) -> None:
+ store = _store(tmp_path)
+ store.record("evt-1", TRUE_POSITIVE, reviewer="robert")
+ with store.path.open("a", encoding="utf-8") as handle:
+ handle.write("not json\n")
+ handle.write("\n")
+ handle.write('"bare string"\n')
+ handle.write(json.dumps({"event_id": "", "verdict": TRUE_POSITIVE}) + "\n")
+ handle.write(json.dumps({"event_id": "evt-2", "verdict": "nonsense"}) + "\n")
+
+ assert list(store.reviews()) == ["evt-1"]
+ assert len(store.history()) == 1
+
+
+def test_a_review_missing_optional_fields_still_loads(tmp_path: Path) -> None:
+ store = _store(tmp_path)
+ store.path.parent.mkdir(parents=True, exist_ok=True)
+ store.path.write_text(
+ json.dumps({"event_id": "evt-1", "verdict": TRUE_POSITIVE}) + "\n", encoding="utf-8"
+ )
+ assert store.reviews()["evt-1"].reviewer == "unknown"
+
+
+# ---------------------------------------------------------------------------
+# The criterion it exists to unblock
+# ---------------------------------------------------------------------------
+
+
+def _audit_with_hard_denies(tmp_path: Path, count: int) -> tuple[Path, list[str]]:
+ path = tmp_path / "decisions.jsonl"
+ logger = AuditLogger(str(path), policy_fingerprint="sha256:test")
+ for index in range(count):
+ logger.log(
+ actor="agent",
+ action=f"rm -rf / #{index}",
+ decision={
+ "allowed": False,
+ "risk_score": 171,
+ "reason": "destructive system command",
+ "state": "hard_deny",
+ },
+ context={},
+ )
+ ids = [json.loads(line)["event_id"] for line in path.read_text(encoding="utf-8").splitlines()]
+ return path, ids
+
+
+def _criterion(report: object) -> object:
+ return next(c for c in report.criteria if c.name == "hard_deny_reviewed") # type: ignore[attr-defined]
+
+
+def test_reviewing_every_event_passes_the_criterion(tmp_path: Path) -> None:
+ audit, ids = _audit_with_hard_denies(tmp_path, 3)
+ store = _store(tmp_path)
+ for event_id in ids:
+ store.record(event_id, TRUE_POSITIVE, reviewer="robert")
+
+ report = build_report(audit, min_decisions=1, min_days=0.0, review_path=store.path)
+ criterion = _criterion(report)
+ assert criterion.passed is True # type: ignore[attr-defined]
+ assert "robert" in criterion.detail # type: ignore[attr-defined]
+
+
+def test_one_unreviewed_event_holds_the_gate(tmp_path: Path) -> None:
+ audit, ids = _audit_with_hard_denies(tmp_path, 3)
+ store = _store(tmp_path)
+ for event_id in ids[:2]:
+ store.record(event_id, TRUE_POSITIVE, reviewer="robert")
+
+ criterion = _criterion(
+ build_report(audit, min_decisions=1, min_days=0.0, review_path=store.path)
+ )
+ assert criterion.passed is False # type: ignore[attr-defined]
+ assert "1 of 3" in criterion.detail # type: ignore[attr-defined]
+
+
+def test_a_false_positive_blocks_the_advance_even_when_all_are_reviewed(
+ tmp_path: Path,
+) -> None:
+ """
+ Enforcing a rule a human already called wrong is the exact outcome phase 1
+ exists to prevent. Reviewing everything is not the same as approving it.
+ """
+ audit, ids = _audit_with_hard_denies(tmp_path, 3)
+ store = _store(tmp_path)
+ store.record(ids[0], TRUE_POSITIVE, reviewer="robert")
+ store.record(ids[1], TRUE_POSITIVE, reviewer="robert")
+ store.record(ids[2], FALSE_POSITIVE, reviewer="robert", note="scoped cleanup, not root")
+
+ criterion = _criterion(
+ build_report(audit, min_decisions=1, min_days=0.0, review_path=store.path)
+ )
+ assert criterion.passed is False # type: ignore[attr-defined]
+ assert "FALSE" in criterion.detail # type: ignore[attr-defined]
+ assert "restart the window" in criterion.detail # type: ignore[attr-defined]
+
+
+def test_events_beyond_the_sample_cap_cannot_be_reviewed_away(tmp_path: Path) -> None:
+ """
+ The report only samples N hard_deny events. Reviewing the sampled ones must
+ not pass a gate covering events nobody was ever shown.
+ """
+ audit, ids = _audit_with_hard_denies(tmp_path, 6)
+ store = _store(tmp_path)
+ for event_id in ids[:2]:
+ store.record(event_id, TRUE_POSITIVE, reviewer="robert")
+
+ criterion = _criterion(
+ build_report(
+ audit,
+ min_decisions=1,
+ min_days=0.0,
+ review_path=store.path,
+ max_hard_deny_samples=2,
+ )
+ )
+ assert criterion.passed is False # type: ignore[attr-defined]
+ assert "4 of 6" in criterion.detail # type: ignore[attr-defined]
+
+
+def test_reviews_reach_the_report_events(tmp_path: Path) -> None:
+ audit, ids = _audit_with_hard_denies(tmp_path, 2)
+ store = _store(tmp_path)
+ store.record(ids[0], TRUE_POSITIVE, reviewer="robert", note="genuinely destructive")
+
+ events = build_report(audit, review_path=store.path).hard_deny_events
+ assert events[0]["review"]["verdict"] == TRUE_POSITIVE
+ assert events[0]["review"]["note"] == "genuinely destructive"
+ assert events[1]["review"] is None
+ assert all(e["event_id"] for e in events)
+
+
+def test_without_a_review_path_nothing_is_reviewed(tmp_path: Path) -> None:
+ """Back-compat: callers that never opted in behave exactly as before."""
+ audit, _ = _audit_with_hard_denies(tmp_path, 2)
+ report = build_report(audit, min_decisions=1, min_days=0.0)
+ assert _criterion(report).passed is False # type: ignore[attr-defined]
+ assert all(e["review"] is None for e in report.hard_deny_events)
diff --git a/tests/test_rollout_report.py b/tests/test_rollout_report.py
index f714d8e..ca91879 100644
--- a/tests/test_rollout_report.py
+++ b/tests/test_rollout_report.py
@@ -78,13 +78,13 @@ def test_hard_deny_sample_is_bounded(tmp_path: Path) -> None:
assert len(build_report(path, max_hard_deny_samples=5).hard_deny_events) == 5
-def test_hard_deny_review_never_auto_passes(tmp_path: Path) -> None:
+def test_an_unreviewed_hard_deny_blocks_the_advance(tmp_path: Path) -> None:
"""
The tool can count and list these; it cannot judge them. Auto-passing would
turn the phase gate into a formality, which is exactly the failure mode the
criterion exists to prevent.
"""
- path = _audit(tmp_path, [("a", "allow", "ok")] * 3)
+ path = _audit(tmp_path, [("a", "hard_deny", "destructive")])
report = build_report(path, min_decisions=1, min_days=0.0)
reviewed = next(c for c in report.criteria if c.name == "hard_deny_reviewed")
@@ -92,6 +92,20 @@ def test_hard_deny_review_never_auto_passes(tmp_path: Path) -> None:
assert report.ready_to_advance is False
+def test_a_window_with_no_hard_deny_has_nothing_to_review(tmp_path: Path) -> None:
+ """
+ Nothing to judge is not the same as a judgement withheld. Blocking here
+ would mean a well-behaved agent could never leave phase 1 — the criterion
+ would be unsatisfiable rather than strict.
+ """
+ path = _audit(tmp_path, [("a", "allow", "ok")] * 3)
+ report = build_report(path, min_decisions=1, min_days=0.0)
+
+ reviewed = next(c for c in report.criteria if c.name == "hard_deny_reviewed")
+ assert reviewed.passed is True
+ assert "no hard_deny events" in reviewed.detail
+
+
def test_insufficient_window_fails_the_criterion(tmp_path: Path) -> None:
path = _audit(tmp_path, [("a", "allow", "ok")] * 3)
report = build_report(path, min_decisions=10_000, min_days=14.0)
@@ -331,3 +345,43 @@ def test_json_output_is_machine_readable(
payload: dict[str, Any] = json.loads(capsys.readouterr().out)
assert payload["window"]["total_decisions"] == 2
assert isinstance(payload["criteria"], list)
+
+
+def test_timestamps_that_are_not_strings_are_ignored(tmp_path: Path) -> None:
+ path = tmp_path / "decisions.jsonl"
+ path.write_text(
+ json.dumps(
+ {
+ "actor": "a",
+ "action": "x",
+ "ts": 12345,
+ "decision": {"allowed": True, "risk_score": 0, "reason": "ok", "state": "allow"},
+ "context": {},
+ }
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ assert build_report(path).first_event is None
+
+
+def test_text_output_without_timestamps_omits_the_range(
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
+) -> None:
+ path = tmp_path / "decisions.jsonl"
+ path.write_text(
+ json.dumps(
+ {
+ "actor": "a",
+ "action": "x",
+ "decision": {"allowed": True, "risk_score": 0, "reason": "ok", "state": "allow"},
+ "context": {},
+ }
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ main(["--audit-path", str(path)])
+ out = capsys.readouterr().out
+ assert "window" in out
+ assert " -> " not in out