fix(compliance): stop the phone PII pattern matching inside opaque identifiers - #271
Conversation
…s test test_request_enrichment_fails_closed_without_gate_url compared the whole result dict against a three-key literal that omitted idempotency_key, so it failed against the implementation it was introduced alongside (#259). engine/gate_egress.py documents the authoritative contract: "one attempt per call; retry is the caller's decision and requires the idempotency key returned in the result". All three return paths of request_enrichment honour that, the sibling success-path test already asserts result["idempotency_key"], and engine/health/enrichment_trigger.py forwards the whole envelope to its caller, so the key is load-bearing on the fail-closed path too. The test encoded the wrong envelope, so the test is corrected. Strict whole-dict equality is kept — the envelope stays exactly pinned, with the expected key derived from the module's public enrichment_idempotency_key helper rather than a hardcoded digest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nwd3DgnqkpEuaBMPKYLhF3
…fiers handle_match builds query_id as `q_` + uuid4().hex[:12]. The PHONE value pattern was the only one in _PII_PATTERNS without boundary guards, so any 10-digit run inside that token matched it. ComplianceEngine.redact_response then removed the field outright (PIIHandler.redact pops the key), so the match response silently lost query_id. Measured on 200,000 generated ids: 1.572% were classified as a phone number. That is a live response-shape defect, and it is what made tests/test_handlers.py::test_match_returns_structure fail intermittently — the assertion is correct, the engine was dropping the key. `\b` is not usable here: the pattern can start with `+` or `(`, which are not word characters, so a leading `\b` would break `+1 (555) 123-4567`. SSN and IP_ADDRESS can use `\b` because they start with a digit. Lookarounds for [0-9A-Za-z_] give the same protection without that constraint. Verified: 0/200,000 false positives after the change, while `555-123-4567`, `+1 (555) 123-4567`, `5551234567` and `call 555.123.4567 now` are all still detected, as is detection by field name. Both directions are pinned by new regression tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nwd3DgnqkpEuaBMPKYLhF3
The overlap gate blocked publication: tests/unit/test_gate_egress.py textually conflicts with PR #264 ("fix(test): repair the fail-closed enrichment assertion that is red on main") and PR #262. PR #264 already contains the same correction, derived independently and byte-identical in the block it touches, and additionally asserts the idempotency key on the SDK-error path. That PR owns this file, so carrying a duplicate here would only add an add/add conflict for whichever landed second. This branch keeps only the compliance repair, which neither #264 nor #262 touches. This PR therefore stacks on #264's head so the enrichment assertion is inherited rather than duplicated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nwd3DgnqkpEuaBMPKYLhF3
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
✅ PR reviewable size is within recommended limits |
L9 Audit Harness Report
Step Results
Architecture Audit Findings
See Spec Coverage
See Next StepsAll checks passed. Safe to merge. |
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
The regex guard change is narrowly scoped, directly addresses the verified failure mode, and is backed by targeted regression tests for both false-positive prevention and true-positive retention.
Review effort: Lite
Findings: None
What changed in this PR
This PR fixes an intermittent loss of query_id in match responses by preventing the phone-number value regex from matching 10-digit runs embedded inside opaque alphanumeric identifiers (e.g., q_<12 hex>), which previously caused response redaction to drop the field.
Changes:
- Added alphanumeric/underscore lookaround guards to the
PIICategory.PHONEregex to avoid matches inside longer tokens. - Added regression coverage ensuring
query_id-shaped opaque tokens are not detected as phone PII. - Added coverage to ensure common real phone formats are still detected by value-pattern matching.
| File | Description |
|---|---|
engine/compliance/pii.py |
Tightens the phone PII value regex with lookaround guards to avoid false positives in opaque identifiers. |
tests/compliance/test_hipaa.py |
Adds regression tests for the opaque-identifier false positive and for continued detection of real phone formats. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
CI status on
|
The enrichment assertion is red on main and takes seven checks down with it on this PR: Test Suite, Pre-commit Hooks (via the pytest hook), Coverage (Codecov), Quality Gate, Baseline Ratchet (Required Tests + Verdict, which records it as an unledgered finding), and the CI Gate rollup that blocks merge. The fix exists in open PRs #264 and #262, which make byte-identical changes to this hunk. Porting it rather than waiting: it no-ops once main carries it. This is the exact hunk both PRs carry, deliberately with no edits of my own. An earlier attempt here rewrote the same assertion with an explanatory comment inside the dict, which made it textually different and so a genuine add/add conflict — that is what the overlap gate caught, and it was right to. Identical text on both sides merges cleanly in a three-way merge, so this port conflicts with neither PR. engine/gate_egress.py documents the contract this restores: "retry is the caller's decision and requires the idempotency key returned in the result". All three return paths of request_enrichment honour it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nwd3DgnqkpEuaBMPKYLhF3
|
Correction: the enrichment fix is now ported into this PR (
|
| Check | Was | Now |
|---|---|---|
Test Suite, Pre-commit Hooks, Coverage, Quality Gate, Baseline Ratchet, CI Gate |
red on this one assertion | expected green on 568493b |
OpenSSF Scorecard |
red | still red — unchanged, see below |
OpenSSF Scorecard stands as described earlier: it dies at Pull gcr.io/openssf/scorecard-action:v2.4.0 before checkout or any analysis, the last five supply-chain.yml runs on main failed identically including on this PR's base 9c8e3cf, and the one re-run I spent reproduced it at the same step. Not a flake, not this diff, and the remedy (bumping the pinned action SHA) belongs in its own PR rather than widening this one.
Generated by Claude Code



Problem
tests/test_handlers.py::test_match_returns_structurefails intermittently — it passes in isolation, so it reads like a flake:It is not a flake.
handle_matchbuildsquery_idasq_+uuid4().hex[:12], andPIICategory.PHONEwas the only entry in_PII_PATTERNSwithout boundary guards, so any 10-digit run inside that opaque token matched the phone regex.ComplianceEngine.redact_responsethen removed the field outright —PIIHandler.redactpops the key rather than masking it — so the match response silently lostquery_id.Measured over 200,000 generated ids: 1.572% were classified as a phone number. The assertion was right; the engine was dropping the key on roughly 1 in 64 match requests.
Fix
Added
(?<[0-9A-Za-z_])/(?[0-9A-Za-z_])guards to the phone pattern so a digit run cannot match inside a longer alphanumeric token.Alternatives rejected:
\banchors (what SSN and IP_ADDRESS use) — unusable here. The phone pattern may start with+or(, which are not word characters, so a leading\bbreaks+1 (555) 123-4567. SSN and IP can use\bonly because they start with a digit.query_id's format — cosmetic. It leaves every other opaque identifier misclassifiable.Fixing the detector keeps
_PII_PATTERNSthe single source of truth for value-based PII.Risk
Blast radius: PII value-pattern detection only. Phone detection by field name (
_PII_FIELD_HINTS) is untouched, andredact_responseis reachable only fromhandle_match(engine/handlers.py:641), so no other response path was affected.Rollback: revert the single regex change; the regression tests revert with it.
Evidence
False positives eliminated, real detection retained:
Deterministic before/after on the same input (
q_5c9382647927):Full suite, with a working Docker daemon so nothing was skipped for a missing service:
The 10 remaining skips are all pre-existing and none are environment-caused: 2 ×
openapi-spec-validatorabsent (not declared inrequirements-dev.txtorrequirements-ci.txt, so CI skips identically), 1 ×shared-models.yamlnot generated, 1 × documented@pytest.mark.skipfrom PR #62, 6 × guarded on unimplemented loader/generator methods. No test was skipped, weakened, or xfailed to reach green.Gates
semgrepclean, or findings triaged below — semgrep, gitleaks and bandit all PASS on the changed filesReviewer focus
The lookaround guards are the whole change — please sanity-check them against phone formats this repo cares about. Four are pinned by
test_detect_phone_by_pattern_still_matches_real_numbers, and detection by field name is unaffected either way.One deliberate narrowing: a number immediately followed by an alphanumeric, such as an inline extension
5551234567x123, is no longer matched by value. That is the same guard that stops opaque ids matching, and such a field is still caught by name if it is calledphone.Audited and found clean, so not changed here: the engine's other opaque identifier,
enrichment_idempotency_key→ceg:enrich:<tenant>:<entity>:<16 hex>, measured 0/100,000 detections after this change.Out of scope, reported not fixed:
make cypher-lintis documented inAGENTS.mdbut has no Makefile target.Note on scope: this branch originally also carried the fix for
tests/unit/test_gate_egress.py::test_request_enrichment_fails_closed_without_gate_url, the other failure onmain. Open PR #264 already contains that correction, byte-identical in the block it touches plus an extra assertion, so the duplicate was reverted here rather than contested — that failure is #264's to land, and it is the one red test expected on this PR until #264 merges.Changes by intent
Modified
engine/compliance/pii.py— guard the phone value-pattern so a digit run inside a longer token is not PII; this is the defecttests/compliance/test_hipaa.py— pin both directions: opaquequery_id-shaped tokens are not PII, and four real phone formats still are. Without the second test the fix could silently degrade into a weakened detectorFiles touched
pending — the bot fills this in on push
🤖 Generated with Claude Code
https://claude.ai/code/session_01Nwd3DgnqkpEuaBMPKYLhF3
Generated by Claude Code