@webdecoy/ai-protection 0.1.0-alpha.8 → 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 +47 -1
- package/action-runtime.mjs +1 -1
- package/actions.d.mts +6 -1
- package/actions.mjs +2 -1
- package/mcp.d.mts +2 -0
- package/mcp.mjs +6 -5
- package/package.json +3 -2
- package/tool-effects.mjs +45 -0
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.
|
|
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
|
|
@@ -190,3 +190,49 @@ without changing admission. The action API also supports optional `toolSchema:
|
|
|
190
190
|
{serverId, hash}` on trusted registered definitions; it is snapshotted and validated,
|
|
191
191
|
never read from tool arguments. This is SDK-reported metadata, not authorization
|
|
192
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.
|
package/action-runtime.mjs
CHANGED
|
@@ -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.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
|
@@ -33,7 +33,12 @@ export interface ActionWork {
|
|
|
33
33
|
measure?(result:unknown,context:ActionContext):number;
|
|
34
34
|
}
|
|
35
35
|
/** Server-supplied metadata, not proof of a verified schema or authorization. */
|
|
36
|
-
export interface
|
|
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; }
|
|
37
42
|
export interface ActionEvent {
|
|
38
43
|
readonly toolSchema?: ToolSchemaEvidence;
|
|
39
44
|
/** Pseudonymous application-authenticated subject, not WebDecoy-verified agent identity. */
|
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 => {
|
|
@@ -75,7 +76,7 @@ export function createActionProtection(options) {
|
|
|
75
76
|
const t = definition.toolSchema;
|
|
76
77
|
if (t !== undefined && (!t || typeof t.serverId !== 'string' || !token.test(t.serverId) ||
|
|
77
78
|
typeof t.hash !== 'string' || !/^[a-f0-9]{64}$/.test(t.hash))) throw Error('Invalid tool schema evidence');
|
|
78
|
-
const toolSchema = t === undefined ? undefined : Object.freeze({serverId:t.serverId,hash:t.hash});
|
|
79
|
+
const toolSchema = t === undefined ? undefined : Object.freeze({serverId:t.serverId,hash:t.hash,...(t.effect===undefined?{}:{effect:snapshotToolEffect(t.effect)})});
|
|
79
80
|
actions.set(name, Object.freeze({...definition,toolSchema,requiredScopes:Object.freeze([...definition.requiredScopes])}));
|
|
80
81
|
}
|
|
81
82
|
if (!actions.size || actions.size > 128) throw Error('Expected 1–128 actions');
|
package/mcp.d.mts
CHANGED
|
@@ -7,6 +7,8 @@ import type {ActionContext, ActionDefinition, ActionEvent, ActionRuntime, Truste
|
|
|
7
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
|
}
|
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, toolSchema: undefined, 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))
|
|
@@ -31,7 +32,7 @@ export function createProtectedMCPHandler(options) {
|
|
|
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')])) : {};
|
|
33
34
|
if (options.discovery) for (const [name, tool] of Object.entries(tools))
|
|
34
|
-
tool.toolSchema = Object.freeze({serverId:options.discovery.serverId,hash:hashes[name]});
|
|
35
|
+
tool.toolSchema = Object.freeze({serverId:options.discovery.serverId,hash:hashes[name],effect:inferToolEffect(name,tool.inputSchema,tool.annotations)});
|
|
35
36
|
const runtime = options.sharedRuntime && { ...options.sharedRuntime };
|
|
36
37
|
const serverId = options.discovery?.serverId;
|
|
37
38
|
const catalogReporter = options.discovery ? createReporter({
|
|
@@ -42,7 +43,7 @@ export function createProtectedMCPHandler(options) {
|
|
|
42
43
|
method: 'POST', redirect: 'error', signal,
|
|
43
44
|
headers: { Authorization: `Bearer ${runtime.webdecoyKey}`, 'X-WebDecoy-Property-ID': runtime.propertyId, 'Content-Type': 'application/json' },
|
|
44
45
|
body: JSON.stringify({ schema: 3, request_id: randomUUID(), timestamp: new Date().toISOString(), action: 'tool_discovery',
|
|
45
|
-
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 })) } })
|
|
46
47
|
});
|
|
47
48
|
await response.body?.cancel();
|
|
48
49
|
if (!response.ok) throw Error('Discovery reporting unavailable');
|
|
@@ -197,8 +198,8 @@ export function createProtectedMCPHandler(options) {
|
|
|
197
198
|
res.once('close', () => { void server.close().catch(() => { }); });
|
|
198
199
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
199
200
|
const visible = Object.entries(tools).filter(([, tool]) => tool.requiredScopes.every(s => caller.scopes.includes(s)));
|
|
200
|
-
|
|
201
|
-
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} : {}) })) };
|
|
202
203
|
});
|
|
203
204
|
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
204
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.
|
|
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",
|
package/tool-effects.mjs
ADDED
|
@@ -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
|
+
}
|