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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@
| | |
|---|---|
| **Last updated** | 2026-06-08 |
| **Current phase** | Phase 6 — Governance & Tenancy (**5 / 10 steps**) |
| **Overall** | **69 / 84 steps** — Phases 0–5 complete |
| **Next action** | **Step 6.6bWORM signed audit export**: an HMAC-signed export bundle over the events + chain head (reusing the `ProvenanceSigner` pattern), `POST /v1/audit/export` + `ragctl audit export/verify` → immutability at rest (write to S3 Object Lock). Completes Step 6.6 (6.6a — the read API + chain verification — shipped). |
| **Current phase** | Phase 6 — Governance & Tenancy (**6 / 10 steps**) |
| **Overall** | **70 / 84 steps** — Phases 0–5 complete |
| **Next action** | **Step 6.7BYOK (Bring Your Own Key)**: KMS integration (AWS KMS / GCP KMS / HashiCorp Vault) + envelope encryption for embeddings; per-tenant keys. New ground (no existing crypto seam beyond the HMAC signers from 5.1 / 6.6b). |

**Recently shipped**

- **6.6a** 🚧 Audit read API + chain verification — shared `AuditWriter`/store on `app.state`; `GET /v1/audit` (tenant-scoped, newest-first, `chain_verified`) + `GET /v1/audit/verify` (whole-log integrity); `cfg.audit.enabled` (default on); first slice of Step 6.6 (WORM export is 6.6b) — [#153](https://github.com/officialCodeWork/AgentContextOS/pull/153)
- **6.6** ✅ Immutable audit log — **6.6a** shared `AuditWriter`/store on `app.state` + `GET /v1/audit` (tenant-scoped, `chain_verified`) + `GET /v1/audit/verify` (whole-log) + `cfg.audit.enabled` ([#153](https://github.com/officialCodeWork/AgentContextOS/pull/153)); **6.6b** `AuditExporter` self-verifying WORM bundle (SHA-256 `content_hash` + HMAC, offline `verify()`), `POST /v1/audit/export` (tenant-scoped) + `ragctl audit`, `cfg.audit.export_secret` ([#154](https://github.com/officialCodeWork/AgentContextOS/pull/154))
- **6.5** ✅ PII egress policies — `PiiPolicyEngine` (`rag-pii`) answers `egress_text` over the context (`list[Chunk]`) + agent answer (`str`) the gateway already passes, applying the per-tenant `pii_policy` (allow / redact / mask / block); reuses the Step 1.7 detector + rewriters; opt-in `cfg.pii.enabled`; emits `pii.egress_blocked` — [#152](https://github.com/officialCodeWork/AgentContextOS/pull/152)
- **6.4** ✅ ACL egress verifier — `AclEgressVerifier` re-checks returned `ChunkRef`s against the principal's labels at the gateway router boundary (defense-in-depth, same overlap semantics, independent of the PDP); `acl.egress_violation` event; red-team zero-violation-rate gate — [#151](https://github.com/officialCodeWork/AgentContextOS/pull/151)
- **6.3** ✅ ACL push-down — opt-in `AclPolicyEngine` And-merges `any_in("acl_labels", principal.acl_labels)` into every `read_chunk` query (overlap, fail-closed); `acl.egress_denied` event — [#150](https://github.com/officialCodeWork/AgentContextOS/pull/150)
Expand Down Expand Up @@ -60,9 +60,9 @@
| 3 | Gateway & Agent Runtime | 11 | **11** | 0 |
| 4 | Reliability | 6 | **6** | 0 |
| 5 | Eval & Observability | 7 | **7** | 0 |
| 6 | Governance & Tenancy | 10 | **5** | 5 |
| 6 | Governance & Tenancy | 10 | **6** | 4 |
| 7 | Pilot, Harden, GA | 10 | 0 | 10 |
| **Total** | | **84** | **69** | **15** |
| **Total** | | **84** | **70** | **14** |

---

Expand Down Expand Up @@ -654,7 +654,7 @@
| 6.3 | ACL push-down at retrieval | ✅ | [#150](https://github.com/officialCodeWork/AgentContextOS/pull/150) — opt-in `AclPolicyEngine` And-merges `any_in("acl_labels", …)` into every `read_chunk` push-down (overlap, fail-closed); `acl.egress_denied` |
| 6.4 | ACL egress verifier | ✅ | [#151](https://github.com/officialCodeWork/AgentContextOS/pull/151) — `AclEgressVerifier` re-checks returned chunks at the gateway router boundary (defense-in-depth above the 6.3 push-down); `acl.egress_violation`; zero-violation-rate red-team gate |
| 6.5 | PII policies | ✅ | [#152](https://github.com/officialCodeWork/AgentContextOS/pull/152) — `PiiPolicyEngine` egress_text decorator (allow / redact / mask / block per tenant) over answer + context; reuses Step 1.7 detector; `pii.egress_blocked` |
| 6.6 | Immutable audit log | 🚧 | **6.6a** [#153](https://github.com/officialCodeWork/AgentContextOS/pull/153) — read API `GET /v1/audit` (tenant-scoped) + `GET /v1/audit/verify` (whole-log chain) + shared store wiring + `cfg.audit`. **6.6b** WORM signed export |
| 6.6 | Immutable audit log | | **6.6a** [#153](https://github.com/officialCodeWork/AgentContextOS/pull/153) — read API `GET /v1/audit` + `GET /v1/audit/verify` + shared store + `cfg.audit`. **6.6b** [#154](https://github.com/officialCodeWork/AgentContextOS/pull/154)`AuditExporter` signed WORM bundle + `POST /v1/audit/export` + `ragctl audit` |
| 6.7 | BYOK (Bring Your Own Key) | ⏳ | KMS integration (AWS KMS, GCP KMS, HashiCorp Vault); envelope encryption for embeddings |
| 6.8 | SSO / SCIM | ⏳ | OIDC + SAML IdP federation; SCIM 2.0 user provisioning; per-tenant IdP config |
| 6.9 | Air-gapped install bundle | ⏳ | Signed tarball with all images + Helm chart; offline bootstrap; cosign verification |
Expand Down Expand Up @@ -719,6 +719,14 @@ Step 0.7c shipped the foundation — an append-only `AuditStore` SPI, a `NoopAud
- **Read API on by default**: new `cfg.audit.enabled` (default **true**) — unlike the behaviour-changing ACL / PII toggles, the audit log is a passive, tenant-scoped compliance record, so exposing it out of the box is the expected enterprise default; off → endpoints 404 (`AuditNotFoundError`), events still recorded. New `AuditListResponse` / `AuditVerifyResponse` wire types (`rag_core.gateway_types`); `AuditError` / `AuditNotFoundError` (→ 404); `dist/openapi` + `dist/rag.schema` regenerated
- **Scope:** read + verify only (WORM signed export is 6.6b); today the populated event is `corpus.route` (every query) — expanding audit coverage (ACL / PII / ingest decisions) is a follow-up, the surface + chain are in place for it. ~10 gateway tests (tenant-scoped list, **cross-tenant isolation**, verify ok + **tamper detection**, filters/pagination, disabled→404, no-auth→401, shared-store wiring); all gates green (ruff, mypy --strict 298 files, RAG001, schema/openapi-drift, policy-coverage, log-schema). [ADR-0038](docs/adr/ADR-0038-immutable-audit-log.md), [architecture/audit-log.md](docs/architecture/audit-log.md), [reference/audit.md](docs/reference/audit.md)

#### 6.6b — WORM signed export ✅ [#154](https://github.com/officialCodeWork/AgentContextOS/pull/154)

- The hash chain (6.6a) gives tamper-*evidence* for the live log; 6.6b adds immutability *at rest* via a **self-verifying signed export**. New **`AuditExporter`** (`rag_core.audit_export`, pure stdlib) builds an **`AuditExport`** bundle with **two independent integrity checks**: a **`content_hash`** (SHA-256 over the canonical serialisation of the events — pins the exact set + order, recomputed on verify so any add/remove/edit is caught *even without the secret*) and a **`signature`** (HMAC-SHA256 over `f"{timestamp}.{content_hash}"`, mirroring the Step 5.1 `ProvenanceSigner` scheme). `verify()` returns `{signed, content_ok, verified, reason}` with `reason` ∈ `ok` / `unsigned` / `no_secret` / `content_mismatch` / `signature_mismatch`; `chain_verified` attests the source store's whole-log chain was intact at export time
- **`POST /v1/audit/export`** is **tenant-scoped** (the caller's events only — same isolation as `GET /v1/audit`); `ragctl audit` exports the whole log in-process. Because integrity is `content_hash` + HMAC over *whatever events are included* (not the global chain head), the **same `verify()` works for both** — a tenant subset isn't a contiguous chain. The bundle verifies **offline** (no live store), so it's the artifact an operator archives to immutable storage (S3 Object Lock)
- **Opt-in signing**: new `cfg.audit.export_secret` (env-interpolated, mirrors `provenance.signing_secret`); empty (default) → content-hashed but **unsigned** bundle (`verify` → `unsigned`), set it to enable signature verification. `build_app(audit_export_secret=…)` → `app.state.audit_exporter`; `build_app_from_config` from config
- New `AuditExport` / `AuditExportSignature` / `AuditExportVerification` core types (`dist/schemas` regenerated); `POST /v1/audit/export` → `dist/openapi`; **`ragctl audit`** (seed → export → verify round-trip, `--out` writes the bundle, `--verify FILE` checks one offline). ~23 new tests (exporter unit: content_hash determinism, sign→verify, unsigned, no_secret, wrong-secret, **content tamper → content_mismatch**, **signature tamper → signature_mismatch**, empty; gateway: tenant-scoped + signed + **offline-verifies** + cross-tenant isolation + unsigned + disabled→404 + no-auth→401; ragctl round-trip + file verify). All gates green (ruff, mypy --strict 299 files, RAG001, schema/openapi-drift, policy-coverage, log-schema). [ADR-0038](docs/adr/ADR-0038-immutable-audit-log.md), [architecture/audit-log.md](docs/architecture/audit-log.md), [reference/audit.md](docs/reference/audit.md)
- **Deferred:** a durable live-store `AuditStore` backend (Postgres / append-only ledger) and expanding what gets audited beyond `corpus.route`.

---

## Phase 7 — Pilot, Harden, GA (Weeks 34–40) ⏳
Expand Down Expand Up @@ -876,6 +884,7 @@ Complete log of every PR. Routine Dependabot bumps are grouped; everything else
| [#151](https://github.com/officialCodeWork/AgentContextOS/pull/151) | 2026-06-08 | feat(policy): ACL egress verifier — defense-in-depth re-check (Step 6.4) |
| [#152](https://github.com/officialCodeWork/AgentContextOS/pull/152) | 2026-06-08 | feat(pii): PII egress policies — PiiPolicyEngine egress_text decorator (Step 6.5) |
| [#153](https://github.com/officialCodeWork/AgentContextOS/pull/153) | 2026-06-08 | feat(gateway): audit read API + chain verification (Step 6.6a) |
| [#154](https://github.com/officialCodeWork/AgentContextOS/pull/154) | 2026-06-08 | feat(audit): WORM signed export — AuditExporter + POST /v1/audit/export (Step 6.6b) |
| #78–#80, #116–#118 | Open | Dependabot bumps — awaiting merge |
| #81 | Closed | Dependabot bump — superseded |

Expand Down
8 changes: 8 additions & 0 deletions apps/gateway/src/rag_gateway/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
from rag_chunker import HeadingAwareChunker
from rag_core import get_logger
from rag_core.audit import AuditWriter
from rag_core.audit_export import AuditExporter
from rag_core.errors import (
ACLDeniedError,
AuthError,
Expand Down Expand Up @@ -374,6 +375,7 @@ def build_app(
pii_enabled: bool = False,
pii_detector: Any | None = None,
audit_enabled: bool = True,
audit_export_secret: str = "",
enable_cors: bool = True,
default_tenant_id: TenantId | None = None,
) -> FastAPI:
Expand Down Expand Up @@ -475,6 +477,11 @@ def build_app(
app.state.audit_writer = audit_writer
app.state.audit_store = audit_writer.store
app.state.audit_enabled = audit_enabled
# WORM signed export (Step 6.6b) — POST /v1/audit/export returns a
# self-verifying AuditExport bundle, HMAC-signed when a secret is configured
# (empty → unsigned but still content-hashed). The artifact an operator
# archives to immutable storage (S3 Object Lock).
app.state.audit_exporter = AuditExporter(audit_export_secret)
# Corpus router (Step 3.5) sits above the retrieval router; with the
# default empty corpus store it degrades to a single unconstrained
# retrieval, preserving the pre-3.5 demo behaviour. It shares the audit
Expand Down Expand Up @@ -716,6 +723,7 @@ async def info() -> dict[str, Any]:
"GET /v1/status/logs/stream",
"GET /v1/audit",
"GET /v1/audit/verify",
"POST /v1/audit/export",
"WS /v1/status/ws",
"GET /v1/connectors/status",
"GET /healthz",
Expand Down
26 changes: 25 additions & 1 deletion apps/gateway/src/rag_gateway/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@
from __future__ import annotations

from fastapi import APIRouter, Request
from rag_core.audit_export import AuditExporter
from rag_core.errors import AuditNotFoundError, AuthError
from rag_core.gateway_types import AuditListResponse, AuditVerifyResponse, GatewayError
from rag_core.spi.audit_store import AuditStore
from rag_core.types import AuditEvent, RequestContext
from rag_core.types import AuditEvent, AuditExport, RequestContext

_MAX_LIMIT = 1000

Expand Down Expand Up @@ -112,4 +113,27 @@ async def verify_audit(request: Request) -> AuditVerifyResponse:
store = _require_store(request)
return AuditVerifyResponse(ok=store.verify_chain(), event_count=len(store.events()))

@router.post(
"/v1/audit/export",
response_model=AuditExport,
responses={
401: {"model": GatewayError, "description": "Missing or invalid auth"},
404: {"model": GatewayError, "description": "Audit read API disabled"},
},
summary="Signed WORM export of the tenant's audit log (Step 6.6b)",
)
async def export_audit(request: Request) -> AuditExport:
"""Return a self-verifying export bundle of the calling tenant's events.

The bundle is content-hashed and (when ``cfg.audit.export_secret`` is set)
HMAC-signed, so a consumer verifies it offline; ``chain_verified`` attests
the source log's whole-log hash chain was intact at export time. It is the
artifact an operator archives to immutable (WORM) storage.
"""
ctx = _require_ctx(request)
store = _require_store(request)
exporter: AuditExporter = request.app.state.audit_exporter
mine = [e for e in store.events() if e.tenant_id == ctx.tenant_id]
return exporter.export(mine, tenant_id=ctx.tenant_id, chain_verified=store.verify_chain())

return router
3 changes: 3 additions & 0 deletions apps/gateway/src/rag_gateway/wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -712,7 +712,9 @@ def build_app_from_config(cfg: RagConfig, **overrides: Any) -> FastAPI:

# Immutable audit-log read API (Step 6.6) — on by default (tenant-scoped,
# passive compliance record); flip off to withhold the HTTP read surface.
# ``export_secret`` (6.6b) signs the WORM export bundle when set.
audit_enabled = overrides.pop("audit_enabled", cfg.audit.enabled)
audit_export_secret = overrides.pop("audit_export_secret", cfg.audit.export_secret)

return build_app(
corpus_store=corpus_store,
Expand All @@ -727,6 +729,7 @@ def build_app_from_config(cfg: RagConfig, **overrides: Any) -> FastAPI:
acl_verify_egress=acl_verify_egress,
pii_enabled=pii_enabled,
audit_enabled=audit_enabled,
audit_export_secret=audit_export_secret,
**overrides,
)

Expand Down
52 changes: 50 additions & 2 deletions apps/gateway/tests/test_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@
from __future__ import annotations

from fastapi.testclient import TestClient
from rag_core.audit_export import AuditExporter
from rag_core.types import AuditExport
from rag_gateway import build_app

_ACME = {"X-Tenant-Id": "acme", "X-Principal-Id": "alice"}
_GLOBEX = {"X-Tenant-Id": "globex", "X-Principal-Id": "mallory"}


def _client(*, audit_enabled: bool = True) -> TestClient:
return TestClient(build_app(audit_enabled=audit_enabled))
def _client(*, audit_enabled: bool = True, export_secret: str = "") -> TestClient:
return TestClient(build_app(audit_enabled=audit_enabled, audit_export_secret=export_secret))


def _post_query(client: TestClient, headers: dict[str, str], q: str = "anything") -> None:
Expand Down Expand Up @@ -128,3 +130,49 @@ def test_default_build_app_enables_audit() -> None:
assert app.state.audit_enabled is True
# the read API + the corpus router share one store
assert app.state.audit_store is app.state.audit_writer.store


# ---------------------------------------------------------------------------
# POST /v1/audit/export — signed WORM bundle (Step 6.6b)
# ---------------------------------------------------------------------------
def test_export_is_tenant_scoped_and_signed_and_offline_verifies() -> None:
client = _client(export_secret="topsecret")
_post_query(client, _ACME)
r = client.post("/v1/audit/export", headers=_ACME)
assert r.status_code == 200, r.text
body = r.json()
assert body["tenant_id"] == "acme"
assert body["count"] >= 1
assert body["chain_verified"] is True
assert body["signature"] is not None
assert all(e["tenant_id"] == "acme" for e in body["events"])
# the bundle verifies offline with the same secret
export = AuditExport.model_validate(body)
assert AuditExporter("topsecret").verify(export).verified is True
# and fails under a wrong secret
assert AuditExporter("wrong").verify(export).reason == "signature_mismatch"


def test_export_cross_tenant_isolation() -> None:
client = _client(export_secret="topsecret")
_post_query(client, _ACME)
body = client.post("/v1/audit/export", headers=_GLOBEX).json()
assert body["count"] == 0
assert body["events"] == []


def test_export_unsigned_without_secret() -> None:
client = _client() # no export secret
_post_query(client, _ACME)
body = client.post("/v1/audit/export", headers=_ACME).json()
assert body["signature"] is None
assert body["content_hash"] # still content-hashed


def test_export_disabled_is_404() -> None:
client = _client(audit_enabled=False)
assert client.post("/v1/audit/export", headers=_ACME).status_code == 404


def test_export_no_auth_is_401() -> None:
assert _client().post("/v1/audit/export").status_code == 401
Loading
Loading