Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
# survive a rebuild regardless of what the shell that runs it has exported.
#
# cp .env.example .env
# cp .env.provider.example .env.provider # gateway only: ANTHROPIC/OPENAI key
#
# Compose reads these files directly rather than through ${VAR} interpolation,
# because interpolation gives an exported shell variable precedence over the
# file — which silently defeats a key rotation.
# sed -i "s/^AETHERYA_CONSOLE_API_KEY=.*/AETHERYA_CONSOLE_API_KEY=$(openssl rand -hex 32)/" .env
#
# .env is gitignored. .env.example is not — keep real secrets out of it.
Expand All @@ -13,10 +18,11 @@ AETHERYA_CONSOLE_API_KEY=replace-me
# is self-consistent, not that this service produced it.
AETHERYA_ATTESTATION_KEY=replace-me

# The gateway forwards to a real provider. Without a key the container starts,
# answers /health, and returns 502 on every completion.
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
# Answering a held action in the approval queue. The first authorises the
# operator to answer at all; the second signs the proof that unblocks the agent.
# Both are needed or Approve fails at the click.
AETHERYA_APPROVALS_API_KEY=replace-me
AETHERYA_CONFIRMATION_HMAC_KEY=replace-me

# Gateway target. The actor must exist in capability_matrix.actors, or phase 3
# refuses every call — the gateway refuses to start at phase 3 without it.
Expand Down
6 changes: 6 additions & 0 deletions .env.provider.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Upstream provider credentials, read only by the gateway service.
# Kept out of .env so the decision service never receives a credential it
# has no use for. Without one the gateway starts, answers /health, and
# returns 502 on every completion.
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@ CLAUDE.md
demo.cast
# Local secrets for the compose stack. Never commit.
.env
.env.provider
RESUMEN-SESION.md
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ ENV PATH="/opt/venv/bin:$PATH"
COPY pyproject.toml README.md ./
COPY src/ ./src/

# The provider SDKs are here because the same image runs the gateway, which
# cannot reach an upstream without them — installed, it answers; absent, every
# completion is a 502 that reads like a network fault. They are optional extras
# for a library consumer and mandatory for this image.
RUN pip install --no-cache-dir -U pip \
&& pip install --no-cache-dir ".[redis]"
&& pip install --no-cache-dir ".[redis,llm,anthropic]"

# Pre-download the sentence-transformers model into the image.
ENV HF_HOME=/opt/models
Expand Down
7 changes: 6 additions & 1 deletion config/policy.docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,12 @@ confirmation:
token_pattern: "^ack:[a-z0-9_-]{8,}$"
min_context_length: 12
signed_proof:
enabled: false
# On in the container profiles because they ship the approval queue.
# An approval that is not cryptographically bound to the exact action
# is a token anyone can type; with this on, a proof is scoped to one
# action, single-use, and useless anywhere else. The repo default
# stays off so `aetherya decide` needs no key management.
enabled: true
proof_param: confirm_proof
key_env: AETHERYA_CONFIRMATION_HMAC_KEY
keyring_env: AETHERYA_CONFIRMATION_HMAC_KEYRING
Expand Down
7 changes: 6 additions & 1 deletion config/policy.slim.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,12 @@ confirmation:
token_pattern: "^ack:[a-z0-9_-]{8,}$"
min_context_length: 12
signed_proof:
enabled: false
# On in the container profiles because they ship the approval queue.
# An approval that is not cryptographically bound to the exact action
# is a token anyone can type; with this on, a proof is scoped to one
# action, single-use, and useless anywhere else. The repo default
# stays off so `aetherya decide` needs no key management.
enabled: true
proof_param: confirm_proof
key_env: AETHERYA_CONFIRMATION_HMAC_KEY
keyring_env: AETHERYA_CONFIRMATION_HMAC_KEYRING
Expand Down
62 changes: 41 additions & 21 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ services:
image: redis:7-alpine
container_name: aetherya-redis
ports:
- "6379:6379"
# Loopback only. This holds the confirmation replay store, so network
# access to it means deleting replay records and reusing approval proofs
# — the single-use guarantee lives in here, not just in the signature.
- "127.0.0.1:6379:6379"
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis-data:/data
Expand All @@ -28,30 +31,27 @@ services:
build: .
container_name: aetherya-decision
ports:
- "8080:8080"
# Bound to the loopback interface on the host, not 0.0.0.0. In a container
# the app-level "must come from 127.0.0.1" check can never pass — every
# client arrives as the bridge gateway — so the port publication is what
# actually keeps the admin routes off the network.
- "127.0.0.1:8080:8080"
depends_on:
redis:
condition: service_healthy
# Secrets come from the file, never from `${VAR}` interpolation. Compose
# gives the invoking shell precedence over .env, so an exported variable
# silently wins over the file — which is how a rotated console key kept
# reverting to an older value and locked the console out with no
# indication that two different keys were in play.
env_file:
- .env
environment:
# Shared rate-limit state. With the in-process backend, N replicas mean
# N x the configured limit — see docs/security-model.md.
AETHERYA_RATE_LIMIT_REDIS_URL: redis://redis:6379/0
AETHERYA_CONFIRMATION_REPLAY_REDIS_URL: redis://redis:6379/1

# Secrets come from .env (see .env.example), not from whatever the shell
# running `docker compose` happens to have exported — otherwise rotating a
# key silently reverts to the default on the next rebuild by someone else.
#
# Signs the audit chain. Replace before this is anything but a local demo:
# without a real secret the attestation proves the file is self-consistent,
# not that this service produced it.
AETHERYA_ATTESTATION_KEY: "${AETHERYA_ATTESTATION_KEY:-local-dev-attestation-key-replace-me}"

# Gates the operator console. Recording a hard-deny review requires this
# to be set — the verdict is attributed to a named reviewer, so it has to
# be authenticated. Reads are gated by it too once it exists.
AETHERYA_CONSOLE_API_KEY: "${AETHERYA_CONSOLE_API_KEY:-local-dev-console-key-replace-me}"

# Pin the policy so a replica refuses to start under an unintended one.
# Get the value with: aetherya policy fingerprint --json
# Left unset locally so the stack comes up before you have run that.
Expand All @@ -72,6 +72,8 @@ services:
- --policy-path=config/policy.docker.yaml
- --audit-path=audit/decisions.jsonl
- --require-semantic-ready
# Safe only because the port above is published on loopback.
- --approval-trust-network-boundary

restart: unless-stopped

Expand All @@ -90,14 +92,17 @@ services:
build: .
container_name: aetherya-gateway
ports:
- "8090:8090"
# Loopback only: this process holds your upstream API key and
# authenticates nobody.
- "127.0.0.1:8090:8090"
depends_on:
aetherya:
condition: service_healthy
environment:
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
AETHERYA_ATTESTATION_KEY: "${AETHERYA_ATTESTATION_KEY:-local-dev-attestation-key-replace-me}"
# Same reasoning as above. The provider key lives in its own file so the
# decision service never receives a credential it has no use for.
env_file:
- .env
- .env.provider
volumes:
- audit-data:/app/audit
- ./config:/app/config:ro
Expand All @@ -112,6 +117,21 @@ services:
- --actor=${AETHERYA_GATEWAY_ACTOR:-robert}
- --policy-path=config/policy.docker.yaml
- --audit-path=audit/decisions.jsonl
# The image's healthcheck probes 8080 and asserts fields only the decision
# service reports, so the gateway inherits a check it can never pass.
healthcheck:
test:
- CMD
- python
- -c
- |
import json, urllib.request
d = json.load(urllib.request.urlopen("http://127.0.0.1:8090/health", timeout=4))
raise SystemExit(0 if d.get("ok") and d.get("upstream_key_present") else 1)
interval: 15s
timeout: 5s
start_period: 15s
retries: 3
restart: unless-stopped

volumes:
Expand Down
14 changes: 14 additions & 0 deletions src/aetherya/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ def serve_api(
enable_approval_routes: bool = True,
warmup_semantic: bool = True,
require_semantic_ready: bool = False,
approval_sign_local_only: bool = True,
) -> None:
warmup_semantic_layer(
policy_path=policy_path,
Expand All @@ -345,6 +346,7 @@ def serve_api(
enable_decide_routes=enable_decide_routes,
enable_audit_routes=enable_audit_routes,
enable_approval_routes=enable_approval_routes,
approval_sign_local_only=approval_sign_local_only,
)
api = AetheryaAPI(settings)
server = build_server(
Expand All @@ -368,6 +370,17 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--constitution-path", default=None)
parser.add_argument("--default-actor", default="robert")
parser.add_argument("--max-body-bytes", type=int, default=1_048_576)
parser.add_argument(
"--approval-trust-network-boundary",
action="store_true",
help=(
"Stop requiring that admin requests arrive from 127.0.0.1. Necessary "
"in a container, where every client appears as the bridge gateway and "
"the check can never pass — publish the port as 127.0.0.1:PORT:PORT "
"so the host is the boundary instead. Do not set this on a service "
"whose port is reachable from anywhere else."
),
)
parser.add_argument(
"--no-warmup-semantic",
action="store_true",
Expand Down Expand Up @@ -423,6 +436,7 @@ def main(argv: list[str] | None = None) -> int:
enable_approval_routes=enable_approval_routes,
warmup_semantic=not bool(args.no_warmup_semantic),
require_semantic_ready=bool(args.require_semantic_ready),
approval_sign_local_only=not bool(args.approval_trust_network_boundary),
)
return 0
except KeyboardInterrupt:
Expand Down
34 changes: 31 additions & 3 deletions src/aetherya/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,20 @@ def _call_anthropic(self, body: dict[str, Any]) -> dict[str, Any]:
continue
messages.append({"role": role, "content": str(content or "")})

model = str(body.get("model") or self.settings.model)
request: dict[str, Any] = {
"model": body.get("model") or self.settings.model,
"model": model,
"max_tokens": int(body.get("max_tokens") or self.settings.max_tokens),
"messages": messages,
"thinking": {"type": "adaptive"},
"output_config": {"effort": self.settings.anthropic_effort},
}
# Adaptive thinking and the effort control exist on the 4.6+ families
# and are a 400 on anything older, which takes down every request
# rather than degrading. Sent only where the model is known to accept
# them, so pointing the gateway at an older or unfamiliar model works
# with less depth instead of not at all.
if _supports_adaptive_thinking(model):
request["thinking"] = {"type": "adaptive"}
request["output_config"] = {"effort": self.settings.anthropic_effort}
if system_parts:
request["system"] = "\n\n".join(system_parts)

Expand Down Expand Up @@ -383,6 +390,27 @@ def stream(self, body: dict[str, Any]) -> list[dict[str, Any]]:
return frames


# Families that accept `thinking: {"type": "adaptive"}` and `output_config.effort`.
# An allowlist rather than a denylist: an unrecognised model then loses depth,
# which is a worse answer, instead of returning 400 on every request, which is
# no answer at all. Add a family here when it is known to support them.
_ADAPTIVE_THINKING_FAMILIES: Final[tuple[str, ...]] = (
"claude-opus-4-6",
"claude-opus-4-7",
"claude-opus-4-8",
"claude-opus-5",
"claude-sonnet-4-6",
"claude-sonnet-5",
"claude-fable-5",
"claude-mythos-5",
)


def _supports_adaptive_thinking(model: str) -> bool:
name = model.strip().lower()
return any(name.startswith(family) for family in _ADAPTIVE_THINKING_FAMILIES)


def _parse_arguments(raw: Any) -> dict[str, Any]:
if isinstance(raw, dict):
return dict(raw)
Expand Down
13 changes: 11 additions & 2 deletions src/aetherya/gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from aetherya.audit import AuditLogger
from aetherya.config import load_policy_config
from aetherya.gateway import AetheryaGateway, GatewaySettings, UpstreamError
from aetherya.gateway import _PROVIDERS, AetheryaGateway, GatewaySettings, UpstreamError

DEFAULT_PORT = 8090
MAX_BODY_BYTES = 4_194_304
Expand Down Expand Up @@ -54,14 +54,23 @@ def do_GET(self) -> None: # noqa: N802
path = self.path.split("?", 1)[0].rstrip("/") or "/"
if path == "/health":
gateway = self.gateway
# A gateway with no upstream credential answers every completion
# with a 502. Reporting that as healthy is a lie a readiness probe
# would believe, so the key is part of the contract rather than
# something you discover on the first real request.
key_present = False
if gateway is not None:
key_env = _PROVIDERS.get(gateway.settings.provider.strip().lower())
key_present = bool(key_env and os.getenv(key_env[0], "").strip())
self._send_json(
200,
{
"ok": gateway is not None,
"ok": gateway is not None and key_present,
"provider": gateway.settings.provider if gateway else None,
"model": gateway.settings.model if gateway else None,
"phase": gateway.phase.number if gateway else None,
"phase_name": gateway.phase.name if gateway else None,
"upstream_key_present": key_present,
},
)
return
Expand Down
25 changes: 25 additions & 0 deletions src/aetherya/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,30 @@ def _mode_thresholds(cfg: PolicyConfig | Any, mode: str) -> dict[str, int]:
}


def _audit_action(action: ActionRequest) -> dict[str, Any]:
"""
The structured shape of what was requested, for the audit trail.

`raw_input` is already recorded, but as prose: recovering which tool an
agent used means regexing a string, and the console's tool column read as
empty for every real decision because nothing ever wrote this. Phase 1 is
supposed to tell you what vocabulary your agent actually speaks, and that
needs structure rather than a rendering.

Parameter *names* only. Values carry commands, file contents and — for a
confirmed action — `confirm_proof`, which is a single-use credential. The
audit trail is exported, mirrored and archived; a credential written there
cannot be taken back out.
"""
return {
"intent": action.intent,
"tool": action.tool,
"target": action.target,
"operation": action.parameters.get("operation"),
"parameter_names": sorted(str(name) for name in action.parameters),
}


def _fail_closed(
*,
raw_input: str,
Expand Down Expand Up @@ -923,6 +947,7 @@ def _shadow_call() -> Any:
context: dict[str, Any] = {
"mode": mode.value,
"signals": [s.__dict__ for s in agg.breakdown],
"action": _audit_action(action),
}
if explainability:
context["explainability"] = explainability
Expand Down
Loading
Loading