Skip to content

Stop a host's callback from breaking the guard that calls it - #160

Merged
patchstackdave merged 2 commits into
mainfrom
user-callbacks-cannot-break-the-guard
Aug 20, 2026
Merged

Stop a host's callback from breaking the guard that calls it#160
patchstackdave merged 2 commits into
mainfrom
user-callbacks-cannot-break-the-guard

Conversation

@patchstackdave

Copy link
Copy Markdown
Contributor

ENG-3648

createProtection takes three callbacks from the host — onError, onDetect, onSkip. Only onSkip was wrapped so a throw could not escape, with the reason written beside it:

onSkip … a reporting callback must never affect request handling

The other two were unguarded, across 23 call sites. This is not a new rule; it is that rule applied where it was missing.

onDetect is the sharp one — it runs on the request path

Verified directly rather than reasoned about: a handler that throws propagates out of the fetch guard and the request fails. And it fires only when a rule matched, so a bug in the host's logging callback turns "we noticed something" into "the app broke", on precisely the requests that mattered. That is the inverse of the single promise this package makes.

onError costs boots and refreshes

resolveRules reports a recovered condition through onError — the rule fetch failed, falling back to cached or bundled rules. Handing that to a broken handler aborted createProtection, so an app lost protection entirely over a bug in its own logging. On the refresh path the same throw becomes an unhandled rejection inside a poll loop, which kills a long-lived process long after the mistake was made.

Found while writing #158's test: an assertion about that diagnostic being contained failed for an unrelated reason, which was this. I removed my one-off guard there rather than ship it, because a single wrapped call site implies a containment the other 22 don't have.

Shape

One notify(fn, arg, label) helper at every site. It returns whether the callback ran, and the engine uses that: a host handler which runs takes over error reporting, one which throws falls through to the built-in report-once logging — otherwise a rule error would vanish between two broken reporters.

Not silent. A throwing hook is a bug in the host's code, so the first failure per hook is reported — once per process, because these run per request and the alternative is an unbounded log flood.

Tests

tests/protect/callback-containment.test.ts, 9 cases. Every containment test is paired with a control, because "contained" has a cheap wrong implementation: never call the callback at all.

  • serves the request when onDetect throws, and is not wedged for later requests
  • still blocks when onDetect throws — containment must not become a bypass; a reporting callback is downstream of the decision, so a broken one costs the report, never the enforcement
  • still calls a callback that works — the control for both of the above
  • boots when onError throws; refreshes when onError throws
  • onSkip still contained (locking pre-existing behaviour while generalising the rule it demonstrated)
  • notify reports delivery accurately, warns once rather than per call, and survives a runtime with no console

Mutation-checked, four ways: letting the throw escape fails seven; never calling the callback fails only the controls; warning per failure fails only the warn-once test; always claiming delivery fails only the fall-through test.

Full suite green (1276 passed, 6 skipped), typecheck and template typecheck clean.

createProtection takes three callbacks from the host: onError, onDetect and
onSkip. Only onSkip was wrapped so a throw could not escape, with the reason
written next to it — a reporting callback must never affect request handling. The
other two were unguarded, across 23 call sites.

onDetect is the sharp one, because it runs on the request path. A handler that
throws propagates out of the fetch guard and the request fails, and it fires only
when a rule matched — so a bug in the host's logging turns "we noticed
something" into "the app broke", on precisely the requests that mattered. That
is the opposite of the one promise this package makes.

onError costs boots and refreshes. Reporting a recovered condition — the rule
fetch failed, falling back to cached or bundled rules — to a broken handler
aborted createProtection, so an app lost protection entirely over a bug in its
own logging. On the refresh path the same throw becomes an unhandled rejection in
a poll loop, which kills a long-lived process long after the mistake was made.

So this is not a new rule, it is the rule onSkip already demonstrated, applied
where it was missing. One helper at every site, returning whether the callback
ran so a caller can fall back: the engine uses that to keep its own report-once
logging when a host handler throws, rather than letting a rule error disappear
between two broken reporters. Not silent either — the first failure per callback
is warned about once per process, since these run per request and the choice is
between an unbounded log flood and hiding the host's bug forever.

Every containment test is paired with a control, because containment has a cheap
wrong implementation: never call the callback at all. That mutation is caught
only by the controls. One further test says containment is not a bypass — a
broken onDetect still blocks in block mode, because a reporting callback sits
downstream of the decision and costs the report, never the enforcement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderbuds

coderbuds Bot commented Aug 20, 2026

Copy link
Copy Markdown

Comprehensive callback-containment feature correctly wraps host callbacks contextually.

🎯 Quality: 100% Elite · 📦 Size: Medium

📈 This month: Your 109th PR — above team average · Averaging Excellent

See how your team is trending →

Two holes in the previous commit, both from scoping the problem by callback name
instead of by what the code actually calls.

An async callback escapes a try/catch entirely. `async () => { throw ... }` does
not throw — it returns a rejected promise, which settles after containment has
returned, and on Node an unhandled rejection terminates the process by default.
So the fix as written contained synchronous hosts and left async ones able to
kill the app: worse than the throw it set out to catch, because the app dies
rather than losing a report. notify now attaches a rejection handler when the
callback hands back a thenable, and routes it to the same one-time warning.

That hole was not limited to the new code. The two pre-existing try/catch
wrappers around onSkip had it as well, which is the argument for the containment
living in one place instead of being written out at each site.

onBlock, onEgressBlock and onScan were still called directly, because the first
pass enumerated three callbacks by name rather than everything the code invokes.
onBlock runs after the decision to block and before the block response is
produced, so an escaping throw did not let the request through — it replaced the
403 with the callback's exception. That is not availability, it is enforcement
integrity: what a blocked request returns has to come from the rule and never
from reporting code. onScan is worse in a quieter way, since a throw there
aborted lazy init and left every later request unscreened.

Reachability, stated because severity depends on it: onEgressBlock is wired from
createProtection today, while onBlock and onScan are reachable only through the
adapters, which the public entry point does not forward to. They are guarded
anyway — the invariant belongs at the call site, not in an assumption that
nobody passes the option.

Return contract now documents its limit: for an async callback, a true result can
only mean the call started, so the engine's fall-through to its own logging
applies to synchronous handlers only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@patchstackdave

Copy link
Copy Markdown
Contributor Author

/review

@patchstackdave
patchstackdave merged commit 5ff0f64 into main Aug 20, 2026
6 checks passed
@patchstackdave
patchstackdave deleted the user-callbacks-cannot-break-the-guard branch August 20, 2026 14:46
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.

3 participants