Extend the detector: add a signal, a cross-check, or a hard rule
Quadrant: How-to (task-oriented). Audience: Blue detection engineers and contributors adding coverage to their own engine.
Warning: This page is defensive-only, local-only, and self-target-only. It teaches you to extend the detection catalog of the engine you build and run on your own loopback (
127.0.0.1), validated against automation simulators you launch yourself. It is not third-party evasion guidance and gives you nothing to point at a host you do not operate. Every number you produce here is reference-measured on your machine — it describes your run, not a guarantee about live traffic.
This is the procedure for adding one unit of coverage — a signal, a cross-check, or a hard rule — to the standalone detection engine (internal/scoring, internal/signals) so it fires, scores honestly, and shows up truthfully in the Observatory and the reference tables. It assumes you can already build and run the engine and drive a red profile against it. If you cannot yet, do Run the detection engine first.
For the model you are extending — layers, dedup, noisy-OR, the hard-rule table, and the ScoreTrace — read Detection engine internals alongside this page. The verdict tables you will update at the end live in Hard rules, verdicts and signal-ID reference.
The full procedure, end to end:
flowchart TD
S1["1. Choose layer + (layer, group)"] --> S2["2. Add the signal (internal/signals)"]
S2 --> S3["3. Cross-check (L6, x.* namespace) if it is a contradiction"]
S3 --> S4["4. Promotion rule as data (promotionRules) — optional"]
S4 --> S5["5. Guard the FP path (applyFPMitigation damp)"]
S5 --> S6["6. Do not fork trace logic (golden test)"]
S6 --> S7["7. Validate end to end (Observatory + Docker e2e)"]
S7 --> S8["8. Update the reader surface in lockstep"]
Before you start
- You have the engine building and running on loopback:
bin/server.exe -addr 127.0.0.1:8443 -web web(and, for client tells, the rebuiltweb/detector.wasm). See Run the detection engine. - You can run the red harness:
node test/e2e/runner.mjs. See Red-team catalog. - You know which layer your evidence belongs to (L1–L7) and whether it is collected in the browser (
js/wasm) or on the server. If not, read Detection engine internals first.
Note: Everything below changes the standalone engine (the one the tests and the Observatory exercise). Because Gate embeds the same
internal/scoring/internal/signalsengine, coverage you add here is the coverage the proxy runs at the edge. You do not edit two engines — see Which piece am I using?.
Step 1 — Choose the layer and the group
Every signal carries a layer (L1–L7) and an id of the form l{n}.{group}.{item} (lowercase; layer tokens are uppercase L1–L7, but signal IDs are lowercase). The group is not cosmetic — it drives dedup.
During combination, dedupGroups keeps the highest-scoring signal per (layer, group) correlation group and drops the rest. So the rule is:
- Signals that share a root cause must share a
(layer, group). Two tells that are really the same underlying fact (for example, two different reads of the same webdriver leak) belong in one group so they cannot double-count into the score. - Independent tells must use distinct groups (or an empty group). An empty group means the signal always stands alone and is never deduped against a sibling.
Pick the group deliberately. Putting an independent tell in an existing group silently suppresses it whenever a stronger sibling fires; putting a correlated tell in its own group inflates a single root cause into two contributions.
Step 2 — Add the signal
Mint the id and set its fields honestly. The signal schema is in internal/signals/signals.go:
id— lowercasel{n}.{group}.{item}, e.g.l1.cdp.proxy_leak.layer—L1..L7.weight— the maximum contribution, 0..100. This is the ceiling this signal can ever add.confidence— 0..1, how sure you are the observation means what you think.-
verdict— the per-signal verdict (OKSUSPICIOUSBOTUNKNOWN), distinct from the request verdict.SUSPICIOUS-or-BOTis whatfired(id)reads;BOTalone is whatfiredBot(id)reads (this matters in Step 4). -
collected—jswasmserver, where the evidence was gathered. - plus
value,expectedHuman,score,notes.
Set weight and confidence honestly. The per-signal score is weight × severity × confidence, clamped to 0..weight — with severity fixed by the per-signal verdict (BOT = 1, SUSPICIOUS = ½, OK/UNKNOWN = 0):
- Low confidence means a weak weight, never a penalty. A tell you are unsure about gets a small ceiling; it does not push the score negative and it does not get promoted by wishful weighting.
- The layer cap (
LayerCap = 60) and noisy-OR mean an over-weighted signal cannot dominate a layer on its own — but honest weighting is still your job, because the golden and e2e checks in Steps 6–7 will catch a weight that flips a human baseline.
Emit it from the right place:
- L1–L4 client tells (navigator/CDP/UA/window, integrity/guard, mouse/key/event) are collected in the browser and emitted from
cmd/wasm(collected: wasmorjs), then carried in theclient.signals[]of theSessionReport. - L5/L6 signals (traffic/RIT/correlation/abuse; cross-checks) are computed on the server path (
collected: server) and land innetwork.signals[]/crosschecks[].
Step 3 — Add a cross-check (L6), if the tell is a contradiction
If your evidence is not a single observation but a contradiction between two claims — “the UA says Chrome but the TLS fingerprint says something else” — model it as a cross-check, not a plain signal. Cross-checks are the L6 family and carry the x. group prefix (e.g. x.ua_vs_ja4, x.ua_vs_h2, x.browser_no_js, x.uach_present) — not l6.x.*.
A CrossCheck (see internal/signals/signals.go) has: id (in the x. namespace), inputs, claim, observed, consistent (bool), weight, score. Fill inputs/claim/observed explicitly and set consistent = false when the two disagree. That is exactly what a hard rule reads through crossFail(id) in Step 4 — a cross-check that does not set consistent cannot be promoted.
Step 4 — Author a promotion rule as data (optional)
A signal or cross-check contributes to the risk score on its own. You only add a hard rule when a specific combination should promote the verdict regardless of the score. Hard rules are data, not code branches: append a promoRule{id, verdict, pred, why} to the promotionRules list in internal/scoring/hardrules.go.
Build pred from the predicate helpers:
fired(id)— a signal with that id isSUSPICIOUS-or-BOT.firedBot(id)— that id isBOTonly.crossFail(id)— a named cross-check came backconsistent == false.- context:
hasClient,browserClaim,combined.
Choose the verdict deliberately:
- DENY only for a combination you are confident is automated — typically two independent tells (an artifact plus a webdriver/CDP tell), so a single false read cannot promote to a block.
- CHALLENGE for a heuristic. Any rule that can catch a real human must be CHALLENGE, never DENY. The existing heuristics
HR-10,HR-11,HR-12,HR-17are all CHALLENGE for exactly this reason (e.g. zero-interaction and datacenter-ASN can describe some real people).
Place the rule by precedence, not number. promotionRules is evaluated in first-match order, which is not numeric order (see the ordered table in Hard rules, verdicts and signal-ID reference). Insert your rule where its verdict should win relative to the rules around it — a broad CHALLENGE placed above a specific DENY will shadow the DENY.
Write a plain-language why field. It is not an internal note: it publishes to the Observatory trace and to the hard-rules reference table. Name the mechanism in Blue voice (“a CDP leak together with any automation hint”), never third-party evasion framing, and never an internal spec identifier.
Note: Give your rule the next id in the engine-plane sequence for its plane. The detection engine evaluates HR-1..HR-21 only.
HR-22..HR-30are Gate-plane rules (proxy edge: bans, rate-limit, smuggling, token) that this engine never evaluates and the Observatory never shows — do not reuse or extend into that range here.
Step 5 — Guard the false-positive path
Before you ship a DENY, decide whether the honest move is a damp, not a promotion. applyFPMitigation runs before combination and reduces scores that a legitimate-but-unusual client would otherwise trip. It is a damp, not a promotion — the worked example is the privacy-browser case, where canvas/webgl scores are halved so a privacy browser’s blocked-fingerprinting reads do not stack into a bot score.
Use applyFPMitigation (in internal/scoring) when your new signal will also fire for a legitimate configuration — a privacy browser, a hardened extension, an older device. Damp it there instead of, or in addition to, weighting it down.
Then check your change against the baselines before shipping any DENY:
- Confirm the privacy-browser and old-device baselines still score ALLOW (or at worst CHALLENGE), not DENY.
- Remember the metric asymmetry:
classify()scores a human CHALLENGE as TN, sohumanFPRis DENY-only and under-reports friction. Inspect the human baseline’s challenge-rate separately — a rule that starts challenging real humans will not show up inhumanFPR.
Step 6 — Do not fork the rule logic (the divergence guard)
The Observatory trace must show what the engine actually did. Engine.ScoreWithTrace shares assemble()/decide() with Score() and uses combineTrace, which reuses the same dedupGroups keep-rule, the same noisy-OR, and the same promotionRules table. Because of that, the golden test asserts trace.Score == Combine and ScoreWithTrace == Score.
So: add your signal, cross-check, and rule in one place (the shared data/logic) and let both paths read it. Never add a special case to the trace path — that is exactly what the golden test exists to catch. Run it after every change to the scoring logic.
Run the golden trace-equivalence tests (they assert trace.Score == Combine and ScoreWithTrace == Score):
go test ./internal/scoring/ -run 'ScoreWithTrace|CombineTrace'
Step 7 — Validate end to end
-
Watch it fire in the Observatory. With the engine running dev-gated (
HMN_PLAYGROUND=1) on loopback, launch the matching bundled profile and open itsGET /playground/explain/{id}trace. Confirm your signal appears with the score you expect, that dedup kept/dropped the right sibling, and — if you added a rule — that it shows asmatched/wonwith yourwhytext. See Detection Observatory and Observatory architecture.Note: If no profile exercises your tell yet, add one first — see Write a red profile. Adding a profile has three registration points (the
runner.mjsPROFILESarray, thelaunchProfilesallowlist incmd/server/launch.go, and the Observatory catalog inweb/playground.html); missing any one makes it silently absent. -
Run the Docker e2e harness (authoritative).
make e2e # or faster: make e2e-quick # or only the catalog: make up && make attack && make e2e-assertHost
node test/e2e/runner.mjsis for local debugging only — not completion authority. -
Confirm the trade-off is honest. Your change is good only if
botTPRimproved without raisinghumanFPR, and without a spike in the human baseline’s challenge-rate (Step 5). If a bot profile that should now be caught still passes (FN), re-check your group choice (Step 1) and per-signal verdict (Step 2 —firedvsfiredBot). If a human baseline flipped to DENY, prefer a damp (Step 5) or a CHALLENGE (Step 4) over the DENY.
Step 8 — Update the reader surface in lockstep
A rule that fires but is undocumented is a drift bug. When you add or change an engine hard rule:
- Add the row to the engine-plane hard-rule table in Hard rules, verdicts and signal-ID reference — id, verdict, the
why, and the signal/cross-check IDs it fires on. - Keep it on the engine plane: HR-1..HR-21 only, no
HR-22..HR-30.
Important: Reader-facing pages must never carry an internal source-of-truth identifier. Name the signal IDs (
l{n}.{group}.{item}), the layer tokens (L1–L7), the cross-checks (x.*), and the hard rule (HR-N) — nothing else.
Checklist
- Layer and
(layer, group)chosen so correlated tells share a group and independent tells do not. - Signal id lowercase
l{n}.{group}.{item}; weight/confidence honest (low confidence → weak weight, no penalty); per-signal verdict set; emitted fromcmd/wasm(L1–L4) or the server path (L5/L6). - Cross-checks in the
x.namespace withinputs/claim/observed/consistentset socrossFail()can read them. - Any hard rule appended as a
promoRuletopromotionRules, verdict chosen deliberately (human-catching heuristic ⇒ CHALLENGE), placed by precedence, with a plainwhy. - FP guarded:
applyFPMitigationdamp added where a legitimate client would trip; privacy-browser and old-device baselines re-checked before any DENY. - Golden test run (no forked trace logic):
trace.Score == Combine,ScoreWithTrace == Score. - End-to-end (Docker): fires in the Observatory;
make e2e/make attackshowsbotTPRup,humanFPRflat, challenge-rate on the human baseline inspected. - Reader surface updated in lockstep: engine-plane row in
hard-rules-verdicts.md+ derivation-index row; no internal spec IDs on reader pages.
Related
- Detection engine internals — the model this page extends.
- Hard rules, verdicts and signal-ID reference — the table you update in Step 8.
- Run the detection engine — build and run the engine on loopback.
- Write a red profile — add the simulator that exercises your tell.
- Red-team catalog — the bundled profiles and their expected verdicts.
- Detection Observatory · Observatory architecture — where you watch it fire.