@webdecoy/ai-protection 0.1.0-alpha.1 → 0.1.0-alpha.10
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/ARCHITECTURE.md +3 -6
- package/MCP.md +271 -0
- package/NEXTJS.md +1 -1
- package/README.md +51 -12
- package/WORK.md +123 -0
- package/action-runtime.mjs +48 -0
- package/actions.d.mts +105 -0
- package/actions.mjs +181 -0
- package/mcp.d.mts +45 -0
- package/mcp.mjs +236 -0
- package/package.json +31 -5
- package/tool-effects.mjs +52 -0
- package/work.mjs +53 -0
- package/RELEASE.md +0 -75
package/ARCHITECTURE.md
CHANGED
|
@@ -119,15 +119,13 @@ values in rule IDs/reason codes.
|
|
|
119
119
|
|
|
120
120
|
Ingest deduplicates by organization/property/request ID: the first accepted report
|
|
121
121
|
wins, including its receipt time. There are no automatic retries. Reports are
|
|
122
|
-
bounded to 32 KiB and 36 checks (32 local rules, quota, concurrency, cloud and browser evidence).
|
|
123
|
-
limits traffic to 6000 reports/minute per source IP with a burst of 200; excess
|
|
124
|
-
reports are dropped by this SDK after a generic warning, without affecting chat.
|
|
122
|
+
bounded to 32 KiB and 36 checks (32 local rules, quota, concurrency, cloud and browser evidence). Rate-limited reports are dropped by this SDK after a generic warning, without affecting chat.
|
|
125
123
|
|
|
126
124
|
The dashboard presents these as **SDK-reported application decisions**, separate
|
|
127
125
|
from server-derived detector evidence. They are not summed into detection counts,
|
|
128
126
|
charged as detections, or treated as proof of blocked inference or savings. The
|
|
129
127
|
same request ID lets users correlate the two sources. Counts use receipt time;
|
|
130
|
-
|
|
128
|
+
report availability depends on service retention. A prolonged outage
|
|
131
129
|
can prevent delivery, so this is not complete audit coverage.
|
|
132
130
|
|
|
133
131
|
`onObservation(event, {signal})` can return a promise. `report()` catches rejection,
|
|
@@ -162,5 +160,4 @@ provider attempt; it is not an automatic middleware spending cap. Both controls
|
|
|
162
160
|
default to observe/open; enforce/closed is an explicit state-availability tradeoff.
|
|
163
161
|
Usage events are separate from schema-1 request reports and include numeric rates,
|
|
164
162
|
tokens and call/request/reservation UUIDs. They do not include raw identities or
|
|
165
|
-
model content. See README
|
|
166
|
-
private app repository's integrations/ai-abuse documentation.
|
|
163
|
+
model content. See the README for configuration and integration examples.
|
package/MCP.md
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# MCP tool protection (Alpha)
|
|
2
|
+
|
|
3
|
+
`@webdecoy/ai-protection/mcp` is a Node HTTP handler for a closed registry of
|
|
4
|
+
protected tools. It runs in your application's backend. Your MCP traffic stays
|
|
5
|
+
on your server; configured shared limits and sanitized action reports use the
|
|
6
|
+
WebDecoy runtime. Your application owns authentication, tenant membership and
|
|
7
|
+
resource authorization.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Available in `0.1.0-alpha.5` and later compatible alpha releases:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install @webdecoy/ai-protection@0.1.0-alpha.10 @modelcontextprotocol/sdk@1.31.0
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Node 22.22.3+ and MCP SDK **1.31.0**. The MCP SDK is an optional peer, so
|
|
18
|
+
ordinary request/action SDK installs do not install it. Only `/mcp` imports it.
|
|
19
|
+
TypeScript Node applications also need their usual `@types/node` development
|
|
20
|
+
dependency. Tests exercise MCP protocol **2025-11-25** with the official client.
|
|
21
|
+
|
|
22
|
+
## Connect your application
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import {createServer} from 'node:http';
|
|
26
|
+
import {createProtectedMCPHandler} from '@webdecoy/ai-protection/mcp';
|
|
27
|
+
import {verifyAccessToken, records} from './your-application.js';
|
|
28
|
+
|
|
29
|
+
const handler = createProtectedMCPHandler({
|
|
30
|
+
resource: 'https://api.example.com/mcp',
|
|
31
|
+
authorizationServer: 'https://your-issuer.example.com/',
|
|
32
|
+
policyVersion: 'records_v1',
|
|
33
|
+
// Your verifier validates issuer, audience/resource, signature and expiry,
|
|
34
|
+
// then resolves tenant membership from trusted application state.
|
|
35
|
+
authenticate: (request, {signal}) => verifyAccessToken(request, {signal}),
|
|
36
|
+
tools: {
|
|
37
|
+
'records.read': {
|
|
38
|
+
description: 'Read an owned record',
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: 'object', properties: {id: {type: 'string'}},
|
|
41
|
+
required: ['id'], additionalProperties: false,
|
|
42
|
+
},
|
|
43
|
+
requiredScopes: ['records:read'],
|
|
44
|
+
validate: args => args !== null && typeof args === 'object'
|
|
45
|
+
&& !Array.isArray(args) && Object.keys(args).length === 1
|
|
46
|
+
&& 'id' in args && typeof args.id === 'string',
|
|
47
|
+
authorize: ({caller, args}) => records.canRead(caller, args),
|
|
48
|
+
// The database query must scope by the trusted tenant, even after authorize.
|
|
49
|
+
// Await all work; return a complete MCP result, not a detached stream/task.
|
|
50
|
+
execute: ({caller, args, signal}) => records.readForTenant(caller.tenant, args, signal),
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const server = createServer((req, res) => { void handler(req, res); });
|
|
55
|
+
server.requestTimeout = 10000;
|
|
56
|
+
server.headersTimeout = 10000;
|
|
57
|
+
server.listen(8093, '127.0.0.1'); // Put your existing HTTPS proxy in front.
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The application imports above are integration hooks, not exports from this SDK.
|
|
61
|
+
See the [runnable Auth0-backed example](examples/mcp/README.md) for a concrete
|
|
62
|
+
verifier and test registry. The verifier returns the [trusted caller contract](actions.d.mts):
|
|
63
|
+
schema, subject, tenant, issuer, authentication method, expiry and scopes. Never
|
|
64
|
+
derive tenant or permissions from tool arguments, a session ID, agent name or a
|
|
65
|
+
WebDecoy API key. A separate WebDecoy-owned Auth0 account is not required.
|
|
66
|
+
|
|
67
|
+
`inputSchema` advertises the tool shape; the application `validate` hook enforces
|
|
68
|
+
it before dispatch. Tool listing is filtered by scopes, while every tool call
|
|
69
|
+
independently checks scopes, validation and application permissions. Do not expose
|
|
70
|
+
the same operation through an unguarded alternate handler.
|
|
71
|
+
|
|
72
|
+
## Shared controls and evidence
|
|
73
|
+
|
|
74
|
+
Supply `sharedRuntime` with the server-only WebDecoy URL, API key, property ID and
|
|
75
|
+
stable subject secret. Add `limits.callerQuota`, `limits.tenantQuota` and/or
|
|
76
|
+
`limits.concurrency` to each protected tool. These use the same configuration as
|
|
77
|
+
the [action API](examples/actions/README.md#shared-limits-and-hosted-evidence).
|
|
78
|
+
Limits follow verified identities, not MCP connection/session identifiers.
|
|
79
|
+
|
|
80
|
+
Authentication and explicit permission denials always stop dispatch. Shared-state
|
|
81
|
+
failure follows each limit's configured `failureMode`; use enforce/closed for a
|
|
82
|
+
hard admission limit. Observe/open does not establish a hard ceiling. This adapter
|
|
83
|
+
does not itself run request bot detection or a prompt scanner. If you add detector
|
|
84
|
+
checks elsewhere, keep their default fail-open behavior separate from permissions.
|
|
85
|
+
|
|
86
|
+
Optional `onEvent` receives sanitized action outcomes. Shared-runtime reporting is
|
|
87
|
+
best effort. Events omit tokens, raw identities, tool arguments and result content.
|
|
88
|
+
A completed event means the callback resolved; independently check your own
|
|
89
|
+
database/provider for side-effect confirmation. A failed/disconnected call may
|
|
90
|
+
have unknown work. Never retry writes without application/provider idempotency.
|
|
91
|
+
|
|
92
|
+
## Supported boundary
|
|
93
|
+
|
|
94
|
+
- Stateless Streamable HTTP at `/mcp`: initialize, ping, scoped `tools/list`,
|
|
95
|
+
protected `tools/call`, and caller-bound cancellation notifications.
|
|
96
|
+
- Protected-resource metadata at `/.well-known/oauth-protected-resource/mcp`;
|
|
97
|
+
missing/invalid credentials get HTTP 401, missing scopes get HTTP 403 challenges.
|
|
98
|
+
Permission/limit denials after admission use MCP tool errors with bounded retry
|
|
99
|
+
guidance in `_meta["webdecoy.com/action-error"]`.
|
|
100
|
+
- Host must match `resource`; present Origin headers must be explicitly allowed.
|
|
101
|
+
Non-browser clients can omit Origin. Preserve Host through your trusted proxy.
|
|
102
|
+
- At most 16 KiB per JSON message, five-second body read, one-second authentication
|
|
103
|
+
deadline and at most 128 active tool calls per handler. These local bounds are
|
|
104
|
+
distinct from shared caller quotas.
|
|
105
|
+
- SSE carries the final MCP response. Tools must await completion; arbitrary live
|
|
106
|
+
tool-result streams are unsupported. A disconnect requests cooperative abort;
|
|
107
|
+
explicit cancellation is scoped to the authenticated caller/client/request ID.
|
|
108
|
+
Uncooperative work retains its local active slot until it settles.
|
|
109
|
+
- No session store, replay/resumption, automatic retry or exactly-once execution.
|
|
110
|
+
Supplied session/replay IDs are rejected; standalone SSE GET and DELETE return
|
|
111
|
+
405. Reconnects do not grant fresh identity allowances.
|
|
112
|
+
- Resources, prompts, tasks, sampling, elicitation, other routes and pre-existing
|
|
113
|
+
MCP handlers are not wrapped. Unregistered methods/tools do not dispatch.
|
|
114
|
+
Weighted tool work is available in the alpha API; see [bounded work](WORK.md)
|
|
115
|
+
for its runtime prerequisite and application-enforced bounds. Full product
|
|
116
|
+
acceptance remains separate from this adapter's tested contract.
|
|
117
|
+
|
|
118
|
+
This is an explicit tools integration, not transparent protection of an existing
|
|
119
|
+
whole MCP server. See the [MCP transport specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports)
|
|
120
|
+
and [authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
|
|
121
|
+
|
|
122
|
+
For weighted search/export limits and tenant concurrency, see [bounded tool work](WORK.md).
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
## Opt-in tool discovery (alpha.7+)
|
|
126
|
+
|
|
127
|
+
Add these options alongside your existing `tools` and authentication configuration:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
sharedRuntime: {
|
|
131
|
+
webdecoyUrl: 'https://ai-protection.webdecoy.com',
|
|
132
|
+
webdecoyKey: process.env.WEBDECOY_API_KEY!,
|
|
133
|
+
propertyId: process.env.WEBDECOY_PROPERTY_ID!,
|
|
134
|
+
subjectSecret: process.env.WEBDECOY_SUBJECT_SECRET!, // at least 32 bytes
|
|
135
|
+
},
|
|
136
|
+
discovery: { serverId: 'records-api' },
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Use a stable, non-secret server label (1–96 letters, digits, `_`, `.`, `:`, `-`,
|
|
140
|
+
starting with a letter or digit). Reuse it across replicas/releases of the same
|
|
141
|
+
logical server. Give separate servers separate labels within a property. Do not
|
|
142
|
+
include tenant IDs, hostnames containing secrets, or customer information.
|
|
143
|
+
|
|
144
|
+
An authenticated MCP client calls `tools/list`. Each nonempty response queues a
|
|
145
|
+
best-effort advertisement of the visible tool names and SHA-256 input-schema
|
|
146
|
+
hashes. The AI Protection dashboard shows **MCP tool inventory**, including
|
|
147
|
+
tools that have never executed. This does not scan unwrapped servers, hidden tools,
|
|
148
|
+
resources, prompts or alternate routes. No network request is made at handler
|
|
149
|
+
construction. Discovery is off unless configured and requires sharedRuntime.
|
|
150
|
+
|
|
151
|
+
Hashes cover the JSON input schema with recursively sorted object keys; array
|
|
152
|
+
order is preserved. Raw schemas, descriptions, arguments, results, credentials,
|
|
153
|
+
caller identities and required scopes are not uploaded. A schema hash is a
|
|
154
|
+
fingerprint, not encryption; someone with a candidate schema can compare it.
|
|
155
|
+
Multiple hashes for one server/tool show reported variants in the seven-day
|
|
156
|
+
receipt window. Rolling deployments and benign schema edits can cause variants.
|
|
157
|
+
They do not establish an attack, breaking change, removal or missing permission.
|
|
158
|
+
The UI uses the latest receipt's hash, not a claim about deployment order.
|
|
159
|
+
|
|
160
|
+
Discovery uses schema-3 reports on the existing reporting endpoint; deploy a
|
|
161
|
+
compatible hosted runtime first. Old runtimes reject these optional reports
|
|
162
|
+
without affecting tool listing. Reporting has bounded pending work and a deadline,
|
|
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.
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
### Calls before discovery (alpha.8+)
|
|
170
|
+
|
|
171
|
+
With `discovery` enabled, the adapter also attaches the registered server label
|
|
172
|
+
and schema fingerprint to each `tools/call` action report. This includes local
|
|
173
|
+
permission/scope denials for registered tools. It works before a client calls
|
|
174
|
+
`tools/list`; no listing round trip is required to execute an authorized tool.
|
|
175
|
+
Unknown tool names and requests rejected before authentication do not gain
|
|
176
|
+
registry metadata. Turning discovery off omits the new call metadata too.
|
|
177
|
+
|
|
178
|
+
The inventory combines advertisements and action evidence by property, server
|
|
179
|
+
label and tool name. Attempt/start/completion reports sharing an action ID count
|
|
180
|
+
once. Counts include denied attempts, so they are not successful-execution totals.
|
|
181
|
+
“Call evidence only” means no advertisement was captured in the bounded seven-day
|
|
182
|
+
sample. Clients can call directly; hidden scopes, sampling and missing telemetry
|
|
183
|
+
also limit coverage. This is not an attack or permission-gap verdict. Older action
|
|
184
|
+
reports without metadata still appear in the general activity table, aggregated
|
|
185
|
+
by tool name across servers; their server/schema association remains unknown.
|
|
186
|
+
|
|
187
|
+
Deploy a runtime accepting optional `tool_action.tool_schema` before upgrading an
|
|
188
|
+
integration with discovery enabled. Older runtimes reject affected action reports
|
|
189
|
+
without changing admission. The action API also supports optional `toolSchema:
|
|
190
|
+
{serverId, hash}` on trusted registered definitions; it is snapshotted and validated,
|
|
191
|
+
never read from tool arguments. This is SDK-reported metadata, not authorization
|
|
192
|
+
or independent verification of a schema.
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
### Advisory side effects (alpha.9+)
|
|
196
|
+
|
|
197
|
+
With discovery enabled, the SDK adds a fixed classification and reason code to
|
|
198
|
+
catalog/call metadata: `unknown`, `read_only`, `mutating`, or `destructive`.
|
|
199
|
+
This is heuristic inference, not a prompt scanner, authorization policy, verified
|
|
200
|
+
behavior or proof that side effects occurred. Classification never allows or
|
|
201
|
+
blocks a request. Existing validate/authorize/scopes/limits still control dispatch.
|
|
202
|
+
|
|
203
|
+
You may supply MCP boolean hints on a protected tool:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Supported annotations are readOnlyHint, destructiveHint, idempotentHint and
|
|
210
|
+
openWorldHint. The adapter snapshots those booleans and includes them in scoped
|
|
211
|
+
MCP listing responses. Only classification codes enter WebDecoy reports; raw
|
|
212
|
+
annotations, schemas and descriptions are not uploaded. The schema fingerprint
|
|
213
|
+
continues to cover inputSchema, not annotations or implementation code.
|
|
214
|
+
|
|
215
|
+
The classifier checks tokenized tool names and explicit top-level `action`,
|
|
216
|
+
`operation` or `method` selectors (`const`/up to 64 enum values). It does not
|
|
217
|
+
resolve schema references, scan arbitrary text/arguments, execute tools, or call a
|
|
218
|
+
model. Destructive/mutating signals take precedence over a contradictory read-only
|
|
219
|
+
hint and are labeled conflicting. With no useful signals it reports unknown.
|
|
220
|
+
Name heuristics can be wrong (for example, a status tool named after deletion).
|
|
221
|
+
As the [MCP specification](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)
|
|
222
|
+
requires, annotations must not be treated as guarantees from an untrusted server.
|
|
223
|
+
|
|
224
|
+
The dashboard shows inferred evidence separately from a customer classification.
|
|
225
|
+
Use **Review classification** to select a label or return to SDK inference.
|
|
226
|
+
Overrides are property/server/tool settings bound to the current input-schema
|
|
227
|
+
hash; a different hash falls back to inference and prompts review. Changes to
|
|
228
|
+
annotations or implementation without a schema change do not invalidate an
|
|
229
|
+
override, so review those changes yourself. A saved classification changes only
|
|
230
|
+
the dashboard label; it is not read by runtime enforcement. Overrides persist as
|
|
231
|
+
customer settings until reset or property deletion; SDK evidence retains its
|
|
232
|
+
seven-day window. Where evidence disagrees for the latest received schema, the
|
|
233
|
+
review order is destructive, mutating, unknown, then read-only. Older SDKs without
|
|
234
|
+
classification metadata remain unknown, though customers can label their tools.
|
|
235
|
+
|
|
236
|
+
Requires a runtime accepting optional `effect` evidence. Deploy it before
|
|
237
|
+
upgrading a discovery-enabled integration. Large registries are split into
|
|
238
|
+
bounded catalog batches so added metadata stays within report body limits.
|
|
239
|
+
|
|
240
|
+
## Reviewing tool permissions
|
|
241
|
+
|
|
242
|
+
Discovery reports configuration evidence for each registered tool: the number of
|
|
243
|
+
`requiredScopes`, the presence of the mandatory `authorize` callback, and whether
|
|
244
|
+
an additional `policy` callback is configured. It never reports scope names or
|
|
245
|
+
callback code. These are SDK-reported settings, not verified authorization quality.
|
|
246
|
+
This metadata starts in Node alpha.10. Deploy a compatible runtime before upgrading.
|
|
247
|
+
|
|
248
|
+
The inventory asks you to review a potentially mutating/destructive tool with no
|
|
249
|
+
required scopes. This does **not** mean it is unprotected: application authorization
|
|
250
|
+
may already provide sufficient protection. Review the named server/tool in your
|
|
251
|
+
registry and check the application's tenant/resource ownership checks. Where your
|
|
252
|
+
OAuth model uses per-tool scopes, configure them on that tool, for example:
|
|
253
|
+
|
|
254
|
+
```js
|
|
255
|
+
requiredScopes: ['records:write'],
|
|
256
|
+
authorize: ({caller, args, signal}) =>
|
|
257
|
+
canModifyRecord(caller.tenant, caller.subject, args.id, {signal}),
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
`canModifyRecord` is your application's authorization function. Preserve ownership
|
|
261
|
+
checks at the database transaction that performs the write. After deploying, make
|
|
262
|
+
an authenticated tools/list or tools/call request and refresh the inventory.
|
|
263
|
+
The dashboard cannot edit or inspect your application callback. An additional
|
|
264
|
+
policy cannot override a deny from application authorization.
|
|
265
|
+
|
|
266
|
+
Older/missing evidence remains unknown. Conflicting reported configurations for
|
|
267
|
+
the selected input-schema hash remain conflicting. The seven-day bounded sample
|
|
268
|
+
can include both sides of a rolling deployment; it does not certify the latest
|
|
269
|
+
running configuration or prove that unwrapped routes are protected. A changed
|
|
270
|
+
scope or callback does not change the input-schema hash. No enforcement behavior
|
|
271
|
+
changes when discovery is enabled.
|
package/NEXTJS.md
CHANGED
|
@@ -132,7 +132,7 @@ enabling the pilot; absent/unavailable reporting never changes request decisions
|
|
|
132
132
|
`upstream_attempted` remains false: this adapter cannot observe model activity.
|
|
133
133
|
- Account policy caches and per-call timeouts are documented in README. Cold remote admission can wait roughly two seconds with defaults, plus up to
|
|
134
134
|
one second of IP resolution. Optional quota, lease acquisition and reservation
|
|
135
|
-
each add their own deadline. See
|
|
135
|
+
each add their own deadline. See the README configuration section.
|
|
136
136
|
|
|
137
137
|
Use the AI SDK's documented `consumeSseStream: consumeStream` handling alongside
|
|
138
138
|
`abortSignal` when returning UI streams. Provider cancellation/billing remains
|
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.3`.** 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.
|
|
@@ -129,7 +129,7 @@ npm test
|
|
|
129
129
|
```
|
|
130
130
|
|
|
131
131
|
Tests use a local detector and local AI model; no live keys or paid inference.
|
|
132
|
-
|
|
132
|
+
Source and release history are available in this public repository.
|
|
133
133
|
|
|
134
134
|
## Shared account quotas (opt-in)
|
|
135
135
|
|
|
@@ -153,7 +153,7 @@ observation, with a one-second timeout and fail-open for state errors. Use
|
|
|
153
153
|
Enforced exhaustion returns 429 and `Retry-After`; explicit `check` callers use
|
|
154
154
|
`decision.retryAfterSeconds` and must enforce the decision themselves.
|
|
155
155
|
|
|
156
|
-
This
|
|
156
|
+
This uses the hosted `/api/v1/sdk/ai-abuse/quota` endpoint.
|
|
157
157
|
Observation and enforcement share counters. In default schema 1, each allowed
|
|
158
158
|
admission consumes a unit, including retries and requests later cancelled/blocked
|
|
159
159
|
by another check; there are no automatic retries or refunds. Opt-in schema 2
|
|
@@ -179,7 +179,7 @@ capacity limits do not change the detector's separate failure policy. State can
|
|
|
179
179
|
commit just before a timeout, so a failed check does not prove no unit was used.
|
|
180
180
|
|
|
181
181
|
|
|
182
|
-
## Distributed concurrency
|
|
182
|
+
## Distributed concurrency
|
|
183
183
|
|
|
184
184
|
Optional concurrency policy shares per-account and property/feature capacity
|
|
185
185
|
across app replicas. Defaults are observe/open; detector failure policy is
|
|
@@ -202,11 +202,9 @@ proof a remote provider stopped. Upstream work must honor cancellation and have
|
|
|
202
202
|
a real runtime bound. Fail-open outages cannot guarantee a concurrency cap.
|
|
203
203
|
Released replay tombstones remain 24 hours: the pilot cap is 10,000 granted
|
|
204
204
|
acquisitions/day/property and 32 policies/property. This is not a throughput SLA.
|
|
205
|
-
The private app repository's `integrations/ai-abuse/CONCURRENCY.md` documents the
|
|
206
|
-
wire contract, failure behavior, deployment order and validation evidence.
|
|
207
205
|
|
|
208
206
|
|
|
209
|
-
## Upstream model budgets
|
|
207
|
+
## Upstream model budgets
|
|
210
208
|
|
|
211
209
|
Opt-in token and integer micro-USD budgets reserve a conservative maximum before
|
|
212
210
|
each provider attempt and reconcile only confirmed usage. Configure account,
|
|
@@ -234,8 +232,7 @@ is based on admission time, not the provider's invoice period.
|
|
|
234
232
|
There is an Ollama final-usage normalizer for native generate/chat metadata;
|
|
235
233
|
other provider clients need a reviewed application adapter. The SDK never parses
|
|
236
234
|
or stores prompts/outputs to meter usage. This does not change WebDecoy plans or
|
|
237
|
-
create a subscription meter.
|
|
238
|
-
contains examples, supported workloads, privacy, capacity and release gates.
|
|
235
|
+
create a subscription meter.
|
|
239
236
|
|
|
240
237
|
## Optional browser evidence
|
|
241
238
|
|
|
@@ -292,7 +289,7 @@ metadata values still leave the app; never place secrets in those values.
|
|
|
292
289
|
Reporting timeouts and queue capacity each have a maximum of 10000 (ms/events).
|
|
293
290
|
Application rules and hooks must not block the event loop. There is no unconditional
|
|
294
291
|
wall-clock SLA for arbitrary customer code or uncooperative hosting runtimes.
|
|
295
|
-
See
|
|
292
|
+
See the installation and configuration sections above for supported versions and setup.
|
|
296
293
|
|
|
297
294
|
### Recovering an uncertain quota admission (opt-in)
|
|
298
295
|
|
|
@@ -319,5 +316,47 @@ unknown operation blindly. The stored quota count/retry hint is an original-wind
|
|
|
319
316
|
snapshot, not current quota state.
|
|
320
317
|
|
|
321
318
|
Only admission is deduplicated. Repeated application/model calls still require
|
|
322
|
-
application-level idempotency.
|
|
323
|
-
|
|
319
|
+
application-level idempotency. The hosted runtime must support quota schema 2 before enabling this option.
|
|
320
|
+
|
|
321
|
+
## Action authorization (Alpha)
|
|
322
|
+
|
|
323
|
+
Version `0.1.0-alpha.3` includes an action boundary at `@webdecoy/ai-protection/actions`. See the
|
|
324
|
+
[record-action example and integration contract](examples/actions/README.md).
|
|
325
|
+
This API requires your server authentication and application authorization.
|
|
326
|
+
An [Auth0 access-token example](examples/auth0/README.md) verifies signed tokens
|
|
327
|
+
and maps tenant membership. The [MCP adapter](MCP.md) provides a separate `/mcp` entrypoint for stateless
|
|
328
|
+
HTTP tool dispatch starting in `0.1.0-alpha.4`.
|
|
329
|
+
It requires the optional, pinned MCP SDK peer. Optional shared quotas, concurrency and hosted
|
|
330
|
+
action events are documented in the action guide and use the hosted AI Protection runtime and dashboard.
|
|
331
|
+
|
|
332
|
+
## Weighted tool work (Alpha)
|
|
333
|
+
|
|
334
|
+
Node alpha.5 adds weighted tool-work reservations and tenant concurrency. See
|
|
335
|
+
[bounded tool work](WORK.md) for installation, enforced application bounds and
|
|
336
|
+
retry/unknown-outcome semantics. These units are separate from model usage and billing.
|
|
337
|
+
|
|
338
|
+
### Opt-in tool caller attribution
|
|
339
|
+
|
|
340
|
+
With a runtime supporting caller evidence, set `sharedRuntime.reportCaller: true`
|
|
341
|
+
on `createActionProtection`. It defaults to false. Hosted reports and `onEvent`
|
|
342
|
+
then include `caller: {schema: 1, source: 'application_auth', id: '<digest>'}`
|
|
343
|
+
after successful authentication, including subsequent permission denials.
|
|
344
|
+
Failed authentication, rejected arguments before authentication, and unknown tools
|
|
345
|
+
have no caller attribution. Existing request admission reporting is unchanged.
|
|
346
|
+
|
|
347
|
+
The SDK derives this HMAC-SHA256 pseudonym from the server-owned `subjectSecret`,
|
|
348
|
+
a dedicated versioned domain, property ID, issuer, application tenant and subject.
|
|
349
|
+
Replicas must use the same secret to correlate callers. Raw subjects, issuers,
|
|
350
|
+
tenants, scopes, tokens and tool arguments are not added to reports. OAuth clients
|
|
351
|
+
and agent signers are not treated as the authenticated subject. Your authentication
|
|
352
|
+
hook must verify credentials and tenant membership; WebDecoy does not independently
|
|
353
|
+
verify those credentials from this report, and a pseudonym is not a unique person.
|
|
354
|
+
|
|
355
|
+
Use a randomly generated secret of at least 32 bytes and store it server-side.
|
|
356
|
+
Rotating it changes pseudonyms and also changes existing shared-limit identities
|
|
357
|
+
that use this secret; coordinate rotation because it can reset quota continuity.
|
|
358
|
+
Historical pseudonyms are not relinked. AI Protection displays a seven-day receipt
|
|
359
|
+
window and existing report retention purges expired records in bounded background
|
|
360
|
+
sweeps. Counts cover reported, consistently attributed actions only; dropped reports,
|
|
361
|
+
older SDKs and conflicting bindings leave gaps. Install server support before
|
|
362
|
+
enabling this option: older runtimes reject the additional field.
|
package/WORK.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Bounded tool work (Alpha)
|
|
2
|
+
|
|
3
|
+
The Node action and MCP APIs support optional weighted tool-work reservations.
|
|
4
|
+
Available in `@webdecoy/ai-protection@0.1.0-alpha.5`. Requires the
|
|
5
|
+
`/api/v1/sdk/ai-abuse/work` contract enabled on the hosted runtime. Go/Python model budgets
|
|
6
|
+
and invocation controls remain available, but do not expose this new weighted
|
|
7
|
+
operation API. The first integration is the TypeScript MCP tools adapter.
|
|
8
|
+
|
|
9
|
+
## Configure at the action, before execution
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
limits: {
|
|
13
|
+
work: {
|
|
14
|
+
ruleId: 'search_work_v1',
|
|
15
|
+
windowSeconds: 60,
|
|
16
|
+
maxUnits: 11, // one fixed unit plus at most five rows at two units each
|
|
17
|
+
limits: {caller: 22, tenant: 44, tool: 88},
|
|
18
|
+
mode: 'enforce',
|
|
19
|
+
failureMode: 'closed',
|
|
20
|
+
measure: result => result.workUnits, // server-computed, synchronous, confirmed
|
|
21
|
+
},
|
|
22
|
+
concurrency: {
|
|
23
|
+
ruleId: 'search_parallel_v1', accountLimit: 1, featureLimit: 10,
|
|
24
|
+
mode: 'enforce', failureMode: 'closed',
|
|
25
|
+
},
|
|
26
|
+
tenantConcurrency: {
|
|
27
|
+
ruleId: 'tenant_search_parallel_v1', accountLimit: 2, featureLimit: 10,
|
|
28
|
+
mode: 'enforce', failureMode: 'closed',
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Use `sharedRuntime` on the action/MCP handler. No browser credentials, prompts,
|
|
34
|
+
arguments or results are sent to the work service. Verified caller identity and
|
|
35
|
+
tenant are hashed; an HMAC binds the action, policy version and canonical arguments
|
|
36
|
+
to the operation. Use a stable server-only subject secret. Changing that secret or
|
|
37
|
+
rule IDs creates new accounting identities; rotate/drain deliberately.
|
|
38
|
+
|
|
39
|
+
All three unit allowances are evaluated atomically across runtime replicas before
|
|
40
|
+
callback dispatch. `caller` is scoped to issuer/tenant/subject, `tenant` is scoped
|
|
41
|
+
to issuer/tenant, and `tool` is shared within this property's rule. Zero/omitted
|
|
42
|
+
limits disable that dimension; at least one positive limit is required. Distinct
|
|
43
|
+
tools need distinct rule IDs. Reconnecting or creating a new MCP session does not
|
|
44
|
+
reset the identity allowance. Invocation quotas count attempts separately; model
|
|
45
|
+
budgets count tokens separately. These units never become a WebDecoy billing meter.
|
|
46
|
+
|
|
47
|
+
Existing `concurrency.accountLimit` is per caller; `tenantConcurrency.accountLimit`
|
|
48
|
+
is per tenant. Each `featureLimit` is property/rule-wide. Admission denial releases
|
|
49
|
+
already acquired slots; unconfirmed work keeps its slots until lease expiry.
|
|
50
|
+
Lease expiry requests cooperative cancellation, not proof a remote operation stopped.
|
|
51
|
+
|
|
52
|
+
## Enforce resource bounds in the application
|
|
53
|
+
|
|
54
|
+
`maxUnits` must be a conservative upper bound you actually enforce. The SDK provides
|
|
55
|
+
`context.work.maxUnits` to the callback, but cannot constrain arbitrary database or
|
|
56
|
+
storage work. Validate requested bounds before dispatch, apply tenant predicates and
|
|
57
|
+
row limits at the data source, and check byte lengths **before** appending/sending
|
|
58
|
+
export payload. Do not fetch an unbounded result and trim it afterward.
|
|
59
|
+
|
|
60
|
+
The [bounded search/export example](examples/mcp/work-tools.mjs) implements:
|
|
61
|
+
|
|
62
|
+
- Search: at most five rows, weight `1 + 2 × returned rows`, reserve 11 units.
|
|
63
|
+
- Export: at most 256 UTF-8 payload bytes, weight `16 + payload bytes`, reserve 272.
|
|
64
|
+
Transport framing/JSON encoding are not included in that payload-byte allowance.
|
|
65
|
+
- Forbidden admin: application authorization denies before any callback.
|
|
66
|
+
|
|
67
|
+
The synthetic source contains fixed small records. A real data source must enforce
|
|
68
|
+
its own database scan, CPU/time and storage-read bounds too; a row limit is not a
|
|
69
|
+
claim that a query scans only those rows. The separate weights use separate rules,
|
|
70
|
+
not interchangeable token or monetary estimates.
|
|
71
|
+
|
|
72
|
+
Run `node examples/mcp/start-work.mjs` after configuring the existing Auth0 example
|
|
73
|
+
variables and `WEBDECOY_URL`, `WEBDECOY_KEY`, `WEBDECOY_PROPERTY_ID`, and
|
|
74
|
+
`WEBDECOY_SUBJECT_SECRET`. The fixture binds to loopback and makes no model calls.
|
|
75
|
+
It requires an updated runtime. Replace its single-subject allowlist with current
|
|
76
|
+
application membership for an actual customer deployment.
|
|
77
|
+
|
|
78
|
+
## Settlement, failures and retries
|
|
79
|
+
|
|
80
|
+
After a completed callback, a synchronous `measure(result, context)` may report
|
|
81
|
+
confirmed integer usage between zero and `maxUnits`. Without it, the fixed maximum
|
|
82
|
+
weight is charged. Confirmed lower usage releases the difference in the original
|
|
83
|
+
admission window. Never measure from untrusted request claims. Errors, cancellation,
|
|
84
|
+
crashes, asynchronous/invalid measurement, excess usage and missing/failed settlement
|
|
85
|
+
keep the conservative maximum charged. Successful results survive reporting or
|
|
86
|
+
settlement failure and expose unknown accounting evidence.
|
|
87
|
+
|
|
88
|
+
Unknown work is never refunded just because a lease or settlement deadline expires.
|
|
89
|
+
A hard ceiling depends on application-enforced maximum work, enforce/closed settings,
|
|
90
|
+
and bounded completion. Observe/open can execute without a confirmed reservation and
|
|
91
|
+
must not claim a ceiling. Detector fail-open is separate from work-state failure.
|
|
92
|
+
|
|
93
|
+
A reservation has a server-generated timestamp/UUID operation ID. For recovery across
|
|
94
|
+
application retries, persist an ID from `createQuotaOperationId` (exported from
|
|
95
|
+
`@webdecoy/ai-protection/fetch`) in trusted application state, and provide it through
|
|
96
|
+
`work.operationId(context)`. Do not accept an arbitrary client ID or use MCP message,
|
|
97
|
+
agent or session IDs as this key. Reuse requires the same caller/tenant/tool, bounds,
|
|
98
|
+
policy and argument binding. Conflicts deny even in open mode.
|
|
99
|
+
|
|
100
|
+
The SDK makes no automatic work-RPC or callback retries. An identical reserve replay
|
|
101
|
+
returns accounting evidence but **never grants another dispatch**; the SDK returns
|
|
102
|
+
`work_replay` (409). Lost admission replies may therefore consume capacity without
|
|
103
|
+
executing work. A changed retry returns `work_conflict` (409). Return stored business
|
|
104
|
+
results through your own idempotency layer when appropriate. Never mint a new ID to
|
|
105
|
+
retry an unknown write blindly. Application/provider idempotency remains necessary;
|
|
106
|
+
this is not an exactly-once side-effect guarantee. Identical settlement is idempotent;
|
|
107
|
+
conflicting or over-bound settlement is rejected without lowering the charge.
|
|
108
|
+
|
|
109
|
+
## Windows and evidence
|
|
110
|
+
|
|
111
|
+
- Fixed UTC-aligned windows: 1–86,400 seconds. Boundary bursts are possible; use
|
|
112
|
+
concurrency controls separately. Limits/maxima: integer units up to 10^12.
|
|
113
|
+
- IDs: ten-minute admission lifetime, at most 30 seconds future skew; expired IDs
|
|
114
|
+
remain invalid even after receipts are purged. Settlement deadline: one hour.
|
|
115
|
+
- Receipts retained through the window end plus 24 hours. Unknown charge persists
|
|
116
|
+
for its admission window; this is not a lifetime allowance or infinite ledger.
|
|
117
|
+
- At most 32 work rules and 10,000 retained operations per property, including denials.
|
|
118
|
+
Capacity failures follow state-failure policy. Existing replay works at capacity.
|
|
119
|
+
These are operational bounds, not commercial plan changes.
|
|
120
|
+
- Reports show reserved/charged units, remaining allowance at admission and
|
|
121
|
+
reserved/settled/unknown/unavailable/denied/replay status. Remaining is a snapshot,
|
|
122
|
+
not current balance. Delivery is best effort; missing reports do not imply no work.
|
|
123
|
+
Local callback completion is not independent proof of a remote side effect.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {prepareWork} from './work.mjs';
|
|
2
|
+
import {prepareQuota,quotaHash} from './quota.mjs';
|
|
3
|
+
import {prepareConcurrency} from './concurrency.mjs';
|
|
4
|
+
import {validPropertyID} from './account.mjs';
|
|
5
|
+
import {createReporter} from './reporting.mjs';
|
|
6
|
+
|
|
7
|
+
export function prepareActionRuntime(options, definitions) {
|
|
8
|
+
const config=options.sharedRuntime;
|
|
9
|
+
if(!config){if([...definitions.values()].some(d=>d.limits))throw Error('Action limits require sharedRuntime');return null;}
|
|
10
|
+
const url=new URL(config.webdecoyUrl);
|
|
11
|
+
if(!['https:','http:'].includes(url.protocol)||url.username||url.password||url.pathname!=='/'||url.search||url.hash||
|
|
12
|
+
(url.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(url.hostname))||!validPropertyID(config.propertyId)||
|
|
13
|
+
typeof config.webdecoyKey!=='string'||!config.webdecoyKey||/[^\x21-\x7e]/.test(config.webdecoyKey)||
|
|
14
|
+
typeof config.subjectSecret!=='string'||!config.subjectSecret.isWellFormed()||Buffer.byteLength(config.subjectSecret)<32)throw Error('Invalid action runtime');
|
|
15
|
+
if(config.reportCaller !== undefined && typeof config.reportCaller !== "boolean")throw Error("Invalid caller reporting option");
|
|
16
|
+
const c={...config};const limits=new Map(),ruleIDs=new Set();
|
|
17
|
+
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
|
+
for(const [name,d] of definitions){
|
|
19
|
+
const l=d.limits??{},gates=[];
|
|
20
|
+
for(const [key,tenant]of [['callerQuota',false],['tenantQuota',true]])if(l[key]){
|
|
21
|
+
const q=l[key];if(ruleIDs.has(q.ruleId))throw Error('Action limits require distinct rule IDs');ruleIDs.add(q.ruleId);
|
|
22
|
+
const gate=prepareQuota({...c,accountQuota:{...q,idempotency:false,operationId:undefined,sessionLimit:0,subject:ctx=>subject(ctx,tenant)}});
|
|
23
|
+
gates.push(async(ctx,signal)=>{const r=await gate(ctx,signal);if(r.check)r.check.id=tenant?'tenant_quota':'caller_quota';return r;});
|
|
24
|
+
}
|
|
25
|
+
const concurrencies=[];
|
|
26
|
+
for(const [key,tenant] of [['concurrency',false],['tenantConcurrency',true]])if(l[key]){
|
|
27
|
+
const option=l[key];if(ruleIDs.has(option.ruleId))throw Error('Action limits require distinct rule IDs');ruleIDs.add(option.ruleId);
|
|
28
|
+
const gate=prepareConcurrency({...c,concurrency:{...option,subject:ctx=>subject(ctx,tenant)}});
|
|
29
|
+
concurrencies.push(async(ctx,signal)=>{const r=await gate(ctx,signal);if(tenant)r.check.id='tenant_concurrency';return r;});
|
|
30
|
+
}
|
|
31
|
+
let work=null;
|
|
32
|
+
if(l.work){if(ruleIDs.has(l.work.ruleId))throw Error('Action limits require distinct rule IDs');ruleIDs.add(l.work.ruleId);work=prepareWork(c,l.work,name,options.policyVersion);}
|
|
33
|
+
limits.set(name,{gates,concurrencies,work});
|
|
34
|
+
}
|
|
35
|
+
const reporter=createReporter({reportingTimeoutMs:c.reportingTimeoutMs??1000,maxPendingReports:c.maxPendingReports??100,
|
|
36
|
+
onObservation:async(event,{signal})=>{
|
|
37
|
+
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}))];
|
|
39
|
+
const payload={schema:2,request_id:event.eventId,timestamp:event.timestamp,decision:event.decision,reason:event.reason,
|
|
40
|
+
degraded:event.checks.some(c=>c.decision==='unavailable'),checks,handler_attempted:event.attempted,
|
|
41
|
+
action:event.decision==='deny'?'denied':event.outcome==='unknown'?'handler_error':'forwarded',
|
|
42
|
+
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.effect?{effect:event.toolSchema.effect}:{}),...(event.toolSchema.permissions?{permissions:event.toolSchema.permissions}:{})}}:{}),...(event.caller?{caller:event.caller}:{}),...(event.work?{work:event.work}:{})}};
|
|
43
|
+
const response=await fetch(new URL('/api/v1/sdk/ai-abuse/reports',url),{method:'POST',redirect:'error',signal,
|
|
44
|
+
headers:{Authorization:`Bearer ${c.webdecoyKey}`,'X-WebDecoy-Property-ID':c.propertyId,'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
45
|
+
await response.body?.cancel();if(!response.ok)throw Error('Action reporting unavailable');
|
|
46
|
+
}});
|
|
47
|
+
return {limits,callerEvidence:caller=>c.reportCaller?Object.freeze({schema:1,source:'application_auth',id:quotaHash(c.subjectSecret,'webdecoy.actions.evidence.caller.v1',c.propertyId.toLowerCase(),caller.issuer,caller.tenant,caller.subject)}):undefined,report:event=>reporter.send(event),flush:()=>reporter.flush()};
|
|
48
|
+
}
|