@webdecoy/ai-protection 0.1.0-alpha.7 → 0.1.0-alpha.9

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.7 @modelcontextprotocol/sdk@1.31.0
14
+ npm install @webdecoy/ai-protection@0.1.0-alpha.9 @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
@@ -143,7 +143,7 @@ include tenant IDs, hostnames containing secrets, or customer information.
143
143
 
144
144
  An authenticated MCP client calls `tools/list`. Each nonempty response queues a
145
145
  best-effort advertisement of the visible tool names and SHA-256 input-schema
146
- hashes. The AI Protection dashboard shows **Advertised MCP tools**, including
146
+ hashes. The AI Protection dashboard shows **MCP tool inventory**, including
147
147
  tools that have never executed. This does not scan unwrapped servers, hidden tools,
148
148
  resources, prompts or alternate routes. No network request is made at handler
149
149
  construction. Discovery is off unless configured and requires sharedRuntime.
@@ -164,3 +164,75 @@ never blocks authorization, and does not retry. Call `await handler.flush()` at
164
164
  host shutdown/lifecycle boundary to drain pending discovery reports; it does not
165
165
  wait for active tool calls or action reports. Abruptly terminated hosts can lose
166
166
  reports. Discovery advertisements never increment tool action or request counts.
167
+
168
+
169
+ ### Calls before discovery (alpha.8+)
170
+
171
+ With `discovery` enabled, the adapter also attaches the registered server label
172
+ and schema fingerprint to each `tools/call` action report. This includes local
173
+ permission/scope denials for registered tools. It works before a client calls
174
+ `tools/list`; no listing round trip is required to execute an authorized tool.
175
+ Unknown tool names and requests rejected before authentication do not gain
176
+ registry metadata. Turning discovery off omits the new call metadata too.
177
+
178
+ The inventory combines advertisements and action evidence by property, server
179
+ label and tool name. Attempt/start/completion reports sharing an action ID count
180
+ once. Counts include denied attempts, so they are not successful-execution totals.
181
+ “Call evidence only” means no advertisement was captured in the bounded seven-day
182
+ sample. Clients can call directly; hidden scopes, sampling and missing telemetry
183
+ also limit coverage. This is not an attack or permission-gap verdict. Older action
184
+ reports without metadata still appear in the general activity table, aggregated
185
+ by tool name across servers; their server/schema association remains unknown.
186
+
187
+ Deploy a runtime accepting optional `tool_action.tool_schema` before upgrading an
188
+ integration with discovery enabled. Older runtimes reject affected action reports
189
+ without changing admission. The action API also supports optional `toolSchema:
190
+ {serverId, hash}` on trusted registered definitions; it is snapshotted and validated,
191
+ never read from tool arguments. This is SDK-reported metadata, not authorization
192
+ or independent verification of a schema.
193
+
194
+
195
+ ### Advisory side effects (alpha.9+)
196
+
197
+ With discovery enabled, the SDK adds a fixed classification and reason code to
198
+ catalog/call metadata: `unknown`, `read_only`, `mutating`, or `destructive`.
199
+ This is heuristic inference, not a prompt scanner, authorization policy, verified
200
+ behavior or proof that side effects occurred. Classification never allows or
201
+ blocks a request. Existing validate/authorize/scopes/limits still control dispatch.
202
+
203
+ You may supply MCP boolean hints on a protected tool:
204
+
205
+ ```ts
206
+ annotations: { readOnlyHint: true, destructiveHint: false },
207
+ ```
208
+
209
+ Supported annotations are readOnlyHint, destructiveHint, idempotentHint and
210
+ openWorldHint. The adapter snapshots those booleans and includes them in scoped
211
+ MCP listing responses. Only classification codes enter WebDecoy reports; raw
212
+ annotations, schemas and descriptions are not uploaded. The schema fingerprint
213
+ continues to cover inputSchema, not annotations or implementation code.
214
+
215
+ The classifier checks tokenized tool names and explicit top-level `action`,
216
+ `operation` or `method` selectors (`const`/up to 64 enum values). It does not
217
+ resolve schema references, scan arbitrary text/arguments, execute tools, or call a
218
+ model. Destructive/mutating signals take precedence over a contradictory read-only
219
+ hint and are labeled conflicting. With no useful signals it reports unknown.
220
+ Name heuristics can be wrong (for example, a status tool named after deletion).
221
+ As the [MCP specification](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)
222
+ requires, annotations must not be treated as guarantees from an untrusted server.
223
+
224
+ The dashboard shows inferred evidence separately from a customer classification.
225
+ Use **Review classification** to select a label or return to SDK inference.
226
+ Overrides are property/server/tool settings bound to the current input-schema
227
+ hash; a different hash falls back to inference and prompts review. Changes to
228
+ annotations or implementation without a schema change do not invalidate an
229
+ override, so review those changes yourself. A saved classification changes only
230
+ the dashboard label; it is not read by runtime enforcement. Overrides persist as
231
+ customer settings until reset or property deletion; SDK evidence retains its
232
+ seven-day window. Where evidence disagrees for the latest received schema, the
233
+ review order is destructive, mutating, unknown, then read-only. Older SDKs without
234
+ classification metadata remain unknown, though customers can label their tools.
235
+
236
+ Requires a runtime accepting optional `effect` evidence. Deploy it before
237
+ upgrading a discovery-enabled integration. Large registries are split into
238
+ bounded catalog batches so added metadata stays within report body limits.
@@ -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.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.effect?{effect:event.toolSchema.effect}:{})}}:{}),...(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
@@ -32,7 +32,15 @@ export interface ActionWork {
32
32
  /** Confirmed work after completed execution. No async/detached measurement; excess/unknown retains maximum. */
33
33
  measure?(result:unknown,context:ActionContext):number;
34
34
  }
35
+ /** Server-supplied metadata, not proof of a verified schema or authorization. */
36
+ export interface ToolEffectEvidence {
37
+ readonly schema: 1;
38
+ readonly level: 'unknown' | 'read_only' | 'mutating' | 'destructive';
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
+ }
41
+ export interface ToolSchemaEvidence { readonly serverId: string; readonly hash: string; readonly effect?: ToolEffectEvidence; }
35
42
  export interface ActionEvent {
43
+ readonly toolSchema?: ToolSchemaEvidence;
36
44
  /** Pseudonymous application-authenticated subject, not WebDecoy-verified agent identity. */
37
45
  readonly caller?: {readonly schema:1;readonly source:'application_auth';readonly id:string};
38
46
  readonly work?:ActionWorkEvidence;
@@ -64,6 +72,10 @@ export interface ActionRuntime {
64
72
  reportingTimeoutMs?:number;maxPendingReports?:number;
65
73
  }
66
74
  export interface ActionDefinition {
75
+ /** Optional non-secret server label and canonical SHA-256 schema hash. MCP discovery fills this automatically.
76
+ * Requires a runtime supporting tool_schema evidence; does not change admission.
77
+ */
78
+ toolSchema?: ToolSchemaEvidence;
67
79
  limits?: ActionLimits;
68
80
  requiredScopes: readonly string[];
69
81
  validate(args: ActionInput): boolean | Promise<boolean>;
package/actions.mjs CHANGED
@@ -2,6 +2,7 @@ import {randomUUID} from 'node:crypto';
2
2
  import {abortable} from './transport.mjs';
3
3
  import {prepareActionRuntime} from './action-runtime.mjs';
4
4
 
5
+ import { snapshotToolEffect } from './tool-effects.mjs';
5
6
  const token = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,95}$/;
6
7
  const bounded = value => typeof value === 'string' && value.isWellFormed() && value.length > 0 && value.length <= 512 && !/[\x00-\x1f\x7f]/.test(value);
7
8
  const freeze = value => {
@@ -72,7 +73,11 @@ export function createActionProtection(options) {
72
73
  definition.requiredScopes.some(s => !bounded(s)) ||
73
74
  ['validate','authorize','execute'].some(k => typeof definition[k] !== 'function') ||
74
75
  (definition.policy !== undefined && typeof definition.policy !== 'function')) throw Error('Invalid action definition');
75
- actions.set(name, Object.freeze({...definition,requiredScopes:Object.freeze([...definition.requiredScopes])}));
76
+ const t = definition.toolSchema;
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)})});
80
+ actions.set(name, Object.freeze({...definition,toolSchema,requiredScopes:Object.freeze([...definition.requiredScopes])}));
76
81
  }
77
82
  if (!actions.size || actions.size > 128) throw Error('Expected 1–128 actions');
78
83
  const runtime=prepareActionRuntime(options,actions);
@@ -91,7 +96,7 @@ export function createActionProtection(options) {
91
96
  function emit(decision, reason, outcome) {
92
97
 
93
98
  const event = Object.freeze({schema:1, eventId:randomUUID(), timestamp:new Date().toISOString(), actionId, action:eventAction, policyVersion,
94
- ...(callerEvidence?{caller:callerEvidence}:{}),evaluation:'local', decision, reason, attempted, outcome,...(work?{work:Object.freeze({...work.evidence})}:{}),checks:Object.freeze(checks.map(c=>Object.freeze({...c})))});
99
+ ...(callerEvidence?{caller:callerEvidence}:{}),...(action?.toolSchema?{toolSchema:action.toolSchema}:{}),evaluation:'local', decision, reason, attempted, outcome,...(work?{work:Object.freeze({...work.evidence})}:{}),checks:Object.freeze(checks.map(c=>Object.freeze({...c})))});
95
100
  if(runtime)void runtime.report(event);
96
101
  if(!sink||pendingEvents>=100)return;
97
102
  pendingEvents++;
package/mcp.d.mts CHANGED
@@ -4,9 +4,11 @@ import type {CallToolResult, Tool} from '@modelcontextprotocol/sdk/types.js';
4
4
  import type {ActionContext, ActionDefinition, ActionEvent, ActionRuntime, TrustedCaller} from './actions.mjs';
5
5
 
6
6
  /** Explicitly registered tool; inputSchema describes discovery, validate enforces input. */
7
- export interface ProtectedTool extends ActionDefinition {
7
+ export interface ProtectedTool extends Omit<ActionDefinition, 'toolSchema'> {
8
8
  description: string;
9
9
  inputSchema: Tool['inputSchema'];
10
+ /** Behavioral hints, never authorization or verified guarantees. */
11
+ annotations?: Pick<NonNullable<Tool['annotations']>, 'readOnlyHint' | 'destructiveHint' | 'idempotentHint' | 'openWorldHint'>;
10
12
  /** Await all protected work and return a complete MCP result, never a detached stream. */
11
13
  execute(context: ActionContext): CallToolResult | Promise<CallToolResult>;
12
14
  }
@@ -21,7 +23,7 @@ export interface ProtectedMCPOptions {
21
23
  policyVersion: string;
22
24
  sharedRuntime?: ActionRuntime;
23
25
  tools: Record<string, ProtectedTool>;
24
- /** Opt-in tools/list metadata: stable non-secret server label and SHA-256 input-schema hashes.
26
+ /** Opt-in tools/list and tools/call metadata: stable non-secret server label and SHA-256 input-schema hashes.
25
27
  * Requires sharedRuntime. Reuse serverId across replicas; separate different servers.
26
28
  */
27
29
  discovery?: { serverId: string };
package/mcp.mjs CHANGED
@@ -4,6 +4,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError } fr
4
4
  import { createActionProtection, ActionDenied } from './actions.mjs';
5
5
  import { createHash, randomUUID } from 'node:crypto';
6
6
  import { createReporter } from './reporting.mjs';
7
+ import { inferToolEffect, snapshotToolHints } from './tool-effects.mjs';
7
8
  const metadataPath = '/.well-known/oauth-protected-resource/mcp';
8
9
  export function createProtectedMCPHandler(options) {
9
10
  const resource = new URL(options.resource), issuer = new URL(options.authorizationServer);
@@ -11,7 +12,7 @@ export function createProtectedMCPHandler(options) {
11
12
  if ((resource.protocol !== 'https:' && !(loopback && resource.protocol === 'http:')) || resource.pathname !== '/mcp' || resource.search || resource.hash || resource.username || resource.password || issuer.protocol !== 'https:' || issuer.search || issuer.hash || issuer.username || issuer.password)
12
13
  throw Error('Invalid MCP resource configuration');
13
14
  const metadataURL = new URL(metadataPath, resource).href;
14
- const tools = Object.fromEntries(Object.entries(options.tools).map(([name, t]) => [name, { ...t, requiredScopes: [...t.requiredScopes], inputSchema: structuredClone(t.inputSchema) }]));
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) }]));
15
16
  // Validate the closed registry at startup, not only after a client arrives.
16
17
  createActionProtection({ policyVersion: options.policyVersion, authenticate: options.authenticate, actions: tools, sharedRuntime: options.sharedRuntime });
17
18
  for (const tool of Object.values(tools))
@@ -30,6 +31,8 @@ export function createProtectedMCPHandler(options) {
30
31
  };
31
32
  const hashes = options.discovery ? Object.fromEntries(Object.entries(tools).map(([name, tool]) =>
32
33
  [name, createHash('sha256').update(canonical(JSON.parse(JSON.stringify(tool.inputSchema)))).digest('hex')])) : {};
34
+ 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)});
33
36
  const runtime = options.sharedRuntime && { ...options.sharedRuntime };
34
37
  const serverId = options.discovery?.serverId;
35
38
  const catalogReporter = options.discovery ? createReporter({
@@ -40,7 +43,7 @@ export function createProtectedMCPHandler(options) {
40
43
  method: 'POST', redirect: 'error', signal,
41
44
  headers: { Authorization: `Bearer ${runtime.webdecoyKey}`, 'X-WebDecoy-Property-ID': runtime.propertyId, 'Content-Type': 'application/json' },
42
45
  body: JSON.stringify({ schema: 3, request_id: randomUUID(), timestamp: new Date().toISOString(), action: 'tool_discovery',
43
- tool_catalog: { server_id: serverId, source: 'tools_list', tools: names.map(name => ({ name, schema_hash: hashes[name] })) } })
46
+ tool_catalog: { server_id: serverId, source: 'tools_list', tools: names.map(name => ({ name, schema_hash: hashes[name], effect: tools[name].toolSchema.effect })) } })
44
47
  });
45
48
  await response.body?.cancel();
46
49
  if (!response.ok) throw Error('Discovery reporting unavailable');
@@ -195,8 +198,8 @@ export function createProtectedMCPHandler(options) {
195
198
  res.once('close', () => { void server.close().catch(() => { }); });
196
199
  server.setRequestHandler(ListToolsRequestSchema, async () => {
197
200
  const visible = Object.entries(tools).filter(([, tool]) => tool.requiredScopes.every(s => caller.scopes.includes(s)));
198
- if (visible.length) void catalogReporter?.send(visible.map(([name]) => name));
199
- return { tools: visible.map(([name, tool]) => ({ name, description: tool.description, inputSchema: tool.inputSchema })) };
201
+ for (let i=0;i<visible.length;i+=64) void catalogReporter?.send(visible.slice(i,i+64).map(([name]) => name));
202
+ return { tools: visible.map(([name, tool]) => ({ name, description: tool.description, inputSchema: tool.inputSchema, ...(tool.annotations ? {annotations:tool.annotations} : {}) })) };
200
203
  });
201
204
  server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
202
205
  if (!Object.hasOwn(tools, request.params.name))
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.7",
13
+ "version": "0.1.0-alpha.9",
14
14
  "exports": {
15
15
  "./fetch": {
16
16
  "types": "./fetch.d.mts",
@@ -62,7 +62,8 @@
62
62
  "mcp.d.mts",
63
63
  "MCP.md",
64
64
  "work.mjs",
65
- "WORK.md"
65
+ "WORK.md",
66
+ "tool-effects.mjs"
66
67
  ],
67
68
  "description": "Bot and abuse protection for AI-powered applications. Server-side request admission for Node.js.",
68
69
  "license": "Apache-2.0",
@@ -0,0 +1,45 @@
1
+ // Advisory inference only. Never used for permission or execution decisions.
2
+ const destructive = new Set(['delete','remove','destroy','drop','truncate','revoke','purge','wipe']);
3
+ const mutating = new Set(['create','update','write','set','send','publish','execute','run','export','charge','pay','transfer','deploy','install','invite','approve','refund']);
4
+ const reading = new Set(['get','fetch','read','list','search','inspect','find','describe','count','lookup']);
5
+ const tokens = value => typeof value === 'string' ? value.replace(/([a-z0-9])([A-Z])/g,'$1 $2').toLowerCase().split(/[^a-z0-9]+/) : [];
6
+ export function snapshotToolHints(value) {
7
+ if(value === undefined)return undefined;
8
+ if(!value || typeof value !== 'object' || Array.isArray(value))throw Error('Invalid tool annotations');
9
+ const out={};
10
+ for(const key of ['readOnlyHint','destructiveHint','idempotentHint','openWorldHint'])if(value[key]!==undefined){
11
+ if(typeof value[key]!=='boolean')throw Error('Invalid tool annotation hint');out[key]=value[key];
12
+ }
13
+ return Object.freeze(out);
14
+ }
15
+ const reasons={unknown:['insufficient_signals'],read_only:['annotation_read_only','name_read_only'],mutating:['annotation_mutating','name_mutating','schema_mutating','conflicting_hints'],destructive:['annotation_destructive','name_destructive','schema_destructive','conflicting_hints']};
16
+ export function snapshotToolEffect(value) {
17
+ if(value===undefined)return undefined;
18
+ if(!value || value.schema!==1 || !Object.hasOwn(reasons,value.level) || !reasons[value.level].includes(value.reason))throw Error('Invalid tool effect evidence');
19
+ return Object.freeze({schema:1,level:value.level,reason:value.reason});
20
+ }
21
+ export function inferToolEffect(name,schema,hints) {
22
+ const nameTokens=tokens(name);
23
+ const schemaTokens=[];
24
+ // Only inspect explicit operation selectors, never descriptions or free text.
25
+ // No refs, nested schema resolution, arguments, network or model calls.
26
+ for(const key of ['action','operation','method']){
27
+ const selector=schema?.properties?.[key];
28
+ for(const value of [selector?.const,...(Array.isArray(selector?.enum)?selector.enum.slice(0,64):[])]){
29
+ if(typeof value!=='string'||value.length>128)continue;
30
+ schemaTokens.push(...tokens(value));
31
+ if(key==='method'&&['POST','PUT','PATCH'].includes(value.toUpperCase()))schemaTokens.push('write');
32
+ }
33
+ }
34
+ let level='unknown',reason='insufficient_signals';
35
+ if(nameTokens.some(t=>destructive.has(t))){level='destructive';reason='name_destructive';}
36
+ else if(schemaTokens.some(t=>destructive.has(t))){level='destructive';reason='schema_destructive';}
37
+ else if(hints?.readOnlyHint!==true&&hints?.destructiveHint===true){level='destructive';reason='annotation_destructive';}
38
+ else if(nameTokens.some(t=>mutating.has(t))){level='mutating';reason='name_mutating';}
39
+ else if(schemaTokens.some(t=>mutating.has(t))){level='mutating';reason='schema_mutating';}
40
+ else if(hints?.readOnlyHint===false){level='mutating';reason='annotation_mutating';}
41
+ else if(hints?.readOnlyHint===true){level='read_only';reason='annotation_read_only';}
42
+ else if(nameTokens.some(t=>reading.has(t))){level='read_only';reason='name_read_only';}
43
+ if(hints?.readOnlyHint===true&&['mutating','destructive'].includes(level))reason='conflicting_hints';
44
+ return snapshotToolEffect({schema:1,level,reason});
45
+ }