agentshield-sdk 7.0.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/CHANGELOG.md +191 -0
- package/LICENSE +21 -0
- package/README.md +975 -0
- package/bin/agent-shield.js +680 -0
- package/package.json +118 -0
- package/src/adaptive.js +330 -0
- package/src/agent-protocol.js +998 -0
- package/src/alert-tuning.js +480 -0
- package/src/allowlist.js +603 -0
- package/src/audit-immutable.js +914 -0
- package/src/audit-streaming.js +469 -0
- package/src/badges.js +196 -0
- package/src/behavior-profiling.js +289 -0
- package/src/benchmark-harness.js +804 -0
- package/src/canary.js +271 -0
- package/src/certification.js +563 -0
- package/src/circuit-breaker.js +321 -0
- package/src/compliance.js +617 -0
- package/src/confidence-tuning.js +324 -0
- package/src/confused-deputy.js +624 -0
- package/src/context-scoring.js +360 -0
- package/src/conversation.js +494 -0
- package/src/cost-optimizer.js +1024 -0
- package/src/ctf.js +462 -0
- package/src/detector-core.js +1999 -0
- package/src/distributed.js +359 -0
- package/src/document-scanner.js +795 -0
- package/src/embedding.js +307 -0
- package/src/encoding.js +429 -0
- package/src/enterprise.js +405 -0
- package/src/errors.js +100 -0
- package/src/eu-ai-act.js +523 -0
- package/src/fuzzer.js +764 -0
- package/src/honeypot.js +328 -0
- package/src/i18n-patterns.js +523 -0
- package/src/index.js +430 -0
- package/src/integrations.js +528 -0
- package/src/llm-redteam.js +670 -0
- package/src/main.js +741 -0
- package/src/main.mjs +38 -0
- package/src/mcp-bridge.js +542 -0
- package/src/mcp-certification.js +846 -0
- package/src/mcp-sdk-integration.js +355 -0
- package/src/mcp-security-runtime.js +741 -0
- package/src/mcp-server.js +740 -0
- package/src/middleware.js +208 -0
- package/src/model-finetuning.js +884 -0
- package/src/model-fingerprint.js +1042 -0
- package/src/multi-agent-trust.js +453 -0
- package/src/multi-agent.js +404 -0
- package/src/multimodal.js +296 -0
- package/src/nist-mapping.js +505 -0
- package/src/observability.js +330 -0
- package/src/openclaw.js +450 -0
- package/src/otel.js +544 -0
- package/src/owasp-2025.js +483 -0
- package/src/pii.js +390 -0
- package/src/plugin-marketplace.js +628 -0
- package/src/plugin-system.js +349 -0
- package/src/policy-dsl.js +775 -0
- package/src/policy-extended.js +635 -0
- package/src/policy.js +443 -0
- package/src/presets.js +409 -0
- package/src/production.js +557 -0
- package/src/prompt-leakage.js +321 -0
- package/src/rag-vulnerability.js +579 -0
- package/src/redteam.js +475 -0
- package/src/response-handler.js +429 -0
- package/src/scanners.js +357 -0
- package/src/self-healing.js +363 -0
- package/src/semantic.js +339 -0
- package/src/shield-score.js +250 -0
- package/src/sso-saml.js +897 -0
- package/src/stream-scanner.js +806 -0
- package/src/testing.js +505 -0
- package/src/threat-encyclopedia.js +629 -0
- package/src/threat-intel-network.js +1017 -0
- package/src/token-analysis.js +467 -0
- package/src/tool-guard.js +412 -0
- package/src/tool-output-validator.js +354 -0
- package/src/utils.js +83 -0
- package/src/watermark.js +235 -0
- package/src/worker-scanner.js +601 -0
- package/types/index.d.ts +2088 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Agent Shield — Confused Deputy Prevention (Meta Incident Response)
|
|
5
|
+
*
|
|
6
|
+
* Addresses the four IAM gaps exposed by Meta's rogue AI agent incident (March 2026):
|
|
7
|
+
* Gap 2: Post-authentication blindness — validates intent after auth succeeds
|
|
8
|
+
* Gap 3: Static credentials — ephemeral, scoped, auto-rotating tokens
|
|
9
|
+
* Gap 4: Confused deputy via MCP — per-user authorization context propagation
|
|
10
|
+
*
|
|
11
|
+
* Gap 1 (inter-agent identity) is already addressed by agent-protocol.js.
|
|
12
|
+
*
|
|
13
|
+
* References:
|
|
14
|
+
* - VentureBeat: "Meta's rogue AI agent passed every identity check"
|
|
15
|
+
* - OWASP Feb 2026: Practical Guide for Secure MCP Server Development
|
|
16
|
+
* - CVE-2026-27826, CVE-2026-27825 (mcp-atlassian)
|
|
17
|
+
*
|
|
18
|
+
* All processing runs locally — no data ever leaves your environment.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const crypto = require('crypto');
|
|
22
|
+
|
|
23
|
+
/** Default HMAC key — callers should override via params.signingKey for production use. */
|
|
24
|
+
const DEFAULT_SIGNING_KEY = 'agent-shield-default-signing-key';
|
|
25
|
+
|
|
26
|
+
// =========================================================================
|
|
27
|
+
// Authorization Context — binds user identity to agent actions
|
|
28
|
+
// =========================================================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Immutable authorization context that flows through delegation chains.
|
|
32
|
+
* Ensures every tool call traces back to the originating user + permissions.
|
|
33
|
+
* Uses HMAC-SHA256 signing to prevent context forgery.
|
|
34
|
+
*/
|
|
35
|
+
class AuthorizationContext {
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} params
|
|
38
|
+
* @param {string} params.userId - Originating user identity
|
|
39
|
+
* @param {string} params.agentId - Agent performing the action
|
|
40
|
+
* @param {string[]} [params.roles] - User's roles
|
|
41
|
+
* @param {string[]} [params.scopes] - Granted permission scopes
|
|
42
|
+
* @param {string} [params.intent] - Declared intent for this session
|
|
43
|
+
* @param {number} [params.ttlMs=300000] - Context TTL (default 5 min)
|
|
44
|
+
* @param {string} [params.parentContextId] - Parent context for delegation
|
|
45
|
+
* @param {string} [params.signingKey] - HMAC key for tamper-proof signatures
|
|
46
|
+
*/
|
|
47
|
+
constructor(params) {
|
|
48
|
+
if (!params.userId) throw new Error('AuthorizationContext requires userId');
|
|
49
|
+
if (!params.agentId) throw new Error('AuthorizationContext requires agentId');
|
|
50
|
+
|
|
51
|
+
this.contextId = crypto.randomUUID();
|
|
52
|
+
this.userId = params.userId;
|
|
53
|
+
this.agentId = params.agentId;
|
|
54
|
+
this.roles = Object.freeze([...(params.roles || [])]);
|
|
55
|
+
this.scopes = Object.freeze([...(params.scopes || [])]);
|
|
56
|
+
this.intent = params.intent || null;
|
|
57
|
+
this.createdAt = Date.now();
|
|
58
|
+
this.expiresAt = this.createdAt + (params.ttlMs !== null && params.ttlMs !== undefined ? params.ttlMs : 300000);
|
|
59
|
+
this.parentContextId = params.parentContextId || null;
|
|
60
|
+
this.delegationDepth = 0;
|
|
61
|
+
this._signingKey = params.signingKey || DEFAULT_SIGNING_KEY;
|
|
62
|
+
|
|
63
|
+
// Sign the context with HMAC to prevent forgery
|
|
64
|
+
this._signature = this._sign();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @returns {boolean} */
|
|
68
|
+
isExpired() {
|
|
69
|
+
return Date.now() >= this.expiresAt;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @returns {boolean} */
|
|
73
|
+
hasScope(scope) {
|
|
74
|
+
return this.scopes.includes(scope) || this.scopes.includes('*');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @returns {boolean} */
|
|
78
|
+
hasRole(role) {
|
|
79
|
+
return this.roles.includes(role) || this.roles.includes('admin');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Creates a child context for delegation — scopes can only narrow, never widen.
|
|
84
|
+
* @param {string} delegateAgentId
|
|
85
|
+
* @param {string[]} [delegateScopes] - Must be subset of current scopes
|
|
86
|
+
* @returns {AuthorizationContext}
|
|
87
|
+
*/
|
|
88
|
+
delegate(delegateAgentId, delegateScopes) {
|
|
89
|
+
if (this.isExpired()) throw new Error('Cannot delegate expired context');
|
|
90
|
+
if (!this.verify()) throw new Error('Context integrity check failed');
|
|
91
|
+
|
|
92
|
+
const narrowedScopes = delegateScopes
|
|
93
|
+
? delegateScopes.filter(s => this.hasScope(s))
|
|
94
|
+
: [...this.scopes];
|
|
95
|
+
|
|
96
|
+
const child = new AuthorizationContext({
|
|
97
|
+
userId: this.userId,
|
|
98
|
+
agentId: delegateAgentId,
|
|
99
|
+
roles: [...this.roles],
|
|
100
|
+
scopes: narrowedScopes,
|
|
101
|
+
intent: this.intent,
|
|
102
|
+
ttlMs: Math.max(0, this.expiresAt - Date.now()),
|
|
103
|
+
parentContextId: this.contextId,
|
|
104
|
+
signingKey: this._signingKey
|
|
105
|
+
});
|
|
106
|
+
child.delegationDepth = this.delegationDepth + 1;
|
|
107
|
+
child._signature = child._sign();
|
|
108
|
+
return child;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Verifies context has not been tampered with using timing-safe comparison.
|
|
113
|
+
* @returns {boolean}
|
|
114
|
+
*/
|
|
115
|
+
verify() {
|
|
116
|
+
const expected = this._sign();
|
|
117
|
+
try {
|
|
118
|
+
return crypto.timingSafeEqual(
|
|
119
|
+
Buffer.from(this._signature, 'hex'),
|
|
120
|
+
Buffer.from(expected, 'hex')
|
|
121
|
+
);
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* HMAC-SHA256 signature over all critical context fields.
|
|
129
|
+
* @returns {string} Hex-encoded HMAC
|
|
130
|
+
* @private
|
|
131
|
+
*/
|
|
132
|
+
_sign() {
|
|
133
|
+
const data = `${this.contextId}:${this.userId}:${this.agentId}:${this.roles.join(',')}:${this.scopes.join(',')}:${this.expiresAt}:${this.parentContextId || ''}`;
|
|
134
|
+
return crypto.createHmac('sha256', this._signingKey).update(data).digest('hex');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// =========================================================================
|
|
139
|
+
// Ephemeral Token Manager — scoped, auto-rotating credentials
|
|
140
|
+
// =========================================================================
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Issues short-lived, scoped tokens that replace static API keys.
|
|
144
|
+
* Tokens are bound to a specific user, agent, and set of actions.
|
|
145
|
+
*/
|
|
146
|
+
class EphemeralTokenManager {
|
|
147
|
+
/**
|
|
148
|
+
* @param {object} [options]
|
|
149
|
+
* @param {number} [options.tokenTtlMs=900000] - Token lifetime (default 15 min)
|
|
150
|
+
* @param {number} [options.maxTokensPerUser=10] - Max active tokens per user
|
|
151
|
+
* @param {number} [options.rotationWindowMs=60000] - Grace period after rotation
|
|
152
|
+
*/
|
|
153
|
+
constructor(options = {}) {
|
|
154
|
+
this.tokenTtlMs = options.tokenTtlMs || 900000;
|
|
155
|
+
this.maxTokensPerUser = options.maxTokensPerUser || 10;
|
|
156
|
+
this.rotationWindowMs = options.rotationWindowMs || 60000;
|
|
157
|
+
this._signingKey = options.signingKey || DEFAULT_SIGNING_KEY;
|
|
158
|
+
this.tokens = new Map();
|
|
159
|
+
this.userTokens = new Map();
|
|
160
|
+
this.revokedTokens = new Set();
|
|
161
|
+
this.stats = { issued: 0, rotated: 0, revoked: 0, expired: 0, validated: 0 };
|
|
162
|
+
this._cleanupInterval = null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Starts automatic cleanup of expired tokens.
|
|
167
|
+
* @param {number} [intervalMs=60000] - Cleanup interval (default 60s)
|
|
168
|
+
*/
|
|
169
|
+
startCleanup(intervalMs = 60000) {
|
|
170
|
+
this.stopCleanup();
|
|
171
|
+
this._cleanupInterval = setInterval(() => this._purgeExpired(), intervalMs);
|
|
172
|
+
if (this._cleanupInterval.unref) this._cleanupInterval.unref();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Stops automatic cleanup. */
|
|
176
|
+
stopCleanup() {
|
|
177
|
+
if (this._cleanupInterval) {
|
|
178
|
+
clearInterval(this._cleanupInterval);
|
|
179
|
+
this._cleanupInterval = null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** @private */
|
|
184
|
+
_purgeExpired() {
|
|
185
|
+
let purged = 0;
|
|
186
|
+
const expiredIds = [];
|
|
187
|
+
|
|
188
|
+
for (const [tokenId, tokenData] of this.tokens) {
|
|
189
|
+
if (this._isTokenExpired(tokenData)) {
|
|
190
|
+
expiredIds.push(tokenId);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const tokenId of expiredIds) {
|
|
195
|
+
this.tokens.delete(tokenId);
|
|
196
|
+
this.revokedTokens.delete(tokenId);
|
|
197
|
+
purged++;
|
|
198
|
+
this.stats.expired++;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Clean stale entries from userTokens
|
|
202
|
+
if (purged > 0) {
|
|
203
|
+
for (const [userId, tokenIds] of this.userTokens) {
|
|
204
|
+
const active = tokenIds.filter(id => this.tokens.has(id));
|
|
205
|
+
if (active.length === 0) {
|
|
206
|
+
this.userTokens.delete(userId);
|
|
207
|
+
} else {
|
|
208
|
+
this.userTokens.set(userId, active);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return purged;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Issues an ephemeral token scoped to specific actions.
|
|
218
|
+
* @param {AuthorizationContext} authCtx
|
|
219
|
+
* @param {string[]} scopes - Scopes this token grants (must be subset of authCtx scopes)
|
|
220
|
+
* @returns {{ tokenId: string, token: string, expiresAt: number, scopes: string[] }}
|
|
221
|
+
*/
|
|
222
|
+
issueToken(authCtx, scopes = []) {
|
|
223
|
+
if (!authCtx.verify()) throw new Error('Context integrity check failed — possible tampering');
|
|
224
|
+
if (authCtx.isExpired()) throw new Error('Cannot issue token for expired context');
|
|
225
|
+
|
|
226
|
+
// Scopes can only narrow, never widen
|
|
227
|
+
const grantedScopes = scopes.length > 0
|
|
228
|
+
? scopes.filter(s => authCtx.hasScope(s))
|
|
229
|
+
: [...authCtx.scopes];
|
|
230
|
+
|
|
231
|
+
// Enforce per-user token limit
|
|
232
|
+
const userTokenList = this.userTokens.get(authCtx.userId) || [];
|
|
233
|
+
const activeTokens = userTokenList.filter(id => {
|
|
234
|
+
const t = this.tokens.get(id);
|
|
235
|
+
return t && !this._isTokenExpired(t);
|
|
236
|
+
});
|
|
237
|
+
if (activeTokens.length >= this.maxTokensPerUser) {
|
|
238
|
+
// Revoke oldest
|
|
239
|
+
const oldest = activeTokens[0];
|
|
240
|
+
this.revokeToken(oldest);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const tokenId = crypto.randomUUID();
|
|
244
|
+
const tokenData = {
|
|
245
|
+
tokenId,
|
|
246
|
+
userId: authCtx.userId,
|
|
247
|
+
agentId: authCtx.agentId,
|
|
248
|
+
contextId: authCtx.contextId,
|
|
249
|
+
scopes: grantedScopes,
|
|
250
|
+
issuedAt: Date.now(),
|
|
251
|
+
expiresAt: Date.now() + this.tokenTtlMs,
|
|
252
|
+
rotatedFrom: null,
|
|
253
|
+
usageCount: 0
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
this.tokens.set(tokenId, tokenData);
|
|
257
|
+
const updated = [...activeTokens, tokenId];
|
|
258
|
+
this.userTokens.set(authCtx.userId, updated);
|
|
259
|
+
this.stats.issued++;
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
tokenId,
|
|
263
|
+
token: this._encodeToken(tokenData),
|
|
264
|
+
expiresAt: tokenData.expiresAt,
|
|
265
|
+
scopes: grantedScopes
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Validates a token and returns its context.
|
|
271
|
+
* @param {string} tokenId
|
|
272
|
+
* @returns {{ valid: boolean, reason: string|null, userId: string|null, scopes: string[] }}
|
|
273
|
+
*/
|
|
274
|
+
validateToken(tokenId) {
|
|
275
|
+
this.stats.validated++;
|
|
276
|
+
const tokenData = this.tokens.get(tokenId);
|
|
277
|
+
|
|
278
|
+
if (!tokenData) {
|
|
279
|
+
return { valid: false, reason: 'Token not found', userId: null, scopes: [] };
|
|
280
|
+
}
|
|
281
|
+
if (this.revokedTokens.has(tokenId)) {
|
|
282
|
+
return { valid: false, reason: 'Token has been revoked', userId: tokenData.userId, scopes: [] };
|
|
283
|
+
}
|
|
284
|
+
if (this._isTokenExpired(tokenData)) {
|
|
285
|
+
this.stats.expired++;
|
|
286
|
+
return { valid: false, reason: 'Token has expired', userId: tokenData.userId, scopes: [] };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
tokenData.usageCount++;
|
|
290
|
+
return { valid: true, reason: null, userId: tokenData.userId, scopes: tokenData.scopes };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Rotates a token — issues new token, old remains valid during grace period.
|
|
295
|
+
* @param {string} oldTokenId
|
|
296
|
+
* @param {AuthorizationContext} authCtx
|
|
297
|
+
* @returns {{ tokenId: string, token: string, expiresAt: number, scopes: string[] }|null}
|
|
298
|
+
*/
|
|
299
|
+
rotateToken(oldTokenId, authCtx) {
|
|
300
|
+
const oldToken = this.tokens.get(oldTokenId);
|
|
301
|
+
if (!oldToken || this.revokedTokens.has(oldTokenId)) return null;
|
|
302
|
+
|
|
303
|
+
// Issue new token with same scopes
|
|
304
|
+
const newTokenResult = this.issueToken(authCtx, oldToken.scopes);
|
|
305
|
+
|
|
306
|
+
// Mark old token for grace-period expiry
|
|
307
|
+
oldToken.expiresAt = Math.min(oldToken.expiresAt, Date.now() + this.rotationWindowMs);
|
|
308
|
+
const newTokenData = this.tokens.get(newTokenResult.tokenId);
|
|
309
|
+
if (newTokenData) newTokenData.rotatedFrom = oldTokenId;
|
|
310
|
+
|
|
311
|
+
this.stats.rotated++;
|
|
312
|
+
return newTokenResult;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Revokes a token immediately.
|
|
317
|
+
* @param {string} tokenId
|
|
318
|
+
*/
|
|
319
|
+
revokeToken(tokenId) {
|
|
320
|
+
this.revokedTokens.add(tokenId);
|
|
321
|
+
this.stats.revoked++;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Revokes all tokens for a user.
|
|
326
|
+
* @param {string} userId
|
|
327
|
+
* @returns {number} Number of tokens revoked
|
|
328
|
+
*/
|
|
329
|
+
revokeAllForUser(userId) {
|
|
330
|
+
const userTokenList = this.userTokens.get(userId) || [];
|
|
331
|
+
let count = 0;
|
|
332
|
+
for (const id of userTokenList) {
|
|
333
|
+
if (!this.revokedTokens.has(id)) {
|
|
334
|
+
this.revokeToken(id);
|
|
335
|
+
count++;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return count;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** @returns {object} */
|
|
342
|
+
getStats() {
|
|
343
|
+
return { ...this.stats, activeTokens: this.tokens.size - this.revokedTokens.size };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** @private */
|
|
347
|
+
_isTokenExpired(tokenData) {
|
|
348
|
+
return Date.now() > tokenData.expiresAt;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** @private */
|
|
352
|
+
_encodeToken(tokenData) {
|
|
353
|
+
const payload = `${tokenData.tokenId}:${tokenData.userId}:${tokenData.scopes.join(',')}:${tokenData.expiresAt}`;
|
|
354
|
+
return crypto.createHmac('sha256', this._signingKey).update(payload).digest('hex');
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// =========================================================================
|
|
359
|
+
// Intent Validator — post-auth action verification
|
|
360
|
+
// =========================================================================
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Validates that tool calls match the agent's declared intent and user permissions.
|
|
364
|
+
* Closes the "post-authentication blindness" gap.
|
|
365
|
+
*/
|
|
366
|
+
class IntentValidator {
|
|
367
|
+
/**
|
|
368
|
+
* @param {object} [options]
|
|
369
|
+
* @param {boolean} [options.requireIntent=false] - Require declared intent for all actions
|
|
370
|
+
* @param {number} [options.maxDelegationDepth=5] - Maximum delegation chain depth
|
|
371
|
+
* @param {Function} [options.onViolation] - Callback on policy violation
|
|
372
|
+
*/
|
|
373
|
+
constructor(options = {}) {
|
|
374
|
+
this.requireIntent = options.requireIntent || false;
|
|
375
|
+
this.maxDelegationDepth = options.maxDelegationDepth || 5;
|
|
376
|
+
this.onViolation = options.onViolation || null;
|
|
377
|
+
this.policies = [];
|
|
378
|
+
this.auditLog = [];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Registers a tool-level authorization policy.
|
|
383
|
+
* @param {object} policy
|
|
384
|
+
* @param {string|RegExp} policy.tool - Tool name or pattern
|
|
385
|
+
* @param {string[]} [policy.requiredScopes] - Scopes needed to use this tool
|
|
386
|
+
* @param {string[]} [policy.requiredRoles] - Roles needed to use this tool
|
|
387
|
+
* @param {string[]} [policy.allowedIntents] - Intents that may use this tool
|
|
388
|
+
* @param {boolean} [policy.requiresHumanApproval=false] - Needs human-in-the-loop
|
|
389
|
+
*/
|
|
390
|
+
addPolicy(policy) {
|
|
391
|
+
this.policies.push({
|
|
392
|
+
tool: policy.tool,
|
|
393
|
+
requiredScopes: policy.requiredScopes || [],
|
|
394
|
+
requiredRoles: policy.requiredRoles || [],
|
|
395
|
+
allowedIntents: policy.allowedIntents || [],
|
|
396
|
+
requiresHumanApproval: policy.requiresHumanApproval || false
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Validates a tool call against the authorization context and policies.
|
|
402
|
+
* @param {string} toolName
|
|
403
|
+
* @param {object} args
|
|
404
|
+
* @param {AuthorizationContext} authCtx
|
|
405
|
+
* @returns {{ allowed: boolean, violations: Array, requiresApproval: boolean }}
|
|
406
|
+
*/
|
|
407
|
+
validateAction(toolName, args, authCtx) {
|
|
408
|
+
const violations = [];
|
|
409
|
+
let requiresApproval = false;
|
|
410
|
+
|
|
411
|
+
// Check context validity
|
|
412
|
+
if (!authCtx || !authCtx.verify()) {
|
|
413
|
+
violations.push({ type: 'integrity', message: 'Authorization context is missing or tampered' });
|
|
414
|
+
} else if (authCtx.isExpired()) {
|
|
415
|
+
violations.push({ type: 'expired', message: 'Authorization context has expired' });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (violations.length > 0) {
|
|
419
|
+
this._logAudit(toolName, authCtx, violations, false);
|
|
420
|
+
return { allowed: false, violations, requiresApproval: false };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Check delegation depth
|
|
424
|
+
if (authCtx.delegationDepth > this.maxDelegationDepth) {
|
|
425
|
+
violations.push({ type: 'delegation_depth', message: `Delegation depth ${authCtx.delegationDepth} exceeds max ${this.maxDelegationDepth}` });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Check intent requirement
|
|
429
|
+
if (this.requireIntent && !authCtx.intent) {
|
|
430
|
+
violations.push({ type: 'missing_intent', message: 'No intent declared for this action' });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Check policies
|
|
434
|
+
for (const policy of this.policies) {
|
|
435
|
+
const toolMatch = policy.tool instanceof RegExp
|
|
436
|
+
? policy.tool.test(toolName)
|
|
437
|
+
: policy.tool === toolName || policy.tool === '*';
|
|
438
|
+
|
|
439
|
+
if (!toolMatch) continue;
|
|
440
|
+
|
|
441
|
+
// Scope check
|
|
442
|
+
for (const scope of policy.requiredScopes) {
|
|
443
|
+
if (!authCtx.hasScope(scope)) {
|
|
444
|
+
violations.push({ type: 'scope', message: `Missing required scope "${scope}" for tool "${toolName}"`, tool: toolName });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Role check
|
|
449
|
+
if (policy.requiredRoles.length > 0) {
|
|
450
|
+
const hasRequired = policy.requiredRoles.some(r => authCtx.hasRole(r));
|
|
451
|
+
if (!hasRequired) {
|
|
452
|
+
violations.push({ type: 'role', message: `Requires role ${policy.requiredRoles.join('|')} for tool "${toolName}"`, tool: toolName });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Intent check — uses word-boundary matching to prevent substring spoofing
|
|
457
|
+
if (policy.allowedIntents.length > 0 && authCtx.intent) {
|
|
458
|
+
const intentWords = authCtx.intent.toLowerCase().split(/[\s_-]+/);
|
|
459
|
+
const intentAllowed = policy.allowedIntents.some(i => {
|
|
460
|
+
const allowedWords = i.toLowerCase().split(/[\s_-]+/);
|
|
461
|
+
return allowedWords.every(w => intentWords.includes(w));
|
|
462
|
+
});
|
|
463
|
+
if (!intentAllowed) {
|
|
464
|
+
violations.push({ type: 'intent', message: `Intent "${authCtx.intent}" not allowed for tool "${toolName}"`, tool: toolName });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Human approval check
|
|
469
|
+
if (policy.requiresHumanApproval) {
|
|
470
|
+
requiresApproval = true;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const allowed = violations.length === 0;
|
|
475
|
+
|
|
476
|
+
if (!allowed && this.onViolation) {
|
|
477
|
+
this.onViolation({ toolName, args, authCtx, violations });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
this._logAudit(toolName, authCtx, violations, allowed);
|
|
481
|
+
return { allowed, violations, requiresApproval };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Returns the audit log.
|
|
486
|
+
* @param {number} [limit=100]
|
|
487
|
+
* @returns {Array}
|
|
488
|
+
*/
|
|
489
|
+
getAuditLog(limit = 100) {
|
|
490
|
+
return this.auditLog.slice(-limit);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** @private */
|
|
494
|
+
_logAudit(toolName, authCtx, violations, allowed) {
|
|
495
|
+
this.auditLog.push({
|
|
496
|
+
timestamp: Date.now(),
|
|
497
|
+
toolName,
|
|
498
|
+
userId: authCtx ? authCtx.userId : null,
|
|
499
|
+
agentId: authCtx ? authCtx.agentId : null,
|
|
500
|
+
contextId: authCtx ? authCtx.contextId : null,
|
|
501
|
+
delegationDepth: authCtx ? authCtx.delegationDepth : null,
|
|
502
|
+
allowed,
|
|
503
|
+
violations: violations.length > 0 ? violations : undefined
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// =========================================================================
|
|
509
|
+
// ConfusedDeputyGuard — MCP-aware per-user authorization
|
|
510
|
+
// =========================================================================
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Prevents confused deputy attacks by enforcing per-user authorization
|
|
514
|
+
* on every tool call, not just per-agent or per-session.
|
|
515
|
+
*/
|
|
516
|
+
class ConfusedDeputyGuard {
|
|
517
|
+
/**
|
|
518
|
+
* @param {object} [options]
|
|
519
|
+
* @param {boolean} [options.enforceContext=true] - Require AuthorizationContext on all calls
|
|
520
|
+
* @param {boolean} [options.logOnly=false] - Log violations without blocking
|
|
521
|
+
*/
|
|
522
|
+
constructor(options = {}) {
|
|
523
|
+
this.enforceContext = options.enforceContext !== false;
|
|
524
|
+
this.logOnly = options.logOnly || false;
|
|
525
|
+
this.tokenManager = new EphemeralTokenManager(options);
|
|
526
|
+
this.intentValidator = new IntentValidator(options);
|
|
527
|
+
this.toolPermissions = new Map();
|
|
528
|
+
this.stats = { checked: 0, allowed: 0, denied: 0, escalations: 0 };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Registers tool-level permission requirements.
|
|
533
|
+
* @param {string} toolName
|
|
534
|
+
* @param {object} requirements
|
|
535
|
+
* @param {string[]} [requirements.scopes] - Required scopes
|
|
536
|
+
* @param {string[]} [requirements.roles] - Required roles
|
|
537
|
+
* @param {boolean} [requirements.requiresHumanApproval] - Needs HITL
|
|
538
|
+
*/
|
|
539
|
+
registerTool(toolName, requirements = {}) {
|
|
540
|
+
this.toolPermissions.set(toolName, requirements);
|
|
541
|
+
this.intentValidator.addPolicy({
|
|
542
|
+
tool: toolName,
|
|
543
|
+
requiredScopes: requirements.scopes || [],
|
|
544
|
+
requiredRoles: requirements.roles || [],
|
|
545
|
+
requiresHumanApproval: requirements.requiresHumanApproval || false
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Wraps a tool call with confused deputy prevention.
|
|
551
|
+
* @param {string} toolName
|
|
552
|
+
* @param {object} args
|
|
553
|
+
* @param {AuthorizationContext} [authCtx]
|
|
554
|
+
* @returns {{ allowed: boolean, violations: Array, requiresApproval: boolean, token: object|null }}
|
|
555
|
+
*/
|
|
556
|
+
wrapToolCall(toolName, args, authCtx) {
|
|
557
|
+
this.stats.checked++;
|
|
558
|
+
|
|
559
|
+
// Enforce context requirement
|
|
560
|
+
if (this.enforceContext && !authCtx) {
|
|
561
|
+
this.stats.denied++;
|
|
562
|
+
const violation = [{ type: 'missing_context', message: 'AuthorizationContext required but not provided — potential confused deputy' }];
|
|
563
|
+
if (!this.logOnly) {
|
|
564
|
+
return { allowed: false, violations: violation, requiresApproval: false, token: null };
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (!authCtx) {
|
|
569
|
+
this.stats.allowed++;
|
|
570
|
+
return { allowed: true, violations: [], requiresApproval: false, token: null };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Validate via intent validator (checks scopes, roles, intent, delegation depth)
|
|
574
|
+
const validation = this.intentValidator.validateAction(toolName, args, authCtx);
|
|
575
|
+
|
|
576
|
+
if (validation.allowed) {
|
|
577
|
+
this.stats.allowed++;
|
|
578
|
+
// Issue ephemeral token for this action
|
|
579
|
+
const token = this.tokenManager.issueToken(authCtx, [toolName]);
|
|
580
|
+
return { allowed: true, violations: [], requiresApproval: validation.requiresApproval, token };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
this.stats.denied++;
|
|
584
|
+
this.stats.escalations += validation.violations.filter(v => v.type === 'scope' || v.type === 'role').length;
|
|
585
|
+
|
|
586
|
+
if (this.logOnly) {
|
|
587
|
+
return { allowed: true, violations: validation.violations, requiresApproval: false, token: null };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return { allowed: false, violations: validation.violations, requiresApproval: validation.requiresApproval, token: null };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Returns combined stats from all sub-components.
|
|
595
|
+
* @returns {object}
|
|
596
|
+
*/
|
|
597
|
+
getStats() {
|
|
598
|
+
return {
|
|
599
|
+
...this.stats,
|
|
600
|
+
tokens: this.tokenManager.getStats(),
|
|
601
|
+
auditEntries: this.intentValidator.getAuditLog().length
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Returns the audit trail for forensic analysis.
|
|
607
|
+
* @param {number} [limit=100]
|
|
608
|
+
* @returns {Array}
|
|
609
|
+
*/
|
|
610
|
+
getAuditLog(limit) {
|
|
611
|
+
return this.intentValidator.getAuditLog(limit);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// =========================================================================
|
|
616
|
+
// Exports
|
|
617
|
+
// =========================================================================
|
|
618
|
+
|
|
619
|
+
module.exports = {
|
|
620
|
+
AuthorizationContext,
|
|
621
|
+
EphemeralTokenManager,
|
|
622
|
+
IntentValidator,
|
|
623
|
+
ConfusedDeputyGuard
|
|
624
|
+
};
|