@webdecoy/ai-protection 0.1.0-alpha.13 → 0.1.0-alpha.15

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
@@ -335,3 +335,40 @@ is normal client behavior, not an enforcement decision. Large listings are split
335
335
  into batches of up to 64 tools; each batch is a report, not a distinct listing
336
336
  count. Unattributed older catalogs are never assigned to a caller retrospectively.
337
337
  Initialization and automatic containment remain outside this feature.
338
+
339
+ ### Manual caller pauses (alpha.14)
340
+
341
+ Enable `sharedRuntime: { ...runtimeOptions, reportCaller: true, callerPause: true }`
342
+ on each protected server after updating the hosted receiver. An organization owner
343
+ or admin can then open a caller timeline and review a pause or resume for that
344
+ property. A reason is required. Pauses can expire or remain until resumed.
345
+ Concurrent stale edits are rejected and each successful change is audited.
346
+
347
+ The check runs after application permissions and before shared limits or execution,
348
+ for each admitted tool call. It adds one network request with a 1000 ms default deadline (`callerPauseTimeoutMs`, range 1–10000 ms),
349
+ no retries and no decision cache. Very short deadlines can fail open during cold API-key authentication. A saved pause applies to subsequent successful
350
+ checks; a call already admitted or running is not cancelled. Resuming needs no cache
351
+ invalidation. A restart reads current state on the next check.
352
+
353
+ Outages, timeouts, malformed responses and unavailable controls fail open with
354
+ `caller_pause_unavailable` evidence; authorization and other limits still apply.
355
+ A saved pause is not a delivery acknowledgment and cannot guarantee enforcement
356
+ while disconnected. This control does not revoke OAuth credentials.
357
+
358
+ Scope is the existing property-scoped pseudonym derived from issuer, tenant and
359
+ subject. Keep `subjectSecret` consistent across replicas. Rotating it changes the
360
+ pseudonym and existing pauses no longer match. Only opt-in Node action integrations
361
+ are covered, including this MCP adapter; Python/Go and unwrapped tools are not.
362
+ Listings and initialization remain available; this pauses new tool execution.
363
+ No automatic pause is applied after a decoy call.
364
+
365
+ #### Pause feedback (alpha.15)
366
+
367
+ The SDK includes the runtime's control revision in action check reports when
368
+ available. The dashboard can then distinguish a saved pause from an SDK-reported
369
+ denial for that exact revision, with server receipt time and request ID. Delayed
370
+ reports from an earlier pause do not confirm a later save. Older runtimes omit
371
+ the revision; enforcement still works but revision feedback remains unknown.
372
+ Report delivery is best effort and requires the normal reporting queue/flush.
373
+ This evidence is not acknowledgment from every replica, provider revocation, or
374
+ independent proof of execution. Outage behavior remains fail open.
@@ -2,6 +2,7 @@ import {prepareWork} from './work.mjs';
2
2
  import {prepareQuota,quotaHash} from './quota.mjs';
3
3
  import {prepareConcurrency} from './concurrency.mjs';
4
4
  import {validPropertyID} from './account.mjs';
5
+ import {readJSON} from './transport.mjs';
5
6
  import {createReporter} from './reporting.mjs';
6
7
 
7
8
  export function prepareActionRuntime(options, definitions) {
@@ -13,6 +14,10 @@ export function prepareActionRuntime(options, definitions) {
13
14
  typeof config.webdecoyKey!=='string'||!config.webdecoyKey||/[^\x21-\x7e]/.test(config.webdecoyKey)||
14
15
  typeof config.subjectSecret!=='string'||!config.subjectSecret.isWellFormed()||Buffer.byteLength(config.subjectSecret)<32)throw Error('Invalid action runtime');
15
16
  if(config.reportCaller !== undefined && typeof config.reportCaller !== "boolean")throw Error("Invalid caller reporting option");
17
+ if(config.callerPause !== undefined && typeof config.callerPause !== 'boolean')throw Error('Invalid caller pause option');
18
+ if(config.callerPause && !config.reportCaller)throw Error('Caller pause requires caller reporting');
19
+ const pauseTimeout=config.callerPauseTimeoutMs??1000;
20
+ if(!Number.isInteger(pauseTimeout)||pauseTimeout<1||pauseTimeout>10000)throw Error('Invalid caller pause timeout');
16
21
  const c={...config};const limits=new Map(),ruleIDs=new Set();
17
22
  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)});
18
23
  for(const [name,d] of definitions){
@@ -35,7 +40,7 @@ export function prepareActionRuntime(options, definitions) {
35
40
  const reporter=createReporter({reportingTimeoutMs:c.reportingTimeoutMs??1000,maxPendingReports:c.maxPendingReports??100,
36
41
  onObservation:async(event,{signal})=>{
37
42
  const checks=[{id:'action_boundary',source:'local',mode:'enforce',decision:event.decision,reason:event.reason,duration_ms:0},
38
- ...event.checks.map(check=>({id:check.id,source:check.source,mode:check.mode,decision:check.decision,reason:check.reason,duration_ms:check.durationMs}))];
43
+ ...event.checks.map(check=>({id:check.id,source:check.source,mode:check.mode,decision:check.decision,reason:check.reason,duration_ms:check.durationMs,...(check.controlRevision?{control_revision:check.controlRevision}:{})}))];
39
44
  const payload={schema:2,request_id:event.eventId,timestamp:event.timestamp,decision:event.decision,reason:event.reason,
40
45
  degraded:event.checks.some(c=>c.decision==='unavailable'),checks,handler_attempted:event.attempted,
41
46
  action:event.decision==='deny'?'denied':event.outcome==='unknown'?'handler_error':'forwarded',
@@ -44,7 +49,28 @@ export function prepareActionRuntime(options, definitions) {
44
49
  headers:{Authorization:`Bearer ${c.webdecoyKey}`,'X-WebDecoy-Property-ID':c.propertyId,'Content-Type':'application/json'},body:JSON.stringify(payload)});
45
50
  await response.body?.cancel();if(!response.ok)throw Error('Action reporting unavailable');
46
51
  }});
47
- return {limits,callerEvidence:caller=>actionCallerEvidence(c,caller),report:event=>reporter.send(event),flush:()=>reporter.flush()};
52
+ const checkCallerPause = c.callerPause ? async (caller,signal) => {
53
+ signal.throwIfAborted();const started=performance.now();
54
+ const check={id:'caller_pause',source:'shared',mode:'enforce',decision:'unavailable',reason:'caller_pause_unavailable',durationMs:0};
55
+ let denial;
56
+ try {
57
+ const id=actionCallerEvidence(c,caller).id;
58
+ const response=await fetch(new URL('/api/v1/sdk/ai-abuse/caller-pause',c.webdecoyUrl),{
59
+ method:'POST',redirect:'error',signal:AbortSignal.any([signal,AbortSignal.timeout(pauseTimeout)]),
60
+ headers:{Authorization:`Bearer ${c.webdecoyKey}`,'Content-Type':'application/json','X-WebDecoy-Property-ID':c.propertyId},body:JSON.stringify({schema:1,caller:id})
61
+ });
62
+ if(!response.ok){await response.body?.cancel();throw Error('Caller control unavailable');}
63
+ const value=await readJSON(response,2048);
64
+ if(value.schema!==1||value.property_id!==c.propertyId.toLowerCase()||value.caller!==id||typeof value.allowed!=='boolean'||value.reason!==(value.allowed?'caller_allowed':'caller_paused'))throw Error('Invalid caller control response');
65
+ // Older runtimes omit revision evidence. Never invent it from receipt time.
66
+ if(value.control_revision != null && (!validPropertyID(value.control_revision)||value.control_revision==='00000000-0000-0000-0000-000000000000'))throw Error('Invalid caller control revision');
67
+ check.decision=value.allowed?'allow':'deny';check.reason=value.reason;
68
+ if(value.control_revision != null)check.controlRevision=value.control_revision.toLowerCase();
69
+ if(!value.allowed)denial={reason:'caller_paused',status:403};
70
+ } catch { signal.throwIfAborted(); }
71
+ check.durationMs=Math.max(0,performance.now()-started);return {check,denial};
72
+ }:null;
73
+ return {limits,checkCallerPause,callerEvidence:caller=>actionCallerEvidence(c,caller),report:event=>reporter.send(event),flush:()=>reporter.flush()};
48
74
  }
49
75
 
50
76
  export function actionCallerEvidence(config,caller) {
package/actions.d.mts CHANGED
@@ -48,7 +48,7 @@ export interface ActionEvent {
48
48
  readonly schema: 1;
49
49
  readonly eventId: string;
50
50
  readonly timestamp: string;
51
- readonly checks: readonly {id:string;source:string;mode:string;decision:string;reason:string;durationMs:number}[];
51
+ readonly checks: readonly {id:string;source:string;mode:string;decision:string;reason:string;durationMs:number;controlRevision?:string}[];
52
52
  readonly actionId: string;
53
53
  readonly action: string;
54
54
  readonly policyVersion: string;
@@ -69,6 +69,10 @@ export interface ActionLimits {
69
69
  export interface ActionRuntime {
70
70
  /** Opt in to scoped caller pseudonyms in reports. Requires a supporting runtime. Default false. */
71
71
  reportCaller?:boolean;
72
+ /** Opt-in online caller pause check before work. Requires reportCaller. Fails open on timeout (default 1000ms); no cache, retry or in-flight cancellation. */
73
+ callerPause?:boolean;
74
+ /** Caller-pause RPC deadline, 1–10000ms; default 1000ms. Short deadlines can fail open during cold authentication. */
75
+ callerPauseTimeoutMs?:number;
72
76
  webdecoyUrl:string;webdecoyKey:string;propertyId:string;subjectSecret:string;
73
77
  reportingTimeoutMs?:number;maxPendingReports?:number;
74
78
  }
package/actions.mjs CHANGED
@@ -133,6 +133,10 @@ export function createActionProtection(options) {
133
133
  // Shared limits run only after application permission checks. Their own
134
134
  // RPC deadlines are separate from local admission and detector availability.
135
135
  clearTimeout(timer);
136
+ if(runtime?.checkCallerPause){
137
+ const r=await runtime.checkCallerPause(caller,admissionSignal);checks.push(r.check);
138
+ if(r.denial)deny(r.denial.reason,r.denial.status);
139
+ }
136
140
  const controls=runtime?.limits.get(name);
137
141
  for(const gate of controls?.gates??[]){
138
142
  const r=await gate(context,admissionSignal);checks.push(r.check);
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.13",
14
+ "version": "0.1.0-alpha.15",
15
15
  "exports": {
16
16
  "./fetch": {
17
17
  "types": "./fetch.d.mts",