Files
spark-control/image/app/redaction/test_gateway.py
T
Keysat 8d839e3714 v0.13.0:4 - redaction gateway, embeddings proxy, expanded audio API
- Add redaction gateway (redaction_gateway.py, redaction/ scrub + tests)
- Add embeddings proxy and spark_embed service (Dockerfile + main.py)
- Expand audio_proxy with speaker-aware handling; deep_health/health/server updates
- Package: configureSparks action + sparkConfig model updates, manifest/main wiring
- Docs: AUDIO_API, EMBEDDINGS, REDACTION_GATEWAY; HANDOFF and runbook/known-issues refresh
2026-06-11 17:45:57 -05:00

183 lines
9.3 KiB
Python

#!/usr/bin/env python3
"""Gateway acceptance test: runs the reference leak fixtures THROUGH the live
/scrub + /rehydrate ASGI endpoints (ner=rules_only, deterministic/offline) plus
the gateway-specific security contract:
- parity: every must_vanish identifier absent from /scrub responses; substance survives
- map-leak: no real value (incl. Tier-1) appears in any response body OR the server map's
Claude-bound surface; Tier-1 values are absent from the stored map entirely
- round-trip: /rehydrate via the server-held map reproduces raw (Tier-1 -> [redacted])
- handle reuse: a 2nd /scrub with the same map_handle keeps tokens stable
- 409 tripwire: strict /rehydrate with an unmapped token
- 410: rehydrate against an unknown/expired handle
- 422 fail-closed: tier1_action=reject on Tier-1 input emits nothing
Run: cd image && python3 -m app.redaction.test_gateway (no Spark/Qwen/network needed)
"""
import asyncio
import os
import re
import sys
import tempfile
import httpx
from fastapi import FastAPI
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import scrub as R # noqa: E402 (vendored engine)
import test_scrub_leak as REF # noqa: E402 (reference fixtures)
# Build the gateway app against a throwaway map store.
os.environ.setdefault("SPARK1_HOST", "<spark-1-ip>")
os.environ.setdefault("SPARK2_HOST", "<spark-2-ip>")
from app.config import Settings # noqa: E402
from app.redaction_gateway import build_router, MapStore # noqa: E402
FAILS = []
def check(cond, msg):
print((" PASS " if cond else " FAIL ") + msg)
if not cond:
FAILS.append(msg)
def tier1_redacted(raw):
s = raw
for _, pat in R.TIER1_PATTERNS:
s = pat.sub("[redacted]", s)
return s
async def main():
db = os.path.join(tempfile.mkdtemp(), "maps.db")
store = MapStore(db, ttl_seconds=3600)
app = FastAPI()
app.include_router(build_router(Settings.from_env(), store))
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://gw") as c:
for case in REF.CASES:
print(f"\n[{case['name']}]")
r = await c.post("/scrub", json={
"task_id": "t-" + case["name"][:8], "actor": "analyst",
"items": [{"id": "ctx_1", "text": case["raw"]}],
"known_entities": case["known"], "ner": "rules_only",
})
check(r.status_code == 200, f"/scrub 200 (got {r.status_code} {r.text[:120]})")
if r.status_code != 200:
continue
d = r.json()
scrubbed = d["items"][0]["scrubbed_text"]
handle = d["map_handle"]
body_blob = r.text
for v in case["must_vanish"]:
check(v not in scrubbed, f"identifier {v!r} absent from scrubbed_text")
check(v not in body_blob, f"identifier {v!r} absent from entire /scrub response body")
for s in case["substance"]:
check(s in scrubbed, f"substance survives: {s!r}")
# map-leak: Tier-1 values must not be in the server-held map at all
stored = store.get(handle)
for v in case["tier1_excluded"]:
check(all(v not in val for val in stored.values()),
f"Tier-1 {v!r} not in server map (excluded, not tokenized)")
# round-trip via the server-held map
rr = await c.post("/rehydrate", json={
"task_id": "t", "map_handle": handle,
"items": [{"id": "out_1", "text": scrubbed}], "strict": True,
})
check(rr.status_code == 200, f"/rehydrate 200 (got {rr.status_code})")
if rr.status_code == 200:
rehy = rr.json()["items"][0]["rehydrated_text"]
check(rehy == tier1_redacted(case["raw"]),
"rehydrate via server map == raw with Tier-1 redacted")
# ── handle reuse keeps tokens stable across calls ──
print("\n[map_handle reuse — stable tokens]")
r1 = await c.post("/scrub", json={"task_id": "reuse", "items": [{"id": "a", "text": "Dana Whitfield called."}],
"known_entities": {"persons": ["Dana Whitfield", "Dana", "Whitfield"]}, "ner": "rules_only"})
h = r1.json()["map_handle"]
tok1 = r1.json()["items"][0]["scrubbed_text"]
r2 = await c.post("/scrub", json={"task_id": "reuse", "map_handle": h,
"items": [{"id": "b", "text": "Dana Whitfield emailed again."}],
"known_entities": {"persons": ["Dana Whitfield", "Dana", "Whitfield"]}, "ner": "rules_only"})
tok2 = r2.json()["items"][0]["scrubbed_text"]
same_token = re.findall(r"\[PERSON_\d+\]", tok1) == re.findall(r"\[PERSON_\d+\]", tok2)
check("Dana Whitfield" not in tok1 and "Dana Whitfield" not in tok2, "name tokenized both calls")
check(same_token and bool(re.search(r"\[PERSON_1\]", tok2)), "same entity -> same token across calls (reuse)")
# ── 409 strict tripwire on unmapped token ──
print("\n[strict rehydrate tripwire]")
r409 = await c.post("/rehydrate", json={"task_id": "reuse", "map_handle": h,
"items": [{"id": "x", "text": "see [PERSON_99] smuggled"}], "strict": True})
check(r409.status_code == 409, f"unmapped token -> 409 (got {r409.status_code})")
# ── 410 unknown/expired handle ──
print("\n[unknown handle -> 410]")
r410 = await c.post("/rehydrate", json={"task_id": "z", "map_handle": "deadbeef" * 4,
"items": [{"id": "x", "text": "[PERSON_1]"}], "strict": True})
check(r410.status_code == 410, f"unknown handle -> 410 (got {r410.status_code})")
# ── 422 fail-closed: tier1_action=reject emits nothing ──
print("\n[fail-closed tier1 reject]")
r422 = await c.post("/scrub", json={"task_id": "fc", "tier1_action": "reject",
"items": [{"id": "x", "text": "Wire to acct 000123456789 today."}],
"known_entities": {}, "ner": "rules_only"})
check(r422.status_code == 422, f"Tier-1 + reject -> 422 (got {r422.status_code})")
check("000123456789" not in r422.text, "rejected call does NOT echo the Tier-1 value")
# ── error bodies expose top-level documented keys (NOT wrapped under "detail") ──
print("\n[error body shape]")
check(r409.json().get("error") == "unknown_tokens" and "tokens" in r409.json(),
"409 body top-level {error:unknown_tokens, tokens:[...]}")
check(r410.json().get("error") == "map_expired", "410 body top-level {error:map_expired}")
check(r422.json().get("error") == "tier1_detected", "422 body top-level {error:tier1_detected}")
# ── tokens_used is BARE (PERSON_1, not [PERSON_1]) per the handover contract ──
print("\n[tokens_used bare]")
rb = await c.post("/scrub", json={"task_id": "bare", "items": [{"id": "a", "text": "Dana Whitfield called."}],
"known_entities": {"persons": ["Dana Whitfield"]}, "ner": "rules_only"})
tu = rb.json()["items"][0]["tokens_used"]
check(tu and all("[" not in t and "]" not in t for t in tu), f"tokens_used bare: {tu}")
# ── P0 fix unit tests: descriptive token-substitution match + fail-closed ──
print("\n[descriptive redaction — P0 fail-open fix]")
from app.redaction_gateway import _redact_descriptive, _apply_tokenmap_to_span, _Contract
tmap = {"[ORG_1]": "Acme Mining"}
# The NER stashed the span with the plaintext name; the final text has it tokenized.
final_text = "He is part of [redacted-was-here] the family that sold [ORG_1] in Texas last year, big deal."
span = "the family that sold Acme Mining in Texas last year"
sub = _apply_tokenmap_to_span(span, tmap)
check(sub == "the family that sold [ORG_1] in Texas last year", "token-substituted span matches scrubbed form")
out, flags = _redact_descriptive(final_text, [span], tmap, "i")
check("[redacted]" in out and "the family that sold" not in out,
"descriptive span removed via token-substituted match (no fail-open leak)")
# substantial span that can't be located anywhere -> fail closed (422)
try:
_redact_descriptive("totally unrelated text", ["the founder who sold his company in Wyoming last year"], {}, "i")
check(False, "unremovable substantial span should fail closed")
except _Contract as e:
check(e.status == 422 and e.body.get("error") == "descriptive_unredactable",
"unremovable substantial descriptive span -> 422 fail-closed")
# ── P0 fix: map store db file is NOT world-readable ──
print("\n[map store file perms — P0]")
import stat as _stat
mode = _stat.S_IMODE(os.stat(db).st_mode)
check(mode & 0o077 == 0, f"map db is 0600-ish (mode={oct(mode)}, no group/other access)")
print()
if FAILS:
print(f"FAILED ({len(FAILS)}):")
for f in FAILS:
print(" - " + f)
sys.exit(1)
print("ALL PASS (gateway acceptance — parity + map-leak + round-trip + tripwires)")
if __name__ == "__main__":
asyncio.run(main())