@webdecoy/ai-protection 0.1.0-alpha.5 → 0.1.0-alpha.6

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/README.md CHANGED
@@ -334,3 +334,29 @@ action events are documented in the action guide and use the hosted AI Protectio
334
334
  Node alpha.5 adds weighted tool-work reservations and tenant concurrency. See
335
335
  [bounded tool work](WORK.md) for installation, enforced application bounds and
336
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.
@@ -11,7 +11,8 @@ export function prepareActionRuntime(options, definitions) {
11
11
  if(!['https:','http:'].includes(url.protocol)||url.username||url.password||url.pathname!=='/'||url.search||url.hash||
12
12
  (url.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(url.hostname))||!validPropertyID(config.propertyId)||
13
13
  typeof config.webdecoyKey!=='string'||!config.webdecoyKey||/[^\x21-\x7e]/.test(config.webdecoyKey)||
14
- typeof config.subjectSecret!=='string'||Buffer.byteLength(config.subjectSecret)<32)throw Error('Invalid action runtime');
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");
15
16
  const c={...config};const limits=new Map(),ruleIDs=new Set();
16
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)});
17
18
  for(const [name,d] of definitions){
@@ -38,10 +39,10 @@ export function prepareActionRuntime(options, definitions) {
38
39
  const payload={schema:2,request_id:event.eventId,timestamp:event.timestamp,decision:event.decision,reason:event.reason,
39
40
  degraded:event.checks.some(c=>c.decision==='unavailable'),checks,handler_attempted:event.attempted,
40
41
  action:event.decision==='deny'?'denied':event.outcome==='unknown'?'handler_error':'forwarded',
41
- tool_action:{action_id:event.actionId,name:event.action,policy_version:event.policyVersion,outcome:event.outcome,...(event.work?{work:event.work}:{})}};
42
+ tool_action:{action_id:event.actionId,name:event.action,policy_version:event.policyVersion,outcome:event.outcome,...(event.caller?{caller:event.caller}:{}),...(event.work?{work:event.work}:{})}};
42
43
  const response=await fetch(new URL('/api/v1/sdk/ai-abuse/reports',url),{method:'POST',redirect:'error',signal,
43
44
  headers:{Authorization:`Bearer ${c.webdecoyKey}`,'X-WebDecoy-Property-ID':c.propertyId,'Content-Type':'application/json'},body:JSON.stringify(payload)});
44
45
  await response.body?.cancel();if(!response.ok)throw Error('Action reporting unavailable');
45
46
  }});
46
- return {limits,report:event=>reporter.send(event),flush:()=>reporter.flush()};
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()};
47
48
  }
package/actions.d.mts CHANGED
@@ -33,6 +33,8 @@ export interface ActionWork {
33
33
  measure?(result:unknown,context:ActionContext):number;
34
34
  }
35
35
  export interface ActionEvent {
36
+ /** Pseudonymous application-authenticated subject, not WebDecoy-verified agent identity. */
37
+ readonly caller?: {readonly schema:1;readonly source:'application_auth';readonly id:string};
36
38
  readonly work?:ActionWorkEvidence;
37
39
  readonly schema: 1;
38
40
  readonly eventId: string;
@@ -56,6 +58,8 @@ export interface ActionLimits {
56
58
  concurrency?: {ruleId:string;accountLimit:number;featureLimit:number;mode?:'observe'|'enforce';failureMode?:'open'|'closed';ttlSeconds?:number;maxSeconds?:number;timeoutMs?:number};
57
59
  }
58
60
  export interface ActionRuntime {
61
+ /** Opt in to scoped caller pseudonyms in reports. Requires a supporting runtime. Default false. */
62
+ reportCaller?:boolean;
59
63
  webdecoyUrl:string;webdecoyKey:string;propertyId:string;subjectSecret:string;
60
64
  reportingTimeoutMs?:number;maxPendingReports?:number;
61
65
  }
package/actions.mjs CHANGED
@@ -3,7 +3,7 @@ import {abortable} from './transport.mjs';
3
3
  import {prepareActionRuntime} from './action-runtime.mjs';
4
4
 
5
5
  const token = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,95}$/;
6
- const bounded = value => typeof value === 'string' && value.length > 0 && value.length <= 512 && !/[\x00-\x1f\x7f]/.test(value);
6
+ const bounded = value => typeof value === 'string' && value.isWellFormed() && value.length > 0 && value.length <= 512 && !/[\x00-\x1f\x7f]/.test(value);
7
7
  const freeze = value => {
8
8
  if (value && typeof value === 'object') { for (const child of Object.values(value)) freeze(child); Object.freeze(value); }
9
9
  return value;
@@ -81,7 +81,7 @@ export function createActionProtection(options) {
81
81
  const actionId = randomUUID();
82
82
  // Unknown caller-controlled action strings are never placed in evidence.
83
83
  const action = actions.get(name), eventAction = action ? name : 'unregistered';
84
- let attempted = false,completed=false,lease,work;
84
+ let attempted = false,completed=false,lease,work,callerEvidence;
85
85
  const leases=[];
86
86
  const checks=[];
87
87
  const deadline = new AbortController();
@@ -91,7 +91,7 @@ export function createActionProtection(options) {
91
91
  function emit(decision, reason, outcome) {
92
92
 
93
93
  const event = Object.freeze({schema:1, eventId:randomUUID(), timestamp:new Date().toISOString(), actionId, action:eventAction, policyVersion,
94
- evaluation:'local', decision, reason, attempted, outcome,...(work?{work:Object.freeze({...work.evidence})}:{}),checks:Object.freeze(checks.map(c=>Object.freeze({...c})))});
94
+ ...(callerEvidence?{caller:callerEvidence}:{}),evaluation:'local', decision, reason, attempted, outcome,...(work?{work:Object.freeze({...work.evidence})}:{}),checks:Object.freeze(checks.map(c=>Object.freeze({...c})))});
95
95
  if(runtime)void runtime.report(event);
96
96
  if(!sink||pendingEvents>=100)return;
97
97
  pendingEvents++;
@@ -107,6 +107,7 @@ export function createActionProtection(options) {
107
107
  let caller;
108
108
  try { caller = callerSnapshot(await evaluate(() => authenticate(authenticationContext, {signal:admissionSignal}))); }
109
109
  catch { cancelled(); deny('authentication_required',401); }
110
+ callerEvidence=runtime?.callerEvidence(caller);
110
111
  cancelled();
111
112
  if (action.requiredScopes.some(scope => !caller.scopes.includes(scope))) deny('missing_scope',403);
112
113
  const context = Object.freeze({caller, args, signal:admissionSignal});
package/package.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "check:package": "node scripts/check-package.mjs",
11
11
  "test:types": "tsc --strict --noEmit --module nodenext --target es2022 test/types.mts test/mcp-types.mts"
12
12
  },
13
- "version": "0.1.0-alpha.5",
13
+ "version": "0.1.0-alpha.6",
14
14
  "exports": {
15
15
  "./fetch": {
16
16
  "types": "./fetch.d.mts",