@webdecoy/ai-protection 0.1.0-alpha.16 → 0.1.0-alpha.18
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 +27 -4
- package/action-runtime.mjs +15 -3
- package/actions.d.mts +7 -0
- package/mcp.d.mts +1 -1
- package/mcp.mjs +9 -6
- 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.18 @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
|
|
@@ -161,9 +161,17 @@ Discovery uses schema-3 reports on the existing reporting endpoint; deploy a
|
|
|
161
161
|
compatible hosted runtime first. Old runtimes reject these optional reports
|
|
162
162
|
without affecting tool listing. Reporting has bounded pending work and a deadline,
|
|
163
163
|
never blocks authorization, and does not retry. Call `await handler.flush()` at a
|
|
164
|
-
host shutdown/lifecycle boundary to drain
|
|
165
|
-
|
|
166
|
-
|
|
164
|
+
host shutdown/lifecycle boundary to drain discovery and tool-action reports already
|
|
165
|
+
queued when flush begins. From alpha.18, the action reporting queue is shared by
|
|
166
|
+
all calls on one handler and is bounded by `sharedRuntime.maxPendingReports`
|
|
167
|
+
(default 100); discovery uses a separately bounded queue. Full queues drop reports
|
|
168
|
+
without retrying or blocking tool execution.
|
|
169
|
+
|
|
170
|
+
Stop accepting new requests and let active tools complete or cancel before the
|
|
171
|
+
final flush. Flush does not wait for running tools, future reports, arbitrary
|
|
172
|
+
`onEvent`/`onReport` callbacks or dashboard persistence. Reporting deadlines still
|
|
173
|
+
apply; an unavailable/timeout/drop is not delivery success. Abruptly terminated
|
|
174
|
+
hosts can lose reports. Discovery advertisements never increment tool action or request counts.
|
|
167
175
|
|
|
168
176
|
|
|
169
177
|
### Calls before discovery (alpha.8+)
|
|
@@ -394,3 +402,18 @@ no retry/cache; unavailable, timed-out or malformed checks fail open with explic
|
|
|
394
402
|
evidence. Application permissions still apply before this check. Saving is not
|
|
395
403
|
an acknowledgment from every instance. Exact revision evidence permits the
|
|
396
404
|
dashboard to show an SDK-reported tool denial separately from saved state.
|
|
405
|
+
|
|
406
|
+
## Caller identity and secret rotation
|
|
407
|
+
|
|
408
|
+
See the [schema-1 caller contract](https://github.com/WebDecoy/ai-protection/blob/main/CALLER_IDENTITY.md) for authenticated/claimed/unavailable distinctions, Node/Go bounds, OAuth client separation, unsupported delegation/signers and caller pseudonym retention/rotation.
|
|
409
|
+
|
|
410
|
+
## Local reporting diagnostics (alpha.17)
|
|
411
|
+
|
|
412
|
+
`sharedRuntime.onReport(receipt)` is an optional best-effort local callback for an
|
|
413
|
+
HTTP report result. Receipts contain `schema: 1`, `eventId`, `actionId` and
|
|
414
|
+
`status: 'accepted' | 'unavailable'`. Correlate with the same IDs in `onEvent`.
|
|
415
|
+
Accepted means HTTP success; inspect dashboard evidence separately for retention.
|
|
416
|
+
Missing receipts are unknown, including queue drops or shutdown. At most 100
|
|
417
|
+
report observers can be pending per action guard; exceptions or hung observers do
|
|
418
|
+
not rerun tool work or change its result. Do not put protected side effects in
|
|
419
|
+
observers. See the source [setup diagnostics](examples/mcp/SETUP.md).
|
package/action-runtime.mjs
CHANGED
|
@@ -20,6 +20,15 @@ export function prepareActionRuntime(options, definitions) {
|
|
|
20
20
|
if(config.callerPause && !config.reportCaller)throw Error('Caller pause requires caller reporting');
|
|
21
21
|
const pauseTimeout=config.callerPauseTimeoutMs??1000;
|
|
22
22
|
if(!Number.isInteger(pauseTimeout)||pauseTimeout<1||pauseTimeout>10000)throw Error('Invalid caller pause timeout');
|
|
23
|
+
if(config.onReport !== undefined && typeof config.onReport !== 'function')throw Error('Invalid reporting observer');
|
|
24
|
+
const reportingObserver=config.onReport;
|
|
25
|
+
let pendingObservers=0;
|
|
26
|
+
const observeReport=(event,status)=>{
|
|
27
|
+
if(!reportingObserver||pendingObservers>=100)return;
|
|
28
|
+
pendingObservers++;
|
|
29
|
+
const receipt=Object.freeze({schema:1,eventId:event.eventId,actionId:event.actionId,status});
|
|
30
|
+
Promise.resolve().then(()=>reportingObserver(receipt)).catch(()=>{}).finally(()=>{pendingObservers--;});
|
|
31
|
+
};
|
|
23
32
|
const c={...config};const limits=new Map(),ruleIDs=new Set();
|
|
24
33
|
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)});
|
|
25
34
|
for(const [name,d] of definitions){
|
|
@@ -47,9 +56,12 @@ export function prepareActionRuntime(options, definitions) {
|
|
|
47
56
|
degraded:event.checks.some(c=>c.decision==='unavailable'),checks,handler_attempted:event.attempted,
|
|
48
57
|
action:event.decision==='deny'?'denied':event.outcome==='unknown'?'handler_error':'forwarded',
|
|
49
58
|
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}:{})}};
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
try {
|
|
60
|
+
const response=await fetch(new URL('/api/v1/sdk/ai-abuse/reports',url),{method:'POST',redirect:'error',signal,
|
|
61
|
+
headers:{Authorization:`Bearer ${c.webdecoyKey}`,'X-WebDecoy-Property-ID':c.propertyId,'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
62
|
+
await response.body?.cancel();if(!response.ok)throw Error('Action reporting unavailable');
|
|
63
|
+
observeReport(event,'accepted');
|
|
64
|
+
} catch(error) { observeReport(event,'unavailable'); throw error; }
|
|
53
65
|
}});
|
|
54
66
|
const checkCallerPause = c.callerPause ? async (caller,signal) => {
|
|
55
67
|
signal.throwIfAborted();const started=performance.now();
|
package/actions.d.mts
CHANGED
|
@@ -66,7 +66,14 @@ export interface ActionLimits {
|
|
|
66
66
|
tenantQuota?: ActionQuota;
|
|
67
67
|
concurrency?: {ruleId:string;accountLimit:number;featureLimit:number;mode?:'observe'|'enforce';failureMode?:'open'|'closed';ttlSeconds?:number;maxSeconds?:number;timeoutMs?:number};
|
|
68
68
|
}
|
|
69
|
+
export interface ActionReportReceipt {
|
|
70
|
+
readonly schema:1;readonly eventId:string;readonly actionId:string;
|
|
71
|
+
/** HTTP success response only; not proof of retained dashboard evidence. */
|
|
72
|
+
readonly status:'accepted'|'unavailable';
|
|
73
|
+
}
|
|
69
74
|
export interface ActionRuntime {
|
|
75
|
+
/** Best-effort local HTTP reporting observer. Bounded to 100 pending calls; missing receipts remain unknown. Never contains raw response/error/identity data. */
|
|
76
|
+
onReport?(receipt:ActionReportReceipt):void|Promise<void>;
|
|
70
77
|
/** Opt in to scoped caller pseudonyms in reports. Requires a supporting runtime. Default false. */
|
|
71
78
|
reportCaller?:boolean;
|
|
72
79
|
/** Opt-in online caller pause check before work. Requires reportCaller. Fails open on timeout (default 1000ms); no cache, retry or in-flight cancellation. */
|
package/mcp.d.mts
CHANGED
|
@@ -44,6 +44,6 @@ export interface ProtectedMCPOptions {
|
|
|
44
44
|
*/
|
|
45
45
|
export function createProtectedMCPHandler(options: ProtectedMCPOptions):
|
|
46
46
|
((request: IncomingMessage, response: ServerResponse) => Promise<void>) & {
|
|
47
|
-
/** Drain
|
|
47
|
+
/** Drain already queued discovery/action reports within reporting deadlines. Does not wait for running tools, future reports or observer callbacks. */
|
|
48
48
|
flush(): Promise<void>;
|
|
49
49
|
};
|
package/mcp.mjs
CHANGED
|
@@ -31,7 +31,6 @@ export function createProtectedMCPHandler(options) {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
// Validate the closed registry at startup, not only after a client arrives.
|
|
34
|
-
createActionProtection({ policyVersion: options.policyVersion, authenticate: options.authenticate, actions: tools, sharedRuntime: options.sharedRuntime });
|
|
35
34
|
for (const tool of Object.values(tools))
|
|
36
35
|
for (const scope of tool.requiredScopes)
|
|
37
36
|
if (!/^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope))
|
|
@@ -50,6 +49,12 @@ export function createProtectedMCPHandler(options) {
|
|
|
50
49
|
[name, createHash('sha256').update(canonical(JSON.parse(JSON.stringify(tool.inputSchema)))).digest('hex')])) : {};
|
|
51
50
|
if (options.discovery) for (const [name, tool] of Object.entries(tools))
|
|
52
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'})})});
|
|
52
|
+
// Validate after discovery supplies the schema required by tool pauses.
|
|
53
|
+
if (typeof options.authenticate !== 'function') throw Error('Invalid MCP authenticator');
|
|
54
|
+
// One bounded reporting runtime per handler. The caller is passed per run,
|
|
55
|
+
// never held in mutable shared request state. HTTP authentication stays above
|
|
56
|
+
// dispatch; the action boundary independently validates and snapshots it.
|
|
57
|
+
const guard = createActionProtection({ policyVersion: options.policyVersion, authenticate: caller => caller, actions: tools, sharedRuntime: options.sharedRuntime, onEvent: options.onEvent });
|
|
53
58
|
const runtime = options.sharedRuntime && { ...options.sharedRuntime };
|
|
54
59
|
const serverId = options.discovery?.serverId;
|
|
55
60
|
const catalogReporter = options.discovery ? createReporter({
|
|
@@ -176,7 +181,6 @@ export function createProtectedMCPHandler(options) {
|
|
|
176
181
|
res.end();
|
|
177
182
|
return;
|
|
178
183
|
}
|
|
179
|
-
const guard = createActionProtection({ policyVersion: options.policyVersion, authenticate: () => caller, actions: tools, sharedRuntime: options.sharedRuntime, onEvent: options.onEvent });
|
|
180
184
|
// Scope escalation belongs at HTTP level, before the SDK opens an SSE stream.
|
|
181
185
|
if (message.method === 'tools/call' && typeof message.params?.name === 'string') {
|
|
182
186
|
const definition = Object.hasOwn(tools, message.params.name) ? tools[message.params.name] : undefined;
|
|
@@ -184,7 +188,7 @@ export function createProtectedMCPHandler(options) {
|
|
|
184
188
|
// Run the same admission path for its sanitized denial evidence. It cannot
|
|
185
189
|
// dispatch with a missing required scope, regardless of discovery results.
|
|
186
190
|
try {
|
|
187
|
-
await guard.run(message.params.name, {},
|
|
191
|
+
await guard.run(message.params.name, {}, caller, { signal: disconnected.signal });
|
|
188
192
|
}
|
|
189
193
|
catch (e) {
|
|
190
194
|
if (!(e instanceof ActionDenied))
|
|
@@ -224,7 +228,7 @@ export function createProtectedMCPHandler(options) {
|
|
|
224
228
|
running.started = true;
|
|
225
229
|
const signal = AbortSignal.any([extra.signal, disconnected.signal, running.controller.signal]);
|
|
226
230
|
try {
|
|
227
|
-
return await guard.run(request.params.name, (request.params.arguments ?? {}),
|
|
231
|
+
return await guard.run(request.params.name, (request.params.arguments ?? {}), caller, { signal });
|
|
228
232
|
}
|
|
229
233
|
catch (e) {
|
|
230
234
|
if (signal.aborted)
|
|
@@ -236,7 +240,6 @@ export function createProtectedMCPHandler(options) {
|
|
|
236
240
|
}
|
|
237
241
|
finally {
|
|
238
242
|
cleanup();
|
|
239
|
-
void guard.flush();
|
|
240
243
|
}
|
|
241
244
|
});
|
|
242
245
|
await server.connect(transport);
|
|
@@ -249,5 +252,5 @@ export function createProtectedMCPHandler(options) {
|
|
|
249
252
|
res.destroy();
|
|
250
253
|
}
|
|
251
254
|
};
|
|
252
|
-
return Object.assign(handle, { flush: async () => { await catalogReporter?.flush(); } });
|
|
255
|
+
return Object.assign(handle, { flush: async () => { await Promise.all([catalogReporter?.flush(), guard.flush()]); } });
|
|
253
256
|
}
|
package/package.json
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"test:types": "tsc --strict --noEmit --module nodenext --target es2022 test/types.mts test/mcp-types.mts",
|
|
12
12
|
"test:workers": "node --test test/workers/*.test.mjs"
|
|
13
13
|
},
|
|
14
|
-
"version": "0.1.0-alpha.
|
|
14
|
+
"version": "0.1.0-alpha.18",
|
|
15
15
|
"exports": {
|
|
16
16
|
"./fetch": {
|
|
17
17
|
"types": "./fetch.d.mts",
|