@dinpd/ai-agent-guard 0.1.0
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 +234 -0
- package/dist/index.d.ts +214 -0
- package/dist/index.js +443 -0
- package/examples/circuit-breaker-demo.ts +138 -0
- package/examples/mcp-tool-call-demo.ts +155 -0
- package/examples/pii-exfiltration-demo.ts +137 -0
- package/examples/quickstart-agent-loop.ts +108 -0
- package/examples/support-refund-demo.ts +119 -0
- package/examples/support-refund-policy.json +110 -0
- package/examples/tool-gate-demo.ts +95 -0
- package/package.json +65 -0
- package/policies/mcp-tool-gateway.json +50 -0
- package/policies/pii-egress.json +75 -0
- package/policies/refund-payment.json +26 -0
- package/policies/shell-browser-guard.json +55 -0
- package/policies/tool-spend-cap.json +24 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
const DEFAULT_SENSITIVE_CLASSIFICATIONS = [
|
|
2
|
+
"pii",
|
|
3
|
+
"phi",
|
|
4
|
+
"payment",
|
|
5
|
+
"secret",
|
|
6
|
+
"regulated",
|
|
7
|
+
"customer_data",
|
|
8
|
+
];
|
|
9
|
+
const DEFAULT_SENSITIVE_DESTINATIONS = [
|
|
10
|
+
"external_email",
|
|
11
|
+
"webhook",
|
|
12
|
+
"third_party_saas",
|
|
13
|
+
"file_export",
|
|
14
|
+
"model_provider",
|
|
15
|
+
"browser_form",
|
|
16
|
+
];
|
|
17
|
+
export class AgentPassGuard {
|
|
18
|
+
policy;
|
|
19
|
+
now;
|
|
20
|
+
idGenerator;
|
|
21
|
+
usedIdempotencyKeys = new Set();
|
|
22
|
+
usageByJob = new Map();
|
|
23
|
+
constructor(options) {
|
|
24
|
+
this.policy = options.policy;
|
|
25
|
+
this.now = options.now || (() => new Date());
|
|
26
|
+
this.idGenerator = options.idGenerator || randomDecisionId;
|
|
27
|
+
}
|
|
28
|
+
check(input) {
|
|
29
|
+
const reasons = [];
|
|
30
|
+
const challengeFor = new Set();
|
|
31
|
+
const toolPolicy = this.policy.tools?.[input.tool];
|
|
32
|
+
if (!toolPolicy) {
|
|
33
|
+
reasons.push(`tool is not declared: ${input.tool}`);
|
|
34
|
+
return this.decision("deny", input, reasons);
|
|
35
|
+
}
|
|
36
|
+
this.evaluateToolPolicy(input, toolPolicy, reasons, challengeFor);
|
|
37
|
+
this.evaluateFlowPolicy(input, toolPolicy, reasons, challengeFor);
|
|
38
|
+
this.evaluateBudgetPolicy(input, reasons, challengeFor);
|
|
39
|
+
if (reasons.length > 0)
|
|
40
|
+
return this.decision("deny", input, reasons);
|
|
41
|
+
if (challengeFor.size > 0) {
|
|
42
|
+
return this.decision("challenge_required", input, ["approval is required"], challengeFor);
|
|
43
|
+
}
|
|
44
|
+
this.commit(input, toolPolicy);
|
|
45
|
+
return this.decision("allow", input, []);
|
|
46
|
+
}
|
|
47
|
+
reset() {
|
|
48
|
+
this.usedIdempotencyKeys.clear();
|
|
49
|
+
this.usageByJob.clear();
|
|
50
|
+
}
|
|
51
|
+
evaluateToolPolicy(input, policy, reasons, challengeFor) {
|
|
52
|
+
if (policy.action && policy.action !== input.action) {
|
|
53
|
+
reasons.push(`tool action mismatch: expected ${policy.action}, got ${input.action}`);
|
|
54
|
+
}
|
|
55
|
+
if (policy.maxAmountUsd !== undefined && input.amountUsd !== undefined && input.amountUsd > policy.maxAmountUsd) {
|
|
56
|
+
reasons.push(`amount exceeds maxAmountUsd ${policy.maxAmountUsd}`);
|
|
57
|
+
}
|
|
58
|
+
if (policy.requireIdempotencyKey && !input.idempotencyKey) {
|
|
59
|
+
reasons.push("idempotencyKey is required");
|
|
60
|
+
}
|
|
61
|
+
if (policy.singleUse && input.idempotencyKey && this.usedIdempotencyKeys.has(input.idempotencyKey)) {
|
|
62
|
+
reasons.push("idempotencyKey was already used");
|
|
63
|
+
}
|
|
64
|
+
if (policy.allowedDomains && input.externalDomain && !matchesAnyDomain(input.externalDomain, policy.allowedDomains)) {
|
|
65
|
+
reasons.push(`externalDomain is not allowed: ${input.externalDomain}`);
|
|
66
|
+
}
|
|
67
|
+
for (const field of presentBlockedFields(input.fieldSet, policy.blockedFields)) {
|
|
68
|
+
reasons.push(`field is blocked: ${field}`);
|
|
69
|
+
}
|
|
70
|
+
for (const field of absentAllowedFields(input.fieldSet, policy.allowedFields)) {
|
|
71
|
+
reasons.push(`field is not allowed: ${field}`);
|
|
72
|
+
}
|
|
73
|
+
if (policy.requiresApproval && !input.approvalId) {
|
|
74
|
+
challengeFor.add("tool");
|
|
75
|
+
}
|
|
76
|
+
if (policy.requiresApprovalIfPii && hasSensitiveData(input, this.policy) && !input.approvalId) {
|
|
77
|
+
challengeFor.add("pii");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
evaluateFlowPolicy(input, toolPolicy, reasons, challengeFor) {
|
|
81
|
+
if (!input.dataFrom || !input.dataTo) {
|
|
82
|
+
if (hasSensitiveData(input, this.policy) && isSensitiveDestination(input, this.policy)) {
|
|
83
|
+
reasons.push("dataFrom and dataTo are required for sensitive data movement");
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const flow = this.policy.flows?.find((candidate) => matchesFlow(candidate, input));
|
|
88
|
+
if (flow) {
|
|
89
|
+
if (flow.decision === "deny")
|
|
90
|
+
reasons.push(`flow is denied: ${input.dataFrom} -> ${input.dataTo}`);
|
|
91
|
+
if (flow.maxRecords !== undefined && input.recordCount !== undefined && input.recordCount > flow.maxRecords) {
|
|
92
|
+
reasons.push(`recordCount exceeds maxRecords ${flow.maxRecords}`);
|
|
93
|
+
}
|
|
94
|
+
if (flow.allowedDomains && input.externalDomain && !matchesAnyDomain(input.externalDomain, flow.allowedDomains)) {
|
|
95
|
+
reasons.push(`externalDomain is not allowed for flow: ${input.externalDomain}`);
|
|
96
|
+
}
|
|
97
|
+
for (const field of presentBlockedFields(input.fieldSet, flow.blockedFields)) {
|
|
98
|
+
reasons.push(`field is blocked by flow: ${field}`);
|
|
99
|
+
}
|
|
100
|
+
for (const field of absentAllowedFields(input.fieldSet, flow.allowedFields)) {
|
|
101
|
+
reasons.push(`field is not allowed by flow: ${field}`);
|
|
102
|
+
}
|
|
103
|
+
if (flow.requiresApproval && !input.approvalId)
|
|
104
|
+
challengeFor.add("flow");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (hasSensitiveData(input, this.policy) && isSensitiveDestination(input, this.policy)) {
|
|
108
|
+
const defaultDecision = this.policy.defaultSensitiveDestinationDecision || "deny";
|
|
109
|
+
if (defaultDecision === "deny") {
|
|
110
|
+
reasons.push(`sensitive data movement has no allowed flow: ${input.dataFrom} -> ${input.dataTo}`);
|
|
111
|
+
}
|
|
112
|
+
else if (defaultDecision === "challenge_required" && !input.approvalId) {
|
|
113
|
+
challengeFor.add("pii");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (toolPolicy.requiresApprovalIfPii && hasSensitiveData(input, this.policy) && !input.approvalId) {
|
|
117
|
+
challengeFor.add("pii");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
evaluateBudgetPolicy(input, reasons, challengeFor) {
|
|
121
|
+
const budget = this.policy.budgets;
|
|
122
|
+
if (!budget || !input.jobId)
|
|
123
|
+
return;
|
|
124
|
+
const nowMs = this.now().getTime();
|
|
125
|
+
const usage = this.usageByJob.get(input.jobId) || newJobUsage(nowMs);
|
|
126
|
+
const nextToolCalls = usage.toolCalls + 1;
|
|
127
|
+
const nextSameToolCalls = (usage.toolCallsByName.get(input.tool) || 0) + 1;
|
|
128
|
+
const nextTokens = usage.tokens + (input.estimatedTokens || 0);
|
|
129
|
+
const nextCost = usage.estimatedCostUsd + (input.estimatedCostUsd || 0);
|
|
130
|
+
const nextRuntimeMs = nowMs - usage.startedAtMs;
|
|
131
|
+
const attemptKey = attemptKeyFor(input);
|
|
132
|
+
const attempts = (usage.toolAttempts.get(attemptKey) || 0) + 1;
|
|
133
|
+
if (!input.approvalId) {
|
|
134
|
+
if (budget.challengeAfterToolCallsPerJob !== undefined &&
|
|
135
|
+
nextToolCalls > budget.challengeAfterToolCallsPerJob) {
|
|
136
|
+
challengeFor.add("budget");
|
|
137
|
+
}
|
|
138
|
+
if (budget.challengeAfterTokensPerJob !== undefined && nextTokens > budget.challengeAfterTokensPerJob) {
|
|
139
|
+
challengeFor.add("budget");
|
|
140
|
+
}
|
|
141
|
+
if (budget.challengeAfterEstimatedCostUsdPerJob !== undefined &&
|
|
142
|
+
nextCost > budget.challengeAfterEstimatedCostUsdPerJob) {
|
|
143
|
+
challengeFor.add("budget");
|
|
144
|
+
}
|
|
145
|
+
if (budget.challengeAfterRuntimeMsPerJob !== undefined && nextRuntimeMs > budget.challengeAfterRuntimeMsPerJob) {
|
|
146
|
+
challengeFor.add("budget");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (budget.maxToolCallsPerJob !== undefined && nextToolCalls > budget.maxToolCallsPerJob) {
|
|
150
|
+
reasons.push(`job exceeds maxToolCallsPerJob ${budget.maxToolCallsPerJob}`);
|
|
151
|
+
}
|
|
152
|
+
if (budget.maxSameToolCallsPerJob !== undefined && nextSameToolCalls > budget.maxSameToolCallsPerJob) {
|
|
153
|
+
reasons.push(`job exceeds maxSameToolCallsPerJob ${budget.maxSameToolCallsPerJob}`);
|
|
154
|
+
}
|
|
155
|
+
if (budget.maxIdenticalToolCallsPerJob !== undefined && attempts > budget.maxIdenticalToolCallsPerJob) {
|
|
156
|
+
reasons.push(`job exceeds maxIdenticalToolCallsPerJob ${budget.maxIdenticalToolCallsPerJob}`);
|
|
157
|
+
}
|
|
158
|
+
if (budget.maxTokensPerJob !== undefined && nextTokens > budget.maxTokensPerJob) {
|
|
159
|
+
reasons.push(`job exceeds maxTokensPerJob ${budget.maxTokensPerJob}`);
|
|
160
|
+
}
|
|
161
|
+
if (budget.maxEstimatedCostUsdPerJob !== undefined && nextCost > budget.maxEstimatedCostUsdPerJob) {
|
|
162
|
+
reasons.push(`job exceeds maxEstimatedCostUsdPerJob ${budget.maxEstimatedCostUsdPerJob}`);
|
|
163
|
+
}
|
|
164
|
+
if (budget.maxRuntimeMsPerJob !== undefined && nextRuntimeMs > budget.maxRuntimeMsPerJob) {
|
|
165
|
+
reasons.push(`job exceeds maxRuntimeMsPerJob ${budget.maxRuntimeMsPerJob}`);
|
|
166
|
+
}
|
|
167
|
+
if (budget.maxRetriesPerTool !== undefined && attempts > budget.maxRetriesPerTool + 1) {
|
|
168
|
+
reasons.push(`tool exceeds maxRetriesPerTool ${budget.maxRetriesPerTool}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
commit(input, toolPolicy) {
|
|
172
|
+
if (toolPolicy.singleUse && input.idempotencyKey) {
|
|
173
|
+
this.usedIdempotencyKeys.add(input.idempotencyKey);
|
|
174
|
+
}
|
|
175
|
+
if (!input.jobId)
|
|
176
|
+
return;
|
|
177
|
+
const usage = this.usageByJob.get(input.jobId) || newJobUsage(this.now().getTime());
|
|
178
|
+
usage.toolCalls += 1;
|
|
179
|
+
usage.tokens += input.estimatedTokens || 0;
|
|
180
|
+
usage.estimatedCostUsd += input.estimatedCostUsd || 0;
|
|
181
|
+
const attemptKey = attemptKeyFor(input);
|
|
182
|
+
usage.toolAttempts.set(attemptKey, (usage.toolAttempts.get(attemptKey) || 0) + 1);
|
|
183
|
+
usage.toolCallsByName.set(input.tool, (usage.toolCallsByName.get(input.tool) || 0) + 1);
|
|
184
|
+
this.usageByJob.set(input.jobId, usage);
|
|
185
|
+
}
|
|
186
|
+
decision(type, input, reasons, challengeFor = new Set()) {
|
|
187
|
+
const uniqueReasons = [...new Set(reasons)];
|
|
188
|
+
const event = this.event(type, input, uniqueReasons);
|
|
189
|
+
return {
|
|
190
|
+
type,
|
|
191
|
+
allow: type === "allow",
|
|
192
|
+
challengeRequired: type === "challenge_required",
|
|
193
|
+
reasons: uniqueReasons,
|
|
194
|
+
challenge: type === "challenge_required"
|
|
195
|
+
? {
|
|
196
|
+
reason: uniqueReasons[0] || "approval is required",
|
|
197
|
+
requiredApprovalFor: [...challengeFor],
|
|
198
|
+
tool: input.tool,
|
|
199
|
+
action: input.action,
|
|
200
|
+
resource: input.resource,
|
|
201
|
+
amountUsd: input.amountUsd,
|
|
202
|
+
dataFrom: input.dataFrom,
|
|
203
|
+
dataTo: input.dataTo,
|
|
204
|
+
externalDomain: input.externalDomain,
|
|
205
|
+
}
|
|
206
|
+
: undefined,
|
|
207
|
+
event,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
event(type, input, reasons) {
|
|
211
|
+
return {
|
|
212
|
+
decisionId: this.idGenerator(),
|
|
213
|
+
decision: type,
|
|
214
|
+
allowed: type === "allow",
|
|
215
|
+
reasons,
|
|
216
|
+
agentId: input.agentId,
|
|
217
|
+
tool: input.tool,
|
|
218
|
+
action: input.action,
|
|
219
|
+
jobId: input.jobId,
|
|
220
|
+
userId: input.userId,
|
|
221
|
+
resource: input.resource,
|
|
222
|
+
callFingerprint: input.callFingerprint,
|
|
223
|
+
amountUsd: input.amountUsd,
|
|
224
|
+
idempotencyKey: input.idempotencyKey,
|
|
225
|
+
approvalId: input.approvalId,
|
|
226
|
+
dataFrom: input.dataFrom,
|
|
227
|
+
dataTo: input.dataTo,
|
|
228
|
+
destinationType: input.destinationType,
|
|
229
|
+
externalDomain: input.externalDomain,
|
|
230
|
+
dataClassification: input.dataClassification || [],
|
|
231
|
+
fieldSet: input.fieldSet || [],
|
|
232
|
+
recordCount: input.recordCount,
|
|
233
|
+
estimatedTokens: input.estimatedTokens,
|
|
234
|
+
estimatedCostUsd: input.estimatedCostUsd,
|
|
235
|
+
issuedAt: this.now().toISOString(),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
export function createGuard(options) {
|
|
240
|
+
return new AgentPassGuard(options);
|
|
241
|
+
}
|
|
242
|
+
export class AgentPassToolGate {
|
|
243
|
+
guard;
|
|
244
|
+
constructor(options) {
|
|
245
|
+
this.guard = "guard" in options ? options.guard : new AgentPassGuard(options);
|
|
246
|
+
}
|
|
247
|
+
check(input) {
|
|
248
|
+
return this.guard.check(input);
|
|
249
|
+
}
|
|
250
|
+
async run(input, execute) {
|
|
251
|
+
const decision = this.guard.check(input);
|
|
252
|
+
if (!decision.allow) {
|
|
253
|
+
return {
|
|
254
|
+
executed: false,
|
|
255
|
+
decision,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
const result = await execute({ check: input, decision });
|
|
259
|
+
return {
|
|
260
|
+
executed: true,
|
|
261
|
+
decision,
|
|
262
|
+
result,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
reset() {
|
|
266
|
+
this.guard.reset();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
export function createToolGate(options) {
|
|
270
|
+
return new AgentPassToolGate(options);
|
|
271
|
+
}
|
|
272
|
+
export class AgentPassMcpToolGate {
|
|
273
|
+
gate;
|
|
274
|
+
mappings;
|
|
275
|
+
defaultAction;
|
|
276
|
+
constructor(options) {
|
|
277
|
+
this.gate = "guard" in options ? new AgentPassToolGate({ guard: options.guard }) : new AgentPassToolGate(options);
|
|
278
|
+
this.mappings = options.mappings || {};
|
|
279
|
+
this.defaultAction = options.defaultAction || "read";
|
|
280
|
+
}
|
|
281
|
+
check(callOrRequest, context) {
|
|
282
|
+
return this.gate.check(this.toGuardCheck(callOrRequest, context));
|
|
283
|
+
}
|
|
284
|
+
async run(callOrRequest, context, execute) {
|
|
285
|
+
const call = normalizeMcpToolCall(callOrRequest);
|
|
286
|
+
const check = this.toGuardCheck(call, context);
|
|
287
|
+
return this.gate.run(check, ({ decision }) => execute({
|
|
288
|
+
check,
|
|
289
|
+
decision,
|
|
290
|
+
call,
|
|
291
|
+
arguments: call.arguments || {},
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
toGuardCheck(callOrRequest, context) {
|
|
295
|
+
return mcpToolCallToGuardCheck(callOrRequest, context, {
|
|
296
|
+
mappings: this.mappings,
|
|
297
|
+
defaultAction: this.defaultAction,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
reset() {
|
|
301
|
+
this.gate.reset();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
export function createMcpToolGate(options) {
|
|
305
|
+
return new AgentPassMcpToolGate(options);
|
|
306
|
+
}
|
|
307
|
+
export function mcpToolCallToGuardCheck(callOrRequest, context, options = {}) {
|
|
308
|
+
const call = normalizeMcpToolCall(callOrRequest);
|
|
309
|
+
const args = call.arguments || {};
|
|
310
|
+
const mapping = options.mappings?.[call.name] || {};
|
|
311
|
+
const action = readMcpMappedValue(mapping.action, args, call, context) || inferMcpAction(call.name, options.defaultAction);
|
|
312
|
+
return {
|
|
313
|
+
agentId: context.agentId,
|
|
314
|
+
jobId: context.jobId,
|
|
315
|
+
userId: context.userId,
|
|
316
|
+
approvalId: context.approvalId,
|
|
317
|
+
retryCount: context.retryCount,
|
|
318
|
+
tool: call.name,
|
|
319
|
+
action,
|
|
320
|
+
resource: readMcpMappedValue(mapping.resource, args, call, context),
|
|
321
|
+
callFingerprint: readMcpMappedValue(mapping.callFingerprint, args, call, context) || `${call.name}:${stableStringify(args)}`,
|
|
322
|
+
amountUsd: readMcpMappedValue(mapping.amountUsd, args, call, context),
|
|
323
|
+
idempotencyKey: readMcpMappedValue(mapping.idempotencyKey, args, call, context),
|
|
324
|
+
dataFrom: readMcpMappedValue(mapping.dataFrom, args, call, context),
|
|
325
|
+
dataTo: readMcpMappedValue(mapping.dataTo, args, call, context),
|
|
326
|
+
destinationType: readMcpMappedValue(mapping.destinationType, args, call, context),
|
|
327
|
+
externalDomain: readMcpMappedValue(mapping.externalDomain, args, call, context),
|
|
328
|
+
dataClassification: readMcpMappedValue(mapping.dataClassification, args, call, context),
|
|
329
|
+
fieldSet: readMcpMappedValue(mapping.fieldSet, args, call, context),
|
|
330
|
+
recordCount: readMcpMappedValue(mapping.recordCount, args, call, context),
|
|
331
|
+
estimatedTokens: readMcpMappedValue(mapping.estimatedTokens, args, call, context),
|
|
332
|
+
estimatedCostUsd: readMcpMappedValue(mapping.estimatedCostUsd, args, call, context),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
function hasSensitiveData(input, policy) {
|
|
336
|
+
const sensitive = new Set((policy.sensitiveClassifications || DEFAULT_SENSITIVE_CLASSIFICATIONS).map(normalize));
|
|
337
|
+
return (input.dataClassification || []).some((classification) => sensitive.has(normalize(classification)));
|
|
338
|
+
}
|
|
339
|
+
function isSensitiveDestination(input, policy) {
|
|
340
|
+
const destinations = new Set((policy.sensitiveDestinationTypes || DEFAULT_SENSITIVE_DESTINATIONS).map(normalize));
|
|
341
|
+
return Boolean(input.destinationType && destinations.has(normalize(input.destinationType)));
|
|
342
|
+
}
|
|
343
|
+
function matchesFlow(flow, input) {
|
|
344
|
+
if (!matchesPattern(flow.from, input.dataFrom || ""))
|
|
345
|
+
return false;
|
|
346
|
+
if (!matchesPattern(flow.to, input.dataTo || ""))
|
|
347
|
+
return false;
|
|
348
|
+
if (flow.destinationType && !matchesPattern(flow.destinationType, input.destinationType || ""))
|
|
349
|
+
return false;
|
|
350
|
+
if (!flow.dataClassification || flow.dataClassification.length === 0)
|
|
351
|
+
return true;
|
|
352
|
+
const actual = new Set((input.dataClassification || []).map(normalize));
|
|
353
|
+
return flow.dataClassification.some((classification) => actual.has(normalize(classification)));
|
|
354
|
+
}
|
|
355
|
+
function matchesPattern(pattern, value) {
|
|
356
|
+
return pattern === "*" || normalize(pattern) === normalize(value);
|
|
357
|
+
}
|
|
358
|
+
function presentBlockedFields(fieldSet, blockedFields) {
|
|
359
|
+
if (!fieldSet || !blockedFields)
|
|
360
|
+
return [];
|
|
361
|
+
const blocked = new Set(blockedFields.map(normalize));
|
|
362
|
+
return fieldSet.filter((field) => blocked.has(normalize(field)));
|
|
363
|
+
}
|
|
364
|
+
function absentAllowedFields(fieldSet, allowedFields) {
|
|
365
|
+
if (!fieldSet || !allowedFields)
|
|
366
|
+
return [];
|
|
367
|
+
const allowed = new Set(allowedFields.map(normalize));
|
|
368
|
+
return fieldSet.filter((field) => !allowed.has(normalize(field)));
|
|
369
|
+
}
|
|
370
|
+
function matchesAnyDomain(domain, allowedDomains) {
|
|
371
|
+
const normalizedDomain = normalize(domain);
|
|
372
|
+
return allowedDomains.some((allowed) => {
|
|
373
|
+
const normalizedAllowed = normalize(allowed);
|
|
374
|
+
return normalizedDomain === normalizedAllowed || normalizedDomain.endsWith(`.${normalizedAllowed}`);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
function attemptKeyFor(input) {
|
|
378
|
+
return [input.tool, input.action, input.callFingerprint || input.resource || "", input.idempotencyKey || ""].join("|");
|
|
379
|
+
}
|
|
380
|
+
function newJobUsage(startedAtMs) {
|
|
381
|
+
return {
|
|
382
|
+
startedAtMs,
|
|
383
|
+
toolCalls: 0,
|
|
384
|
+
tokens: 0,
|
|
385
|
+
estimatedCostUsd: 0,
|
|
386
|
+
toolAttempts: new Map(),
|
|
387
|
+
toolCallsByName: new Map(),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function normalize(value) {
|
|
391
|
+
return value.trim().toLowerCase();
|
|
392
|
+
}
|
|
393
|
+
function randomDecisionId() {
|
|
394
|
+
return `dec_${Math.random().toString(36).slice(2, 12)}`;
|
|
395
|
+
}
|
|
396
|
+
function normalizeMcpToolCall(callOrRequest) {
|
|
397
|
+
return "params" in callOrRequest ? callOrRequest.params : callOrRequest;
|
|
398
|
+
}
|
|
399
|
+
function readMcpMappedValue(value, args, call, context) {
|
|
400
|
+
if (typeof value === "function") {
|
|
401
|
+
const mapper = value;
|
|
402
|
+
return mapper(args, call, context);
|
|
403
|
+
}
|
|
404
|
+
return value;
|
|
405
|
+
}
|
|
406
|
+
function inferMcpAction(toolName, defaultAction = "read") {
|
|
407
|
+
const normalized = normalize(toolName);
|
|
408
|
+
if (containsAny(normalized, ["refund", "credit", "charge", "payment", "pay", "transfer"]))
|
|
409
|
+
return "pay";
|
|
410
|
+
if (containsAny(normalized, ["send", "email", "message", "notify", "post"]))
|
|
411
|
+
return "send";
|
|
412
|
+
if (containsAny(normalized, ["delete", "remove", "destroy"]))
|
|
413
|
+
return "delete";
|
|
414
|
+
if (containsAny(normalized, ["deploy", "rollback", "release"]))
|
|
415
|
+
return "deploy";
|
|
416
|
+
if (containsAny(normalized, ["export", "download", "dump"]))
|
|
417
|
+
return "export";
|
|
418
|
+
if (containsAny(normalized, ["admin", "permission", "role", "iam", "secret", "shell", "exec"]))
|
|
419
|
+
return "admin";
|
|
420
|
+
if (containsAny(normalized, ["update", "write", "create", "insert", "patch", "set"]))
|
|
421
|
+
return "write";
|
|
422
|
+
if (containsAny(normalized, ["read", "get", "list", "search", "find", "lookup", "query"]))
|
|
423
|
+
return "read";
|
|
424
|
+
return defaultAction;
|
|
425
|
+
}
|
|
426
|
+
function containsAny(value, needles) {
|
|
427
|
+
return needles.some((needle) => value.includes(needle));
|
|
428
|
+
}
|
|
429
|
+
function stableStringify(value) {
|
|
430
|
+
return JSON.stringify(stableValue(value));
|
|
431
|
+
}
|
|
432
|
+
function stableValue(value) {
|
|
433
|
+
if (Array.isArray(value))
|
|
434
|
+
return value.map(stableValue);
|
|
435
|
+
if (!value || typeof value !== "object")
|
|
436
|
+
return value;
|
|
437
|
+
const input = value;
|
|
438
|
+
const output = {};
|
|
439
|
+
for (const key of Object.keys(input).sort()) {
|
|
440
|
+
output[key] = stableValue(input[key]);
|
|
441
|
+
}
|
|
442
|
+
return output;
|
|
443
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createToolGate, type GuardCheck, type GuardPolicy } from "../src/index.ts";
|
|
2
|
+
|
|
3
|
+
const repeatedCallGate = createToolGate({
|
|
4
|
+
policy: {
|
|
5
|
+
tools: {
|
|
6
|
+
"web.search": {
|
|
7
|
+
action: "read",
|
|
8
|
+
},
|
|
9
|
+
},
|
|
10
|
+
budgets: {
|
|
11
|
+
maxIdenticalToolCallsPerJob: 2,
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
now: () => new Date("2026-06-11T12:00:00Z"),
|
|
15
|
+
idGenerator: demoIds("repeat"),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const spendGate = createToolGate({
|
|
19
|
+
policy: spendPolicy(),
|
|
20
|
+
now: () => new Date("2026-06-11T12:00:00Z"),
|
|
21
|
+
idGenerator: demoIds("spend"),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await runSearch(repeatedCallGate, "1. Search executes", {
|
|
25
|
+
agentId: "research-agent",
|
|
26
|
+
jobId: "repeat-loop",
|
|
27
|
+
tool: "web.search",
|
|
28
|
+
action: "read",
|
|
29
|
+
resource: "query:best refund workflow",
|
|
30
|
+
callFingerprint: "web.search:best-refund-workflow",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await runSearch(repeatedCallGate, "2. Same search executes once more", {
|
|
34
|
+
agentId: "research-agent",
|
|
35
|
+
jobId: "repeat-loop",
|
|
36
|
+
tool: "web.search",
|
|
37
|
+
action: "read",
|
|
38
|
+
resource: "query:best refund workflow",
|
|
39
|
+
callFingerprint: "web.search:best-refund-workflow",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
await runSearch(repeatedCallGate, "3. Third identical search is denied", {
|
|
43
|
+
agentId: "research-agent",
|
|
44
|
+
jobId: "repeat-loop",
|
|
45
|
+
tool: "web.search",
|
|
46
|
+
action: "read",
|
|
47
|
+
resource: "query:best refund workflow",
|
|
48
|
+
callFingerprint: "web.search:best-refund-workflow",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await runSearch(spendGate, "4. First expensive search executes", {
|
|
52
|
+
agentId: "research-agent",
|
|
53
|
+
jobId: "spend-loop",
|
|
54
|
+
tool: "web.search",
|
|
55
|
+
action: "read",
|
|
56
|
+
resource: "query:agent framework survey",
|
|
57
|
+
estimatedTokens: 500,
|
|
58
|
+
estimatedCostUsd: 0.04,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await runSearch(spendGate, "5. Soft token threshold requires approval", {
|
|
62
|
+
agentId: "research-agent",
|
|
63
|
+
jobId: "spend-loop",
|
|
64
|
+
tool: "web.search",
|
|
65
|
+
action: "read",
|
|
66
|
+
resource: "query:agent framework survey details",
|
|
67
|
+
estimatedTokens: 400,
|
|
68
|
+
estimatedCostUsd: 0.03,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await runSearch(spendGate, "6. Approved search executes", {
|
|
72
|
+
agentId: "research-agent",
|
|
73
|
+
jobId: "spend-loop",
|
|
74
|
+
tool: "web.search",
|
|
75
|
+
action: "read",
|
|
76
|
+
resource: "query:agent framework survey details",
|
|
77
|
+
estimatedTokens: 400,
|
|
78
|
+
estimatedCostUsd: 0.03,
|
|
79
|
+
approvalId: "approval-budget-1",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await runSearch(spendGate, "7. Hard token and cost caps deny execution", {
|
|
83
|
+
agentId: "research-agent",
|
|
84
|
+
jobId: "spend-loop",
|
|
85
|
+
tool: "web.search",
|
|
86
|
+
action: "read",
|
|
87
|
+
resource: "query:agent framework survey more",
|
|
88
|
+
estimatedTokens: 400,
|
|
89
|
+
estimatedCostUsd: 0.04,
|
|
90
|
+
approvalId: "approval-budget-1",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
async function runSearch(
|
|
94
|
+
gate: ReturnType<typeof createToolGate>,
|
|
95
|
+
label: string,
|
|
96
|
+
check: GuardCheck,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const execution = await gate.run(check, async () => ({
|
|
99
|
+
results: [`result for ${check.resource || check.tool}`],
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
console.log(
|
|
103
|
+
JSON.stringify(
|
|
104
|
+
{
|
|
105
|
+
label,
|
|
106
|
+
executed: execution.executed,
|
|
107
|
+
decision: execution.decision.type,
|
|
108
|
+
reasons: execution.decision.reasons,
|
|
109
|
+
challenge: execution.decision.challenge,
|
|
110
|
+
result: execution.executed ? execution.result : undefined,
|
|
111
|
+
},
|
|
112
|
+
null,
|
|
113
|
+
2,
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function spendPolicy(): GuardPolicy {
|
|
119
|
+
return {
|
|
120
|
+
tools: {
|
|
121
|
+
"web.search": {
|
|
122
|
+
action: "read",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
budgets: {
|
|
126
|
+
challengeAfterTokensPerJob: 800,
|
|
127
|
+
challengeAfterEstimatedCostUsdPerJob: 0.08,
|
|
128
|
+
maxTokensPerJob: 1200,
|
|
129
|
+
maxEstimatedCostUsdPerJob: 0.1,
|
|
130
|
+
maxSameToolCallsPerJob: 10,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function demoIds(prefix: string): () => string {
|
|
136
|
+
let next = 1;
|
|
137
|
+
return () => `${prefix}-demo-dec-${next++}`;
|
|
138
|
+
}
|