Skip to content

CARD-P2-01 S4: rag_api producer governed-host allowlist (G1-A) + LLM/IMDS paid-call guard (G1-F) - #115

Merged
dboichuk-2 merged 3 commits into
mainfrom
agent/files-p201-s4-host-allowlist-llm-guard
Sep 25, 2026
Merged

dboichuk-2 merged 3 commits into
mainfrom
agent/files-p201-s4-host-allowlist-llm-guard

Conversation

@Wolbrom1

Copy link
Copy Markdown

CARD-P2-01 S4 — rag_api producer governed-host allowlist (G1-A) + LLM/IMDS paid-call guard (G1-F)

FILES lane. Branch off 5dfe7ad (the #114 head, S1 + link redaction), so this stacks on #114 — until #114 merges, the diff vs main also shows the S1 files.

G1-A — producer-side governed-HOST allowlist (RV-145 N1, the owed half of CORE-CITATION-GOVERNED-LINK)

#556 shipped the scheme/storage/presign half in Core; the host-level rule (RAG_GOVERNED_LINK_HOSTS) had no source of truth in rag_api. This adds it on the producer side, in app/routes/document_routes.py:

  • Mechanism. RAG_GOVERNED_LINK_HOSTS is read once at import (_governed_link_hosts()), comma- and/or whitespace-separated, lower-cased. _reject_ungoverned_link keeps the scheme guard first (non-https still refused as link_scheme_not_allowed); then, when the allowlist is configured, an https link whose host is not admitted is refused with the new typed reason link_host_not_allowed — same 422 envelope shape as the scheme case (message + link.reason + link.allowed_hosts).
  • Match rule. Hostname-only via urlparse(...).hostname (drops userinfo/credentials and port, lower-cases). An exact entry (a.example.com) matches only itself; a dot-prefixed entry (.example.com) matches any subdomain but not the bare parent and not a look-alike (evilexample.com). Userinfo spoofing (https://allowed.host@evil.com/) is matched on the real host, evil.com.
  • The raw link is never echoed — only the redacted hostname (length-capped), the reason, and the allowed list. Refusal is logged once at WARNING with the bounded host only.
  • Conservative default (OPEN FOR RICHARD, §11). When RAG_GOVERNED_LINK_HOSTS is unset/empty, the host check is DISABLED and current https-only behaviour is preserved with zero new rejections. The allowlist VALUE is a Richard policy/disclosure call — no host value or default list is invented here.

G1-F — LLM/IMDS paid-call test guard (RV-150 N1 twin, per CARD-N5)

In tests/conftest.py (test-only; no production behaviour change):

  • Autouse _block_the_real_llm_provider replaces the boto3 bedrock-runtime client's socket-opening step (converse/converse_stream/invoke_model) on every live holder (app.config.llm and document_routes.llm), with raising=True on the .client attribute (rename-loud). Placement blocks the socket step, not the high-level llm a test may stub.
  • AWS_EC2_METADATA_DISABLED=true + dummy AWS creds/region are set before app.config imports (via setdefault, so CI's own values win), so the import-time boto3 session makes no IMDS probe (proven with a socket spy: 0 attempts to 169.254.169.254).
  • Pins SUM_UP_KNOWLEDGE_FILES off and proves that flipping it still cannot reach a provider (the guard, not the default, is what stands between tests and inference).

Tests (red-first, proven under --network none in the shipped-runtime image, Python 3.10.21)

  • tests/test_embed_link_host_allowlist.py — SET: non-allowlisted host refused (link_host_not_allowed); exact host, dot-prefixed subdomain, and userinfo+port host all accepted; bare parent + suffix look-alike + userinfo-spoof refused; raw link never echoed (body or log); refusal before any upload/extraction/store; length cap; matcher + parser unit cases. UNSET: arbitrary https host still ingests (conservative-default regression). Scheme guard runs first even with a host allowlist.
  • tests/test_llm_provider_is_blocked_in_tests.py — summarizer path blocked; direct llm.invoke blocked; client converse/converse_stream/invoke_model raise the guard message; deliberate-regression (a real client DOES attempt the network — proving the guard is load-bearing); stubbed-llm untouched; attribute-name pin; config-reload gap; gate pin; no IMDS probe at import.

Red-first: the host behavioural tests fail on 5dfe7ad (guard absent, 27 red) and the LLM file fails collection (ImportError, guard symbols absent). One targeted one-line mutation per guard was injected and each reddened the relevant tests; both files were restored byte-identical (md5/sha verified) before commit — no mutation is in this tree.

Acceptance ladder

  • IMPLEMENTED — yes (G1-A in document_routes.py; G1-F in conftest.py).
  • TEST-PROVEN — yes (red→green under --network none; per-guard mutation caught).
  • REVIEWED — pending (independent RV).
  • PUSHED — yes (this draft).
  • MERGED / DEPLOYED / LIVE-VERIFIED — Demian/operator. The RAG_GOVERNED_LINK_HOSTS value is OPEN FOR RICHARD; until set, the host guard is inert by design.

🤖 Generated with Claude Code

Richard and others added 3 commits September 24, 2026 19:45
…ined link_state, typed failures

B. extraction.degraded (CARD-DEGRADED-EXTRACTION-SIGNAL). `locator_kind: "none"` meant both
"no addressable units" and "the DOCX structured walk failed and fell back to a flat read".
SafeDocxLoader now records WHY a structured walk returned None and stamps
`extraction_degraded` on the flat Documents ONLY for a failure (docx_block_unrepresentable,
docx_structure_unreadable) -- never for the legitimate no-body case. The receipt rolls it up
into `extraction.degraded {reason, scope, lost, units_affected}` and forces `status: partial`;
the stamp rides every stored chunk (cmetadata). The #90 limitation test is converted from
pinning the collision to asserting the distinction, with the no-body NEGATIVE binding.

C. QUARANTINED on the read side. Every /query* hit carries a top-level
`link_state: present | quarantined | none`, derived from the stored-link backfill card's own
marker (`quarantined_link`, CARD-F-EMBED-LINK-BACKFILL MUTATION). No second marker; metadata
still goes out exactly as stored.

D. FAILED typing. The five 400/503 intake failures were distinguishable only by prose.
`X-Failure-Attribution` (the same token the log carries) and `X-Error-Reference` now ride
the response; detail stays a string, no status code changes. client_safe_error carries
X-Error-Reference; the upload-path refusal carries request:invalid_path. /text returns the
extraction receipt (empty stays a 200 there). 422 verdict/empty/link/validation shapes pinned.

E. Never-reads-complete regression over every combination of escalation, read bound,
incomplete image coverage and degraded.

Red-first vs afd44b9 (base app mounted, new tests): 45 failed / 25 passed, 0 errors.
Green: 70/70 targeted. Full suite --network none, files01-ocr-test:wip: branch 40F/959P/141S/28E
vs base 40F/890P/143S/28E, identical failure sets (NLTK tagger absent in the image).
Six source mutations each reddened the tests that guard them. SYNTHETIC fixtures only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ATION decision (2))

A quarantined marker must never store or serve the raw refused URL -- it can carry
credentials, a token, or a javascript:/data: payload, and untrustworthiness is why it
was quarantined. `_redact_quarantined_link` reduces it to {scheme, host, refusal_reason,
sha256}: host is urlparse.hostname (no userinfo/port), sha256 is a one-way digest for
correlation. `_redacted_metadata` replaces a raw `quarantined_link` string on emit in
`_on_the_wire`, so the raw never leaves the process. This supersedes serving the raw
`quarantined_link` verbatim (43bb826).

retrieved == stored still holds: the redaction-aware backfill stores the object at rest
(CARD-F-EMBED-LINK-BACKFILL MUTATION updated 2026-09-25), and the wire serves it
unchanged; a legacy raw-at-rest row is redacted on emit. link_state (present|quarantined
|none) is unchanged -- derived from the PRESENCE of the marker, object or string.

Storage side: rag_api ingestion writes no quarantine marker (a bad `link` is refused at
write time, 422, before any store), so the only at-rest writer is the backfill; the
read-side transform covers legacy raw rows regardless.

Red-first vs 43bb826 (its app mounted, new tests): 5 failed / 28 passed -- the raw URL
rode the wire and there was no redaction helper. Green on branch: 83 passed (full S1
set). Four one-line mutations each reddened the guarding tests (redaction no-op 4F;
sha256 constant 3F; host=netloc leaks creds 4F; link_state ignores marker 17F);
worktree byte-identical, no leftover containers. SYNTHETIC, --network none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…IMDS paid-call guard (G1-F)

G1-A (producer-side governed-HOST allowlist): after the existing https scheme guard,
_reject_ungoverned_link also checks the link host against RAG_GOVERNED_LINK_HOSTS (read once at
import; comma/space-separated; exact host OR dot-prefixed subdomain suffix; hostname-only via
urlparse.hostname, which drops userinfo+port). A scheme-valid link on a non-allowlisted host is
refused with the typed reason link_host_not_allowed (same 422 envelope as link_scheme_not_allowed;
the raw link is never echoed -- only the redacted host, reason, allowed_hosts). CONSERVATIVE
DEFAULT: unset/empty allowlist => host check DISABLED, scheme-only behaviour preserved with zero new
rejections (the allowlist VALUE is OPEN FOR RICHARD, card 11). No host value invented.

G1-F (LLM/IMDS paid-call test guard, N5 twin of N1): conftest autouse _block_the_real_llm_provider
replaces the boto3 bedrock-runtime client's socket-opening step (converse/converse_stream/
invoke_model) on every live holder (app.config.llm and document_routes.llm);
AWS_EC2_METADATA_DISABLED + dummy creds are set before app.config imports so the import-time boto3
session makes no IMDS probe. Pins SUM_UP_KNOWLEDGE_FILES off and proves flipping it still cannot
reach a provider.

Tests (red-first, proven under --network none in the shipped-runtime image):
tests/test_embed_link_host_allowlist.py, tests/test_llm_provider_is_blocked_in_tests.py. Existing
scheme-guard and embeddings/rerank paid-call guards unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dboichuk-2
dboichuk-2 marked this pull request as ready for review September 25, 2026 10:07
@dboichuk-2
dboichuk-2 merged commit aa66a3a into main Sep 25, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants