Browser tamper detection for hostile environments.
Shield detects DevTools, automation drivers, extension injection, and environment spoofing — surfaces findings as structured risk signals composable with Blindspot spans and Scent identity risk scoring.
npm install @tindalabs/shieldShield ships three top-level APIs. Pick by what you want to do with the result:
| Goal | API | What it does |
|---|---|---|
| Observe / score the session — log signals, gate a feature, feed a risk model | assess() |
Detects DevTools / automation / extensions / frame embedding. Returns structured signals + risk.score (0–1) + OTel span attributes. No side effects. |
| Block devtools / copy / print / screenshots / clipboard / keyboard shortcuts on an element | ContentProtector |
Manual control: pass an element + options, get an instance with dispose(). Always-on for everyone who hits the page. |
| Both — observe first, then activate protections only when the session warrants it | assessAndProtect() |
Runs assess(), evaluates a declarative PolicyRule[] (e.g. "watermark when riskScore ≥ 0.3"), activates the matching strategies. Legitimate users see no overhead. |
For OTel-instrumented protection (every block / detection becomes a span event), wrap any of the above with attachShieldToSpan().
The primary API is a single async call that returns structured signals, a risk summary, and OTel-compatible span attributes:
import { assess } from '@tindalabs/shield';
const result = await assess();
console.log(result.signals);
// {
// 'shield.devtools.open': false,
// 'shield.automation.webdriver': false,
// 'shield.automation.headless': false,
// 'shield.frame.embedded': false,
// 'shield.extension.detected': false,
// 'shield.extension.names': '',
// }
console.log(result.risk);
// { score: 0, flags: [] }
// Attach to a Blindspot / OpenTelemetry span
span.setAttributes(result.spanAttributes);import { assess } from '@tindalabs/shield';
import { useSpan } from '@tindalabs/blindspot-react';
const { setAttribute } = useSpan();
const shield = await assess();
Object.entries(shield.spanAttributes).forEach(([k, v]) => setAttribute(k, v));Shield's signals compose directly with Scent's identity and risk engine:
import { assess } from '@tindalabs/shield';
import { init as initScent } from '@tindalabs/scent-sdk';
const scent = initScent({ apiKey: '...', endpoint: '...' });
const shield = await assess();
// shield.signals merges into the snapshot alongside browser fingerprint signals
const obs = await scent.observe({ extraSignals: shield.signals });
await scent.flush();
// The server risk engine now sees webdriver/headless/devtools signals
// alongside canvas, fonts, hardware and all other collected signals.| Option | Type | Default | Description |
|---|---|---|---|
devtools |
boolean |
true |
Run DevTools detection (async, timing-based) |
extensions |
boolean |
true |
Run browser extension DOM/JS scan |
timeout |
number |
400 |
Max ms before async detections resolve with false |
extensionConfig |
ExtensionConfig[] |
built-in | Extension signatures to check against |
interface ShieldAssessment {
signals: {
'shield.devtools.open': boolean;
'shield.automation.webdriver': boolean;
'shield.automation.headless': boolean;
'shield.frame.embedded': boolean;
'shield.extension.detected': boolean;
'shield.extension.names': string; // comma-separated
};
risk: {
score: number; // 0–1 normalised threat score
flags: string[]; // ['webdriver', 'devtools_open', ...]
};
spanAttributes: Record<string, string | boolean | number>;
}| Flag | Score contribution | Triggered by |
|---|---|---|
webdriver |
0.9 | navigator.webdriver === true |
headless |
0.7 | Headless UA string, zero plugins, missing Permissions API |
devtools_open |
0.4 | Timing/debugger/size detectors |
frame_embedded |
0.3 | window.self !== window.top (cross-origin) |
extension |
0.2 | DOM selector or JS global signature match |
assessAndProtect() bridges both APIs with a declarative policy engine. It runs assess(), evaluates a set of rules against the result, and activates a ContentProtector with exactly the strategies each session warrants — zero overhead for legitimate users, full defence for automation and high-risk sessions.
import { assessAndProtect } from '@tindalabs/shield';
const { assessment, protector } = await assessAndProtect(contentEl, {
policies: [
// Watermark any session with measurable risk — embed score for traceability
{
when: { riskScore: { gte: 0.2 } },
enable: ['enableWatermark'],
watermarkOptions: (a) => ({ text: `RISK-${Math.round(a.risk.score * 100)}` }),
},
// Selection + clipboard lockdown for high-risk sessions
{
when: { riskScore: { gte: 0.6 } },
enable: ['preventSelection', 'preventClipboard', 'preventKeyboardShortcuts'],
},
// Always block headless browsers regardless of score
{
when: { signals: { 'shield.automation.headless': true } },
enable: ['preventScreenshots', 'preventContextMenu'],
},
],
});
// protector is null when no rules matched (legitimate session — no overhead)
if (protector) {
console.log('Protection active — score:', assessment.risk.score);
}All matched rules are merged: a session with score 0.8 triggers watermark + selection + clipboard + keyboard in one pass.
Pass a spanEmitter to emit shield.policy.triggered events and wire ContentProtector callbacks to child spans:
import { assessAndProtect } from '@tindalabs/shield';
import { getTracer, getRouteContext } from '@tindalabs/blindspot';
await assessAndProtect(contentEl, {
policies: [/* ... */],
spanEmitter: (name, attrs) => {
const span = getTracer().startSpan(name, { attributes: attrs }, getRouteContext());
span.end();
},
});| Option | Type | Description |
|---|---|---|
policies |
PolicyRule[] |
Ordered list of rules. All matching rules are merged. |
targetElement |
HTMLElement | null |
Element to protect. Defaults to document.body. |
customHandlers |
CustomEventHandlers |
Forwarded to ContentProtector. |
spanEmitter |
SpanEmitter |
Uses attachShieldToSpan and emits policy OTel events. |
assessOptions |
AssessOptions |
Forwarded to the internal assess() call. |
| Field | Type | Description |
|---|---|---|
when |
PolicyCondition |
Conditions that must all match. An empty {} always matches. |
enable |
StrategyKey[] |
Strategies to activate when the condition matches. |
watermarkOptions |
WatermarkOptions | (a: ShieldAssessment) => WatermarkOptions |
Static or factory watermark config. Last matched rule wins. |
| Field | Type | Description |
|---|---|---|
riskScore.gte |
number |
Score must be ≥ this value. |
riskScore.lt |
number |
Score must be < this value. |
signals |
Partial<ShieldSignals> |
All listed signal values must match. |
| Event | When |
|---|---|
shield.policy.triggered |
At least one rule matched — includes shield.policy.risk_score, shield.policy.matched_rules, shield.policy.enabled_strategies |
shield.policy.evaluated |
No rules matched — includes shield.policy.matched_rules: 0, shield.policy.protection_activated: false |
assessAndProtect() exists for the awkward middle ground: blanket protection breaks the experience for legitimate users, but no protection lets scrapers walk away with everything. The policy engine activates only the strategies the session's risk profile warrants — humans see nothing, automation hits a wall.
LLM training crawlers, prompt-based scrapers, and headless research agents share the same signal profile as conventional automation: shield.automation.webdriver, shield.automation.headless, patched-API heuristics, missing plugin metadata. A single policy rule flips watermarking and selection/clipboard lockdown on those sessions while human visitors get the unmodified page.
The watermarkOptions factory receives the full assessment, so anything that does get scraped carries a forensic trace back to the session that extracted it:
{
when: { signals: { 'shield.automation.headless': true } },
enable: ['enableWatermark', 'preventSelection', 'preventClipboard'],
watermarkOptions: (a) => ({
text: `SHIELD-${Math.round(a.risk.score * 100)}-${Date.now().toString(36)}`,
}),
}Pair with spanEmitter and every triggered rule emits shield.policy.triggered to your OTel pipeline — operators can see in real time how often, and by what signal, their content is being targeted, without instrumenting strategies by hand.
Financial, legal, and media documents need strong protection — but blanket DRM breaks screen readers, accessibility tooling, and developers debugging their own portal. Risk-keyed policy rules flip protection on only when warranted (high risk, automation signals, specific extensions) and leave the long tail of normal sessions completely untouched. The same engine handles both ends of the risk spectrum with one config.
If every session needs the same protection (e.g. a known-private internal tool where any visitor is high-trust by definition), skip the engine and use ContentProtector directly (next section). assessAndProtect() adds an assess() round trip and is only worth it when protection should vary per session.
Shield also exports the full protection suite for active content defense: blocks DevTools, prevents copy/print/selection, adds watermarks, detects extension injection and iframe embedding.
import { ContentProtector } from '@tindalabs/shield';
const protector = new ContentProtector({
preventDevTools: true,
preventKeyboardShortcuts: true,
preventPrinting: true,
preventClipboard: true,
clipboardOptions: { preventCopy: true, preventCut: true, preventPaste: false },
enableWatermark: true,
watermarkOptions: { text: 'Confidential', userId: 'user-123' },
// …and more (selection, context menu, screenshots, extensions, iframe embedding)
// — see REFERENCE.md for the complete options table.
});
protector.protect();
// Later:
protector.unprotect();
protector.dispose();See REFERENCE.md for the full ContentProtector API and all strategy options.
attachShieldToSpan() is a thin wrapper around ContentProtector that turns every protection event into a span event. Each blocked copy, print, keyboard shortcut, devtools open, or screenshot attempt becomes a shield.* event in your tracing pipeline — no manual callback wiring per strategy.
Shield is framework-agnostic about OTel — it doesn't depend on @opentelemetry/api. You provide a SpanEmitter callback; Shield calls it.
import { attachShieldToSpan } from '@tindalabs/shield';
const protector = attachShieldToSpan(
{ preventClipboard: true, enableWatermark: true, watermarkOptions: { text: 'Confidential' } },
(name, attrs) => span.addEvent(name, attrs),
);
protector.protect();import { attachShieldToSpan } from '@tindalabs/shield';
import { getTracer, getRouteContext } from '@tindalabs/blindspot';
const protector = attachShieldToSpan(
{ preventClipboard: true, preventScreenshots: true },
(name, attrs) => {
const span = getTracer().startSpan(name, { attributes: attrs }, getRouteContext());
span.end();
},
);
protector.protect();Emitting each event as a short-lived span (start + immediately end) means findings reach Tempo/Honeycomb/Jaeger without waiting for the long-lived navigation span to close.
| Event name | Fires when | Attributes |
|---|---|---|
shield.devtools.opened / shield.devtools.closed |
DevTools state changes | — |
shield.selection.attempted |
User tries to select content | — |
shield.context_menu.attempted |
Right-click / long-press | — |
shield.print.attempted |
Print dialog opens | — |
shield.keyboard_shortcut.blocked |
Blocked shortcut fires | shield.keyboard.key, shield.keyboard.code |
shield.clipboard.copy / .cut / .paste |
Clipboard action attempted | — |
shield.screenshot.attempted |
PrintScreen / Win+Shift+S / Cmd+Shift+3/4/5 | — |
shield.extension.detected |
Known scraping extension found | shield.extension.id, shield.extension.name, shield.extension.risk |
shield.frame.embedding.detected |
Page rendered inside an iframe | shield.frame.external |
shield.protection.bypassed |
A protection strategy was circumvented | shield.bypass.method |
shield.content.hidden / .restored |
Protected content toggled | shield.hidden.reason (hidden only) |
Emitter exceptions are swallowed — telemetry sink failure never crashes the protected page. Any customHandlers you also pass in options still get called after the emit.
Pair with assess() for an end-to-end picture: route-level risk score plus per-interaction protection events, all keyed off the same span.
A local demo app is included at demo/. It exercises both APIs in a dark-themed single-page app:
- Environment Assessment — runs
assess()and displays each signal, the risk score bar, and the raw OTel span attributes ready to copy. - Active Content Protection — full
ContentProtectorcontrols: every strategy toggle, watermark options, live events log.
cd demo
npm install
npm run dev # http://localhost:5175 (or next available port)Shield is one of three composable browser-layer packages:
| Package | What it does |
|---|---|
| @tindalabs/blindspot | Privacy-first OTel frontend observability |
| @tindalabs/shield | Tamper detection & active content protection |
| @tindalabs/scent | Probabilistic identity continuity |
MIT © Tindalabs