Skip to content

protect: make fail-open observable; stop startup hanging on the rule API - #118

Merged
patchstackdave merged 1 commit into
mainfrom
feat/observable-fail-open
Aug 13, 2026
Merged

protect: make fail-open observable; stop startup hanging on the rule API#118
patchstackdave merged 1 commit into
mainfrom
feat/observable-fail-open

Conversation

@patchstackdave

Copy link
Copy Markdown
Contributor

Two enforcement-visibility gaps raised by an external review.

1. Fail-open was silent

The guard deliberately passes traffic it can't inspect — a request body over the cap, a response over the screening cap, a live stream, a binary body, a read/decode failure, a DNS resolver failure (or no resolver on this runtime). Each of those is a real hole in enforcement, and nothing recorded it — so "always-on" read as "always inspected", and a secret-redaction rule could silently never apply.

Now every such bypass is counted and reported:

const p = await createProtection({ onSkip: ({ phase, reason, detail, count }) => metrics.inc(...) })
p.coverage() // { skipped: { 'response:body-cap': 3, 'response:live-stream': 12, 'egress:resolver-failed': 1 } }
  • readTextResponse returns { text } | { skip: reason }, so the reason is precise (body-cap, live-stream, non-text-content-type, binary-body, read-failed, decode-failed, clone-failed).
  • The node response path (cap overflow, non-text/binary passthrough), the node request path (body cap) and the egress resolver (resolver-failed / resolver-unavailable) all report through the same channel.
  • A throwing onSkip can never affect request handling.

2. Startup could hang for 30 s

createProtection awaited the rule fetch with the client's full 30 s timeout, so a slow/hanging API delayed app boot — and hosted platforms fail a deploy whose health check is slow (Replit publishes fail past ~5 s). The initial load now gets a short budget — bootTimeoutMs, default 5 s — and falls back to last-known-good / bundled rules (already the existing fallback chain, so protection is retained). Refreshes keep the full budget (refreshTimeoutMs). Both rule clients accept timeoutMs.

Verified by test: a fetch that never resolves boots in ~300 ms and the guard is still enforcing the fallback ruleset.

Also

Documents the mode-default discrepancy honestly: this API defaults to dry-run, while the scaffolded guard passes mode: 'block' (dropping to dry-run only on PATCHSTACK_MODE=dry-run). Both intentional; previously the comment and the generated guard appeared to contradict each other.

Tests

tests/protect/coverage-skips.test.ts — cap/stream/binary skips are recorded and reported, repeats count, coverage stays empty when everything was inspected, a throwing onSkip is harmless, and the non-blocking-boot case. Full suite green (715), typecheck + build clean.

…ule API

Two enforcement-visibility gaps from an external review.

1. FAIL-OPEN WAS SILENT. The guard deliberately passes traffic it can't inspect —
   a request body over the cap, a response over the screening cap, a live stream, a
   binary body, a read/decode failure, a DNS resolver failure (or no resolver on this
   runtime). Each is a real hole in enforcement, and nothing recorded it, so
   "always-on" read as "always inspected". Every such bypass is now counted and
   reported: `onSkip({ phase, reason, detail, count })` plus
   `protection.coverage() -> { skipped: { 'response:body-cap': n, … } }`.
   readTextResponse now returns { text } | { skip: reason } so the reason is precise
   (body-cap / live-stream / binary-body / read-failed / decode-failed), and the node
   response + request paths and the egress resolver report through the same channel.
   A throwing onSkip can never affect request handling.

2. STARTUP COULD HANG FOR 30s. createProtection awaited the rule fetch with the
   client's full 30s timeout, so a slow API delayed app boot — and hosted platforms
   fail a deploy whose health check is slow. The INITIAL load now gets a short budget
   (bootTimeoutMs, default 5s) and falls back to last-known-good / bundled rules,
   which was already the fallback chain; refreshes keep the full budget
   (refreshTimeoutMs). Verified: a hanging fetch boots in ~300ms, still protected by
   the fallback ruleset.

Also documents the mode-default discrepancy honestly: this API defaults to dry-run
while the scaffolded guard passes mode: 'block' — both intentional, previously
confusing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderbuds

coderbuds Bot commented Aug 13, 2026

Copy link
Copy Markdown

Adds observable fail-open coverage tracking and startup timeout for rule API fetches.

🎯 Quality: 100% Elite · 📦 Size: Medium

📈 This month: Your 52nd PR — above team average · Averaging Excellent

See how your team is trending →

@patchstackdave

Copy link
Copy Markdown
Contributor Author

/review

@patchstackdave
patchstackdave merged commit e7747a4 into main Aug 13, 2026
5 checks passed
@patchstackdave
patchstackdave deleted the feat/observable-fail-open branch August 13, 2026 11:55
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