@webdecoy/ai-protection 0.1.0-alpha.5 → 0.1.0-alpha.7
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 +45 -1
- package/README.md +26 -0
- package/action-runtime.mjs +4 -3
- package/actions.d.mts +4 -0
- package/actions.mjs +4 -3
- package/mcp.d.mts +8 -1
- package/mcp.mjs +37 -4
- package/package.json +1 -1
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.7 @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
|
|
@@ -120,3 +120,47 @@ whole MCP server. See the [MCP transport specification](https://modelcontextprot
|
|
|
120
120
|
and [authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
|
|
121
121
|
|
|
122
122
|
For weighted search/export limits and tenant concurrency, see [bounded tool work](WORK.md).
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
## Opt-in tool discovery (alpha.7+)
|
|
126
|
+
|
|
127
|
+
Add these options alongside your existing `tools` and authentication configuration:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
sharedRuntime: {
|
|
131
|
+
webdecoyUrl: 'https://ai-protection.webdecoy.com',
|
|
132
|
+
webdecoyKey: process.env.WEBDECOY_API_KEY!,
|
|
133
|
+
propertyId: process.env.WEBDECOY_PROPERTY_ID!,
|
|
134
|
+
subjectSecret: process.env.WEBDECOY_SUBJECT_SECRET!, // at least 32 bytes
|
|
135
|
+
},
|
|
136
|
+
discovery: { serverId: 'records-api' },
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Use a stable, non-secret server label (1–96 letters, digits, `_`, `.`, `:`, `-`,
|
|
140
|
+
starting with a letter or digit). Reuse it across replicas/releases of the same
|
|
141
|
+
logical server. Give separate servers separate labels within a property. Do not
|
|
142
|
+
include tenant IDs, hostnames containing secrets, or customer information.
|
|
143
|
+
|
|
144
|
+
An authenticated MCP client calls `tools/list`. Each nonempty response queues a
|
|
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
|
|
147
|
+
tools that have never executed. This does not scan unwrapped servers, hidden tools,
|
|
148
|
+
resources, prompts or alternate routes. No network request is made at handler
|
|
149
|
+
construction. Discovery is off unless configured and requires sharedRuntime.
|
|
150
|
+
|
|
151
|
+
Hashes cover the JSON input schema with recursively sorted object keys; array
|
|
152
|
+
order is preserved. Raw schemas, descriptions, arguments, results, credentials,
|
|
153
|
+
caller identities and required scopes are not uploaded. A schema hash is a
|
|
154
|
+
fingerprint, not encryption; someone with a candidate schema can compare it.
|
|
155
|
+
Multiple hashes for one server/tool show reported variants in the seven-day
|
|
156
|
+
receipt window. Rolling deployments and benign schema edits can cause variants.
|
|
157
|
+
They do not establish an attack, breaking change, removal or missing permission.
|
|
158
|
+
The UI uses the latest receipt's hash, not a claim about deployment order.
|
|
159
|
+
|
|
160
|
+
Discovery uses schema-3 reports on the existing reporting endpoint; deploy a
|
|
161
|
+
compatible hosted runtime first. Old runtimes reject these optional reports
|
|
162
|
+
without affecting tool listing. Reporting has bounded pending work and a deadline,
|
|
163
|
+
never blocks authorization, and does not retry. Call `await handler.flush()` at a
|
|
164
|
+
host shutdown/lifecycle boundary to drain pending discovery reports; it does not
|
|
165
|
+
wait for active tool calls or action reports. Abruptly terminated hosts can lose
|
|
166
|
+
reports. Discovery advertisements never increment tool action or request counts.
|
package/README.md
CHANGED
|
@@ -334,3 +334,29 @@ action events are documented in the action guide and use the hosted AI Protectio
|
|
|
334
334
|
Node alpha.5 adds weighted tool-work reservations and tenant concurrency. See
|
|
335
335
|
[bounded tool work](WORK.md) for installation, enforced application bounds and
|
|
336
336
|
retry/unknown-outcome semantics. These units are separate from model usage and billing.
|
|
337
|
+
|
|
338
|
+
### Opt-in tool caller attribution
|
|
339
|
+
|
|
340
|
+
With a runtime supporting caller evidence, set `sharedRuntime.reportCaller: true`
|
|
341
|
+
on `createActionProtection`. It defaults to false. Hosted reports and `onEvent`
|
|
342
|
+
then include `caller: {schema: 1, source: 'application_auth', id: '<digest>'}`
|
|
343
|
+
after successful authentication, including subsequent permission denials.
|
|
344
|
+
Failed authentication, rejected arguments before authentication, and unknown tools
|
|
345
|
+
have no caller attribution. Existing request admission reporting is unchanged.
|
|
346
|
+
|
|
347
|
+
The SDK derives this HMAC-SHA256 pseudonym from the server-owned `subjectSecret`,
|
|
348
|
+
a dedicated versioned domain, property ID, issuer, application tenant and subject.
|
|
349
|
+
Replicas must use the same secret to correlate callers. Raw subjects, issuers,
|
|
350
|
+
tenants, scopes, tokens and tool arguments are not added to reports. OAuth clients
|
|
351
|
+
and agent signers are not treated as the authenticated subject. Your authentication
|
|
352
|
+
hook must verify credentials and tenant membership; WebDecoy does not independently
|
|
353
|
+
verify those credentials from this report, and a pseudonym is not a unique person.
|
|
354
|
+
|
|
355
|
+
Use a randomly generated secret of at least 32 bytes and store it server-side.
|
|
356
|
+
Rotating it changes pseudonyms and also changes existing shared-limit identities
|
|
357
|
+
that use this secret; coordinate rotation because it can reset quota continuity.
|
|
358
|
+
Historical pseudonyms are not relinked. AI Protection displays a seven-day receipt
|
|
359
|
+
window and existing report retention purges expired records in bounded background
|
|
360
|
+
sweeps. Counts cover reported, consistently attributed actions only; dropped reports,
|
|
361
|
+
older SDKs and conflicting bindings leave gaps. Install server support before
|
|
362
|
+
enabling this option: older runtimes reject the additional field.
|
package/action-runtime.mjs
CHANGED
|
@@ -11,7 +11,8 @@ export function prepareActionRuntime(options, definitions) {
|
|
|
11
11
|
if(!['https:','http:'].includes(url.protocol)||url.username||url.password||url.pathname!=='/'||url.search||url.hash||
|
|
12
12
|
(url.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(url.hostname))||!validPropertyID(config.propertyId)||
|
|
13
13
|
typeof config.webdecoyKey!=='string'||!config.webdecoyKey||/[^\x21-\x7e]/.test(config.webdecoyKey)||
|
|
14
|
-
typeof config.subjectSecret!=='string'||Buffer.byteLength(config.subjectSecret)<32)throw Error('Invalid action runtime');
|
|
14
|
+
typeof config.subjectSecret!=='string'||!config.subjectSecret.isWellFormed()||Buffer.byteLength(config.subjectSecret)<32)throw Error('Invalid action runtime');
|
|
15
|
+
if(config.reportCaller !== undefined && typeof config.reportCaller !== "boolean")throw Error("Invalid caller reporting option");
|
|
15
16
|
const c={...config};const limits=new Map(),ruleIDs=new Set();
|
|
16
17
|
const subject=(ctx,tenant)=>({accountId:tenant?quotaHash(c.subjectSecret,'webdecoy.actions.tenant.v1',ctx.caller.tenant):quotaHash(c.subjectSecret,'webdecoy.actions.caller.v1',ctx.caller.issuer,ctx.caller.tenant,ctx.caller.subject)});
|
|
17
18
|
for(const [name,d] of definitions){
|
|
@@ -38,10 +39,10 @@ export function prepareActionRuntime(options, definitions) {
|
|
|
38
39
|
const payload={schema:2,request_id:event.eventId,timestamp:event.timestamp,decision:event.decision,reason:event.reason,
|
|
39
40
|
degraded:event.checks.some(c=>c.decision==='unavailable'),checks,handler_attempted:event.attempted,
|
|
40
41
|
action:event.decision==='deny'?'denied':event.outcome==='unknown'?'handler_error':'forwarded',
|
|
41
|
-
tool_action:{action_id:event.actionId,name:event.action,policy_version:event.policyVersion,outcome:event.outcome,...(event.work?{work:event.work}:{})}};
|
|
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
43
|
const response=await fetch(new URL('/api/v1/sdk/ai-abuse/reports',url),{method:'POST',redirect:'error',signal,
|
|
43
44
|
headers:{Authorization:`Bearer ${c.webdecoyKey}`,'X-WebDecoy-Property-ID':c.propertyId,'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
44
45
|
await response.body?.cancel();if(!response.ok)throw Error('Action reporting unavailable');
|
|
45
46
|
}});
|
|
46
|
-
return {limits,report:event=>reporter.send(event),flush:()=>reporter.flush()};
|
|
47
|
+
return {limits,callerEvidence:caller=>c.reportCaller?Object.freeze({schema:1,source:'application_auth',id:quotaHash(c.subjectSecret,'webdecoy.actions.evidence.caller.v1',c.propertyId.toLowerCase(),caller.issuer,caller.tenant,caller.subject)}):undefined,report:event=>reporter.send(event),flush:()=>reporter.flush()};
|
|
47
48
|
}
|
package/actions.d.mts
CHANGED
|
@@ -33,6 +33,8 @@ export interface ActionWork {
|
|
|
33
33
|
measure?(result:unknown,context:ActionContext):number;
|
|
34
34
|
}
|
|
35
35
|
export interface ActionEvent {
|
|
36
|
+
/** Pseudonymous application-authenticated subject, not WebDecoy-verified agent identity. */
|
|
37
|
+
readonly caller?: {readonly schema:1;readonly source:'application_auth';readonly id:string};
|
|
36
38
|
readonly work?:ActionWorkEvidence;
|
|
37
39
|
readonly schema: 1;
|
|
38
40
|
readonly eventId: string;
|
|
@@ -56,6 +58,8 @@ export interface ActionLimits {
|
|
|
56
58
|
concurrency?: {ruleId:string;accountLimit:number;featureLimit:number;mode?:'observe'|'enforce';failureMode?:'open'|'closed';ttlSeconds?:number;maxSeconds?:number;timeoutMs?:number};
|
|
57
59
|
}
|
|
58
60
|
export interface ActionRuntime {
|
|
61
|
+
/** Opt in to scoped caller pseudonyms in reports. Requires a supporting runtime. Default false. */
|
|
62
|
+
reportCaller?:boolean;
|
|
59
63
|
webdecoyUrl:string;webdecoyKey:string;propertyId:string;subjectSecret:string;
|
|
60
64
|
reportingTimeoutMs?:number;maxPendingReports?:number;
|
|
61
65
|
}
|
package/actions.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import {abortable} from './transport.mjs';
|
|
|
3
3
|
import {prepareActionRuntime} from './action-runtime.mjs';
|
|
4
4
|
|
|
5
5
|
const token = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,95}$/;
|
|
6
|
-
const bounded = value => typeof value === 'string' && value.length > 0 && value.length <= 512 && !/[\x00-\x1f\x7f]/.test(value);
|
|
6
|
+
const bounded = value => typeof value === 'string' && value.isWellFormed() && value.length > 0 && value.length <= 512 && !/[\x00-\x1f\x7f]/.test(value);
|
|
7
7
|
const freeze = value => {
|
|
8
8
|
if (value && typeof value === 'object') { for (const child of Object.values(value)) freeze(child); Object.freeze(value); }
|
|
9
9
|
return value;
|
|
@@ -81,7 +81,7 @@ export function createActionProtection(options) {
|
|
|
81
81
|
const actionId = randomUUID();
|
|
82
82
|
// Unknown caller-controlled action strings are never placed in evidence.
|
|
83
83
|
const action = actions.get(name), eventAction = action ? name : 'unregistered';
|
|
84
|
-
let attempted = false,completed=false,lease,work;
|
|
84
|
+
let attempted = false,completed=false,lease,work,callerEvidence;
|
|
85
85
|
const leases=[];
|
|
86
86
|
const checks=[];
|
|
87
87
|
const deadline = new AbortController();
|
|
@@ -91,7 +91,7 @@ export function createActionProtection(options) {
|
|
|
91
91
|
function emit(decision, reason, outcome) {
|
|
92
92
|
|
|
93
93
|
const event = Object.freeze({schema:1, eventId:randomUUID(), timestamp:new Date().toISOString(), actionId, action:eventAction, policyVersion,
|
|
94
|
-
evaluation:'local', decision, reason, attempted, outcome,...(work?{work:Object.freeze({...work.evidence})}:{}),checks:Object.freeze(checks.map(c=>Object.freeze({...c})))});
|
|
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})))});
|
|
95
95
|
if(runtime)void runtime.report(event);
|
|
96
96
|
if(!sink||pendingEvents>=100)return;
|
|
97
97
|
pendingEvents++;
|
|
@@ -107,6 +107,7 @@ export function createActionProtection(options) {
|
|
|
107
107
|
let caller;
|
|
108
108
|
try { caller = callerSnapshot(await evaluate(() => authenticate(authenticationContext, {signal:admissionSignal}))); }
|
|
109
109
|
catch { cancelled(); deny('authentication_required',401); }
|
|
110
|
+
callerEvidence=runtime?.callerEvidence(caller);
|
|
110
111
|
cancelled();
|
|
111
112
|
if (action.requiredScopes.some(scope => !caller.scopes.includes(scope))) deny('missing_scope',403);
|
|
112
113
|
const context = Object.freeze({caller, args, signal:admissionSignal});
|
package/mcp.d.mts
CHANGED
|
@@ -21,6 +21,10 @@ export interface ProtectedMCPOptions {
|
|
|
21
21
|
policyVersion: string;
|
|
22
22
|
sharedRuntime?: ActionRuntime;
|
|
23
23
|
tools: Record<string, ProtectedTool>;
|
|
24
|
+
/** Opt-in tools/list metadata: stable non-secret server label and SHA-256 input-schema hashes.
|
|
25
|
+
* Requires sharedRuntime. Reuse serverId across replicas; separate different servers.
|
|
26
|
+
*/
|
|
27
|
+
discovery?: { serverId: string };
|
|
24
28
|
/** Exact browser origins; requests without Origin are permitted after authentication. */
|
|
25
29
|
allowedOrigins?: string[];
|
|
26
30
|
/** Best-effort, sanitized action events. */
|
|
@@ -33,4 +37,7 @@ export interface ProtectedMCPOptions {
|
|
|
33
37
|
* Does not wrap existing MCP servers, resources, prompts, tasks or alternate routes.
|
|
34
38
|
*/
|
|
35
39
|
export function createProtectedMCPHandler(options: ProtectedMCPOptions):
|
|
36
|
-
(request: IncomingMessage, response: ServerResponse) => Promise<void
|
|
40
|
+
((request: IncomingMessage, response: ServerResponse) => Promise<void>) & {
|
|
41
|
+
/** Drain pending discovery reports at shutdown. Does not wait for running tools. */
|
|
42
|
+
flush(): Promise<void>;
|
|
43
|
+
};
|
package/mcp.mjs
CHANGED
|
@@ -2,6 +2,8 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
2
2
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
4
4
|
import { createActionProtection, ActionDenied } from './actions.mjs';
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
import { createReporter } from './reporting.mjs';
|
|
5
7
|
const metadataPath = '/.well-known/oauth-protected-resource/mcp';
|
|
6
8
|
export function createProtectedMCPHandler(options) {
|
|
7
9
|
const resource = new URL(options.resource), issuer = new URL(options.authorizationServer);
|
|
@@ -16,12 +18,40 @@ export function createProtectedMCPHandler(options) {
|
|
|
16
18
|
for (const scope of tool.requiredScopes)
|
|
17
19
|
if (!/^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope))
|
|
18
20
|
throw Error('Invalid OAuth scope');
|
|
21
|
+
// Opt-in metadata only. Never collect descriptions, raw schemas, scopes,
|
|
22
|
+
// credentials, arguments or resource URLs in discovery reports.
|
|
23
|
+
if (options.discovery !== undefined && (!options.sharedRuntime || !options.discovery ||
|
|
24
|
+
!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,95}$/.test(options.discovery.serverId)))
|
|
25
|
+
throw Error('Discovery requires sharedRuntime and a stable serverId');
|
|
26
|
+
const canonical = value => {
|
|
27
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
28
|
+
if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
|
|
29
|
+
return '{' + Object.keys(value).sort().map(key => JSON.stringify(key) + ':' + canonical(value[key])).join(',') + '}';
|
|
30
|
+
};
|
|
31
|
+
const hashes = options.discovery ? Object.fromEntries(Object.entries(tools).map(([name, tool]) =>
|
|
32
|
+
[name, createHash('sha256').update(canonical(JSON.parse(JSON.stringify(tool.inputSchema)))).digest('hex')])) : {};
|
|
33
|
+
const runtime = options.sharedRuntime && { ...options.sharedRuntime };
|
|
34
|
+
const serverId = options.discovery?.serverId;
|
|
35
|
+
const catalogReporter = options.discovery ? createReporter({
|
|
36
|
+
reportingTimeoutMs: runtime.reportingTimeoutMs ?? 1000,
|
|
37
|
+
maxPendingReports: runtime.maxPendingReports ?? 100,
|
|
38
|
+
onObservation: async (names, { signal }) => {
|
|
39
|
+
const response = await fetch(new URL('/api/v1/sdk/ai-abuse/reports', runtime.webdecoyUrl), {
|
|
40
|
+
method: 'POST', redirect: 'error', signal,
|
|
41
|
+
headers: { Authorization: `Bearer ${runtime.webdecoyKey}`, 'X-WebDecoy-Property-ID': runtime.propertyId, 'Content-Type': 'application/json' },
|
|
42
|
+
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] })) } })
|
|
44
|
+
});
|
|
45
|
+
await response.body?.cancel();
|
|
46
|
+
if (!response.ok) throw Error('Discovery reporting unavailable');
|
|
47
|
+
}
|
|
48
|
+
}) : null;
|
|
19
49
|
const active = new Map();
|
|
20
50
|
const scopes = [...new Set(Object.values(tools).flatMap(t => t.requiredScopes))];
|
|
21
51
|
const origins = new Set(options.allowedOrigins ?? []);
|
|
22
52
|
function respond(res, status, error, headers = {}) { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...headers }); res.end(JSON.stringify({ error })); }
|
|
23
53
|
function challenge(res, status, scope) { respond(res, status, status === 401 ? 'unauthorized' : 'insufficient_scope', { 'WWW-Authenticate': `Bearer resource_metadata="${metadataURL}"${status === 401 ? '' : `, error="insufficient_scope", scope="${scope.join(' ')}"`}` }); }
|
|
24
|
-
|
|
54
|
+
const handle = async function (req, res) {
|
|
25
55
|
const disconnected = new AbortController();
|
|
26
56
|
req.once('aborted', () => disconnected.abort());
|
|
27
57
|
res.once('close', () => { if (!res.writableEnded)
|
|
@@ -163,9 +193,11 @@ export function createProtectedMCPHandler(options) {
|
|
|
163
193
|
const server = new Server({ name: 'webdecoy-protected-tools', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
164
194
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: false });
|
|
165
195
|
res.once('close', () => { void server.close().catch(() => { }); });
|
|
166
|
-
server.setRequestHandler(ListToolsRequestSchema, async () =>
|
|
167
|
-
|
|
168
|
-
|
|
196
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
197
|
+
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 })) };
|
|
200
|
+
});
|
|
169
201
|
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
170
202
|
if (!Object.hasOwn(tools, request.params.name))
|
|
171
203
|
throw new McpError(ErrorCode.InvalidParams, 'Tool unavailable');
|
|
@@ -197,4 +229,5 @@ export function createProtectedMCPHandler(options) {
|
|
|
197
229
|
res.destroy();
|
|
198
230
|
}
|
|
199
231
|
};
|
|
232
|
+
return Object.assign(handle, { flush: async () => { await catalogReporter?.flush(); } });
|
|
200
233
|
}
|
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.7",
|
|
14
14
|
"exports": {
|
|
15
15
|
"./fetch": {
|
|
16
16
|
"types": "./fetch.d.mts",
|