agentgov-gateway 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. agentgov_gateway-0.1.0/.gitignore +79 -0
  2. agentgov_gateway-0.1.0/Dockerfile +45 -0
  3. agentgov_gateway-0.1.0/PKG-INFO +66 -0
  4. agentgov_gateway-0.1.0/README.md +35 -0
  5. agentgov_gateway-0.1.0/agentgov_gateway/__init__.py +6 -0
  6. agentgov_gateway-0.1.0/agentgov_gateway/config.py +140 -0
  7. agentgov_gateway-0.1.0/agentgov_gateway/device_key.py +111 -0
  8. agentgov_gateway-0.1.0/agentgov_gateway/event_queue.py +176 -0
  9. agentgov_gateway-0.1.0/agentgov_gateway/hook_integrity.py +83 -0
  10. agentgov_gateway-0.1.0/agentgov_gateway/log_scrubber.py +81 -0
  11. agentgov_gateway-0.1.0/agentgov_gateway/main.py +533 -0
  12. agentgov_gateway-0.1.0/agentgov_gateway/middleware/__init__.py +11 -0
  13. agentgov_gateway-0.1.0/agentgov_gateway/middleware/auth.py +174 -0
  14. agentgov_gateway-0.1.0/agentgov_gateway/middleware/base.py +66 -0
  15. agentgov_gateway-0.1.0/agentgov_gateway/middleware/policy.py +171 -0
  16. agentgov_gateway-0.1.0/agentgov_gateway/middleware/repo_authz.py +116 -0
  17. agentgov_gateway-0.1.0/agentgov_gateway/middleware/usage.py +144 -0
  18. agentgov_gateway-0.1.0/agentgov_gateway/middleware/work_item.py +134 -0
  19. agentgov_gateway-0.1.0/agentgov_gateway/policy_cache.py +206 -0
  20. agentgov_gateway-0.1.0/agentgov_gateway/rate_limit.py +88 -0
  21. agentgov_gateway-0.1.0/agentgov_gateway/revocation_channel.py +99 -0
  22. agentgov_gateway-0.1.0/agentgov_gateway/session_rehydration.py +72 -0
  23. agentgov_gateway-0.1.0/agentgov_gateway/snapshot_poller.py +99 -0
  24. agentgov_gateway-0.1.0/agentgov_gateway/synthetic.py +85 -0
  25. agentgov_gateway-0.1.0/agentgov_gateway/upstream/__init__.py +1 -0
  26. agentgov_gateway-0.1.0/agentgov_gateway/upstream/anthropic.py +126 -0
  27. agentgov_gateway-0.1.0/agentgov_gateway/upstream/base.py +26 -0
  28. agentgov_gateway-0.1.0/fly.toml +52 -0
  29. agentgov_gateway-0.1.0/pyproject.toml +75 -0
  30. agentgov_gateway-0.1.0/tests/conftest.py +45 -0
  31. agentgov_gateway-0.1.0/tests/test_device_key.py +132 -0
  32. agentgov_gateway-0.1.0/tests/test_log_scrubber.py +69 -0
  33. agentgov_gateway-0.1.0/tests/test_policy_cache.py +129 -0
  34. agentgov_gateway-0.1.0/tests/test_repo_normalize.py +20 -0
  35. agentgov_gateway-0.1.0/tests/test_stream_abort.py +93 -0
  36. agentgov_gateway-0.1.0/tests/test_upstream_dual_mode.py +58 -0
  37. agentgov_gateway-0.1.0/uv.lock +1609 -0
@@ -0,0 +1,79 @@
1
+ # ============================================================================
2
+ # AgentGov top-level .gitignore
3
+ # Per-subsystem gitignores live inside control-tower/, gateway/, cli/ if needed.
4
+ # ============================================================================
5
+
6
+ # ---- Secrets & environment ----
7
+ .env
8
+ .env.local
9
+ .env.*.local
10
+ .env.production
11
+ !.env.example
12
+ !.env.act.example
13
+ *.pem
14
+ *.key
15
+ !signing_keys.example/*.pub
16
+
17
+ # ---- Python ----
18
+ __pycache__/
19
+ *.py[cod]
20
+ *$py.class
21
+ *.so
22
+ .Python
23
+ .venv/
24
+ venv/
25
+ env/
26
+ ENV/
27
+ build/
28
+ dist/
29
+ *.egg-info/
30
+ .eggs/
31
+ *.egg
32
+ .pytest_cache/
33
+ .mypy_cache/
34
+ .ruff_cache/
35
+ .coverage
36
+ .coverage.*
37
+ htmlcov/
38
+ coverage.xml
39
+ *.cover
40
+ .tox/
41
+
42
+ # ---- Node / Next.js ----
43
+ node_modules/
44
+ .next/
45
+ out/
46
+ .vercel/
47
+ .turbo/
48
+ *.tsbuildinfo
49
+ next-env.d.ts
50
+ .pnpm-debug.log*
51
+ npm-debug.log*
52
+ yarn-debug.log*
53
+ yarn-error.log*
54
+ .pnpm-store/
55
+
56
+ # ---- Supabase ----
57
+ supabase/.branches/
58
+ supabase/.temp/
59
+ supabase/functions/*/deno.lock
60
+
61
+ # ---- Gateway runtime state ----
62
+ # The gateway keeps SQLite caches + disk-backed event queues in these paths.
63
+ # They are ephemeral by design and must never be committed.
64
+ gateway/.runtime/
65
+ gateway/policy_cache.sqlite*
66
+ gateway/event_queue/
67
+ ~/.agentgov/
68
+
69
+ # ---- IDE / OS ----
70
+ .vscode/
71
+ .idea/
72
+ *.swp
73
+ *.swo
74
+ .DS_Store
75
+ Thumbs.db
76
+
77
+ # ---- Local overrides ----
78
+ *.local
79
+ CLAUDE.local.md
@@ -0,0 +1,45 @@
1
+ # Fly.io / self-host image for the AgentGov gateway.
2
+ #
3
+ # HARD RULE (§12.1): this container must NEVER run on a governed developer's
4
+ # machine. `agentgov doctor` checks host fingerprint against known developer
5
+ # machines and refuses.
6
+
7
+ FROM python:3.12-slim AS build
8
+
9
+ WORKDIR /app
10
+
11
+ ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
12
+ PIP_NO_CACHE_DIR=1 \
13
+ PYTHONDONTWRITEBYTECODE=1
14
+
15
+ RUN pip install --upgrade pip uv==0.4.20
16
+
17
+ COPY pyproject.toml uv.lock* README.md /app/
18
+ COPY agentgov_gateway /app/agentgov_gateway
19
+ RUN uv sync --frozen 2>/dev/null || uv sync
20
+
21
+
22
+ FROM python:3.12-slim AS run
23
+
24
+ WORKDIR /app
25
+
26
+ ENV PYTHONDONTWRITEBYTECODE=1 \
27
+ PYTHONUNBUFFERED=1 \
28
+ AGENTGOV_ENV=production \
29
+ POLICY_CACHE_PATH=/app/.runtime/policy_cache.sqlite \
30
+ EVENT_QUEUE_DIR=/app/.runtime/event_queue
31
+
32
+ COPY --from=build /app /app
33
+
34
+ # Non-root user — the vault key never touches root's crash dumps.
35
+ RUN useradd -u 10001 -r -s /usr/sbin/nologin gateway \
36
+ && mkdir -p /app/.runtime \
37
+ && chown -R gateway:gateway /app/.runtime
38
+ USER gateway
39
+
40
+ EXPOSE 8000 9090
41
+
42
+ HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
43
+ CMD python -c "import httpx; httpx.get('http://127.0.0.1:8000/health', timeout=3).raise_for_status()" || exit 1
44
+
45
+ CMD ["/app/.venv/bin/uvicorn", "agentgov_gateway.main:app", "--host", "0.0.0.0", "--port", "8000"]
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.5
2
+ Name: agentgov-gateway
3
+ Version: 0.1.0
4
+ Summary: AgentGov FastAPI gateway (vaults ANTHROPIC_API_KEY; enforces from signed policy cache; §12.1)
5
+ Author: AgentGov Contributors
6
+ License: Apache-2.0
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: aiosqlite>=0.20.0
9
+ Requires-Dist: cryptography>=43.0.0
10
+ Requires-Dist: fastapi>=0.115.0
11
+ Requires-Dist: httpx[http2]>=0.27.2
12
+ Requires-Dist: prometheus-client>=0.21.0
13
+ Requires-Dist: pydantic-settings>=2.5.0
14
+ Requires-Dist: pydantic>=2.9.0
15
+ Requires-Dist: pynacl>=1.5.0
16
+ Requires-Dist: python-json-logger>=2.0.7
17
+ Requires-Dist: structlog>=24.4.0
18
+ Requires-Dist: supabase>=2.9.0
19
+ Requires-Dist: uuid7>=0.1.0
20
+ Requires-Dist: uvicorn[standard]>=0.32.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: httpx[http2]>=0.27.2; extra == 'dev'
23
+ Requires-Dist: mypy>=1.11.2; extra == 'dev'
24
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
25
+ Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
26
+ Requires-Dist: pytest>=8.3.0; extra == 'dev'
27
+ Requires-Dist: respx>=0.21.1; extra == 'dev'
28
+ Requires-Dist: ruff>=0.6.9; extra == 'dev'
29
+ Requires-Dist: types-python-dateutil; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # agentgov-gateway
33
+
34
+ The local FastAPI gateway that Claude Code (via `ANTHROPIC_BASE_URL=http://localhost:8000`) talks to.
35
+
36
+ Runs on each developer's machine. Vaults the upstream credential (X25519 sealed-box decrypted from the signed policy snapshot). Enforces repo-lock + work-item binding + budget middleware chain. Streams SSE verbatim to Claude Code while tee-parsing usage frames for attribution.
37
+
38
+ Part of the AgentGov project — see [github.com/deepakahu/agentGov](https://github.com/deepakahu/agentGov) for the full monorepo (control tower, CLI, client assets, Supabase migrations, docs).
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install agentgov-gateway
44
+ ```
45
+
46
+ ## Configure
47
+
48
+ Requires these env vars — see the project `.env.example` for the full list:
49
+
50
+ - `CONTROL_TOWER_URL` — where your AgentGov control tower is deployed
51
+ - `GATEWAY_SERVICE_TOKEN` — a `gtw_...` token from `agentgov register-device`
52
+ - `POLICY_SIGNING_PUBLIC_KEY_B64` — Ed25519 public key (control tower gives you this)
53
+ - `DEVICE_KEY_PATH` — path to your X25519 device private key (default: `./.runtime/device_key.priv`)
54
+ - `UPSTREAM_MODE` — `oauth_passthrough` (default, personal) or `apikey_vault`
55
+
56
+ ## Run
57
+
58
+ ```bash
59
+ uvicorn agentgov_gateway.main:app --host 127.0.0.1 --port 8000
60
+ ```
61
+
62
+ Under systemd / launchd for persistent operation.
63
+
64
+ ## License
65
+
66
+ Apache-2.0
@@ -0,0 +1,35 @@
1
+ # agentgov-gateway
2
+
3
+ The local FastAPI gateway that Claude Code (via `ANTHROPIC_BASE_URL=http://localhost:8000`) talks to.
4
+
5
+ Runs on each developer's machine. Vaults the upstream credential (X25519 sealed-box decrypted from the signed policy snapshot). Enforces repo-lock + work-item binding + budget middleware chain. Streams SSE verbatim to Claude Code while tee-parsing usage frames for attribution.
6
+
7
+ Part of the AgentGov project — see [github.com/deepakahu/agentGov](https://github.com/deepakahu/agentGov) for the full monorepo (control tower, CLI, client assets, Supabase migrations, docs).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install agentgov-gateway
13
+ ```
14
+
15
+ ## Configure
16
+
17
+ Requires these env vars — see the project `.env.example` for the full list:
18
+
19
+ - `CONTROL_TOWER_URL` — where your AgentGov control tower is deployed
20
+ - `GATEWAY_SERVICE_TOKEN` — a `gtw_...` token from `agentgov register-device`
21
+ - `POLICY_SIGNING_PUBLIC_KEY_B64` — Ed25519 public key (control tower gives you this)
22
+ - `DEVICE_KEY_PATH` — path to your X25519 device private key (default: `./.runtime/device_key.priv`)
23
+ - `UPSTREAM_MODE` — `oauth_passthrough` (default, personal) or `apikey_vault`
24
+
25
+ ## Run
26
+
27
+ ```bash
28
+ uvicorn agentgov_gateway.main:app --host 127.0.0.1 --port 8000
29
+ ```
30
+
31
+ Under systemd / launchd for persistent operation.
32
+
33
+ ## License
34
+
35
+ Apache-2.0
@@ -0,0 +1,6 @@
1
+ """AgentGov gateway — FastAPI proxy between Claude Code and Anthropic.
2
+
3
+ Vaults ANTHROPIC_API_KEY. Enforces from a signed policy cache. Streams SSE.
4
+ """
5
+
6
+ __version__ = "0.1.0"
@@ -0,0 +1,140 @@
1
+ """Gateway configuration (pydantic-settings, env-first, agentgov.toml overlay).
2
+
3
+ Config validation is fail-fast. In particular:
4
+ - POLICY_SNAPSHOT_INTERVAL_SEC has a floor of 60 s (§12.2, per user direct
5
+ instruction). Anything lower is rejected at boot.
6
+ - ANTHROPIC_API_KEY must be present and NEVER logged. Its presence is
7
+ verified but the value is never rendered by our __repr__.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ from pydantic import AliasChoices, Field, SecretStr, field_validator
16
+ from pydantic_settings import BaseSettings, SettingsConfigDict
17
+
18
+ # Floors — see CLAUDE.md §12.2. Rejecting below-floor at boot protects the
19
+ # control tower's Vercel invocation budget from a misconfigured customer.
20
+ POLICY_SNAPSHOT_INTERVAL_FLOOR_SEC = 60
21
+ REVOCATION_DELTA_POLL_FLOOR_SEC = 15
22
+
23
+
24
+ class Settings(BaseSettings):
25
+ """All gateway config. Read once at boot; treated as immutable after."""
26
+
27
+ model_config = SettingsConfigDict(
28
+ env_file=".env",
29
+ env_file_encoding="utf-8",
30
+ case_sensitive=False,
31
+ extra="ignore",
32
+ )
33
+
34
+ # ---- Runtime ---------------------------------------------------------
35
+ agentgov_env: Literal["development", "staging", "production"] = "development"
36
+ log_level: str = "INFO"
37
+ log_format: Literal["json", "text"] = "json"
38
+
39
+ # ---- Upstream mode (§13.1) ------------------------------------------
40
+ # `oauth_passthrough` — the gateway forwards the Authorization header
41
+ # verbatim to Anthropic; no local vault; nothing to steal.
42
+ # `apikey_vault` — the gateway decrypts a per-device sealed credential
43
+ # from the policy snapshot and swaps it into the Authorization header
44
+ # before forwarding. Requires device registration (§14.1).
45
+ # Personal-mode default is `oauth_passthrough`. Enterprise-remote (§12)
46
+ # keeps setting ANTHROPIC_API_KEY via env for backwards compat.
47
+ upstream_mode: Literal["oauth_passthrough", "apikey_vault"] = "oauth_passthrough"
48
+ anthropic_base_url: str = "https://api.anthropic.com"
49
+
50
+ # ---- Legacy env-provided key (only for §12 enterprise-remote deploys) ---
51
+ # If set + `upstream_mode = apikey_vault`, this key is used INSTEAD of a
52
+ # per-device sealed credential. Do NOT set this in personal/team mode.
53
+ anthropic_api_key: SecretStr = Field(
54
+ default=SecretStr(""),
55
+ validation_alias=AliasChoices("ANTHROPIC_API_KEY", "anthropic_api_key"),
56
+ )
57
+
58
+ # ---- Device identity (§14.1) -----------------------------------------
59
+ device_key_path: Path = Path(".runtime/device_key.priv")
60
+ device_id: str = ""
61
+
62
+ # ---- Optional downstream Headroom hop -------------------------------
63
+ # §1 — Headroom is NOT modified by us; it's an OPTIONAL downstream service.
64
+ # When enabled, we forward /v1/messages to Headroom which then forwards to
65
+ # Anthropic with its own compression config.
66
+ headroom_enabled: bool = False
67
+ headroom_upstream_url: str = ""
68
+
69
+ # ---- Control tower --------------------------------------------------
70
+ control_tower_url: str
71
+ gateway_service_token: SecretStr
72
+
73
+ # ---- Policy sync (§12.2) --------------------------------------------
74
+ policy_snapshot_interval_sec: int = 300
75
+ revocation_delta_poll_interval_sec: int = 20
76
+ policy_staleness_warn_hours: int = 1
77
+ policy_staleness_fail_hours: int = 24
78
+ policy_signing_public_key_b64: str
79
+
80
+ # ---- Supabase Realtime (revocation fast path) -----------------------
81
+ supabase_url: str = ""
82
+ supabase_anon_key: SecretStr = SecretStr("")
83
+
84
+ # ---- Local runtime state ---------------------------------------------
85
+ policy_cache_path: Path = Path(".runtime/policy_cache.sqlite")
86
+ event_queue_dir: Path = Path(".runtime/event_queue")
87
+
88
+ # ---- Prometheus ------------------------------------------------------
89
+ prometheus_metrics_enabled: bool = True
90
+ prometheus_metrics_port: int = 9090
91
+
92
+ # ---- Validators ------------------------------------------------------
93
+ @field_validator("policy_snapshot_interval_sec")
94
+ @classmethod
95
+ def _floor_snapshot(cls, v: int) -> int:
96
+ if v < POLICY_SNAPSHOT_INTERVAL_FLOOR_SEC:
97
+ raise ValueError(
98
+ f"POLICY_SNAPSHOT_INTERVAL_SEC={v} is below the floor of "
99
+ f"{POLICY_SNAPSHOT_INTERVAL_FLOOR_SEC}s (CLAUDE.md §12.2)"
100
+ )
101
+ return v
102
+
103
+ @field_validator("revocation_delta_poll_interval_sec")
104
+ @classmethod
105
+ def _floor_delta(cls, v: int) -> int:
106
+ if v < REVOCATION_DELTA_POLL_FLOOR_SEC:
107
+ raise ValueError(
108
+ f"REVOCATION_DELTA_POLL_INTERVAL_SEC={v} is below the floor of "
109
+ f"{REVOCATION_DELTA_POLL_FLOOR_SEC}s (CLAUDE.md §12.2)"
110
+ )
111
+ return v
112
+
113
+ @field_validator("policy_staleness_fail_hours")
114
+ @classmethod
115
+ def _fail_cap(cls, v: int) -> int:
116
+ if v > 24 or v < 1:
117
+ raise ValueError("POLICY_STALENESS_FAIL_HOURS must be in [1, 24] (CLAUDE.md §12.2)")
118
+ return v
119
+
120
+ # NOTE: we no longer force ANTHROPIC_API_KEY at boot. Personal-mode
121
+ # deployments (§13, default) don't need it. `apikey_vault` mode reads its
122
+ # key from the per-device sealed credential in the policy snapshot.
123
+ # The old always-required check would break local-mode installs.
124
+
125
+
126
+ _settings: Settings | None = None
127
+
128
+
129
+ def settings() -> Settings:
130
+ """Return the singleton Settings instance, constructing on first call."""
131
+ global _settings
132
+ if _settings is None:
133
+ _settings = Settings() # type: ignore[call-arg]
134
+ return _settings
135
+
136
+
137
+ def _reset_for_tests() -> None:
138
+ """Test-only helper — the settings singleton is re-read from env."""
139
+ global _settings
140
+ _settings = None
@@ -0,0 +1,111 @@
1
+ """Device X25519 keypair — persistence + sealed-box decrypt (CLAUDE.md §14.2).
2
+
3
+ First-run: generate a keypair, write the private key to disk (0600),
4
+ send the public key to the control tower during `agentgov register-device`.
5
+
6
+ Every subsequent gateway boot: load the private key into RAM. Use it to decrypt
7
+ `upstream_credential` ciphertext from each policy snapshot.
8
+
9
+ Wire format matches control-tower/lib/sealed-box.ts (NaCl `box`, ephemeral
10
+ sender keypair). Byte-parity guarded by test_device_key.py.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import json
17
+ import logging
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from nacl.public import Box, PrivateKey, PublicKey
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ class SealedBoxError(Exception):
28
+ """Raised when a sealed box fails to decrypt (bad key, nonce, ciphertext)."""
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class DeviceKeyPair:
33
+ """32-byte X25519 keypair. private_key held in memory only."""
34
+
35
+ private_key_b64: str
36
+ public_key_b64: str
37
+
38
+
39
+ def generate() -> DeviceKeyPair:
40
+ """Create a fresh keypair."""
41
+ priv = PrivateKey.generate()
42
+ return DeviceKeyPair(
43
+ private_key_b64=base64.b64encode(bytes(priv)).decode(),
44
+ public_key_b64=base64.b64encode(bytes(priv.public_key)).decode(),
45
+ )
46
+
47
+
48
+ def load_or_create(path: Path) -> DeviceKeyPair:
49
+ """Load a persisted keypair (via load()) or generate + persist a new one.
50
+
51
+ On generation, writes `{path}` (private) and `{path}.pub` (public).
52
+ """
53
+ if path.exists():
54
+ return load(path)
55
+ path.parent.mkdir(parents=True, exist_ok=True)
56
+ kp = generate()
57
+ path.write_text(kp.private_key_b64 + "\n")
58
+ path.chmod(0o600)
59
+ pub_path = path.with_suffix(path.suffix + ".pub")
60
+ pub_path.write_text(kp.public_key_b64 + "\n")
61
+ log.info("device_key: generated new keypair at %s (pub: %s)", path, pub_path)
62
+ return kp
63
+
64
+
65
+ def load(path: Path) -> DeviceKeyPair:
66
+ """Load a keypair whose private key is already on disk. Derives pub from priv."""
67
+ priv_b64 = path.read_text().strip()
68
+ priv_bytes = base64.b64decode(priv_b64)
69
+ if len(priv_bytes) != 32:
70
+ raise ValueError(
71
+ f"device_key: private key at {path} is {len(priv_bytes)} bytes, expected 32"
72
+ )
73
+ priv = PrivateKey(priv_bytes)
74
+ return DeviceKeyPair(
75
+ private_key_b64=priv_b64,
76
+ public_key_b64=base64.b64encode(bytes(priv.public_key)).decode(),
77
+ )
78
+
79
+
80
+ def unseal(sealed: dict[str, Any], recipient_private_key_b64: str) -> bytes:
81
+ """Decrypt a sealed-box envelope produced by lib/sealed-box.ts.
82
+
83
+ Envelope keys: ephemeral_pubkey_b64, nonce_b64, ciphertext_b64.
84
+ Returns the plaintext bytes.
85
+ """
86
+ try:
87
+ eph_pub_b64 = sealed["ephemeral_pubkey_b64"]
88
+ nonce_b64 = sealed["nonce_b64"]
89
+ ct_b64 = sealed["ciphertext_b64"]
90
+ except KeyError as e:
91
+ raise SealedBoxError(f"missing envelope field: {e}") from e
92
+
93
+ try:
94
+ eph_pub = PublicKey(base64.b64decode(eph_pub_b64))
95
+ priv = PrivateKey(base64.b64decode(recipient_private_key_b64))
96
+ nonce = base64.b64decode(nonce_b64)
97
+ ct = base64.b64decode(ct_b64)
98
+ except Exception as e:
99
+ raise SealedBoxError(f"envelope decode failed: {e}") from e
100
+
101
+ box = Box(priv, eph_pub)
102
+ try:
103
+ return box.decrypt(ct, nonce)
104
+ except Exception as e:
105
+ raise SealedBoxError(f"decryption failed: {e}") from e
106
+
107
+
108
+ def unseal_json(sealed: dict[str, Any], recipient_private_key_b64: str) -> dict[str, Any]:
109
+ """Decrypt + JSON-parse. Return dict."""
110
+ parsed: dict[str, Any] = json.loads(unseal(sealed, recipient_private_key_b64).decode("utf-8"))
111
+ return parsed
@@ -0,0 +1,176 @@
1
+ """Disk-backed at-least-once event queue (§R-P8).
2
+
3
+ Every TokenEvent + AuthzDecision the middleware chain produces goes on a
4
+ local JSONL queue file. A worker task batches up to 50 or 5 s of events
5
+ and POSTs to the control-tower /api/ingest. On failure it retries with
6
+ exponential backoff and NEVER drops. On success it truncates the batch
7
+ from the file. Never blocks the request hot path.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import contextlib
14
+ import json
15
+ import logging
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ import httpx
21
+
22
+ if TYPE_CHECKING:
23
+ from .config import Settings
24
+
25
+
26
+ def _now_iso() -> str:
27
+ return datetime.now(UTC).isoformat()
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+ MAX_BATCH = 50
32
+ FLUSH_INTERVAL_SEC = 5.0
33
+ QUEUE_FILE = "queue.jsonl"
34
+
35
+ # gap-P1-6: queue size ceiling + shed policy.
36
+ # If the control tower is unreachable for hours, an idle laptop's disk still
37
+ # has room, but a busy dev's queue grows without bound. We cap at 50_000
38
+ # pending items (~10 MB on disk at typical row size) and, when the cap is hit,
39
+ # SHED THE OLDEST — never the newest. Rationale: oldest events are the least
40
+ # useful (most likely already stale in the user's mental model); newest are
41
+ # the ones a debugging session wants. The shed count is logged loudly + a
42
+ # best-effort authz_decisions row is queued so the loss is audited.
43
+ MAX_QUEUE_SIZE = 50_000
44
+ SHED_WATERMARK = int(MAX_QUEUE_SIZE * 0.9) # start shedding at 90% full
45
+
46
+
47
+ class EventQueue:
48
+ def __init__(self, settings: Settings) -> None:
49
+ self._s = settings
50
+ self._dir = Path(settings.event_queue_dir)
51
+ self._dir.mkdir(parents=True, exist_ok=True)
52
+ self._path = self._dir / QUEUE_FILE
53
+ self._lock = asyncio.Lock()
54
+ self._pending: list[dict[str, Any]] = []
55
+ self._stop = asyncio.Event()
56
+ self._flusher: asyncio.Task[None] | None = None
57
+
58
+ def start(self) -> None:
59
+ if self._flusher is None:
60
+ self._flusher = asyncio.create_task(self._flush_loop(), name="event_queue_flush")
61
+
62
+ async def stop(self) -> None:
63
+ self._stop.set()
64
+ if self._flusher is not None:
65
+ self._flusher.cancel()
66
+ with contextlib.suppress(asyncio.CancelledError):
67
+ await self._flusher
68
+
69
+ async def enqueue_event(self, event: dict[str, Any]) -> None:
70
+ async with self._lock:
71
+ self._pending.append({"kind": "event", "payload": event})
72
+ self._enforce_ceiling_locked()
73
+ await self._persist_all()
74
+
75
+ async def enqueue_decision(self, decision: dict[str, Any]) -> None:
76
+ async with self._lock:
77
+ self._pending.append({"kind": "decision", "payload": decision})
78
+ self._enforce_ceiling_locked()
79
+ await self._persist_all()
80
+
81
+ def _enforce_ceiling_locked(self) -> None:
82
+ """gap-P1-6: shed oldest when the queue exceeds the ceiling.
83
+
84
+ Called with self._lock held. Logs loudly + appends a synthetic
85
+ SHED audit row so the drop is recorded even after the fact.
86
+ """
87
+ if len(self._pending) <= MAX_QUEUE_SIZE:
88
+ return
89
+ # Shed enough to bring us back under the watermark so we don't
90
+ # thrash on the next enqueue.
91
+ target = SHED_WATERMARK
92
+ drop = len(self._pending) - target
93
+ if drop <= 0:
94
+ return
95
+ dropped_kinds = {"event": 0, "decision": 0}
96
+ for item in self._pending[:drop]:
97
+ dropped_kinds[item.get("kind", "event")] = (
98
+ dropped_kinds.get(item.get("kind", "event"), 0) + 1
99
+ )
100
+ self._pending = self._pending[drop:]
101
+ log.error(
102
+ "event_queue: SHED %d oldest items (queue >= %d); dropped=%s. "
103
+ "Control tower likely unreachable. Attribution for these events is lost.",
104
+ drop,
105
+ MAX_QUEUE_SIZE,
106
+ dropped_kinds,
107
+ )
108
+ # Append an audit note about the shed so the ledger has a marker.
109
+ self._pending.append(
110
+ {
111
+ "kind": "decision",
112
+ "payload": {
113
+ "session_id": None,
114
+ "user_id": None,
115
+ "repo_claimed": None,
116
+ "repo_id": None,
117
+ "decision": "DENY", # data-loss counts as a policy failure
118
+ "reason_code": "EVENT_QUEUE_SHED",
119
+ "sync_path": "local",
120
+ "detail": {"dropped_count": drop, "dropped_kinds": dropped_kinds},
121
+ "occurred_at": _now_iso(),
122
+ },
123
+ }
124
+ )
125
+
126
+ async def _persist_all(self) -> None:
127
+ # Overwrite the file with the current pending list. Cheap for small
128
+ # queues; if it ever grows we can move to append-only + compaction.
129
+ loop = asyncio.get_running_loop()
130
+ await loop.run_in_executor(None, self._write_sync)
131
+
132
+ def _write_sync(self) -> None:
133
+ with self._path.open("w", encoding="utf-8") as f:
134
+ for item in self._pending:
135
+ f.write(json.dumps(item, separators=(",", ":")) + "\n")
136
+
137
+ async def _flush_loop(self) -> None:
138
+ while not self._stop.is_set():
139
+ try:
140
+ await asyncio.wait_for(self._stop.wait(), timeout=FLUSH_INTERVAL_SEC)
141
+ return
142
+ except TimeoutError:
143
+ await self._flush_once()
144
+
145
+ async def _flush_once(self) -> None:
146
+ async with self._lock:
147
+ if not self._pending:
148
+ return
149
+ batch = self._pending[:MAX_BATCH]
150
+ events = [i["payload"] for i in batch if i["kind"] == "event"]
151
+ decisions = [i["payload"] for i in batch if i["kind"] == "decision"]
152
+ try:
153
+ token = self._s.gateway_service_token.get_secret_value()
154
+ async with httpx.AsyncClient(timeout=15.0) as client:
155
+ resp = await client.post(
156
+ f"{self._s.control_tower_url.rstrip('/')}/api/ingest",
157
+ headers={
158
+ "Authorization": f"Bearer {token}",
159
+ "Content-Type": "application/json",
160
+ },
161
+ content=json.dumps({"events": events, "decisions": decisions}),
162
+ )
163
+ if resp.status_code >= 500:
164
+ log.warning("ingest: retriable status %s; keeping batch", resp.status_code)
165
+ return
166
+ if resp.status_code >= 400:
167
+ # 4xx = we shipped a bad batch. Drop to prevent poison. Log loudly.
168
+ log.error("ingest: DROP batch on 4xx: %s", resp.text)
169
+ except Exception:
170
+ log.exception("ingest: transport error; keeping batch for next flush")
171
+ return
172
+
173
+ # Success or 4xx → remove from pending + persist.
174
+ async with self._lock:
175
+ self._pending = self._pending[len(batch) :]
176
+ await self._persist_all()