Skip to content

Commit 2486caf

Browse files
shivdeep1claude
andcommitted
Harden gateway, add open-core licensing, Docker demo
- Externalize scoring/challenge/rule config to env vars (Sentry pattern) - Add RateLimitResult with RFC 6585 headers (Retry-After, X-RateLimit-*) - Upgrade all endpoints from .allow() to .check() with structured 429s - Add admin revocation API (POST /admin/revoke, /unrevoke, GET /revoked) - Add asyncio.wait_for(timeout=30) on LLM generation and evaluation - Replace hardcoded thresholds in client.py with config values - Add BSL 1.1 (gateway), CC-BY-4.0 (spec), CLA, update CONTRIBUTING.md - Add Docker multi-container demo (gateway + 2 agents + Redis) - Add CLAUDE.md project conventions - 18 new tests (rate limit headers + revocation API), 338 total passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 79d88ac commit 2486caf

23 files changed

Lines changed: 1719 additions & 146 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,5 @@ WORK_SUMMARY.md
8181
ROLL_OUT_STATUS.md
8282
LLM_HANDOFF.md
8383
.hypothesis/
84+
COWORK.md
8485
.claude/

CLA.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Contributor License Agreement
2+
3+
Thank you for your interest in contributing to the Airlock Protocol.
4+
5+
By submitting a pull request or patch, you agree to the following terms:
6+
7+
## Individual CLA
8+
9+
1. **Grant of Rights.** You grant the Airlock Protocol maintainers a
10+
perpetual, worldwide, non-exclusive, royalty-free, irrevocable license
11+
to use, reproduce, modify, sublicense, and distribute your contributions
12+
under any license chosen by the project.
13+
14+
2. **Original Work.** You represent that your contribution is your original
15+
work, or you have sufficient rights to submit it under this agreement.
16+
17+
3. **No Warranty.** Your contributions are provided as-is, without warranty
18+
of any kind.
19+
20+
4. **Right to Relicense.** You understand that this CLA allows the project
21+
maintainers to change the license of the project in the future, and you
22+
consent to such changes applying to your contributions.
23+
24+
## Why a CLA?
25+
26+
Open-source projects that may need to change licenses (e.g., from Apache 2.0
27+
to BSL, or vice versa) require explicit permission from every contributor.
28+
Without a CLA, relicensing requires tracking down and getting consent from
29+
every individual contributor — which becomes impossible at scale.
30+
31+
Projects like Apache, Google, Microsoft, and HashiCorp all use CLAs for
32+
this reason.
33+
34+
## How to Sign
35+
36+
When you open a pull request, the CLA Assistant bot will ask you to sign
37+
electronically. You only need to sign once.
38+
39+
If you have questions, open an issue or contact the maintainers.

CLAUDE.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Airlock Protocol — CLAUDE.md
2+
3+
## What is this project?
4+
Airlock Protocol — "DMARC for AI Agents". An open trust verification protocol for autonomous AI agents.
5+
Ed25519 cryptography, DID identifiers, 5-phase verification, live registry at api.airlock.ing.
6+
7+
## Tech Stack
8+
- **Language:** Python 3.11+
9+
- **Framework:** FastAPI + Uvicorn
10+
- **Orchestration:** LangGraph
11+
- **Crypto:** PyNaCl (Ed25519)
12+
- **DB:** LanceDB (vector), optional Redis
13+
- **LLM:** LiteLLM (multi-provider)
14+
- **Tests:** pytest + pytest-asyncio + hypothesis
15+
16+
## Running Tests
17+
```bash
18+
pytest # run all tests
19+
pytest tests/ -x # stop on first failure
20+
pytest tests/test_crypto.py # single file
21+
pytest -k "test_verify" # by name pattern
22+
pytest --cov=airlock # with coverage
23+
```
24+
25+
## Running the Gateway
26+
```bash
27+
uvicorn airlock.gateway.app:create_app --factory --port 8000 --reload
28+
```
29+
30+
## Project Structure
31+
```
32+
airlock/
33+
a2a/ — Agent-to-Agent adapter
34+
audit/ — Audit trail
35+
crypto/ — Keys, signing, verifiable credentials
36+
engine/ — Orchestrator, event bus, state machine
37+
gateway/ — FastAPI routes and handlers
38+
integrations/ — Anthropic, LangChain, OpenAI SDKs
39+
registry/ — Agent registry and store
40+
reputation/ — Trust scoring and decay
41+
schemas/ — Pydantic models
42+
sdk/ — Client SDK and middleware
43+
semantic/ — Challenge evaluation + rule engine
44+
tests/ — 27 test files, 198+ tests
45+
```
46+
47+
## Conventions
48+
- **Type hints:** Always use type hints. MyPy strict mode is enabled.
49+
- **Linting:** Ruff. Run `ruff check .` before committing.
50+
- **Async:** Use async/await for all I/O operations. pytest-asyncio with `asyncio_mode = "auto"`.
51+
- **Pydantic:** Use Pydantic v2 models for all data schemas. No raw dicts for structured data.
52+
- **Error handling:** All API errors must return structured JSON with `error`, `detail`, and `status_code` fields.
53+
- **Tests:** Every new feature needs tests. Use `fakeredis` for Redis tests, `asgi-lifespan` for gateway tests.
54+
- **Imports:** Use absolute imports (`from airlock.crypto.keys import ...`), never relative.
55+
- **No print():** Use `logging` module. Never print() in library code.
56+
- **DID format:** Always validate DID strings match `did:key:z6Mk...` pattern before processing.
57+
- **Secrets:** Never log or expose private keys, challenge secrets, or JWT tokens.
58+
59+
## Common Mistakes to Avoid
60+
- Don't use `json.dumps()` for Pydantic models — use `model.model_dump_json()`
61+
- Don't forget `await` on async functions — this causes silent failures
62+
- Don't hardcode ports — use config.py
63+
- Don't skip input validation on DID strings — always validate format
64+
- Don't use `datetime.now()` — use `datetime.utcnow()` or `datetime.now(UTC)` for consistency
65+
- Don't create test files outside of `tests/` directory
66+
- Don't import from `airlock.gateway` in core modules — gateway depends on core, not the reverse
67+
68+
## Architecture Rules
69+
- Gateway layer → Engine layer → Crypto/Registry/Reputation layers
70+
- No circular dependencies between modules
71+
- All events go through the EventBus — no direct cross-module calls
72+
- Orchestrator uses LangGraph state machine — don't bypass the graph

CONTRIBUTING.md

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ pip install -e ".[dev]"
2424
python -m pytest tests/ -v
2525
```
2626

27-
All new code must include tests. The test suite must maintain 306+ passing tests.
27+
All new code must include tests. The test suite must maintain 338+ passing tests.
2828

2929
## Linting
3030

@@ -49,21 +49,22 @@ Type hints are required on all function signatures. No `Any` unless justified.
4949
- **Docstrings**: required on all public APIs (Google style)
5050
- **Imports**: sorted by ruff, one import per line for clarity
5151

52-
## Developer Certificate of Origin (DCO)
52+
## Contributor License Agreement (CLA)
5353

54-
This project uses the [Developer Certificate of Origin](https://developercertificate.org/) (DCO).
55-
All commits must be signed off to certify that you have the right to submit the code under the project's license.
54+
This project requires a [Contributor License Agreement](CLA.md). When you open
55+
your first pull request, the CLA Assistant bot will ask you to sign electronically.
56+
You only need to sign once.
5657

57-
Sign off your commits with the `-s` flag:
58+
The CLA ensures the project maintainers can manage licensing across the open-core
59+
model (Apache 2.0 for SDKs, BSL 1.1 for gateway).
60+
61+
All commits must also be signed off (DCO). Sign off your commits with the `-s` flag:
5862

5963
```bash
6064
git commit -s -m "feat: add new verification check"
6165
```
6266

63-
This adds a `Signed-off-by: Your Name <your@email.com>` line to your commit message.
64-
The DCO check runs in CI and will fail if any commit in your PR is missing a sign-off.
65-
66-
If you forgot to sign off previous commits, you can amend:
67+
If you forgot to sign off previous commits:
6768

6869
```bash
6970
git rebase HEAD~N --signoff # sign off the last N commits
@@ -96,4 +97,13 @@ See [SECURITY.md](SECURITY.md) for responsible disclosure instructions.
9697

9798
## License
9899

99-
By contributing, you agree that your contributions will be licensed under the Apache License 2.0.
100+
This project uses a multi-license model:
101+
102+
| Component | License |
103+
|-----------|---------|
104+
| SDKs, crypto, schemas (`sdks/`, `airlock/crypto/`, `airlock/schemas/`) | Apache 2.0 |
105+
| Gateway, engine (`airlock/gateway/`, `airlock/engine/`) | BSL 1.1 (converts to Apache 2.0 on 2030-04-04) |
106+
| Specification (`docs/spec/`) | CC-BY-4.0 |
107+
108+
By contributing, you agree to the terms of the [CLA](CLA.md), which allows your
109+
contributions to be distributed under the applicable license for the component.

LICENSE-BSL

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
Business Source License 1.1
2+
3+
Licensor: Airlock Protocol Contributors
4+
Licensed Work: Airlock Gateway (airlock/gateway/*, airlock/engine/*)
5+
Change Date: 2030-04-04
6+
Change License: Apache License 2.0
7+
8+
For the full BSL 1.1 license text, see:
9+
https://mariadb.com/bsl11/
10+
11+
Usage Grant: You may use the Licensed Work for any purpose except
12+
offering a commercial hosted service that competes with the Licensor's
13+
paid offerings (registry, enterprise gateway, managed trust service).
14+
15+
On the Change Date, the Licensed Work converts to the Change License
16+
(Apache 2.0) automatically.

LICENSE-CC-BY-4.0

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Creative Commons Attribution 4.0 International (CC-BY-4.0)
2+
3+
Applies to: Protocol specification (docs/spec/*, docs/ietf/*)
4+
5+
For the full license text, see:
6+
https://creativecommons.org/licenses/by/4.0/legalcode
7+
8+
You are free to share and adapt the specification for any purpose,
9+
including commercial use, provided you give appropriate credit to
10+
the Airlock Protocol Contributors.

airlock/client.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ async def averify(
132132
poll_timeout: float = 30.0,
133133
) -> VerifyResult:
134134
"""Async version of :meth:`verify`."""
135+
from airlock.config import get_config # noqa: PLC0415
136+
137+
cfg = get_config()
135138
did = self._normalize_did(did_or_url)
136139

137140
async with self._http_client() as http:
@@ -157,8 +160,8 @@ async def averify(
157160
agent_name=agent_name,
158161
trust_score=trust_score,
159162
verdict="VERIFIED"
160-
if trust_score >= 0.75
161-
else ("DEFERRED" if trust_score >= 0.5 else "REJECTED"),
163+
if trust_score >= cfg.scoring_threshold_high
164+
else ("DEFERRED" if trust_score >= cfg.scoring_initial else "REJECTED"),
162165
session_id=None,
163166
)
164167

airlock/config.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,57 @@ class AirlockConfig(BaseSettings):
7575
# Challenge fallback mode when LLM is unavailable: "ambiguous" (default) or "rule_based".
7676
challenge_fallback_mode: str = "ambiguous"
7777

78+
# -----------------------------------------------------------------------
79+
# Scoring (generic defaults — production overrides via env vars)
80+
# -----------------------------------------------------------------------
81+
scoring_initial: float = 0.5
82+
scoring_half_life_days: float = 30.0
83+
scoring_verified_delta: float = 0.05
84+
scoring_rejected_delta: float = -0.15
85+
scoring_deferred_delta: float = -0.02
86+
scoring_threshold_high: float = 0.75
87+
scoring_threshold_blacklist: float = 0.15
88+
scoring_diminishing_factor: float = 0.1
89+
90+
# -----------------------------------------------------------------------
91+
# Challenge questions (path to external JSON, empty = use built-in generic set)
92+
# -----------------------------------------------------------------------
93+
challenge_questions_path: str = ""
94+
95+
# -----------------------------------------------------------------------
96+
# Rule evaluator thresholds (generic defaults)
97+
# -----------------------------------------------------------------------
98+
rule_keyword_density_max: float = 0.30
99+
rule_coherence_min: float = 0.25
100+
rule_complexity_min_words: int = 25
101+
rule_cross_domain_max: int = 3
102+
rule_min_answer_length: int = 20
103+
rule_min_sentences: int = 2
104+
78105
# Event bus drain timeout during shutdown (seconds).
79106
event_bus_drain_timeout_seconds: float = Field(default=30.0, ge=1.0, le=600.0)
80107

81108
@property
82109
def is_production(self) -> bool:
83110
return self.env == "production"
111+
112+
113+
# ---------------------------------------------------------------------------
114+
# Singleton accessor — avoids re-parsing env vars on every call.
115+
# ---------------------------------------------------------------------------
116+
117+
_config_instance: AirlockConfig | None = None
118+
119+
120+
def get_config() -> AirlockConfig:
121+
"""Return the global AirlockConfig singleton (created on first call)."""
122+
global _config_instance # noqa: PLW0603
123+
if _config_instance is None:
124+
_config_instance = AirlockConfig()
125+
return _config_instance
126+
127+
128+
def _reset_config() -> None:
129+
"""Reset the singleton — for use in tests only."""
130+
global _config_instance # noqa: PLW0603
131+
_config_instance = None

airlock/gateway/error_handlers.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,29 @@
99
from fastapi.exceptions import HTTPException, RequestValidationError
1010
from fastapi.responses import JSONResponse
1111

12+
from airlock.gateway.rate_limit import RateLimitResult
13+
1214
logger = logging.getLogger(__name__)
1315

1416
_PROBLEM_BASE = "https://airlock.ing/problems/"
1517

1618

19+
class RateLimitExceeded(HTTPException):
20+
"""HTTPException enriched with RFC 6585 rate-limit metadata."""
21+
22+
def __init__(self, detail: str, rl: RateLimitResult) -> None:
23+
super().__init__(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=detail)
24+
self.rate_limit_result: RateLimitResult = rl
25+
26+
1727
def _problem_response(
1828
*,
1929
request: Request,
2030
status_code: int,
2131
type_path: str,
2232
title: str,
2333
detail: str | list[Any] | dict[str, Any],
34+
headers: dict[str, str] | None = None,
2435
) -> JSONResponse:
2536
return JSONResponse(
2637
status_code=status_code,
@@ -31,9 +42,20 @@ def _problem_response(
3142
"detail": detail,
3243
"instance": str(request.url.path),
3344
},
45+
headers=headers,
3446
)
3547

3648

49+
def _rate_limit_headers(rl: RateLimitResult) -> dict[str, str]:
50+
"""Build RFC 6585 rate-limit response headers."""
51+
return {
52+
"Retry-After": str(rl.retry_after),
53+
"X-RateLimit-Limit": str(rl.limit),
54+
"X-RateLimit-Remaining": str(rl.remaining),
55+
"X-RateLimit-Reset": str(int(rl.reset_at)),
56+
}
57+
58+
3759
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
3860
title = "HTTP Error"
3961
if exc.status_code == status.HTTP_404_NOT_FOUND:
@@ -47,16 +69,22 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONRe
4769
elif exc.status_code == status.HTTP_503_SERVICE_UNAVAILABLE:
4870
title = "Service Unavailable"
4971
detail: str | list[Any] | dict[str, Any]
50-
if isinstance(exc.detail, (str, list, dict)):
72+
if isinstance(exc.detail, str):
5173
detail = exc.detail
5274
else:
53-
detail = str(exc.detail)
75+
detail = exc.detail # type: ignore[assignment]
76+
77+
headers: dict[str, str] | None = None
78+
if isinstance(exc, RateLimitExceeded):
79+
headers = _rate_limit_headers(exc.rate_limit_result)
80+
5481
return _problem_response(
5582
request=request,
5683
status_code=exc.status_code,
5784
type_path=f"http-{exc.status_code}",
5885
title=title,
5986
detail=detail,
87+
headers=headers,
6088
)
6189

6290

@@ -68,7 +96,7 @@ async def validation_exception_handler(
6896
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
6997
type_path="validation-error",
7098
title="Validation Error",
71-
detail=list(exc.errors()),
99+
detail=exc.errors(),
72100
)
73101

74102

0 commit comments

Comments
 (0)