Two guardrail lies fixed: a config write that reported success and did nothing, a summary that asserted a scan it never ran

2026-08-08

securityguardrailsgovernance

LOCKSTEP TRACEABILITY MATRIX --- api_endpoints: ["PUT /v1/guardrails/config", "GET /v1/guardrails/config", "PUT /auth/guardrails/config"] sdk_methods_updated: ["none — response schema unchanged (Record passthrough)"] mcp_tools_updated: ["br_update_guardrail_config"] ---

What We Built

A production data-protection review scored 2/10 and named two defects as among the worst findings. Both were lies a control told about itself.

Defect 1 — a write that reports success and does nothing. PUT /v1/guardrails/config returned HTTP 200 {"status":"updated","mode":"block"} unconditionally. If the config service was unavailable, saveTenantGuardrailConfig warned to the log and returned — the write silently no-op'd, but the handler had already decided to say "updated". Separately, even a persisted V1 write could be entirely inert: the enforcement middleware (tryV2Pipeline in src/api/middleware/guardrails.ts) checks for an active V2 pipeline config before it ever looks at the V1 key this endpoint writes, and short-circuits if one exists. A tenant with any active V2 pipeline could PUT mode: "block" to the legacy endpoint, get a 200, and have that value never once be consulted by request-time enforcement. An operator would believe inline DLP blocking was live when nothing would ever be blocked.

Defect 2 — a summary that asserts a check it never ran. x-br-guardrail-summary: clean was emitted on responses even when the platform's own inbound PII scanner had flagged SSN, credit card, and email in the same request — because inbound findings (from runPiiSafetyNet, the V2 pipeline warn path, the legacy PII/content-filter path, and the V1 provider chain) only ever set response headers; none of them fed the GuardrailActionCollector that composes X-BR-Guardrail-Summary. An empty collector always serialized as "clean" — indistinguishable from "we scanned this and found nothing."

How It Works

Write-then-read-back, through the same function enforcement uses. New writeTenantGuardrailConfigVerified() in src/api/services/guardrail-store.ts is now the only path to a 200: it refuses outright (409 v2_pipeline_precedence) if the tenant has an active V2 pipeline that would shadow the write, otherwise it persists and immediately reads back through loadTenantGuardrailConfig() — the exact function runPluggableGuardrails() calls at request time — before returning. A write enforcement won't observe now returns 503 write_not_persisted, never 200 updated. All three write paths (PUT /v1/guardrails/config, PUT /auth/guardrails/config, and the br_update_guardrail_config MCP tool) route through the same verified helper so they can't drift. GET /v1/guardrails/config now also reports enforcement: { source, mode, v2_pipeline_count } so an operator can see which config actually governs their traffic.

Every inbound scan now records a finding, not just a header. A single context key (_inboundGuardrailFindings, initialized to [] the moment scanning is attempted) is appended to by every inbound detection path — baseline DLP, the V2 pipeline warn branch, the PII safety net, the legacy PII/content-filter path, and the V1 provider chain. setGuardrailTransparencyHeaders() folds these into the GuardrailActionCollector before computing the summary, idempotently. The collector also now distinguishes three states, not two: not_scanned (nothing ran), clean (ran, found nothing), and modified:pii=N / blocked:... (ran, found something) — mirroring the not_configured vs ok fix applied earlier in this remediation wave. Content the platform's own dlp check rates unsafe can no longer produce an inline clean summary; a regression test proves it using the same check registry the /v1/guardrails/test endpoint uses.

The Numbers

21 new tests across 4 files, each verified to fail with its corresponding fix reverted (exact failure text captured in the PR). Three separate write call-sites (REST ×2, MCP ×1) unified onto one verified helper instead of drifting independently.

Boundary Note

src/api/routes/completions/** was off-limits (concurrent edit in flight). The transparency fix was implemented entirely in src/api/middleware/guardrail-transparency.ts and src/api/middleware/guardrails.ts by reading inbound findings back out of Hono context — no completions-route file was touched.

Risk Called Out

Any tenant that previously set mode: "block" via PUT /v1/guardrails/config while an active V2 pipeline existed was silently running in whatever the V2 pipeline's own defaultOnFail specified — not block. Under this fix, a repeat of that same PUT now returns 409 v2_pipeline_precedence instead of a fake 200. That is surfacing a pre-existing gap, not introducing a new one, but it will change the HTTP response an existing integration sees on that call.

Lockstep Checklist

  • [x] API Routes: No new routes. PUT/GET /v1/guardrails/config and

PUT /auth/guardrails/config behavior changed (new 409/503 responses); response schemas remain Record passthrough.

  • [x] TS SDK: No change required — generated resources already type these responses as

untyped records.

  • [x] Python SDK: No change required, same reason.
  • [x] MCP Schemas: br_update_guardrail_config now throws a descriptive error instead

of silently reporting status: "updated" on an unpersisted or shadowed write.

  • [ ] Master Record: not touched this pass — behavior fix, not a new capability.