Extend the detector: add a signal, a cross-check, or an enforcement rule
Diátaxis quadrant: How-to. Audience: defensive 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 an enforcement 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 enforcement-rule table, and the ScoreTrace — read Detection engine internals alongside this page. The verdict tables you will update at the end live in enforcement rules, verdicts and signal 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. Add a consistency check if the evidence contradicts"]
S3 --> S4["4. Promotion rule as data (promotionRules) — optional"]
S4 --> S5["5. Guard the false-positive path with score damping"]
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 stage your evidence belongs to (seven detection stages) 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 stage and the group
Every signal carries a layer (seven detection stages) and an id of the form l{n}.{group}.{item} (lowercase; layer tokens are uppercase seven detection stages, 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— the legacy machine value for one of the seven named detection stages.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:
- browser-side collection stages client tells (navigator/Chrome DevTools Protocol/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. - network and consistency-check signals (traffic/request-integrity token/correlation/abuse; cross-checks) are computed on the server path (
collected: server) and land innetwork.signals[]/crosschecks[].
Step 3 — Add a cross-check (consistency-check stage), if the tell is a contradiction
If your evidence is not a single observation but a contradiction between two claims — for example, the user-agent header claims Chrome while the encrypted-connection fingerprint does not — model it as a cross-check, not a plain signal. Cross-check machine identifiers use the x. prefix. Existing examples include x.ua_vs_ja4, x.ua_vs_h2, x.browser_no_js, and x.uach_present.
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 an enforcement 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 an enforcement rule when a specific combination should promote the verdict regardless of the score. Enforcement 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/Chrome DevTools Protocol 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. Existing rules for zero interaction, missing expected browser graphics, automation-like timing, and browser claims from hosting networks all challenge because they can also describe 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 enforcement rules, verdicts and signal 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 Chrome DevTools Protocol leak together with any automation hint”), never third-party evasion framing, and never an internal spec identifier.
Note: Give the rule a stable machine identifier in the Core engine namespace. Legacy Core and Gate identifiers overlap, so a number alone does not identify the plane or behavior. The Observatory traces Core rules only. Public labels and explanations must use the descriptive rule name.
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 the automated-profile catch rate (
botTPR) improved without raising the human denial rate (humanFPR), and without a spike in the human baseline’s challenge rate. If an automated profile that should now be caught still passes, re-check the group choice and per-signal verdict. If a human baseline changes to DENY, prefer a damp or CHALLENGE over 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 enforcement rule:
- Add the row to the Core engine enforcement-rule table in enforcement rules, verdicts and signal reference: descriptive name, verdict, explanation, and the exact machine signal identifiers it reads.
- Keep it on the Core engine plane. Gate edge protections are separate and can have overlapping legacy identifiers.
Important: Reader-facing pages lead with the complete descriptive name. Put an exact machine signal or cross-check identifier in code formatting only where a developer must configure, query, or test that value. Never expose internal specification numbers or legacy rule numbers as public concepts.
Weight-0 signals. A signal can also be added as score-exempt (weight 0): it is emitted and audited but contributes nothing to the risk score and matches no rule. The optional behavioral model’s
l4.ml.behavioralis the reference example — see How the self-correcting behavioral model works. Giving such a signal a real weight is a deliberate detection-policy (freeze) decision, not a docs edit.
Checklist
- Stage and
(stage, group)chosen so correlated tells share a group and independent tells do not. - Signal identifier follows the machine schema; weight and confidence are honest (low confidence means weak weight, not a penalty); per-signal verdict is set; signal is emitted from
cmd/wasmfor browser-side collection or from the server path for network and consistency checks. - Cross-checks in the
x.namespace withinputs/claim/observed/consistentset socrossFail()can read them. - Any enforcement rule appended as a
promoRuletopromotionRules, verdict chosen deliberately (human-catching heuristic ⇒ CHALLENGE), placed by precedence, with a plainwhy. - false positive 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 in Docker: fires in the Observatory;
make e2eormake attackshows the automated-profile catch rate up, the human denial rate flat, and the human baseline’s challenge rate 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.
- enforcement rules, verdicts and signal 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.