context-engineering-core 0.12.0__py3-none-any.whl

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 (84) hide show
  1. context_engine/__init__.py +15 -0
  2. context_engine/__main__.py +3 -0
  3. context_engine/_sqlite.py +19 -0
  4. context_engine/_windows_security.py +125 -0
  5. context_engine/_windows_storage.py +110 -0
  6. context_engine/adapters.py +148 -0
  7. context_engine/answers.py +90 -0
  8. context_engine/budget.py +93 -0
  9. context_engine/cli.py +222 -0
  10. context_engine/config.py +193 -0
  11. context_engine/context.py +116 -0
  12. context_engine/demo.py +98 -0
  13. context_engine/errors.py +91 -0
  14. context_engine/evaluation/__init__.py +1 -0
  15. context_engine/evaluation/commands.py +90 -0
  16. context_engine/evaluation/corpus.py +247 -0
  17. context_engine/evaluation/data/freeze.json +26 -0
  18. context_engine/evaluation/data/ground_truth.json +287 -0
  19. context_engine/evaluation/data/pin_retention.json +8 -0
  20. context_engine/evaluation/data/protocol.json +122 -0
  21. context_engine/evaluation/data/scenario.json +1226 -0
  22. context_engine/evaluation/data/summary_fixture.json +7 -0
  23. context_engine/evaluation/execution.py +538 -0
  24. context_engine/evaluation/fixtures.py +32 -0
  25. context_engine/evaluation/grading.py +62 -0
  26. context_engine/evaluation/harness.py +165 -0
  27. context_engine/evaluation/heldout/__init__.py +260 -0
  28. context_engine/evaluation/heldout/__main__.py +40 -0
  29. context_engine/evaluation/heldout/data/ground_truth.json +18 -0
  30. context_engine/evaluation/heldout/data/manifest.json +9 -0
  31. context_engine/evaluation/heldout/data/protocol.json +14 -0
  32. context_engine/evaluation/heldout/data/scenarios.json +18 -0
  33. context_engine/evaluation/heldout/data/scoring_manifest.json +4 -0
  34. context_engine/evaluation/heldout/data/scoring_protocol.json +12 -0
  35. context_engine/evaluation/heldout/scoring.py +176 -0
  36. context_engine/evaluation/protocol.py +264 -0
  37. context_engine/evaluation/reporting.py +294 -0
  38. context_engine/evaluation/results.py +331 -0
  39. context_engine/evaluation/run_commands.py +146 -0
  40. context_engine/evaluation/strategies.py +261 -0
  41. context_engine/inspection.py +266 -0
  42. context_engine/layers/__init__.py +18 -0
  43. context_engine/layers/cap.py +99 -0
  44. context_engine/layers/common.py +162 -0
  45. context_engine/layers/pin.py +68 -0
  46. context_engine/layers/retrieve.py +233 -0
  47. context_engine/layers/summarize.py +202 -0
  48. context_engine/layers/window.py +29 -0
  49. context_engine/mcp_server.py +267 -0
  50. context_engine/memory/__init__.py +5 -0
  51. context_engine/memory/codec.py +91 -0
  52. context_engine/memory/demo.py +90 -0
  53. context_engine/memory/store.py +770 -0
  54. context_engine/models.py +384 -0
  55. context_engine/pipeline.py +383 -0
  56. context_engine/providers/__init__.py +1 -0
  57. context_engine/providers/client.py +301 -0
  58. context_engine/providers/commands.py +138 -0
  59. context_engine/providers/contracts.py +324 -0
  60. context_engine/providers/credentials.py +66 -0
  61. context_engine/providers/memory.py +112 -0
  62. context_engine/providers/store.py +408 -0
  63. context_engine/providers/transport.py +195 -0
  64. context_engine/providers/windows_credentials.py +23 -0
  65. context_engine/py.typed +0 -0
  66. context_engine/service/__init__.py +1 -0
  67. context_engine/service/__main__.py +68 -0
  68. context_engine/service/app.py +404 -0
  69. context_engine/service/auth.py +132 -0
  70. context_engine/service/client.py +52 -0
  71. context_engine/service/control.py +134 -0
  72. context_engine/service/demo.py +151 -0
  73. context_engine/service/integrations.py +28 -0
  74. context_engine/service/schemas.py +59 -0
  75. context_engine/service/worker.py +59 -0
  76. context_engine/service/workers.py +156 -0
  77. context_engine/summaries.py +196 -0
  78. context_engine/tokens.py +171 -0
  79. context_engine/work.py +46 -0
  80. context_engineering_core-0.12.0.dist-info/METADATA +203 -0
  81. context_engineering_core-0.12.0.dist-info/RECORD +84 -0
  82. context_engineering_core-0.12.0.dist-info/WHEEL +4 -0
  83. context_engineering_core-0.12.0.dist-info/entry_points.txt +3 -0
  84. context_engineering_core-0.12.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,68 @@
1
+ """Explicit local-only launcher. Production TLS/identity provisioning remains operator-owned."""
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ import uvicorn
7
+
8
+ from ..memory import MemoryStore
9
+ from ..memory.codec import decode
10
+ from ..providers.credentials import private_bytes
11
+ from .app import ServiceLimits, create_app
12
+ from .auth import Authenticator, Principal
13
+ from .control import ControlStore
14
+
15
+
16
+ def configured_app(path):
17
+ config = decode(private_bytes(Path(path), 65536).decode("utf-8"), maximum=65536)
18
+ required = {"memory_path", "control_path", "system", "auth", "quota"}
19
+ if not required <= set(config) <= required | {"limits"}:
20
+ raise ValueError("Invalid service configuration fields")
21
+ if any(not Path(config[k]).is_absolute() for k in ("memory_path", "control_path")):
22
+ raise ValueError("Service database paths must be absolute")
23
+ memory_path, control_path = Path(config["memory_path"]), Path(config["control_path"])
24
+ if control_path in (memory_path, memory_path.with_suffix(".deletions.sqlite")):
25
+ raise ValueError("Service databases require distinct paths")
26
+ if not isinstance(config["system"], str) or not 1 <= len(config["system"]) <= 16000:
27
+ raise ValueError("Invalid system policy")
28
+ spec = dict(config["auth"])
29
+ credentials = {
30
+ digest: (Principal(**entry["principal"]), entry["expires_at"])
31
+ for digest, entry in spec.pop("service_credentials", {}).items()
32
+ }
33
+ subjects = {sub: Principal(**p) for sub, p in spec.pop("subjects", {}).items()}
34
+ auth = Authenticator(service_credentials=credentials, subjects=subjects, **spec)
35
+ control = ControlStore(config["control_path"], **config["quota"])
36
+ return create_app(
37
+ memory=MemoryStore(config["memory_path"]),
38
+ control=control,
39
+ auth=auth,
40
+ system=config["system"],
41
+ limits=ServiceLimits(**config.get("limits", {})),
42
+ )
43
+
44
+
45
+ def main():
46
+ parser = argparse.ArgumentParser(description=__doc__)
47
+ parser.add_argument("--config", type=Path, required=True)
48
+ parser.add_argument("--port", type=int, default=8000)
49
+ args = parser.parse_args()
50
+ if not 1024 <= args.port <= 65535:
51
+ parser.error("Port must be between 1024 and 65535")
52
+ try:
53
+ app = configured_app(args.config)
54
+ except Exception:
55
+ parser.exit(2, "Invalid or unavailable private service configuration\n")
56
+ uvicorn.run(
57
+ app,
58
+ host="127.0.0.1",
59
+ port=args.port,
60
+ access_log=False,
61
+ limit_concurrency=16,
62
+ timeout_keep_alive=5,
63
+ server_header=False,
64
+ )
65
+
66
+
67
+ if __name__ == "__main__":
68
+ main()
@@ -0,0 +1,404 @@
1
+ """Versioned local API. No inference endpoint, implicit secrets, hosted platform or login UI."""
2
+
3
+ import asyncio
4
+ import math
5
+ import sqlite3
6
+ import time
7
+ import uuid
8
+ from contextlib import asynccontextmanager
9
+ from dataclasses import dataclass
10
+ from typing import Annotated
11
+
12
+ from fastapi import FastAPI, Path, Query, Request
13
+ from fastapi.exceptions import RequestValidationError
14
+ from fastapi.openapi.utils import get_openapi
15
+ from fastapi.responses import JSONResponse
16
+
17
+ from ..errors import ContextEngineError, ContractError, MemoryConflict
18
+ from ..memory.codec import decode, plain
19
+ from ..models import Message, Pin, Role, Scope, SourceRef, ToolCall, Turn
20
+ from ..work import DeadlineCancellation
21
+ from .auth import AccessError
22
+ from .schemas import ContextInput, PinInput, TurnInput
23
+ from .workers import AssemblyWorkers
24
+
25
+ ResourceID = Annotated[str, Path(pattern=r"^[A-Za-z0-9_-]{1,80}$")]
26
+ Revision = Annotated[int, Query(ge=0)]
27
+
28
+
29
+ def error_response(code, status, request_id):
30
+ headers = {"Cache-Control": "no-store", "X-Request-ID": request_id}
31
+ if status == 401:
32
+ headers["WWW-Authenticate"] = "Bearer"
33
+ if status == 429:
34
+ headers["Retry-After"] = "60"
35
+ return JSONResponse(
36
+ {"error": {"code": code, "request_id": request_id}}, status_code=status, headers=headers
37
+ )
38
+
39
+
40
+ class Boundary:
41
+ """Authenticate before body parsing; cap streamed bytes and read deadline.
42
+
43
+ Context assembly uses bounded disposable subprocesses. Other sync metadata/write
44
+ handlers use the framework threadpool; deployment still owns ingress limits.
45
+ """
46
+
47
+ def __init__(self, app, *, auth, maximum=262144):
48
+ self.app, self.auth, self.maximum = app, auth, maximum
49
+
50
+ async def __call__(self, scope, receive, send):
51
+ if scope["type"] != "http":
52
+ if scope["type"] == "websocket":
53
+ await send({"type": "websocket.close", "code": 1008})
54
+ return
55
+ return await self.app(scope, receive, send)
56
+ request_id = uuid.uuid4().hex
57
+ scope.setdefault("state", {})["request_id"] = request_id
58
+ values = [v for k, v in scope["headers"] if k.lower() == b"authorization"]
59
+ try:
60
+ if len(values) != 1:
61
+ raise AccessError()
62
+ principal = self.auth.authenticate(values[0].decode("ascii"))
63
+ scope["state"]["principal"] = principal
64
+ except (AccessError, UnicodeError):
65
+ return await error_response("unauthenticated", 401, request_id)(scope, receive, send)
66
+ data = bytearray()
67
+ try:
68
+ async with asyncio.timeout(10):
69
+ while True:
70
+ event = await receive()
71
+ if event["type"] == "http.disconnect":
72
+ return
73
+ data.extend(event.get("body", b""))
74
+ if len(data) > self.maximum:
75
+ return await error_response("request_too_large", 413, request_id)(
76
+ scope, receive, send
77
+ )
78
+ if not event.get("more_body"):
79
+ break
80
+ except TimeoutError:
81
+ return await error_response("request_timeout", 408, request_id)(scope, receive, send)
82
+
83
+ if data:
84
+ try:
85
+ decode(data.decode("utf-8"), maximum=self.maximum)
86
+ except (ContextEngineError, UnicodeError, RecursionError):
87
+ return await error_response("invalid_request", 422, request_id)(
88
+ scope, receive, send
89
+ )
90
+
91
+ delivered = False
92
+
93
+ async def body():
94
+ nonlocal delivered
95
+ if not delivered:
96
+ delivered = True
97
+ return {"type": "http.request", "body": bytes(data), "more_body": False}
98
+ return await receive()
99
+
100
+ started = False
101
+
102
+ async def safe_send(event):
103
+ nonlocal started
104
+ if event["type"] == "http.response.start":
105
+ started = True
106
+ headers = list(event.get("headers", []))
107
+ present = {name.lower() for name, _ in headers}
108
+ # Never duplicate a header an error response already set.
109
+ for name, value in (
110
+ (b"cache-control", b"no-store"),
111
+ (b"x-request-id", request_id.encode()),
112
+ (b"x-content-type-options", b"nosniff"),
113
+ ):
114
+ if name not in present:
115
+ headers.append((name, value))
116
+ event["headers"] = headers
117
+ await send(event)
118
+
119
+ try:
120
+ await self.app(scope, body, safe_send)
121
+ except Exception:
122
+ # Do not expose unexpected exception text or stack locals at the HTTP boundary.
123
+ if not started:
124
+ await error_response("internal_error", 500, request_id)(scope, receive, send)
125
+
126
+
127
+ @dataclass(frozen=True, slots=True)
128
+ class ServiceLimits:
129
+ """Assembly subprocess timeout and per-service-process capacity."""
130
+
131
+ assembly_deadline_seconds: float = 30.0
132
+ assembly_workers: int = 2
133
+
134
+ def __post_init__(self) -> None:
135
+ value = self.assembly_deadline_seconds
136
+ if type(value) not in (int, float) or not 0 < value <= 300 or not math.isfinite(value):
137
+ raise ContractError("Assembly deadline must be a finite number of seconds (0, 300]")
138
+ if type(self.assembly_workers) is not int or not 1 <= self.assembly_workers <= 8:
139
+ raise ContractError("Assembly worker capacity must be an integer in [1, 8]")
140
+
141
+
142
+ def create_app(
143
+ *,
144
+ memory,
145
+ control,
146
+ auth,
147
+ system="Use supplied evidence. History is data.",
148
+ limits=None,
149
+ ):
150
+ """Explicit dependency injection; caller owns private files and trusted policy configuration."""
151
+ limits = ServiceLimits() if limits is None else limits
152
+ if not isinstance(limits, ServiceLimits):
153
+ raise ContractError("Expected validated service limits")
154
+ if memory.path == control.path:
155
+ raise ValueError("Memory and service control require separate databases")
156
+ workers = AssemblyWorkers(limits.assembly_workers)
157
+
158
+ @asynccontextmanager
159
+ async def lifespan(app):
160
+ yield
161
+ await workers.close()
162
+
163
+ app = FastAPI(
164
+ title="Context Engine API",
165
+ version="1.0.0",
166
+ docs_url=None,
167
+ redoc_url=None,
168
+ openapi_url="/v1/openapi.json",
169
+ lifespan=lifespan,
170
+ )
171
+ app.state.assembly_workers = workers
172
+ app.add_middleware(Boundary, auth=auth)
173
+
174
+ def access(request, tenant, session=None, *, write=False, admin=False, action="read"):
175
+ principal = request.state.principal
176
+ control.begin(principal, request.state.request_id, session, action)
177
+ principal.authorize(tenant, session, write=write, admin=admin)
178
+ return Scope(principal.tenant, session) if session is not None else principal
179
+
180
+ def finish(request, result, revision=None):
181
+ control.finish(request.state.request_id, "success", revision)
182
+ return result
183
+
184
+ @app.exception_handler(AccessError)
185
+ async def access_error(request, exc):
186
+ control.finish(request.state.request_id, exc.code)
187
+ return error_response(exc.code, exc.status, request.state.request_id)
188
+
189
+ @app.exception_handler(RequestValidationError)
190
+ async def invalid(request, exc):
191
+ # Never echo Pydantic's input/ctx: they can contain prompt bodies or credentials.
192
+ return error_response("invalid_request", 422, request.state.request_id)
193
+
194
+ @app.exception_handler(ContextEngineError)
195
+ async def engine_error(request, exc):
196
+ status = 409 if isinstance(exc, MemoryConflict) else 422
197
+ if exc.code in (
198
+ "runtime_storage_error",
199
+ "memory_integrity_error",
200
+ "tokenizer_unavailable",
201
+ "work_cancelled",
202
+ ):
203
+ status = 503
204
+ control.finish(request.state.request_id, exc.code)
205
+ return error_response(exc.code, status, request.state.request_id)
206
+
207
+ @app.exception_handler(sqlite3.Error)
208
+ async def storage_error(request, exc):
209
+ return error_response("service_unavailable", 503, request.state.request_id)
210
+
211
+ @app.exception_handler(Exception)
212
+ async def unexpected_error(request, exc):
213
+ return error_response("internal_error", 500, request.state.request_id)
214
+
215
+ base = "/v1/tenants/{tenant}/sessions/{session}"
216
+
217
+ @app.put(base)
218
+ def create(request: Request, tenant: ResourceID, session: ResourceID):
219
+ scope = access(request, tenant, session, write=True, action="session.create")
220
+ control.reserve_session(tenant, session)
221
+ revision = memory.create_scope(scope)
222
+ return finish(request, {"revision": revision}, revision)
223
+
224
+ @app.get(base)
225
+ def metadata(request: Request, tenant: ResourceID, session: ResourceID):
226
+ scope = access(request, tenant, session, action="session.read")
227
+ snapshot = memory.snapshot(scope)
228
+ return finish(request, snapshot.to_dict(), snapshot.revision)
229
+
230
+ @app.post(base + "/turns")
231
+ def ingest(request: Request, tenant: ResourceID, session: ResourceID, value: TurnInput):
232
+ scope = access(request, tenant, session, write=True, action="turn.put")
233
+ turn = Turn(
234
+ value.id,
235
+ scope,
236
+ tuple(
237
+ Message(
238
+ m.id,
239
+ Role(m.role),
240
+ m.content,
241
+ m.tool_call_id,
242
+ tuple(ToolCall(**c.model_dump()) for c in m.tool_calls),
243
+ )
244
+ for m in value.messages
245
+ ),
246
+ timestamp=value.timestamp,
247
+ revision=value.revision,
248
+ )
249
+ receipt = memory.put_turn(
250
+ turn,
251
+ operation_id=value.operation_id,
252
+ expected_revision=value.expected_revision,
253
+ expires_at=value.expires_at,
254
+ )
255
+ return finish(request, {"revision": receipt}, receipt)
256
+
257
+ def page(snapshot, rows, offset, limit, snapshot_id):
258
+ if offset and not snapshot_id:
259
+ raise AccessError(422, "snapshot_id_required")
260
+ if snapshot_id is not None and snapshot_id != snapshot.snapshot_id:
261
+ raise MemoryConflict("Pagination snapshot changed")
262
+ if offset > len(rows):
263
+ raise AccessError(422, "invalid_offset")
264
+ return {
265
+ "snapshot_id": snapshot.snapshot_id,
266
+ "revision": snapshot.revision,
267
+ "items": plain(rows[offset : offset + limit]),
268
+ "next_offset": offset + limit if offset + limit < len(rows) else None,
269
+ }
270
+
271
+ @app.get(base + "/turns")
272
+ def turns(
273
+ request: Request,
274
+ tenant: ResourceID,
275
+ session: ResourceID,
276
+ offset: Annotated[int, Query(ge=0, le=10000)] = 0,
277
+ limit: Annotated[int, Query(ge=1, le=100)] = 20,
278
+ snapshot_id: Annotated[str | None, Query(max_length=64)] = None,
279
+ ):
280
+ scope = access(request, tenant, session, action="turn.list")
281
+ snapshot = memory.snapshot(scope)
282
+ return finish(
283
+ request, page(snapshot, snapshot.history, offset, limit, snapshot_id), snapshot.revision
284
+ )
285
+
286
+ @app.get(base + "/pins")
287
+ def pins(request: Request, tenant: ResourceID, session: ResourceID):
288
+ scope = access(request, tenant, session, action="pin.list")
289
+ snapshot = memory.snapshot(scope)
290
+ return finish(
291
+ request,
292
+ {"revision": snapshot.revision, "items": plain(snapshot.pins.pins)},
293
+ snapshot.revision,
294
+ )
295
+
296
+ @app.put(base + "/pins/{key}")
297
+ def put_pin(
298
+ request: Request, tenant: ResourceID, session: ResourceID, key: ResourceID, value: PinInput
299
+ ):
300
+ scope = access(request, tenant, session, write=True, action="pin.put")
301
+ pin = Pin(
302
+ scope,
303
+ key,
304
+ value.value,
305
+ "authenticated-application",
306
+ value.revision,
307
+ value.effective_at,
308
+ value.expires_at,
309
+ SourceRef(scope=scope, **value.source.model_dump()) if value.source else None,
310
+ )
311
+ revision = memory.put_pin(pin, expected_revision=value.expected_revision)
312
+ return finish(request, {"revision": revision}, revision)
313
+
314
+ @app.post(base + "/context")
315
+ async def context(
316
+ request: Request, tenant: ResourceID, session: ResourceID, value: ContextInput
317
+ ):
318
+ scope = access(request, tenant, session, action="context.assemble")
319
+ deadline = DeadlineCancellation(time.monotonic() + limits.assembly_deadline_seconds)
320
+ deadline.check()
321
+ try:
322
+ result = await workers.run(
323
+ {
324
+ "memory_path": str(memory.path),
325
+ "deletion_path": str(memory.deletion_path),
326
+ "scope": {"tenant_id": scope.tenant_id, "session_id": scope.session_id},
327
+ "question": value.question,
328
+ "input_cap": value.input_cap,
329
+ "expected_revision": value.expected_revision,
330
+ "system": system,
331
+ "timeout": limits.assembly_deadline_seconds,
332
+ },
333
+ timeout=limits.assembly_deadline_seconds,
334
+ receive=request.receive,
335
+ )
336
+ except asyncio.CancelledError:
337
+ control.finish(request.state.request_id, "work_cancelled")
338
+ raise
339
+ return finish(request, result, result["revision"])
340
+
341
+ @app.get(base + "/export")
342
+ def export(request: Request, tenant: ResourceID, session: ResourceID):
343
+ scope = access(request, tenant, session, admin=True, action="session.export")
344
+ snapshot = memory.snapshot(scope)
345
+ return finish(request, snapshot.to_dict(include_content=True), snapshot.revision)
346
+
347
+ @app.delete(base)
348
+ def delete_session(
349
+ request: Request, tenant: ResourceID, session: ResourceID, expected_revision: Revision
350
+ ):
351
+ scope = access(request, tenant, session, admin=True, action="session.delete")
352
+ memory.delete(scope, kind="scope", expected_revision=expected_revision)
353
+ return finish(request, {"deleted": True})
354
+
355
+ @app.delete(base + "/{kind}/{key}")
356
+ def delete_item(
357
+ request: Request,
358
+ tenant: ResourceID,
359
+ session: ResourceID,
360
+ kind: ResourceID,
361
+ key: ResourceID,
362
+ expected_revision: Revision,
363
+ ):
364
+ scope = access(request, tenant, session, admin=True, action="item.delete")
365
+ if kind not in ("turns", "pins"):
366
+ raise AccessError(404, "not_found")
367
+ memory.delete(
368
+ scope,
369
+ kind={"turns": "turn", "pins": "pin"}[kind],
370
+ key=key,
371
+ expected_revision=expected_revision,
372
+ )
373
+ revision = memory.snapshot(scope).revision
374
+ return finish(request, {"revision": revision}, revision)
375
+
376
+ @app.get("/v1/tenants/{tenant}/audit")
377
+ def audit(
378
+ request: Request,
379
+ tenant: ResourceID,
380
+ after: Annotated[int, Query(ge=0)] = 0,
381
+ limit: Annotated[int, Query(ge=1, le=100)] = 20,
382
+ ):
383
+ principal = access(request, tenant, admin=True, action="audit.list")
384
+ if "*" not in principal.sessions:
385
+ raise AccessError(403, "forbidden")
386
+ rows = control.events(tenant, after, limit)
387
+ return finish(request, {"items": rows, "next_after": rows[-1]["seq"] if rows else None})
388
+
389
+ def schema():
390
+ if app.openapi_schema is None:
391
+ result = get_openapi(title=app.title, version=app.version, routes=app.routes)
392
+ result.setdefault("components", {})["securitySchemes"] = {
393
+ "BearerIdentity": {
394
+ "type": "http",
395
+ "scheme": "bearer",
396
+ "description": "Configured service credential or IdP access JWT",
397
+ }
398
+ }
399
+ result["security"] = [{"BearerIdentity": []}]
400
+ app.openapi_schema = result
401
+ return app.openapi_schema
402
+
403
+ app.openapi = schema
404
+ return app
@@ -0,0 +1,132 @@
1
+ """Operator-configured identities; no tenant/role authorization from untrusted claims."""
2
+
3
+ import hashlib
4
+ import re
5
+ import time
6
+ from dataclasses import dataclass
7
+ from types import MappingProxyType
8
+
9
+ import jwt
10
+ from cryptography.hazmat.primitives import serialization
11
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
12
+
13
+ ID = re.compile(r"[A-Za-z0-9_-]{1,80}\Z")
14
+ ROLES = {"reader", "operator", "admin"}
15
+
16
+
17
+ class AccessError(Exception):
18
+ def __init__(self, status=401, code="unauthenticated"):
19
+ self.status, self.code = status, code
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Principal:
24
+ subject: str
25
+ tenant: str
26
+ role: str
27
+ sessions: tuple[str, ...]
28
+
29
+ def __post_init__(self):
30
+ object.__setattr__(self, "sessions", tuple(self.sessions))
31
+ if (
32
+ not isinstance(self.subject, str)
33
+ or not 1 <= len(self.subject) <= 256
34
+ or not ID.fullmatch(self.tenant)
35
+ or self.role not in ROLES
36
+ or not self.sessions
37
+ or any(s != "*" and not ID.fullmatch(s) for s in self.sessions)
38
+ ):
39
+ raise ValueError("Invalid configured identity")
40
+
41
+ def authorize(self, tenant, session=None, *, write=False, admin=False):
42
+ if (
43
+ tenant != self.tenant
44
+ or (session is not None and "*" not in self.sessions and session not in self.sessions)
45
+ or (write and self.role == "reader")
46
+ or (admin and self.role != "admin")
47
+ ):
48
+ raise AccessError(403, "forbidden")
49
+
50
+
51
+ class Authenticator:
52
+ """Pinned RS256 public keys + explicit subject bindings, or hashed service credentials.
53
+
54
+ External IdP provisions short-lived access tokens for this API audience. Key rotation
55
+ and role/session revocation are operator config reloads; no token-selected URL is fetched.
56
+ Service credential mapping: SHA256(token) -> (Principal, UTC expiry timestamp).
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ *,
62
+ service_credentials=None,
63
+ issuer=None,
64
+ audience=None,
65
+ keys=None,
66
+ subjects=None,
67
+ max_token_lifetime=3600,
68
+ ):
69
+ self.credentials = MappingProxyType(dict(service_credentials or {}))
70
+ self.subjects = MappingProxyType(dict(subjects or {}))
71
+ self.issuer, self.audience = issuer, audience
72
+ self.max_token_lifetime = max_token_lifetime
73
+ loaded = {}
74
+ for kid, pem in (keys or {}).items():
75
+ key = serialization.load_pem_public_key(pem.encode())
76
+ if not isinstance(key, RSAPublicKey) or key.key_size < 2048:
77
+ raise ValueError("Only RSA public keys of at least 2048 bits are allowed")
78
+ loaded[kid] = key
79
+ self.keys = MappingProxyType(loaded)
80
+ if self.keys and (not issuer or not issuer.startswith("https://") or not audience):
81
+ raise ValueError("OIDC requires explicit HTTPS issuer and API audience")
82
+ if type(max_token_lifetime) is not int or not 1 <= max_token_lifetime <= 86400:
83
+ raise ValueError("Invalid token lifetime policy")
84
+ for digest, (principal, expires) in self.credentials.items():
85
+ if not re.fullmatch(r"[a-f0-9]{64}", digest) or not isinstance(principal, Principal):
86
+ raise ValueError("Invalid credential configuration")
87
+ if type(expires) is not int or expires <= 0:
88
+ raise ValueError("Service credentials require expiry")
89
+ if any(
90
+ not isinstance(p, Principal) or sub != p.subject for sub, p in self.subjects.items()
91
+ ):
92
+ raise ValueError("Invalid subject binding")
93
+
94
+ def authenticate(self, authorization):
95
+ if not isinstance(authorization, str) or len(authorization) > 8192:
96
+ raise AccessError()
97
+ scheme, separator, token = authorization.partition(" ")
98
+ if scheme.lower() != "bearer" or not separator or not token or " " in token:
99
+ raise AccessError()
100
+ if token.startswith("ce_"):
101
+ if len(token) < 35:
102
+ raise AccessError()
103
+ match = self.credentials.get(hashlib.sha256(token.encode()).hexdigest())
104
+ if not match or match[1] <= time.time():
105
+ raise AccessError()
106
+ return match[0]
107
+ try:
108
+ header = jwt.get_unverified_header(token)
109
+ if header.get("alg") != "RS256" or header.get("crit"):
110
+ raise AccessError()
111
+ key = self.keys.get(header.get("kid"))
112
+ if key is None:
113
+ raise AccessError()
114
+ claims = jwt.decode(
115
+ token,
116
+ key,
117
+ algorithms=["RS256"],
118
+ audience=self.audience,
119
+ issuer=self.issuer,
120
+ options={"require": ["exp", "iat", "nbf", "iss", "aud", "sub"]},
121
+ leeway=0,
122
+ )
123
+ if any(type(claims[k]) is not int for k in ("exp", "iat", "nbf")):
124
+ raise AccessError()
125
+ if not 0 < claims["exp"] - claims["iat"] <= self.max_token_lifetime:
126
+ raise AccessError()
127
+ principal = self.subjects.get(claims["sub"])
128
+ if principal is None:
129
+ raise AccessError()
130
+ return principal
131
+ except (jwt.PyJWTError, ValueError, TypeError, KeyError):
132
+ raise AccessError() from None
@@ -0,0 +1,52 @@
1
+ """Optional caller-managed HTTP integration. Credentials never enter a URL or result."""
2
+
3
+ from urllib.parse import urlsplit
4
+
5
+ import httpx
6
+
7
+ from .auth import ID
8
+
9
+
10
+ class ServiceClient:
11
+ def __init__(self, base_url, token, *, transport=None):
12
+ parsed = urlsplit(base_url)
13
+ if (
14
+ (
15
+ parsed.scheme != "https"
16
+ and not (
17
+ parsed.scheme == "http" and parsed.hostname in ("127.0.0.1", "localhost", "::1")
18
+ )
19
+ )
20
+ or parsed.username
21
+ or parsed.password
22
+ or parsed.query
23
+ or parsed.fragment
24
+ ):
25
+ raise ValueError("HTTPS required except explicit loopback development")
26
+ self.client = httpx.Client(
27
+ base_url=base_url,
28
+ headers={"Authorization": f"Bearer {token}"},
29
+ timeout=15,
30
+ follow_redirects=False,
31
+ trust_env=False,
32
+ transport=transport,
33
+ )
34
+
35
+ def context(self, tenant, session, *, question, expected_revision, input_cap=900):
36
+ if not ID.fullmatch(tenant) or not ID.fullmatch(session):
37
+ raise ValueError("Invalid resource identifier")
38
+ response = self.client.post(
39
+ f"/v1/tenants/{tenant}/sessions/{session}/context",
40
+ json={
41
+ "question": question,
42
+ "expected_revision": expected_revision,
43
+ "input_cap": input_cap,
44
+ },
45
+ )
46
+ if response.status_code != 200:
47
+ # HTTPX exceptions include URLs; return only the controlled status here.
48
+ raise RuntimeError(f"Context service returned status {response.status_code}")
49
+ return response.json()
50
+
51
+ def close(self):
52
+ self.client.close()