@webdecoy/ai-protection 0.1.0-alpha.11 → 0.1.0-alpha.14
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 +41 -0
- package/NEXTJS.md +2 -2
- package/README.md +5 -3
- package/WORKERS.md +117 -0
- package/account.mjs +1 -1
- package/action-runtime.mjs +28 -1
- package/actions.d.mts +4 -0
- package/actions.mjs +4 -0
- package/admission.mjs +1 -1
- package/mcp.mjs +4 -3
- package/package.json +17 -6
- package/quota.mjs +1 -1
- package/telemetry.mjs +1 -1
- package/workers.d.mts +19 -0
- package/workers.mjs +17 -0
package/MCP.md
CHANGED
|
@@ -320,3 +320,44 @@ rates, generate seeded names, record initialization events, remotely deliver dec
|
|
|
320
320
|
configuration, or provide a labeled test-trigger flow. Configuration requires an
|
|
321
321
|
application deployment. Those remain separate roadmap work. No low-false-positive
|
|
322
322
|
or caller-containment guarantee is made.
|
|
323
|
+
|
|
324
|
+
### Caller-attributed enumeration (alpha.13)
|
|
325
|
+
|
|
326
|
+
With `discovery` and `sharedRuntime.reportCaller: true`, authenticated `tools/list`
|
|
327
|
+
reports carry the same property-scoped pseudonym as tool calls. Raw identities,
|
|
328
|
+
credentials and scopes are not uploaded. Attribution stays off by default.
|
|
329
|
+
The hosted receiver must support caller-attributed catalogs before enabling this
|
|
330
|
+
SDK version; older receivers reject the new optional field without interrupting
|
|
331
|
+
tool listing. Listings with no visible tools also generate an empty catalog.
|
|
332
|
+
|
|
333
|
+
Caller timelines show enumeration separately from calls and decoy trips. Listing
|
|
334
|
+
is normal client behavior, not an enforcement decision. Large listings are split
|
|
335
|
+
into batches of up to 64 tools; each batch is a report, not a distinct listing
|
|
336
|
+
count. Unattributed older catalogs are never assigned to a caller retrospectively.
|
|
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.
|
package/NEXTJS.md
CHANGED
|
@@ -13,8 +13,8 @@ Browser → customer's /api/chat → existing auth / input checks
|
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Install in the application server that owns the model call, not the browser,
|
|
16
|
-
Next.js middleware, or the model provider. Node >=22.22.3 is required; Edge runtime
|
|
17
|
-
is not supported. The application and provider traffic remain on customer
|
|
16
|
+
Next.js middleware, or the model provider. Node >=22.22.3 is required; Next.js Edge runtime
|
|
17
|
+
is not supported. For the experimental Cloudflare Workers adapter, use [the Workers guide](WORKERS.md). The application and provider traffic remain on customer
|
|
18
18
|
infrastructure. WebDecoy receives the client IP, URL pathname (no query string),
|
|
19
19
|
method, user agent, header names and accept-language/accept-encoding values.
|
|
20
20
|
Request bodies, session cookies, authorization values and responses are not sent
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Bot and abuse protection for AI-powered applications. A small Node.js SDK that
|
|
|
4
4
|
checks requests before your application invokes a model. Customer-defined rules run
|
|
5
5
|
locally; proprietary bot detection runs in WebDecoy. See [architecture](ARCHITECTURE.md).
|
|
6
6
|
|
|
7
|
-
**Alpha release: `0.1.0-alpha.
|
|
7
|
+
**Alpha release: `0.1.0-alpha.12`.** Integration mechanics are tested;
|
|
8
8
|
real-world detection accuracy and provider cost savings have not been established.
|
|
9
9
|
Requires a WebDecoy property, a property-scoped API key, and a compatible WebDecoy
|
|
10
10
|
service deployment. This repository contains the SDK, not the detection service.
|
|
@@ -16,7 +16,9 @@ npm install @webdecoy/ai-protection@alpha
|
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
Node.js 22.22.3 or newer is required. The SDK has no runtime npm dependencies.
|
|
19
|
-
|
|
19
|
+
Experimental Cloudflare Workers admission support is available through the
|
|
20
|
+
[`/workers` entry point](WORKERS.md), with Node compatibility enabled. Workers
|
|
21
|
+
concurrency and budgets, and other Edge runtimes, are not supported. Licensed under [Apache-2.0](LICENSE).
|
|
20
22
|
|
|
21
23
|
Set `WEBDECOY_URL=https://ai-protection.webdecoy.com` on your server.
|
|
22
24
|
Create a property-scoped API key with Write Detections permission in WebDecoy,
|
|
@@ -24,7 +26,7 @@ then review results at [AI Protection](https://app.webdecoy.com/ai-protection).
|
|
|
24
26
|
|
|
25
27
|
## Integrate
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
For Node.js, create the instance once in a **server-only module**, then call it inside your
|
|
28
30
|
existing authenticated and validated AI endpoint:
|
|
29
31
|
|
|
30
32
|
```ts
|
package/WORKERS.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Cloudflare Workers
|
|
2
|
+
|
|
3
|
+
The `@webdecoy/ai-protection/workers` entry point provides experimental Workers
|
|
4
|
+
support for request admission, local rules, shared account/session quotas and
|
|
5
|
+
best-effort reporting. Node compatibility is required. Control-plane requests use manual redirects and
|
|
6
|
+
reject non-success responses without following redirects or forwarding credentials.
|
|
7
|
+
Local workerd tests cover
|
|
8
|
+
these features; a deployed staging canary remains required before production use.
|
|
9
|
+
Concurrency leases and budget settlement are not supported by this entry point.
|
|
10
|
+
|
|
11
|
+
## Integration
|
|
12
|
+
|
|
13
|
+
Create a fresh protection object **inside `fetch()` for each incoming request**.
|
|
14
|
+
It binds that request and execution context, so `protect()` and `check()` do not
|
|
15
|
+
take a Request argument. Never store the returned object in module scope. This
|
|
16
|
+
keeps account refresh promises, report queues and `ctx.waitUntil` request-scoped.
|
|
17
|
+
Each request performs a fresh account lookup; the Node SDK's shared cache and
|
|
18
|
+
shadow baseline are not shared between Worker invocations.
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
import {createWorkerAIProtection} from '@webdecoy/ai-protection/workers';
|
|
22
|
+
|
|
23
|
+
export default {
|
|
24
|
+
async fetch(request, env, ctx) {
|
|
25
|
+
// Authenticate and validate first. Derive this context from server state.
|
|
26
|
+
const user = await authenticateAndValidate(request);
|
|
27
|
+
const protect = createWorkerAIProtection(request, {
|
|
28
|
+
webdecoyUrl: env.WEBDECOY_URL,
|
|
29
|
+
webdecoyKey: env.WEBDECOY_KEY,
|
|
30
|
+
propertyId: env.WEBDECOY_PROPERTY_ID,
|
|
31
|
+
subjectSecret: env.WEBDECOY_SUBJECT_SECRET,
|
|
32
|
+
scopeId: 'support-chat', route: '/api/chat', protectionMode: 'observe',
|
|
33
|
+
resolveClientIP: trustedClientIP,
|
|
34
|
+
accountQuota: {
|
|
35
|
+
ruleId: 'chat', limit: 20, windowSeconds: 60, mode: 'enforce',
|
|
36
|
+
failureMode: 'closed', subject: context => ({accountId: context.accountId})
|
|
37
|
+
}
|
|
38
|
+
}, ctx);
|
|
39
|
+
return protect(() => callModel(request.signal), {accountId: user.id});
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`authenticateAndValidate`, `trustedClientIP` and `callModel` are application
|
|
45
|
+
functions. The resolver must return an IP vouched for by your ingress, or `null`
|
|
46
|
+
to skip detection with degraded coverage. On direct Cloudflare ingress you can
|
|
47
|
+
use `request.headers.get('cf-connecting-ip')`; do not assume it identifies the
|
|
48
|
+
original client on Worker/service subrequests. Do not trust arbitrary forwarded
|
|
49
|
+
headers. See [Cloudflare's header behavior](https://developers.cloudflare.com/fundamentals/reference/http-request-headers/#cf-connecting-ip).
|
|
50
|
+
|
|
51
|
+
Configure Wrangler with the tested baseline:
|
|
52
|
+
|
|
53
|
+
```toml
|
|
54
|
+
compatibility_date = "2026-01-01"
|
|
55
|
+
compatibility_flags = ["nodejs_compat"]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Store API credentials and the random subject secret (at least 32 characters) as
|
|
59
|
+
Worker secrets. Shared quota subjects must come from authenticated server state.
|
|
60
|
+
For idempotent quotas, use `idempotency: true` only with a migrated runtime and
|
|
61
|
+
persist a server-generated operation ID if recovery must survive request loss.
|
|
62
|
+
`createQuotaOperationId` is also exported from the Workers entry point.
|
|
63
|
+
|
|
64
|
+
## Streaming and lifecycle
|
|
65
|
+
|
|
66
|
+
The wrapper returns the original Response and does not read or rewrite the model
|
|
67
|
+
stream. Pass the application's cancellation signal to the provider. A successful
|
|
68
|
+
admission report describes callback/response creation, **not** stream completion,
|
|
69
|
+
final token usage or proof that upstream work stopped on disconnect.
|
|
70
|
+
|
|
71
|
+
Reporting is automatically attached through `task => ctx.waitUntil(task)` and is
|
|
72
|
+
bounded by the core reporting timeout (1000ms default, 10000ms maximum). Reporting
|
|
73
|
+
is best-effort; `waitUntil` is not durable delivery. Cloudflare permits up to
|
|
74
|
+
30 seconds of extension after the response completes or the client disconnects.
|
|
75
|
+
Long-running background model work needs a separate execution design. See
|
|
76
|
+
[Cloudflare execution context](https://developers.cloudflare.com/workers/runtime-apis/context/).
|
|
77
|
+
|
|
78
|
+
For custom decisions, use `protect.check(trustedContext)` and report exactly once
|
|
79
|
+
with `protect.report(decision, outcome)`. `flush()` waits only for this request's
|
|
80
|
+
pending reports. The adapter rejects `concurrency` at construction; it exposes
|
|
81
|
+
neither `.concurrent()` nor `createAIBudget`. Using the Node entry point directly
|
|
82
|
+
does not establish Workers support for those features.
|
|
83
|
+
|
|
84
|
+
## Run the fixture
|
|
85
|
+
|
|
86
|
+
From this repository root:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
npm ci
|
|
90
|
+
npm exec -- wrangler secret put WEBDECOY_KEY --config examples/workers/wrangler.toml
|
|
91
|
+
npm exec -- wrangler secret put WEBDECOY_PROPERTY_ID --config examples/workers/wrangler.toml
|
|
92
|
+
npm exec -- wrangler secret put WEBDECOY_SUBJECT_SECRET --config examples/workers/wrangler.toml
|
|
93
|
+
npm exec -- wrangler secret put EXAMPLE_TOKEN --config examples/workers/wrangler.toml
|
|
94
|
+
npm exec -- wrangler dev --config examples/workers/wrangler.toml
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
For local development instead, put these four values in
|
|
98
|
+
`examples/workers/.dev.vars` (gitignored); no remote secret provisioning is needed.
|
|
99
|
+
The example imports the local SDK source. In your own project import the package
|
|
100
|
+
subpath shown above. Send POST `/api/chat` with `Authorization: Bearer <EXAMPLE_TOKEN>`.
|
|
101
|
+
The fixture returns text and invokes no model; replace its auth and callback with
|
|
102
|
+
your application's integration. It starts in observation mode.
|
|
103
|
+
|
|
104
|
+
## Validation and rollout
|
|
105
|
+
|
|
106
|
+
`npm run test:workers` bundles the SDK and executes the Worker in Miniflare/workerd
|
|
107
|
+
with a simulated WebDecoy service. It verifies local/cloud denial, original
|
|
108
|
+
responses, stream passthrough, pre-cancelled requests, degraded detector outages,
|
|
109
|
+
simultaneous requests, asynchronous report completion and idempotent quota retry.
|
|
110
|
+
CI runs this alongside Node, type and package checks. Protocol fixtures do not
|
|
111
|
+
prove remote connectivity, database contention or detection accuracy.
|
|
112
|
+
|
|
113
|
+
Before production, deploy an owned staging Worker against the real service and
|
|
114
|
+
verify report persistence, entitled enforcement, shared quota contention across
|
|
115
|
+
invocations and service outages. Before adding concurrency/budgets, validate long
|
|
116
|
+
streams, midstream client disconnects, provider cancellation, heartbeat failure,
|
|
117
|
+
lease expiry and conservative settlement when usage is unknown.
|
package/account.mjs
CHANGED
|
@@ -13,7 +13,7 @@ export function createAccountBinding({webdecoyUrl, webdecoyKey, propertyId, dete
|
|
|
13
13
|
let next = {status:'unavailable', enforce:false};
|
|
14
14
|
try {
|
|
15
15
|
const response = await fetch(new URL('/api/v1/sdk/ai-abuse/config', webdecoyUrl), {
|
|
16
|
-
headers:{Authorization:`Bearer ${webdecoyKey}`}, redirect:'
|
|
16
|
+
headers:{Authorization:`Bearer ${webdecoyKey}`}, redirect:'manual',
|
|
17
17
|
signal:AbortSignal.any([signal, AbortSignal.timeout(detectorTimeoutMs)])
|
|
18
18
|
});
|
|
19
19
|
if (!response.ok) { await response.body?.cancel(); throw new Error('account_unavailable'); }
|
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){
|
|
@@ -44,5 +49,27 @@ 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
|
+
check.decision=value.allowed?'allow':'deny';check.reason=value.reason;
|
|
66
|
+
if(!value.allowed)denial={reason:'caller_paused',status:403};
|
|
67
|
+
} catch { signal.throwIfAborted(); }
|
|
68
|
+
check.durationMs=Math.max(0,performance.now()-started);return {check,denial};
|
|
69
|
+
}:null;
|
|
70
|
+
return {limits,checkCallerPause,callerEvidence:caller=>actionCallerEvidence(c,caller),report:event=>reporter.send(event),flush:()=>reporter.flush()};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function actionCallerEvidence(config,caller) {
|
|
74
|
+
return config.reportCaller?Object.freeze({schema:1,source:'application_auth',id:quotaHash(config.subjectSecret,'webdecoy.actions.evidence.caller.v1',config.propertyId.toLowerCase(),caller.issuer,caller.tenant,caller.subject)}):undefined;
|
|
48
75
|
}
|
package/actions.d.mts
CHANGED
|
@@ -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/admission.mjs
CHANGED
|
@@ -45,7 +45,7 @@ export function createAdmission(options) {
|
|
|
45
45
|
let verdict;
|
|
46
46
|
try {
|
|
47
47
|
const response = await fetch(new URL('/api/v1/sdk/detect', c.webdecoyUrl), {
|
|
48
|
-
method: 'POST', redirect: '
|
|
48
|
+
method: 'POST', redirect: 'manual',
|
|
49
49
|
headers: {'Authorization': `Bearer ${c.webdecoyKey}`, 'Content-Type': 'application/json'},
|
|
50
50
|
signal: AbortSignal.any([signal, AbortSignal.timeout(c.detectorTimeoutMs)]),
|
|
51
51
|
body: JSON.stringify({
|
package/mcp.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
2
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { actionCallerEvidence } from './action-runtime.mjs';
|
|
4
5
|
import { createActionProtection, ActionDenied } from './actions.mjs';
|
|
5
6
|
import { createHash, randomUUID } from 'node:crypto';
|
|
6
7
|
import { createReporter } from './reporting.mjs';
|
|
@@ -54,12 +55,12 @@ export function createProtectedMCPHandler(options) {
|
|
|
54
55
|
const catalogReporter = options.discovery ? createReporter({
|
|
55
56
|
reportingTimeoutMs: runtime.reportingTimeoutMs ?? 1000,
|
|
56
57
|
maxPendingReports: runtime.maxPendingReports ?? 100,
|
|
57
|
-
onObservation: async (names, { signal }) => {
|
|
58
|
+
onObservation: async ({names,caller}, { signal }) => {
|
|
58
59
|
const response = await fetch(new URL('/api/v1/sdk/ai-abuse/reports', runtime.webdecoyUrl), {
|
|
59
60
|
method: 'POST', redirect: 'error', signal,
|
|
60
61
|
headers: { Authorization: `Bearer ${runtime.webdecoyKey}`, 'X-WebDecoy-Property-ID': runtime.propertyId, 'Content-Type': 'application/json' },
|
|
61
62
|
body: JSON.stringify({ schema: 3, request_id: randomUUID(), timestamp: new Date().toISOString(), action: 'tool_discovery',
|
|
62
|
-
tool_catalog: { server_id: serverId, source: 'tools_list', tools: names.map(name => ({ name, schema_hash: hashes[name], effect: tools[name].toolSchema.effect, permissions: tools[name].toolSchema.permissions, ...(decoys.has(name)?{decoy:decoys.get(name)}:{}) })) } })
|
|
63
|
+
tool_catalog: { server_id: serverId, source: 'tools_list', ...(caller?{caller}:{}), tools: names.map(name => ({ name, schema_hash: hashes[name], effect: tools[name].toolSchema.effect, permissions: tools[name].toolSchema.permissions, ...(decoys.has(name)?{decoy:decoys.get(name)}:{}) })) } })
|
|
63
64
|
});
|
|
64
65
|
await response.body?.cancel();
|
|
65
66
|
if (!response.ok) throw Error('Discovery reporting unavailable');
|
|
@@ -214,7 +215,7 @@ export function createProtectedMCPHandler(options) {
|
|
|
214
215
|
res.once('close', () => { void server.close().catch(() => { }); });
|
|
215
216
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
216
217
|
const visible = Object.entries(tools).filter(([name, tool]) => decoys.get(name) !== 'unadvertised' && tool.requiredScopes.every(s => caller.scopes.includes(s)));
|
|
217
|
-
for (let i=0;i<visible.length;i+=64) void catalogReporter?.send(visible.slice(i,i+64).map(([name]) => name));
|
|
218
|
+
for (let i=0;i<Math.max(1,visible.length);i+=64) void catalogReporter?.send({names:visible.slice(i,i+64).map(([name]) => name),caller:runtime? actionCallerEvidence(runtime,caller):undefined});
|
|
218
219
|
return { tools: visible.map(([name, tool]) => ({ name, description: tool.description, inputSchema: tool.inputSchema, ...(tool.annotations ? {annotations:tool.annotations} : {}) })) };
|
|
219
220
|
});
|
|
220
221
|
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
package/package.json
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
},
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "node --test test/*.test.mjs",
|
|
9
|
-
"prepublishOnly": "npm test && npm run test:types && npm run check:package",
|
|
9
|
+
"prepublishOnly": "npm test && npm run test:workers && npm run test:types && npm run check:package",
|
|
10
10
|
"check:package": "node scripts/check-package.mjs",
|
|
11
|
-
"test:types": "tsc --strict --noEmit --module nodenext --target es2022 test/types.mts test/mcp-types.mts"
|
|
11
|
+
"test:types": "tsc --strict --noEmit --module nodenext --target es2022 test/types.mts test/mcp-types.mts",
|
|
12
|
+
"test:workers": "node --test test/workers/*.test.mjs"
|
|
12
13
|
},
|
|
13
|
-
"version": "0.1.0-alpha.
|
|
14
|
+
"version": "0.1.0-alpha.14",
|
|
14
15
|
"exports": {
|
|
15
16
|
"./fetch": {
|
|
16
17
|
"types": "./fetch.d.mts",
|
|
@@ -31,6 +32,10 @@
|
|
|
31
32
|
"./mcp": {
|
|
32
33
|
"types": "./mcp.d.mts",
|
|
33
34
|
"import": "./mcp.mjs"
|
|
35
|
+
},
|
|
36
|
+
"./workers": {
|
|
37
|
+
"types": "./workers.d.mts",
|
|
38
|
+
"import": "./workers.mjs"
|
|
34
39
|
}
|
|
35
40
|
},
|
|
36
41
|
"files": [
|
|
@@ -63,9 +68,12 @@
|
|
|
63
68
|
"MCP.md",
|
|
64
69
|
"work.mjs",
|
|
65
70
|
"WORK.md",
|
|
66
|
-
"tool-effects.mjs"
|
|
71
|
+
"tool-effects.mjs",
|
|
72
|
+
"workers.mjs",
|
|
73
|
+
"workers.d.mts",
|
|
74
|
+
"WORKERS.md"
|
|
67
75
|
],
|
|
68
|
-
"description": "Bot and abuse protection for AI-powered applications.
|
|
76
|
+
"description": "Bot and abuse protection for AI-powered applications. Request admission for Node.js and experimental Cloudflare Workers.",
|
|
69
77
|
"license": "Apache-2.0",
|
|
70
78
|
"repository": {
|
|
71
79
|
"type": "git",
|
|
@@ -89,7 +97,10 @@
|
|
|
89
97
|
"devDependencies": {
|
|
90
98
|
"typescript": "6.0.3",
|
|
91
99
|
"@modelcontextprotocol/sdk": "1.31.0",
|
|
92
|
-
"@types/node": "22.19.15"
|
|
100
|
+
"@types/node": "22.19.15",
|
|
101
|
+
"esbuild": "^0.28.2",
|
|
102
|
+
"miniflare": "^4.20260730.0",
|
|
103
|
+
"wrangler": "^4.147.0"
|
|
93
104
|
},
|
|
94
105
|
"peerDependencies": {
|
|
95
106
|
"@modelcontextprotocol/sdk": "1.31.0"
|
package/quota.mjs
CHANGED
|
@@ -48,7 +48,7 @@ export function prepareQuota(options) {
|
|
|
48
48
|
signal.throwIfAborted();
|
|
49
49
|
try {
|
|
50
50
|
const response=await fetch(new URL('/api/v1/sdk/ai-abuse/quota',webdecoyUrl),{
|
|
51
|
-
method:'POST',redirect:'
|
|
51
|
+
method:'POST',redirect:'manual',signal:AbortSignal.any([signal,AbortSignal.timeout(q.timeoutMs)]),
|
|
52
52
|
headers:{Authorization:`Bearer ${webdecoyKey}`,'Content-Type':'application/json','X-WebDecoy-Property-ID':propertyId},body:JSON.stringify(payload)
|
|
53
53
|
});
|
|
54
54
|
if(!response.ok){await response.body?.cancel();const error=Error('Quota unavailable');error.terminal=response.status<500;throw error;}
|
package/telemetry.mjs
CHANGED
|
@@ -11,7 +11,7 @@ export function createTelemetry({webdecoyUrl, webdecoyKey, propertyId, reportToW
|
|
|
11
11
|
...(event.handler_status !== undefined ? {handler_status:event.handler_status} : {}),
|
|
12
12
|
action:event.action};
|
|
13
13
|
const response = await fetch(new URL('/api/v1/sdk/ai-abuse/reports',webdecoyUrl),{
|
|
14
|
-
method:'POST',redirect:'
|
|
14
|
+
method:'POST',redirect:'manual',signal,
|
|
15
15
|
headers:{Authorization:`Bearer ${webdecoyKey}`,'Content-Type':'application/json','X-WebDecoy-Property-ID':propertyId},
|
|
16
16
|
body:JSON.stringify(payload)
|
|
17
17
|
});
|
package/workers.d.mts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type {AIProtectionOptions, ProtectionDecision, ReportOutcome} from './fetch.mjs';
|
|
2
|
+
export {createQuotaOperationId} from './fetch.mjs';
|
|
3
|
+
/** Structural type; does not require Cloudflare types in Node consumers. */
|
|
4
|
+
export interface WorkerExecutionContext {waitUntil(task: Promise<unknown>): void}
|
|
5
|
+
export type WorkerAIProtectionOptions<Context = Record<string, unknown>> =
|
|
6
|
+
Omit<AIProtectionOptions<Context>, 'waitUntil' | 'concurrency'>;
|
|
7
|
+
export interface WorkerAIProtection<Context> {
|
|
8
|
+
(handler: () => Response | Promise<Response>, context: Context): Promise<Response>;
|
|
9
|
+
check(context: Context): Promise<ProtectionDecision>;
|
|
10
|
+
report(decision: ProtectionDecision, outcome?: ReportOutcome): Promise<void>;
|
|
11
|
+
flush(): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export interface ContextOptionalWorkerProtection extends WorkerAIProtection<Record<string, unknown>> {
|
|
14
|
+
(handler: () => Response | Promise<Response>, context?: Record<string, unknown>): Promise<Response>;
|
|
15
|
+
check(context?: Record<string, unknown>): Promise<ProtectionDecision>;
|
|
16
|
+
}
|
|
17
|
+
/** Create inside the Worker fetch handler; the returned object belongs to this request only. */
|
|
18
|
+
export function createWorkerAIProtection(request: Request, options: WorkerAIProtectionOptions, ctx: WorkerExecutionContext): ContextOptionalWorkerProtection;
|
|
19
|
+
export function createWorkerAIProtection<Context>(request: Request, options: WorkerAIProtectionOptions<Context>, ctx: WorkerExecutionContext): WorkerAIProtection<Context>;
|
package/workers.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {createAIProtection} from './fetch.mjs';
|
|
2
|
+
export {createQuotaOperationId} from './quota.mjs';
|
|
3
|
+
|
|
4
|
+
// Call inside fetch(), once per incoming request. No request-owned I/O or ctx
|
|
5
|
+
// escapes into a shared instance, including the core's pending config refresh.
|
|
6
|
+
export function createWorkerAIProtection(request, options, ctx) {
|
|
7
|
+
if (!(request instanceof Request)) throw Error('Worker Request required');
|
|
8
|
+
if (!ctx || typeof ctx.waitUntil !== 'function') throw Error('Worker execution context required');
|
|
9
|
+
if (options.concurrency !== undefined) throw Error('Workers concurrency is not supported yet');
|
|
10
|
+
const core = createAIProtection({...options, waitUntil: task => ctx.waitUntil(task)});
|
|
11
|
+
const protect = (handler, context = {}) => core(request, handler, context);
|
|
12
|
+
return Object.assign(protect, {
|
|
13
|
+
check: (context = {}) => core.check(request, context),
|
|
14
|
+
report: core.report,
|
|
15
|
+
flush: core.flush
|
|
16
|
+
});
|
|
17
|
+
}
|