@webdecoy/ai-protection 0.1.0-alpha.15 → 0.1.0-alpha.17
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 +38 -1
- package/action-runtime.mjs +46 -4
- package/actions.d.mts +11 -2
- package/actions.mjs +4 -1
- package/mcp.mjs +2 -1
- 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.17 @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
|
|
@@ -372,3 +372,40 @@ the revision; enforcement still works but revision feedback remains unknown.
|
|
|
372
372
|
Report delivery is best effort and requires the normal reporting queue/flush.
|
|
373
373
|
This evidence is not acknowledgment from every replica, provider revocation, or
|
|
374
374
|
independent proof of execution. Outage behavior remains fail open.
|
|
375
|
+
|
|
376
|
+
### Per-tool pauses (alpha.16)
|
|
377
|
+
|
|
378
|
+
Enable `sharedRuntime.toolPause: true` after updating the hosted runtime. Tool
|
|
379
|
+
pauses cover the exact property, configured server ID and action/tool name,
|
|
380
|
+
across callers and schema versions. MCP supplies tool metadata automatically;
|
|
381
|
+
plain action integrations must provide `toolSchema` on every action. Renaming a
|
|
382
|
+
server/tool changes this identity; an old pause does not cover the new name.
|
|
383
|
+
Tool-only checks do not require `reportCaller` or send a caller pseudonym.
|
|
384
|
+
|
|
385
|
+
Owners/admins manage a tool pause in AI Protection, with an explicit scope
|
|
386
|
+
review, reason, expiry and audit history. Saved controls remain listed even if
|
|
387
|
+
the tool disappears from recent inventory. Other server/tool names stay active.
|
|
388
|
+
The tool remains discoverable; work already admitted is not cancelled.
|
|
389
|
+
|
|
390
|
+
When both `callerPause` and `toolPause` are enabled, they share one online request
|
|
391
|
+
and both checks are reported. Caller denial takes precedence if both deny.
|
|
392
|
+
`callerPauseTimeoutMs` controls the shared deadline (default 1000 ms). There is
|
|
393
|
+
no retry/cache; unavailable, timed-out or malformed checks fail open with explicit
|
|
394
|
+
evidence. Application permissions still apply before this check. Saving is not
|
|
395
|
+
an acknowledgment from every instance. Exact revision evidence permits the
|
|
396
|
+
dashboard to show an SDK-reported tool denial separately from saved state.
|
|
397
|
+
|
|
398
|
+
## Caller identity and secret rotation
|
|
399
|
+
|
|
400
|
+
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.
|
|
401
|
+
|
|
402
|
+
## Local reporting diagnostics (alpha.17)
|
|
403
|
+
|
|
404
|
+
`sharedRuntime.onReport(receipt)` is an optional best-effort local callback for an
|
|
405
|
+
HTTP report result. Receipts contain `schema: 1`, `eventId`, `actionId` and
|
|
406
|
+
`status: 'accepted' | 'unavailable'`. Correlate with the same IDs in `onEvent`.
|
|
407
|
+
Accepted means HTTP success; inspect dashboard evidence separately for retention.
|
|
408
|
+
Missing receipts are unknown, including queue drops or shutdown. At most 100
|
|
409
|
+
report observers can be pending per action guard; exceptions or hung observers do
|
|
410
|
+
not rerun tool work or change its result. Do not put protected side effects in
|
|
411
|
+
observers. See the source [setup diagnostics](examples/mcp/SETUP.md).
|
package/action-runtime.mjs
CHANGED
|
@@ -15,9 +15,20 @@ export function prepareActionRuntime(options, definitions) {
|
|
|
15
15
|
typeof config.subjectSecret!=='string'||!config.subjectSecret.isWellFormed()||Buffer.byteLength(config.subjectSecret)<32)throw Error('Invalid action runtime');
|
|
16
16
|
if(config.reportCaller !== undefined && typeof config.reportCaller !== "boolean")throw Error("Invalid caller reporting option");
|
|
17
17
|
if(config.callerPause !== undefined && typeof config.callerPause !== 'boolean')throw Error('Invalid caller pause option');
|
|
18
|
+
if(config.toolPause !== undefined && typeof config.toolPause !== 'boolean')throw Error('Invalid tool pause option');
|
|
19
|
+
if(config.toolPause && [...definitions.values()].some(d=>!d.toolSchema))throw Error('Tool pause requires toolSchema on every action');
|
|
18
20
|
if(config.callerPause && !config.reportCaller)throw Error('Caller pause requires caller reporting');
|
|
19
21
|
const pauseTimeout=config.callerPauseTimeoutMs??1000;
|
|
20
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
|
+
};
|
|
21
32
|
const c={...config};const limits=new Map(),ruleIDs=new Set();
|
|
22
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)});
|
|
23
34
|
for(const [name,d] of definitions){
|
|
@@ -45,9 +56,12 @@ export function prepareActionRuntime(options, definitions) {
|
|
|
45
56
|
degraded:event.checks.some(c=>c.decision==='unavailable'),checks,handler_attempted:event.attempted,
|
|
46
57
|
action:event.decision==='deny'?'denied':event.outcome==='unknown'?'handler_error':'forwarded',
|
|
47
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}:{})}};
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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; }
|
|
51
65
|
}});
|
|
52
66
|
const checkCallerPause = c.callerPause ? async (caller,signal) => {
|
|
53
67
|
signal.throwIfAborted();const started=performance.now();
|
|
@@ -70,7 +84,35 @@ export function prepareActionRuntime(options, definitions) {
|
|
|
70
84
|
} catch { signal.throwIfAborted(); }
|
|
71
85
|
check.durationMs=Math.max(0,performance.now()-started);return {check,denial};
|
|
72
86
|
}:null;
|
|
73
|
-
|
|
87
|
+
const checkToolPause = c.toolPause ? async (caller,name,toolSchema,signal) => {
|
|
88
|
+
signal.throwIfAborted();const started=performance.now();
|
|
89
|
+
const kinds=c.callerPause?['caller','tool']:['tool'];
|
|
90
|
+
let checks=kinds.map(kind=>({id:kind+'_pause',source:'shared',mode:'enforce',decision:'unavailable',reason:kind+'_pause_unavailable',durationMs:0})),denial;
|
|
91
|
+
try {
|
|
92
|
+
const id=c.callerPause?actionCallerEvidence(c,caller).id:'';
|
|
93
|
+
const response=await fetch(new URL('/api/v1/sdk/ai-abuse/caller-pause',c.webdecoyUrl),{
|
|
94
|
+
method:'POST',redirect:'error',signal:AbortSignal.any([signal,AbortSignal.timeout(pauseTimeout)]),
|
|
95
|
+
headers:{Authorization:`Bearer ${c.webdecoyKey}`,'Content-Type':'application/json','X-WebDecoy-Property-ID':c.propertyId},
|
|
96
|
+
body:JSON.stringify({schema:2,caller:id,server_id:toolSchema.serverId,tool:name})
|
|
97
|
+
});
|
|
98
|
+
if(!response.ok){await response.body?.cancel();throw Error('Tool control unavailable');}
|
|
99
|
+
const value=await readJSON(response,4096);
|
|
100
|
+
if(value.schema!==2||value.property_id!==c.propertyId.toLowerCase()||value.caller!==id||value.server_id!==toolSchema.serverId||value.tool!==name||(!c.callerPause&&value.caller_control!==null))throw Error('Invalid tool control response');
|
|
101
|
+
// Validate the entire combined response before accepting either decision.
|
|
102
|
+
const checked=kinds.map(kind=>{
|
|
103
|
+
const control=value[kind+'_control'];
|
|
104
|
+
if(!control||typeof control.allowed!=='boolean'||control.reason!==kind+(control.allowed?'_allowed':'_paused')||
|
|
105
|
+
(control.control_revision!=null&&!validPropertyID(control.control_revision)))throw Error('Invalid control evidence');
|
|
106
|
+
return {id:kind+'_pause',source:'shared',mode:'enforce',decision:control.allowed?'allow':'deny',reason:control.reason,durationMs:0,
|
|
107
|
+
...(control.control_revision?{controlRevision:control.control_revision.toLowerCase()}:{})};
|
|
108
|
+
});
|
|
109
|
+
checks=checked;const denied=checks.find(check=>check.decision==='deny');
|
|
110
|
+
if(denied)denial={reason:denied.reason,status:403};
|
|
111
|
+
} catch {signal.throwIfAborted();}
|
|
112
|
+
const durationMs=Math.max(0,performance.now()-started);
|
|
113
|
+
return {checks:checks.map(check=>({...check,durationMs})),denial};
|
|
114
|
+
}:null;
|
|
115
|
+
return {limits,checkCallerPause,checkToolPause,callerEvidence:caller=>actionCallerEvidence(c,caller),report:event=>reporter.send(event),flush:()=>reporter.flush()};
|
|
74
116
|
}
|
|
75
117
|
|
|
76
118
|
export function actionCallerEvidence(config,caller) {
|
package/actions.d.mts
CHANGED
|
@@ -66,19 +66,28 @@ 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. */
|
|
73
80
|
callerPause?:boolean;
|
|
74
|
-
/**
|
|
81
|
+
/** Opt-in tool pause by property/server/name across callers and schema versions. Requires toolSchema on each action; no caller reporting required. Combines with callerPause in one RPC and fails open on unavailable controls. */
|
|
82
|
+
toolPause?:boolean;
|
|
83
|
+
/** Caller/tool-pause RPC deadline, 1–10000ms; default 1000ms. Short deadlines can fail open during cold authentication. */
|
|
75
84
|
callerPauseTimeoutMs?:number;
|
|
76
85
|
webdecoyUrl:string;webdecoyKey:string;propertyId:string;subjectSecret:string;
|
|
77
86
|
reportingTimeoutMs?:number;maxPendingReports?:number;
|
|
78
87
|
}
|
|
79
88
|
export interface ActionDefinition {
|
|
80
89
|
/** Optional non-secret server label and canonical SHA-256 schema hash. MCP discovery fills this automatically.
|
|
81
|
-
* Requires a runtime supporting tool_schema evidence
|
|
90
|
+
* Requires a runtime supporting tool_schema evidence. With toolPause enabled, serverId and the action name identify the control scope.
|
|
82
91
|
*/
|
|
83
92
|
toolSchema?: ToolSchemaEvidence;
|
|
84
93
|
limits?: ActionLimits;
|
package/actions.mjs
CHANGED
|
@@ -133,7 +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?.
|
|
136
|
+
if(runtime?.checkToolPause){
|
|
137
|
+
const r=await runtime.checkToolPause(caller,name,action.toolSchema,admissionSignal);checks.push(...r.checks);
|
|
138
|
+
if(r.denial)deny(r.denial.reason,r.denial.status);
|
|
139
|
+
}else if(runtime?.checkCallerPause){
|
|
137
140
|
const r=await runtime.checkCallerPause(caller,admissionSignal);checks.push(r.check);
|
|
138
141
|
if(r.denial)deny(r.denial.reason,r.denial.status);
|
|
139
142
|
}
|
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,8 @@ 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
|
+
createActionProtection({ policyVersion: options.policyVersion, authenticate: options.authenticate, actions: tools, sharedRuntime: options.sharedRuntime });
|
|
53
54
|
const runtime = options.sharedRuntime && { ...options.sharedRuntime };
|
|
54
55
|
const serverId = options.discovery?.serverId;
|
|
55
56
|
const catalogReporter = options.discovery ? createReporter({
|
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.17",
|
|
15
15
|
"exports": {
|
|
16
16
|
"./fetch": {
|
|
17
17
|
"types": "./fetch.d.mts",
|