Skip to content

feat(rules): let rules act on who the User-Agent says it is - #17

Merged
cport1 merged 1 commit into
mainfrom
feat/bot-category-rules
Jul 31, 2026
Merged

feat(rules): let rules act on who the User-Agent says it is#17
cport1 merged 1 commit into
mainfrom
feat/bot-category-rules

Conversation

@cport1

@cport1 cport1 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Companion to WebDecoy/app#498, which adds the registry exporter.

The gap

The docs describe branching on ai_scraper_category === 'training_crawler' and blocking at CRITICAL. RuleContext exposed ip, path, method, userAgent, headers, enrichment, the Web Bot Auth verdict and edge — no agent classification, and no registry in the SDK at all (zero occurrences of GPTBot anywhere in packages/).

So the headline policy of this product category — block AI training crawlers, allow search crawlers — was documented and unimplementable.

API

import { WebDecoy, bots, filter } from '@webdecoy/node';

bots({ categories: ['training_crawler'] })      // no AI training on my content
bots({ ai: true, allow: ['perplexitybot'] })    // all AI, minus one
bots({ agents: ['gptbot', 'ClaudeBot'], action: 'THROTTLE' })

filter({ expression: 'bot.ai and not bot.respects_robots' })

New filter namespace: bot.known, bot.ai, bot.category, bot.name, bot.id, bot.organization, bot.score, bot.respects_robots.

ai: true covers the four AI categories and deliberately excludes search_crawler — blocking Googlebot would deindex the customer's site.

Generated, not hand-written

registry.generated.ts comes from pkg/agents via the exporter; parity-vectors.generated.json records what Go's MatchUserAgent answers for 360 User-Agents, and parity.test.ts replays every one.

Order is load-bearing. Verified the parity test fails on reorder: sorting alphabetically makes applebot shadow applebot-extended, silently reclassifying Apple's AI training crawler.

What this deliberately cannot do

It matches a self-declared User-Agent. That is correct for this policy — GPTBot and ClaudeBot identify honestly, and the decision is about a cooperative agent's access, not about catching a liar. Anything spoofing Chrome passes through, and the docs say so in a caution block.

It is not a threat signal and does not pretend to be: BotRule reports confidence: 70, not the 100 a tripwire reports, because a tripwire proves behaviour while this repeats a claim.

Safety choices

  • allow[] applies before every other clause — an SEO outage from a mis-scoped rule costs more than a scraper getting through
  • an empty config is inert, not default-deny
  • the UA cache is bounded and clears wholesale on overflow: the key is client-controlled, so an unbounded map would be a memory-exhaustion vector

Verification

  • 233 tests pass across all 4 packages, uncached (turbo test --force)
  • check:edge passes — still edge-runtime compatible
  • +28 KB source, 37.7 KB gzipped bundle total

The docs have a "Content Protection Strategy" section telling customers to
branch on ai_scraper_category === 'training_crawler', and a threat table whose
CRITICAL row reads "Block or serve alternative content". Nothing in the request
path could do either.

RuleContext exposed ip, path, method, userAgent, headers, enrichment, the Web
Bot Auth verdict and the edge class. No agent classification, and no registry in
the SDK at all — zero occurrences of GPTBot anywhere in packages/. The only
adjacent signal was edge.class, a four-way script/crawler/verified/browser
bucket that cannot separate a training crawler from a search crawler and only
exists when Cloudflare fronts the request.

So the headline policy of this product category — "block AI training crawlers,
allow search crawlers" — was documented and unimplementable.

WHY A GENERATED TABLE AND NOT A HAND-WRITTEN ONE

pkg/agents in WebDecoy/app is the source of truth and already has 168 entries. A
second hand-maintained copy would drift the first time someone adds a crawler,
which is the exact failure the monorepo already documents for the deprecated
php-sdk/wordpress mirror. registry.generated.ts is emitted by
`go run ./cmd/export-agent-registry`, and parity-vectors.generated.json records
what Go's MatchUserAgent answers for 360 User-Agents so parity.test.ts can prove
the two implementations agree.

ORDER IS LOAD-BEARING

MatchUserAgent returns the first hit, walking categories by priority. The export
preserves that order and the matcher takes the first match, so there is no
second ordering to keep in sync. Sorting the table is not cosmetic: alphabetical
order makes `applebot` shadow `applebot-extended`, silently reclassifying
Apple's AI training crawler as a search crawler. The parity test catches it.

ONE VOCABULARY

Categories use agents.DetectionCategory, not the registry constants, so
bot.category == "training_crawler" in a rule means the same thing as the
training_crawler column in the dashboard. That mapping now lives in one place
and ingest's scorer is pinned to it by a test in the app repo.

WHAT THIS DELIBERATELY CANNOT DO

It matches a self-declared User-Agent, so it only ever acts on agents that
identify honestly. That is correct for this policy — GPTBot and ClaudeBot
declare themselves, and the decision is about a cooperative agent's access to
content, not about catching a liar. It is not a threat signal and does not
pretend to be: BotRule reports confidence 70, not the 100 a tripwire reports,
because a tripwire proves behaviour while this repeats a claim. Anything
spoofing Chrome passes through, and the docs say so in a caution block.

allow[] is applied before every other clause, because an SEO outage from a rule
someone thought they had scoped costs far more than a scraper getting through.
An empty config is inert rather than default-deny for the same reason.

The UA cache is bounded and clears wholesale on overflow: the key is
client-controlled, so an unbounded map would turn a memoisation into a memory
exhaustion vector.

Adds ~28 KB to the bundle (37.7 KB gzipped total) and stays edge-compatible.
@cport1
cport1 merged commit 578b5eb into main Jul 31, 2026
2 checks passed
@cport1
cport1 deleted the feat/bot-category-rules branch July 31, 2026 01:32
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.

1 participant