@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 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.11 @modelcontextprotocol/sdk@1.31.0
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 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.
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).
@@ -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
- const response=await fetch(new URL('/api/v1/sdk/ai-abuse/reports',url),{method:'POST',redirect:'error',signal,
51
- headers:{Authorization:`Bearer ${c.webdecoyKey}`,'X-WebDecoy-Property-ID':c.propertyId,'Content-Type':'application/json'},body:JSON.stringify(payload)});
52
- await response.body?.cancel();if(!response.ok)throw Error('Action reporting unavailable');
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 pending discovery reports at shutdown. Does not wait for running tools. */
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, {}, null, { signal: disconnected.signal });
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 ?? {}), null, { signal });
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.16",
14
+ "version": "0.1.0-alpha.18",
15
15
  "exports": {
16
16
  "./fetch": {
17
17
  "types": "./fetch.d.mts",