@webdecoy/ai-protection 0.1.0-alpha.10 → 0.1.0-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/MCP.md CHANGED
@@ -11,7 +11,7 @@ resource authorization.
11
11
  Available in `0.1.0-alpha.5` and later compatible alpha releases:
12
12
 
13
13
  ```sh
14
- npm install @webdecoy/ai-protection@0.1.0-alpha.10 @modelcontextprotocol/sdk@1.31.0
14
+ npm install @webdecoy/ai-protection@0.1.0-alpha.11 @modelcontextprotocol/sdk@1.31.0
15
15
  ```
16
16
 
17
17
  Requires Node 22.22.3+ and MCP SDK **1.31.0**. The MCP SDK is an optional peer, so
@@ -269,3 +269,54 @@ can include both sides of a rolling deployment; it does not certify the latest
269
269
  running configuration or prove that unwrapped routes are protected. A changed
270
270
  scope or callback does not change the input-schema hash. No enforcement behavior
271
271
  changes when discovery is enabled.
272
+
273
+ ## Explicit decoy tools (Alpha)
274
+
275
+ Decoys are off by default. Node alpha.11 supports up to eight explicitly named
276
+ synthetic tools in the protected registry; requires `discovery` and `sharedRuntime`.
277
+ The combined real/decoy registry still has a 128-tool limit. Deploy a compatible
278
+ runtime before upgrading an integration that enables decoys.
279
+
280
+ ```js
281
+ discovery: {serverId: 'billing'},
282
+ decoys: {
283
+ billing_export_ledger: {
284
+ description: 'Internal ledger export',
285
+ visibility: 'advertised',
286
+ },
287
+ admin_rotate_keys: {
288
+ description: 'Internal key rotation',
289
+ visibility: 'unadvertised',
290
+ },
291
+ },
292
+ ```
293
+
294
+ Choose names and descriptions outside legitimate workflows; examples are not a
295
+ recommended universal decoy set. Collisions with real tools, callback fields and
296
+ invalid definitions fail startup. Definitions are snapshotted. The SDK provides a
297
+ generic object schema; no custom execution, validation or authorization callbacks
298
+ are accepted for decoys. Advertised decoys appear to authenticated listing clients;
299
+ unadvertised decoys never appear in tools/list. Both accept direct authenticated
300
+ calls only to return the ordinary permission-denied error. Customer code is never
301
+ executed, including when shared telemetry is unavailable. Real tools retain their
302
+ normal authorization and behavior.
303
+
304
+ Only reported calls count as decoy calls; listing is not a trip. Discovery and
305
+ call reports carry a fixed decoy visibility marker, not arguments, hashes of
306
+ arguments, descriptions or generated identities. Existing opt-in caller attribution
307
+ can associate calls with application-reported pseudonyms. Invalid unauthenticated
308
+ requests, over-limit transport bodies and unwrapped routes are not covered by this
309
+ trip evidence. Delivery remains best effort. A marker is SDK-reported evidence,
310
+ not independently verified server configuration.
311
+
312
+ The dashboard labels decoys and mixed real/decoy evidence, counts deduplicated
313
+ calls, and excludes synthetic entries from normal permission/advertisement review
314
+ findings. Name-aggregated activity is labeled when it includes decoys. Reusing a
315
+ name for a real tool can yield mixed evidence until retained reports expire.
316
+
317
+ A decoy call can come from legitimate exploration or a naive agent; it is not proof
318
+ of malicious intent. This slice does not auto-block callers, measure false-positive
319
+ rates, generate seeded names, record initialization events, remotely deliver decoy
320
+ configuration, or provide a labeled test-trigger flow. Configuration requires an
321
+ application deployment. Those remain separate roadmap work. No low-false-positive
322
+ or caller-containment guarantee is made.
@@ -39,7 +39,7 @@ export function prepareActionRuntime(options, definitions) {
39
39
  const payload={schema:2,request_id:event.eventId,timestamp:event.timestamp,decision:event.decision,reason:event.reason,
40
40
  degraded:event.checks.some(c=>c.decision==='unavailable'),checks,handler_attempted:event.attempted,
41
41
  action:event.decision==='deny'?'denied':event.outcome==='unknown'?'handler_error':'forwarded',
42
- tool_action:{action_id:event.actionId,name:event.action,policy_version:event.policyVersion,outcome:event.outcome,...(event.toolSchema?{tool_schema:{server_id:event.toolSchema.serverId,hash:event.toolSchema.hash,...(event.toolSchema.effect?{effect:event.toolSchema.effect}:{}),...(event.toolSchema.permissions?{permissions:event.toolSchema.permissions}:{})}}:{}),...(event.caller?{caller:event.caller}:{}),...(event.work?{work:event.work}:{})}};
42
+ tool_action:{action_id:event.actionId,name:event.action,policy_version:event.policyVersion,outcome:event.outcome,...(event.toolSchema?{tool_schema:{server_id:event.toolSchema.serverId,hash:event.toolSchema.hash,...(event.toolSchema.decoy?{decoy:event.toolSchema.decoy}:{}),...(event.toolSchema.effect?{effect:event.toolSchema.effect}:{}),...(event.toolSchema.permissions?{permissions:event.toolSchema.permissions}:{})}}:{}),...(event.caller?{caller:event.caller}:{}),...(event.work?{work:event.work}:{})}};
43
43
  const response=await fetch(new URL('/api/v1/sdk/ai-abuse/reports',url),{method:'POST',redirect:'error',signal,
44
44
  headers:{Authorization:`Bearer ${c.webdecoyKey}`,'X-WebDecoy-Property-ID':c.propertyId,'Content-Type':'application/json'},body:JSON.stringify(payload)});
45
45
  await response.body?.cancel();if(!response.ok)throw Error('Action reporting unavailable');
package/actions.d.mts CHANGED
@@ -39,7 +39,7 @@ export interface ToolEffectEvidence {
39
39
  readonly reason: 'insufficient_signals' | 'annotation_read_only' | 'name_read_only' | 'annotation_mutating' | 'name_mutating' | 'schema_mutating' | 'annotation_destructive' | 'name_destructive' | 'schema_destructive' | 'conflicting_hints';
40
40
  }
41
41
  export interface ToolPermissionEvidence { readonly schema: 1; readonly required_scopes: number; readonly application_authorization: true; readonly additional_policy: boolean; }
42
- export interface ToolSchemaEvidence { readonly permissions?: ToolPermissionEvidence; readonly serverId: string; readonly hash: string; readonly effect?: ToolEffectEvidence; }
42
+ export interface ToolSchemaEvidence { readonly decoy?: 'advertised' | 'unadvertised'; readonly permissions?: ToolPermissionEvidence; readonly serverId: string; readonly hash: string; readonly effect?: ToolEffectEvidence; }
43
43
  export interface ActionEvent {
44
44
  readonly toolSchema?: ToolSchemaEvidence;
45
45
  /** Pseudonymous application-authenticated subject, not WebDecoy-verified agent identity. */
package/actions.mjs CHANGED
@@ -75,8 +75,8 @@ export function createActionProtection(options) {
75
75
  (definition.policy !== undefined && typeof definition.policy !== 'function')) throw Error('Invalid action definition');
76
76
  const t = definition.toolSchema;
77
77
  if (t !== undefined && (!t || typeof t.serverId !== 'string' || !token.test(t.serverId) ||
78
- typeof t.hash !== 'string' || !/^[a-f0-9]{64}$/.test(t.hash))) throw Error('Invalid tool schema evidence');
79
- const toolSchema = t === undefined ? undefined : Object.freeze({serverId:t.serverId,hash:t.hash,...(t.effect===undefined?{}:{effect:snapshotToolEffect(t.effect)}),...(t.permissions===undefined?{}:{permissions:snapshotToolPermissions(t.permissions)})});
78
+ (t.decoy!==undefined && !['advertised','unadvertised'].includes(t.decoy)) || typeof t.hash !== 'string' || !/^[a-f0-9]{64}$/.test(t.hash))) throw Error('Invalid tool schema evidence');
79
+ const toolSchema = t === undefined ? undefined : Object.freeze({serverId:t.serverId,hash:t.hash,...(t.decoy?{decoy:t.decoy}:{}),...(t.effect===undefined?{}:{effect:snapshotToolEffect(t.effect)}),...(t.permissions===undefined?{}:{permissions:snapshotToolPermissions(t.permissions)})});
80
80
  actions.set(name, Object.freeze({...definition,toolSchema,requiredScopes:Object.freeze([...definition.requiredScopes])}));
81
81
  }
82
82
  if (!actions.size || actions.size > 128) throw Error('Expected 1–128 actions');
package/mcp.d.mts CHANGED
@@ -23,6 +23,10 @@ export interface ProtectedMCPOptions {
23
23
  policyVersion: string;
24
24
  sharedRuntime?: ActionRuntime;
25
25
  tools: Record<string, ProtectedTool>;
26
+ /** Opt-in, at most eight named decoys. Requires discovery + sharedRuntime.
27
+ * Never accepts customer execution callbacks. Combined real/decoy registry <=128.
28
+ */
29
+ decoys?: Record<string, {description: string; visibility: 'advertised' | 'unadvertised'}>;
26
30
  /** Opt-in tools/list and tools/call metadata: stable non-secret server label and SHA-256 input-schema hashes.
27
31
  * Requires sharedRuntime. Reuse serverId across replicas; separate different servers.
28
32
  */
package/mcp.mjs CHANGED
@@ -13,6 +13,22 @@ export function createProtectedMCPHandler(options) {
13
13
  throw Error('Invalid MCP resource configuration');
14
14
  const metadataURL = new URL(metadataPath, resource).href;
15
15
  const tools = Object.fromEntries(Object.entries(options.tools).map(([name, t]) => [name, { ...t, toolSchema: undefined, annotations: snapshotToolHints(t.annotations), requiredScopes: [...t.requiredScopes], inputSchema: structuredClone(t.inputSchema) }]));
16
+ const decoys = new Map();
17
+ if (options.decoys !== undefined) {
18
+ if (!options.discovery || !options.sharedRuntime || !options.decoys || typeof options.decoys !== 'object' || Array.isArray(options.decoys)) throw Error('Decoys require discovery and sharedRuntime');
19
+ const entries = Object.entries(options.decoys);
20
+ if (entries.length > 8) throw Error('At most 8 decoys are supported');
21
+ for (const [name, definition] of entries) {
22
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,95}$/.test(name) || Object.hasOwn(tools, name) || !definition ||
23
+ Object.keys(definition).some(k => !['description', 'visibility'].includes(k)) ||
24
+ typeof definition.description !== 'string' || !definition.description.trim() || definition.description.length > 512 ||
25
+ !['advertised', 'unadvertised'].includes(definition.visibility)) throw Error('Invalid or colliding decoy definition');
26
+ decoys.set(name, definition.visibility);
27
+ tools[name] = { description: definition.description, inputSchema: {type:'object'}, requiredScopes:[],
28
+ validate: () => true, authorize: () => false,
29
+ execute: () => { throw Error('Decoy dispatch invariant violated'); } };
30
+ }
31
+ }
16
32
  // Validate the closed registry at startup, not only after a client arrives.
17
33
  createActionProtection({ policyVersion: options.policyVersion, authenticate: options.authenticate, actions: tools, sharedRuntime: options.sharedRuntime });
18
34
  for (const tool of Object.values(tools))
@@ -32,7 +48,7 @@ export function createProtectedMCPHandler(options) {
32
48
  const hashes = options.discovery ? Object.fromEntries(Object.entries(tools).map(([name, tool]) =>
33
49
  [name, createHash('sha256').update(canonical(JSON.parse(JSON.stringify(tool.inputSchema)))).digest('hex')])) : {};
34
50
  if (options.discovery) for (const [name, tool] of Object.entries(tools))
35
- tool.toolSchema = Object.freeze({serverId:options.discovery.serverId,hash:hashes[name],effect:inferToolEffect(name,tool.inputSchema,tool.annotations),permissions:Object.freeze({schema:1,required_scopes:tool.requiredScopes.length,application_authorization:true,additional_policy:typeof tool.policy==='function'})});
51
+ tool.toolSchema = Object.freeze({serverId:options.discovery.serverId,hash:hashes[name],...(decoys.has(name)?{decoy:decoys.get(name)}:{effect:inferToolEffect(name,tool.inputSchema,tool.annotations),permissions:Object.freeze({schema:1,required_scopes:tool.requiredScopes.length,application_authorization:true,additional_policy:typeof tool.policy==='function'})})});
36
52
  const runtime = options.sharedRuntime && { ...options.sharedRuntime };
37
53
  const serverId = options.discovery?.serverId;
38
54
  const catalogReporter = options.discovery ? createReporter({
@@ -43,7 +59,7 @@ export function createProtectedMCPHandler(options) {
43
59
  method: 'POST', redirect: 'error', signal,
44
60
  headers: { Authorization: `Bearer ${runtime.webdecoyKey}`, 'X-WebDecoy-Property-ID': runtime.propertyId, 'Content-Type': 'application/json' },
45
61
  body: JSON.stringify({ schema: 3, request_id: randomUUID(), timestamp: new Date().toISOString(), action: 'tool_discovery',
46
- tool_catalog: { server_id: serverId, source: 'tools_list', tools: names.map(name => ({ name, schema_hash: hashes[name], effect: tools[name].toolSchema.effect, permissions: tools[name].toolSchema.permissions })) } })
62
+ tool_catalog: { server_id: serverId, source: 'tools_list', tools: names.map(name => ({ name, schema_hash: hashes[name], effect: tools[name].toolSchema.effect, permissions: tools[name].toolSchema.permissions, ...(decoys.has(name)?{decoy:decoys.get(name)}:{}) })) } })
47
63
  });
48
64
  await response.body?.cancel();
49
65
  if (!response.ok) throw Error('Discovery reporting unavailable');
@@ -197,7 +213,7 @@ export function createProtectedMCPHandler(options) {
197
213
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: false });
198
214
  res.once('close', () => { void server.close().catch(() => { }); });
199
215
  server.setRequestHandler(ListToolsRequestSchema, async () => {
200
- const visible = Object.entries(tools).filter(([, tool]) => tool.requiredScopes.every(s => caller.scopes.includes(s)));
216
+ const visible = Object.entries(tools).filter(([name, tool]) => decoys.get(name) !== 'unadvertised' && tool.requiredScopes.every(s => caller.scopes.includes(s)));
201
217
  for (let i=0;i<visible.length;i+=64) void catalogReporter?.send(visible.slice(i,i+64).map(([name]) => name));
202
218
  return { tools: visible.map(([name, tool]) => ({ name, description: tool.description, inputSchema: tool.inputSchema, ...(tool.annotations ? {annotations:tool.annotations} : {}) })) };
203
219
  });
package/package.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "check:package": "node scripts/check-package.mjs",
11
11
  "test:types": "tsc --strict --noEmit --module nodenext --target es2022 test/types.mts test/mcp-types.mts"
12
12
  },
13
- "version": "0.1.0-alpha.10",
13
+ "version": "0.1.0-alpha.11",
14
14
  "exports": {
15
15
  "./fetch": {
16
16
  "types": "./fetch.d.mts",