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.
- agentgov_gateway-0.1.0/.gitignore +79 -0
- agentgov_gateway-0.1.0/Dockerfile +45 -0
- agentgov_gateway-0.1.0/PKG-INFO +66 -0
- agentgov_gateway-0.1.0/README.md +35 -0
- agentgov_gateway-0.1.0/agentgov_gateway/__init__.py +6 -0
- agentgov_gateway-0.1.0/agentgov_gateway/config.py +140 -0
- agentgov_gateway-0.1.0/agentgov_gateway/device_key.py +111 -0
- agentgov_gateway-0.1.0/agentgov_gateway/event_queue.py +176 -0
- agentgov_gateway-0.1.0/agentgov_gateway/hook_integrity.py +83 -0
- agentgov_gateway-0.1.0/agentgov_gateway/log_scrubber.py +81 -0
- agentgov_gateway-0.1.0/agentgov_gateway/main.py +533 -0
- agentgov_gateway-0.1.0/agentgov_gateway/middleware/__init__.py +11 -0
- agentgov_gateway-0.1.0/agentgov_gateway/middleware/auth.py +174 -0
- agentgov_gateway-0.1.0/agentgov_gateway/middleware/base.py +66 -0
- agentgov_gateway-0.1.0/agentgov_gateway/middleware/policy.py +171 -0
- agentgov_gateway-0.1.0/agentgov_gateway/middleware/repo_authz.py +116 -0
- agentgov_gateway-0.1.0/agentgov_gateway/middleware/usage.py +144 -0
- agentgov_gateway-0.1.0/agentgov_gateway/middleware/work_item.py +134 -0
- agentgov_gateway-0.1.0/agentgov_gateway/policy_cache.py +206 -0
- agentgov_gateway-0.1.0/agentgov_gateway/rate_limit.py +88 -0
- agentgov_gateway-0.1.0/agentgov_gateway/revocation_channel.py +99 -0
- agentgov_gateway-0.1.0/agentgov_gateway/session_rehydration.py +72 -0
- agentgov_gateway-0.1.0/agentgov_gateway/snapshot_poller.py +99 -0
- agentgov_gateway-0.1.0/agentgov_gateway/synthetic.py +85 -0
- agentgov_gateway-0.1.0/agentgov_gateway/upstream/__init__.py +1 -0
- agentgov_gateway-0.1.0/agentgov_gateway/upstream/anthropic.py +126 -0
- agentgov_gateway-0.1.0/agentgov_gateway/upstream/base.py +26 -0
- agentgov_gateway-0.1.0/fly.toml +52 -0
- agentgov_gateway-0.1.0/pyproject.toml +75 -0
- agentgov_gateway-0.1.0/tests/conftest.py +45 -0
- agentgov_gateway-0.1.0/tests/test_device_key.py +132 -0
- agentgov_gateway-0.1.0/tests/test_log_scrubber.py +69 -0
- agentgov_gateway-0.1.0/tests/test_policy_cache.py +129 -0
- agentgov_gateway-0.1.0/tests/test_repo_normalize.py +20 -0
- agentgov_gateway-0.1.0/tests/test_stream_abort.py +93 -0
- agentgov_gateway-0.1.0/tests/test_upstream_dual_mode.py +58 -0
- 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,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()
|