@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 +37 -0
- package/action-runtime.mjs +28 -2
- package/actions.d.mts +5 -1
- package/actions.mjs +4 -0
- package/package.json +1 -1
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.
|
package/action-runtime.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
14
|
+
"version": "0.1.0-alpha.15",
|
|
15
15
|
"exports": {
|
|
16
16
|
"./fetch": {
|
|
17
17
|
"types": "./fetch.d.mts",
|