@agenticmail/enterprise 0.2.1
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 +183 -0
- package/agenticmail-enterprise.db +0 -0
- package/dashboards/README.md +120 -0
- package/dashboards/dotnet/Program.cs +261 -0
- package/dashboards/express/app.js +146 -0
- package/dashboards/go/main.go +513 -0
- package/dashboards/html/index.html +535 -0
- package/dashboards/java/AgenticMailDashboard.java +376 -0
- package/dashboards/php/index.php +414 -0
- package/dashboards/python/app.py +273 -0
- package/dashboards/ruby/app.rb +195 -0
- package/dist/chunk-77IDQJL3.js +7 -0
- package/dist/chunk-7RGCCHIT.js +115 -0
- package/dist/chunk-DXNKR3TG.js +1355 -0
- package/dist/chunk-IQWA44WT.js +970 -0
- package/dist/chunk-LCUZGIDH.js +965 -0
- package/dist/chunk-N2JVTNNJ.js +2553 -0
- package/dist/chunk-O462UJBH.js +363 -0
- package/dist/chunk-PNKVD2UK.js +26 -0
- package/dist/cli.js +218 -0
- package/dist/dashboard/index.html +558 -0
- package/dist/db-adapter-DEWEFNIV.js +7 -0
- package/dist/dynamodb-CCGL2E77.js +426 -0
- package/dist/engine/index.js +1261 -0
- package/dist/index.js +522 -0
- package/dist/mongodb-ODTXIVPV.js +319 -0
- package/dist/mysql-RM3S2FV5.js +521 -0
- package/dist/postgres-LN7A6MGQ.js +518 -0
- package/dist/routes-2JEPIIKC.js +441 -0
- package/dist/routes-74ZLKJKP.js +399 -0
- package/dist/server.js +7 -0
- package/dist/sqlite-3K5YOZ4K.js +439 -0
- package/dist/turso-LDWODSDI.js +442 -0
- package/package.json +49 -0
- package/src/admin/routes.ts +331 -0
- package/src/auth/routes.ts +130 -0
- package/src/cli.ts +260 -0
- package/src/dashboard/index.html +558 -0
- package/src/db/adapter.ts +230 -0
- package/src/db/dynamodb.ts +456 -0
- package/src/db/factory.ts +51 -0
- package/src/db/mongodb.ts +360 -0
- package/src/db/mysql.ts +472 -0
- package/src/db/postgres.ts +479 -0
- package/src/db/sql-schema.ts +123 -0
- package/src/db/sqlite.ts +391 -0
- package/src/db/turso.ts +411 -0
- package/src/deploy/fly.ts +368 -0
- package/src/deploy/managed.ts +213 -0
- package/src/engine/activity.ts +474 -0
- package/src/engine/agent-config.ts +429 -0
- package/src/engine/agenticmail-bridge.ts +296 -0
- package/src/engine/approvals.ts +278 -0
- package/src/engine/db-adapter.ts +682 -0
- package/src/engine/db-schema.ts +335 -0
- package/src/engine/deployer.ts +595 -0
- package/src/engine/index.ts +134 -0
- package/src/engine/knowledge.ts +486 -0
- package/src/engine/lifecycle.ts +635 -0
- package/src/engine/openclaw-hook.ts +371 -0
- package/src/engine/routes.ts +528 -0
- package/src/engine/skills.ts +473 -0
- package/src/engine/tenant.ts +345 -0
- package/src/engine/tool-catalog.ts +189 -0
- package/src/index.ts +64 -0
- package/src/lib/resilience.ts +326 -0
- package/src/middleware/index.ts +286 -0
- package/src/server.ts +310 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgenticMail Bridge
|
|
3
|
+
*
|
|
4
|
+
* Integrates the enterprise engine with AgenticMail's OpenClaw plugin.
|
|
5
|
+
* This is the glue code that makes enterprise features available
|
|
6
|
+
* inside an agent's OpenClaw session.
|
|
7
|
+
*
|
|
8
|
+
* When an enterprise-managed agent boots:
|
|
9
|
+
* 1. Bridge loads agent config from engine
|
|
10
|
+
* 2. Registers permission middleware on every tool call
|
|
11
|
+
* 3. Injects KB context before LLM calls
|
|
12
|
+
* 4. Streams activity events to the engine
|
|
13
|
+
* 5. Enforces rate limits and budgets
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* // In OpenClaw plugin init
|
|
17
|
+
* import { createAgenticMailBridge } from '@agenticmail/enterprise/bridge';
|
|
18
|
+
* const bridge = await createAgenticMailBridge({
|
|
19
|
+
* engineUrl: process.env.AGENTICMAIL_ENTERPRISE_URL,
|
|
20
|
+
* agentId: process.env.AGENTICMAIL_AGENT_ID,
|
|
21
|
+
* orgId: process.env.AGENTICMAIL_ORG_ID,
|
|
22
|
+
* });
|
|
23
|
+
* bridge.install(openclawPlugin);
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { EnterpriseHook, type EnterpriseHookConfig } from './openclaw-hook.js';
|
|
27
|
+
import { TOOL_INDEX, generateOpenClawToolPolicy } from './tool-catalog.js';
|
|
28
|
+
|
|
29
|
+
// ─── Types ──────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export interface BridgeConfig extends EnterpriseHookConfig {
|
|
32
|
+
/** Auto-configure tool policy on startup */
|
|
33
|
+
autoConfigureTools?: boolean;
|
|
34
|
+
|
|
35
|
+
/** Inject coordination context about enterprise features */
|
|
36
|
+
injectCoordinationContext?: boolean;
|
|
37
|
+
|
|
38
|
+
/** Log enterprise events to console */
|
|
39
|
+
verbose?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ToolInterceptor {
|
|
43
|
+
/** Called before every tool invocation. Return false to block. */
|
|
44
|
+
beforeTool: (toolId: string, params: Record<string, any>, sessionId: string) => Promise<{
|
|
45
|
+
allowed: boolean;
|
|
46
|
+
reason?: string;
|
|
47
|
+
modifiedParams?: Record<string, any>;
|
|
48
|
+
}>;
|
|
49
|
+
|
|
50
|
+
/** Called after every tool invocation */
|
|
51
|
+
afterTool: (toolId: string, params: Record<string, any>, result: any, sessionId: string) => Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Bridge ─────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export class AgenticMailBridge {
|
|
57
|
+
private hook: EnterpriseHook;
|
|
58
|
+
private config: BridgeConfig;
|
|
59
|
+
private toolPolicy: { allowedTools: string[]; blockedTools: string[]; approvalRequired: string[] } | null = null;
|
|
60
|
+
private sessionId: string = '';
|
|
61
|
+
private rateLimiter: { count: number; resetAt: number } = { count: 0, resetAt: 0 };
|
|
62
|
+
|
|
63
|
+
constructor(config: BridgeConfig) {
|
|
64
|
+
this.config = config;
|
|
65
|
+
this.hook = new EnterpriseHook(config);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Initialize the bridge — load config, verify connection
|
|
70
|
+
*/
|
|
71
|
+
async initialize(): Promise<{ connected: boolean; toolPolicy: any }> {
|
|
72
|
+
const connected = await this.hook.healthCheck();
|
|
73
|
+
|
|
74
|
+
if (connected && this.config.autoConfigureTools !== false) {
|
|
75
|
+
this.toolPolicy = await this.hook.getToolPolicy();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (this.config.verbose) {
|
|
79
|
+
console.log(`[Enterprise] Bridge initialized. Connected: ${connected}`);
|
|
80
|
+
if (this.toolPolicy) {
|
|
81
|
+
console.log(`[Enterprise] Tool policy: ${this.toolPolicy.allowedTools.length} allowed, ${this.toolPolicy.blockedTools.length} blocked`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { connected, toolPolicy: this.toolPolicy };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get a tool interceptor that can be registered with OpenClaw
|
|
90
|
+
*/
|
|
91
|
+
getInterceptor(): ToolInterceptor {
|
|
92
|
+
return {
|
|
93
|
+
beforeTool: async (toolId, params, sessionId) => {
|
|
94
|
+
this.sessionId = sessionId;
|
|
95
|
+
|
|
96
|
+
// Quick local check first (no API call)
|
|
97
|
+
if (this.toolPolicy?.blockedTools.includes(toolId)) {
|
|
98
|
+
if (this.config.verbose) console.log(`[Enterprise] BLOCKED: ${toolId} (policy)`);
|
|
99
|
+
return { allowed: false, reason: `Tool "${toolId}" is blocked by enterprise policy` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Rate limit check
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
if (now > this.rateLimiter.resetAt) {
|
|
105
|
+
this.rateLimiter = { count: 0, resetAt: now + 60_000 };
|
|
106
|
+
}
|
|
107
|
+
this.rateLimiter.count++;
|
|
108
|
+
// Default 120/min, will be overridden by policy
|
|
109
|
+
const limit = (this.toolPolicy as any)?.rateLimits?.toolCallsPerMinute || 120;
|
|
110
|
+
if (this.rateLimiter.count > limit) {
|
|
111
|
+
return { allowed: false, reason: `Rate limit exceeded: ${this.rateLimiter.count}/${limit} calls/min` };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Full permission check via engine API
|
|
115
|
+
const result = await this.hook.beforeToolCall({
|
|
116
|
+
toolId, toolName: toolId, parameters: params, sessionId,
|
|
117
|
+
timestamp: new Date(),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (this.config.verbose && !result.allowed) {
|
|
121
|
+
console.log(`[Enterprise] BLOCKED: ${toolId} — ${result.reason}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
allowed: result.allowed,
|
|
126
|
+
reason: result.reason,
|
|
127
|
+
modifiedParams: result.modifiedParameters,
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
afterTool: async (toolId, params, result, sessionId) => {
|
|
132
|
+
await this.hook.afterToolCall(
|
|
133
|
+
{ toolId, toolName: toolId, parameters: params, sessionId, timestamp: new Date() },
|
|
134
|
+
{
|
|
135
|
+
success: !result?.error,
|
|
136
|
+
output: typeof result === 'string' ? result : JSON.stringify(result)?.slice(0, 500),
|
|
137
|
+
error: result?.error,
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Get knowledge base context to inject before LLM call
|
|
146
|
+
*/
|
|
147
|
+
async getKBContext(userMessage: string): Promise<string | null> {
|
|
148
|
+
return this.hook.getKnowledgeContext(userMessage);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Generate coordination context for the agent's system prompt
|
|
153
|
+
*/
|
|
154
|
+
getCoordinationContext(): string {
|
|
155
|
+
if (!this.config.injectCoordinationContext) return '';
|
|
156
|
+
|
|
157
|
+
const blocked = this.toolPolicy?.blockedTools || [];
|
|
158
|
+
const needsApproval = this.toolPolicy?.approvalRequired || [];
|
|
159
|
+
|
|
160
|
+
let ctx = '\n<enterprise-context>\n';
|
|
161
|
+
ctx += '🏢 This agent is managed by AgenticMail Enterprise.\n';
|
|
162
|
+
|
|
163
|
+
if (blocked.length > 0) {
|
|
164
|
+
ctx += `⛔ Blocked tools (do not attempt): ${blocked.join(', ')}\n`;
|
|
165
|
+
}
|
|
166
|
+
if (needsApproval.length > 0) {
|
|
167
|
+
ctx += `⚠️ Tools requiring human approval: ${needsApproval.join(', ')}\n`;
|
|
168
|
+
ctx += 'When using these tools, the call will pause until a human approves or denies.\n';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
ctx += '</enterprise-context>\n';
|
|
172
|
+
return ctx;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Notify engine of session lifecycle
|
|
177
|
+
*/
|
|
178
|
+
async onSessionStart(sessionId: string): Promise<void> {
|
|
179
|
+
this.sessionId = sessionId;
|
|
180
|
+
await this.hook.onSessionStart(sessionId);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async onSessionEnd(sessionId: string): Promise<void> {
|
|
184
|
+
await this.hook.onSessionEnd(sessionId);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Record a message in the conversation log
|
|
189
|
+
*/
|
|
190
|
+
async recordMessage(role: 'user' | 'assistant' | 'system', content: string, opts?: {
|
|
191
|
+
channel?: string;
|
|
192
|
+
tokenCount?: number;
|
|
193
|
+
}): Promise<void> {
|
|
194
|
+
await this.hook.recordMessage({
|
|
195
|
+
sessionId: this.sessionId,
|
|
196
|
+
role,
|
|
197
|
+
content,
|
|
198
|
+
channel: opts?.channel,
|
|
199
|
+
tokenCount: opts?.tokenCount || Math.ceil(content.length / 4),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get the loaded tool policy
|
|
205
|
+
*/
|
|
206
|
+
getToolPolicy() {
|
|
207
|
+
return this.toolPolicy;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Generate OpenClaw-compatible config for tools.allow / tools.deny
|
|
212
|
+
* This is what gets written to the gateway config
|
|
213
|
+
*/
|
|
214
|
+
getOpenClawToolConfig(): Record<string, any> {
|
|
215
|
+
if (!this.toolPolicy) return {};
|
|
216
|
+
return generateOpenClawToolPolicy(
|
|
217
|
+
this.toolPolicy.allowedTools,
|
|
218
|
+
this.toolPolicy.blockedTools,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── Factory ────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Create and initialize the AgenticMail Enterprise bridge
|
|
227
|
+
*/
|
|
228
|
+
export async function createAgenticMailBridge(config: BridgeConfig): Promise<AgenticMailBridge> {
|
|
229
|
+
const bridge = new AgenticMailBridge({
|
|
230
|
+
autoConfigureTools: true,
|
|
231
|
+
injectCoordinationContext: true,
|
|
232
|
+
verbose: false,
|
|
233
|
+
failMode: 'open',
|
|
234
|
+
permissionCacheTtlSec: 30,
|
|
235
|
+
kbMaxTokens: 2000,
|
|
236
|
+
knowledgeBaseEnabled: true,
|
|
237
|
+
activityStreamEnabled: true,
|
|
238
|
+
...config,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await bridge.initialize();
|
|
242
|
+
return bridge;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Example integration with OpenClaw plugin:
|
|
247
|
+
*
|
|
248
|
+
* ```typescript
|
|
249
|
+
* // In @agenticmail/openclaw plugin init
|
|
250
|
+
* import { createAgenticMailBridge } from '@agenticmail/enterprise/bridge';
|
|
251
|
+
*
|
|
252
|
+
* export async function initPlugin(openclaw) {
|
|
253
|
+
* // Check if enterprise mode is enabled
|
|
254
|
+
* const engineUrl = process.env.AGENTICMAIL_ENTERPRISE_URL;
|
|
255
|
+
* if (!engineUrl) return; // Not enterprise, skip
|
|
256
|
+
*
|
|
257
|
+
* const bridge = await createAgenticMailBridge({
|
|
258
|
+
* engineUrl,
|
|
259
|
+
* agentId: process.env.AGENTICMAIL_AGENT_ID,
|
|
260
|
+
* orgId: process.env.AGENTICMAIL_ORG_ID,
|
|
261
|
+
* apiToken: process.env.AGENTICMAIL_ENTERPRISE_TOKEN,
|
|
262
|
+
* });
|
|
263
|
+
*
|
|
264
|
+
* // Register tool interceptor
|
|
265
|
+
* const interceptor = bridge.getInterceptor();
|
|
266
|
+
* openclaw.onBeforeToolCall(async (tool, params, session) => {
|
|
267
|
+
* const result = await interceptor.beforeTool(tool, params, session.id);
|
|
268
|
+
* if (!result.allowed) throw new Error(`Enterprise: ${result.reason}`);
|
|
269
|
+
* return result.modifiedParams || params;
|
|
270
|
+
* });
|
|
271
|
+
*
|
|
272
|
+
* openclaw.onAfterToolCall(async (tool, params, result, session) => {
|
|
273
|
+
* await interceptor.afterTool(tool, params, result, session.id);
|
|
274
|
+
* });
|
|
275
|
+
*
|
|
276
|
+
* // Inject KB context before LLM
|
|
277
|
+
* openclaw.onBeforeLLM(async (messages) => {
|
|
278
|
+
* const lastUserMsg = messages.findLast(m => m.role === 'user');
|
|
279
|
+
* if (lastUserMsg) {
|
|
280
|
+
* const kbContext = await bridge.getKBContext(lastUserMsg.content);
|
|
281
|
+
* if (kbContext) {
|
|
282
|
+
* messages.push({ role: 'system', content: kbContext });
|
|
283
|
+
* }
|
|
284
|
+
* }
|
|
285
|
+
* return messages;
|
|
286
|
+
* });
|
|
287
|
+
*
|
|
288
|
+
* // Add coordination context to system prompt
|
|
289
|
+
* openclaw.appendSystemPrompt(bridge.getCoordinationContext());
|
|
290
|
+
*
|
|
291
|
+
* // Session lifecycle
|
|
292
|
+
* openclaw.onSessionStart((s) => bridge.onSessionStart(s.id));
|
|
293
|
+
* openclaw.onSessionEnd((s) => bridge.onSessionEnd(s.id));
|
|
294
|
+
* }
|
|
295
|
+
* ```
|
|
296
|
+
*/
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval Workflow Engine
|
|
3
|
+
*
|
|
4
|
+
* Human-in-the-loop for sensitive agent operations.
|
|
5
|
+
* When a tool requires approval (based on risk level or side effects),
|
|
6
|
+
* the engine queues the request and notifies approvers.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ─── Types ──────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export interface ApprovalRequest {
|
|
12
|
+
id: string;
|
|
13
|
+
agentId: string;
|
|
14
|
+
agentName: string;
|
|
15
|
+
toolId: string;
|
|
16
|
+
toolName: string;
|
|
17
|
+
reason: string; // Why approval is needed
|
|
18
|
+
riskLevel: string;
|
|
19
|
+
sideEffects: string[];
|
|
20
|
+
parameters?: Record<string, any>; // What the agent wants to do (sanitized)
|
|
21
|
+
context?: string; // Brief description of what the agent is working on
|
|
22
|
+
status: 'pending' | 'approved' | 'denied' | 'expired';
|
|
23
|
+
decision?: ApprovalDecision;
|
|
24
|
+
createdAt: string;
|
|
25
|
+
expiresAt: string; // Auto-deny after this
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ApprovalDecision {
|
|
29
|
+
by: string; // User ID of approver
|
|
30
|
+
action: 'approve' | 'deny';
|
|
31
|
+
reason?: string; // Optional reason for deny
|
|
32
|
+
timestamp: string;
|
|
33
|
+
conditions?: string; // "Approved with conditions: ..."
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ApprovalPolicy {
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
|
|
41
|
+
// What triggers this policy
|
|
42
|
+
triggers: {
|
|
43
|
+
riskLevels?: string[];
|
|
44
|
+
sideEffects?: string[];
|
|
45
|
+
toolIds?: string[]; // Specific tools
|
|
46
|
+
allExternalActions?: boolean; // Any action with side effects
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Who can approve
|
|
50
|
+
approvers: {
|
|
51
|
+
userIds: string[]; // Specific users
|
|
52
|
+
roles: string[]; // Any user with these roles
|
|
53
|
+
requireMultiple?: number; // Require N approvals (default: 1)
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Timing
|
|
57
|
+
timeout: {
|
|
58
|
+
minutes: number;
|
|
59
|
+
defaultAction: 'deny' | 'allow'; // What happens on timeout
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Notification
|
|
63
|
+
notify: {
|
|
64
|
+
channels: ('email' | 'slack' | 'webhook')[];
|
|
65
|
+
webhookUrl?: string;
|
|
66
|
+
slackChannel?: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
enabled: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Engine ─────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export class ApprovalEngine {
|
|
75
|
+
private requests = new Map<string, ApprovalRequest>();
|
|
76
|
+
private policies: ApprovalPolicy[] = [];
|
|
77
|
+
private listeners: ((req: ApprovalRequest) => void)[] = [];
|
|
78
|
+
|
|
79
|
+
addPolicy(policy: ApprovalPolicy) {
|
|
80
|
+
this.policies.push(policy);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
removePolicy(id: string) {
|
|
84
|
+
this.policies = this.policies.filter(p => p.id !== id);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
getPolicies(): ApprovalPolicy[] {
|
|
88
|
+
return [...this.policies];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if a tool call needs approval and create a request if so
|
|
93
|
+
*/
|
|
94
|
+
async requestApproval(opts: {
|
|
95
|
+
agentId: string;
|
|
96
|
+
agentName: string;
|
|
97
|
+
toolId: string;
|
|
98
|
+
toolName: string;
|
|
99
|
+
riskLevel: string;
|
|
100
|
+
sideEffects: string[];
|
|
101
|
+
parameters?: Record<string, any>;
|
|
102
|
+
context?: string;
|
|
103
|
+
}): Promise<ApprovalRequest | null> {
|
|
104
|
+
// Find matching policy
|
|
105
|
+
const policy = this.findMatchingPolicy(opts.toolId, opts.riskLevel, opts.sideEffects);
|
|
106
|
+
if (!policy) return null; // No approval needed
|
|
107
|
+
|
|
108
|
+
const request: ApprovalRequest = {
|
|
109
|
+
id: crypto.randomUUID(),
|
|
110
|
+
agentId: opts.agentId,
|
|
111
|
+
agentName: opts.agentName,
|
|
112
|
+
toolId: opts.toolId,
|
|
113
|
+
toolName: opts.toolName,
|
|
114
|
+
reason: `Policy "${policy.name}" requires approval`,
|
|
115
|
+
riskLevel: opts.riskLevel,
|
|
116
|
+
sideEffects: opts.sideEffects,
|
|
117
|
+
parameters: this.sanitizeParams(opts.parameters),
|
|
118
|
+
context: opts.context,
|
|
119
|
+
status: 'pending',
|
|
120
|
+
createdAt: new Date().toISOString(),
|
|
121
|
+
expiresAt: new Date(Date.now() + policy.timeout.minutes * 60_000).toISOString(),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
this.requests.set(request.id, request);
|
|
125
|
+
|
|
126
|
+
// Notify approvers
|
|
127
|
+
await this.notifyApprovers(request, policy);
|
|
128
|
+
|
|
129
|
+
// Set expiry timer
|
|
130
|
+
setTimeout(() => {
|
|
131
|
+
const req = this.requests.get(request.id);
|
|
132
|
+
if (req && req.status === 'pending') {
|
|
133
|
+
req.status = 'expired';
|
|
134
|
+
if (policy.timeout.defaultAction === 'allow') {
|
|
135
|
+
req.status = 'approved';
|
|
136
|
+
req.decision = {
|
|
137
|
+
by: 'system',
|
|
138
|
+
action: 'approve',
|
|
139
|
+
reason: 'Auto-approved: approval timeout expired',
|
|
140
|
+
timestamp: new Date().toISOString(),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
this.notifyListeners(req);
|
|
144
|
+
}
|
|
145
|
+
}, policy.timeout.minutes * 60_000);
|
|
146
|
+
|
|
147
|
+
// Notify listeners
|
|
148
|
+
this.notifyListeners(request);
|
|
149
|
+
|
|
150
|
+
return request;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Approve or deny a pending request
|
|
155
|
+
*/
|
|
156
|
+
decide(requestId: string, decision: Omit<ApprovalDecision, 'timestamp'>): ApprovalRequest | null {
|
|
157
|
+
const request = this.requests.get(requestId);
|
|
158
|
+
if (!request || request.status !== 'pending') return null;
|
|
159
|
+
|
|
160
|
+
request.status = decision.action === 'approve' ? 'approved' : 'denied';
|
|
161
|
+
request.decision = { ...decision, timestamp: new Date().toISOString() };
|
|
162
|
+
|
|
163
|
+
this.notifyListeners(request);
|
|
164
|
+
return request;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get all pending requests (for the dashboard)
|
|
169
|
+
*/
|
|
170
|
+
getPendingRequests(agentId?: string): ApprovalRequest[] {
|
|
171
|
+
const all = Array.from(this.requests.values()).filter(r => r.status === 'pending');
|
|
172
|
+
return agentId ? all.filter(r => r.agentId === agentId) : all;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get request by ID
|
|
177
|
+
*/
|
|
178
|
+
getRequest(id: string): ApprovalRequest | undefined {
|
|
179
|
+
return this.requests.get(id);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Get history of all requests
|
|
184
|
+
*/
|
|
185
|
+
getHistory(opts?: { agentId?: string; limit?: number; offset?: number }): { requests: ApprovalRequest[]; total: number } {
|
|
186
|
+
let all = Array.from(this.requests.values()).sort((a, b) =>
|
|
187
|
+
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
188
|
+
);
|
|
189
|
+
if (opts?.agentId) all = all.filter(r => r.agentId === opts.agentId);
|
|
190
|
+
const total = all.length;
|
|
191
|
+
const offset = opts?.offset || 0;
|
|
192
|
+
const limit = opts?.limit || 25;
|
|
193
|
+
return { requests: all.slice(offset, offset + limit), total };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Wait for a specific request to be decided (for sync approval flows)
|
|
198
|
+
*/
|
|
199
|
+
async waitForDecision(requestId: string, timeoutMs: number = 300_000): Promise<ApprovalRequest> {
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
const check = setInterval(() => {
|
|
202
|
+
const req = this.requests.get(requestId);
|
|
203
|
+
if (req && req.status !== 'pending') {
|
|
204
|
+
clearInterval(check);
|
|
205
|
+
clearTimeout(timeout);
|
|
206
|
+
resolve(req);
|
|
207
|
+
}
|
|
208
|
+
}, 1000);
|
|
209
|
+
|
|
210
|
+
const timeout = setTimeout(() => {
|
|
211
|
+
clearInterval(check);
|
|
212
|
+
const req = this.requests.get(requestId);
|
|
213
|
+
if (req) {
|
|
214
|
+
req.status = 'expired';
|
|
215
|
+
resolve(req);
|
|
216
|
+
} else {
|
|
217
|
+
reject(new Error('Request not found'));
|
|
218
|
+
}
|
|
219
|
+
}, timeoutMs);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Subscribe to approval request changes
|
|
225
|
+
*/
|
|
226
|
+
onRequest(listener: (req: ApprovalRequest) => void) {
|
|
227
|
+
this.listeners.push(listener);
|
|
228
|
+
return () => { this.listeners = this.listeners.filter(l => l !== listener); };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ─── Private ──────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
private findMatchingPolicy(toolId: string, riskLevel: string, sideEffects: string[]): ApprovalPolicy | undefined {
|
|
234
|
+
return this.policies.find(p => {
|
|
235
|
+
if (!p.enabled) return false;
|
|
236
|
+
if (p.triggers.toolIds?.includes(toolId)) return true;
|
|
237
|
+
if (p.triggers.riskLevels?.includes(riskLevel)) return true;
|
|
238
|
+
if (p.triggers.sideEffects?.some(e => sideEffects.includes(e))) return true;
|
|
239
|
+
if (p.triggers.allExternalActions && sideEffects.length > 0) return true;
|
|
240
|
+
return false;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private sanitizeParams(params?: Record<string, any>): Record<string, any> | undefined {
|
|
245
|
+
if (!params) return undefined;
|
|
246
|
+
// Remove sensitive fields
|
|
247
|
+
const sanitized = { ...params };
|
|
248
|
+
for (const key of ['password', 'token', 'secret', 'key', 'apiKey', 'credential']) {
|
|
249
|
+
if (key in sanitized) sanitized[key] = '***';
|
|
250
|
+
}
|
|
251
|
+
return sanitized;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async notifyApprovers(request: ApprovalRequest, policy: ApprovalPolicy) {
|
|
255
|
+
for (const channel of policy.notify.channels) {
|
|
256
|
+
switch (channel) {
|
|
257
|
+
case 'webhook':
|
|
258
|
+
if (policy.notify.webhookUrl) {
|
|
259
|
+
try {
|
|
260
|
+
await fetch(policy.notify.webhookUrl, {
|
|
261
|
+
method: 'POST',
|
|
262
|
+
headers: { 'Content-Type': 'application/json' },
|
|
263
|
+
body: JSON.stringify({ type: 'approval_request', request }),
|
|
264
|
+
});
|
|
265
|
+
} catch { /* fail silently */ }
|
|
266
|
+
}
|
|
267
|
+
break;
|
|
268
|
+
// Email and Slack notifications would integrate with the existing AgenticMail system
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private notifyListeners(request: ApprovalRequest) {
|
|
274
|
+
for (const listener of this.listeners) {
|
|
275
|
+
try { listener(request); } catch { /* ignore */ }
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|