multicorn-shield 0.11.0 → 0.13.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.
@@ -377,7 +377,6 @@ ${url}
377
377
  }
378
378
  async function waitForConsent(agentId, agentName, apiKey, baseUrl, scope, logger) {
379
379
  const dashboardUrl = deriveDashboardUrl(baseUrl);
380
- console.error("[SHIELD] buildConsentUrl baseUrl:", baseUrl);
381
380
  const consentUrl = buildConsentUrl(agentName, dashboardUrl, scope);
382
381
  process.stderr.write(
383
382
  `[multicorn-shield] Opening consent page...
@@ -315,14 +315,6 @@ async function fetchGrantedScopes(agentId, apiKey, baseUrl, logger) {
315
315
  }
316
316
  async function checkActionPermission(payload, apiKey, baseUrl, logger) {
317
317
  try {
318
- const requestBody = {
319
- agent: payload.agent,
320
- service: payload.service,
321
- actionType: payload.actionType,
322
- status: payload.status,
323
- metadata: payload.metadata
324
- };
325
- console.error("[SHIELD-CLIENT] POST /api/v1/actions request: " + JSON.stringify(requestBody));
326
318
  const response = await fetch(`${baseUrl}/api/v1/actions`, {
327
319
  method: "POST",
328
320
  headers: {
@@ -333,22 +325,18 @@ async function checkActionPermission(payload, apiKey, baseUrl, logger) {
333
325
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
334
326
  });
335
327
  if (response.status === 201) {
336
- console.error(
337
- "[SHIELD-CLIENT] response status=201, returning approved (body not read - backend may have failed approval creation)"
338
- );
328
+ console.error("[SHIELD-CLIENT] POST /api/v1/actions: 201 approved");
339
329
  return { status: "approved" };
340
330
  }
341
331
  if (response.status === 202) {
342
332
  const body = await response.json();
343
333
  const data = isApiSuccess(body) ? body.data : null;
344
- console.error("[SHIELD-CLIENT] response status=202 body=" + JSON.stringify(data ?? body));
334
+ console.error("[SHIELD-CLIENT] POST /api/v1/actions: 202 pending");
345
335
  if (!isApiSuccess(body) || data === null) {
346
336
  return { status: "blocked" };
347
337
  }
348
338
  const approvalId = typeof data["approval_id"] === "string" ? data["approval_id"] : void 0;
349
- console.error(
350
- "[SHIELD-CLIENT] extracted: status=" + String(data["status"]) + " approval_id=" + (approvalId ?? "undefined")
351
- );
339
+ console.error("[SHIELD-CLIENT] extracted: approval_id=" + (approvalId ?? "undefined"));
352
340
  if (approvalId === void 0) {
353
341
  return { status: "blocked" };
354
342
  }
@@ -439,7 +427,6 @@ ${url}
439
427
  }
440
428
  async function waitForConsent(agentId, agentName, apiKey, baseUrl, scope, logger) {
441
429
  const dashboardUrl = deriveDashboardUrl(baseUrl);
442
- console.error("[SHIELD] buildConsentUrl baseUrl:", baseUrl);
443
430
  const consentUrl = buildConsentUrl(agentName, dashboardUrl, scope);
444
431
  process.stderr.write(
445
432
  `[multicorn-shield] Opening consent page...
@@ -509,7 +496,14 @@ function readConfig() {
509
496
  const resolvedBaseUrl = asString(cachedMulticornConfig?.baseUrl) ?? asString(process.env["MULTICORN_BASE_URL"]) ?? "https://api.multicorn.ai";
510
497
  const agentName = asString(pc["agentName"]) ?? process.env["MULTICORN_AGENT_NAME"] ?? agentNameFromOpenclawPlatform(cachedMulticornConfig) ?? asString(cachedMulticornConfig?.agentName) ?? null;
511
498
  const failMode = "closed";
512
- return { apiKey: resolvedApiKey, baseUrl: resolvedBaseUrl, agentName, failMode };
499
+ let apiKey = resolvedApiKey;
500
+ if (apiKey.length > 0 && (!apiKey.startsWith("mcs_") || apiKey.length < 16)) {
501
+ pluginLogger?.error(
502
+ "Invalid API key format. Key must start with mcs_ and be at least 16 characters."
503
+ );
504
+ apiKey = "";
505
+ }
506
+ return { apiKey, baseUrl: resolvedBaseUrl, agentName, failMode };
513
507
  }
514
508
  function asString(value) {
515
509
  return typeof value === "string" && value.length > 0 ? value : void 0;
@@ -8,7 +8,9 @@
8
8
  "additionalProperties": false,
9
9
  "properties": {
10
10
  "apiKey": {
11
- "type": "string"
11
+ "type": "string",
12
+ "pattern": "^mcs_.{12,}$",
13
+ "minLength": 16
12
14
  },
13
15
  "baseUrl": {
14
16
  "type": "string"
package/dist/proxy.cjs CHANGED
@@ -437,12 +437,186 @@ function mapMcpToolToScope(toolName) {
437
437
  return { service: head, permissionLevel, actionType };
438
438
  }
439
439
 
440
+ // src/logger/action-logger.ts
441
+ function createActionLogger(config) {
442
+ if (!config.apiKey || config.apiKey.trim().length === 0) {
443
+ throw new Error(
444
+ "[ActionLogger] API key is required. Provide it via the 'apiKey' config option."
445
+ );
446
+ }
447
+ const baseUrl = config.baseUrl ?? "https://api.multicorn.ai";
448
+ const timeout = config.timeout ?? 5e3;
449
+ if (!baseUrl.startsWith("https://") && !baseUrl.startsWith("http://localhost")) {
450
+ throw new Error(
451
+ `[ActionLogger] Base URL must use HTTPS for security. Received: "${baseUrl}". Use https:// or http://localhost for local development.`
452
+ );
453
+ }
454
+ const endpoint = `${baseUrl}/api/v1/actions`;
455
+ const batchEnabled = config.batchMode?.enabled ?? false;
456
+ const maxBatchSize = config.batchMode?.maxSize ?? 10;
457
+ const flushInterval = config.batchMode?.flushIntervalMs ?? 5e3;
458
+ const queue = [];
459
+ let flushTimer;
460
+ let isShutdown = false;
461
+ async function sendActions(actions) {
462
+ if (actions.length === 0) return;
463
+ const convertAction = (action) => ({
464
+ agent: action.agent,
465
+ service: action.service,
466
+ actionType: action.actionType,
467
+ status: action.status,
468
+ ...action.cost !== void 0 ? { cost: action.cost } : {},
469
+ ...action.metadata !== void 0 ? { metadata: action.metadata } : {}
470
+ });
471
+ const convertedActions = actions.map(convertAction);
472
+ const payload = batchEnabled ? { actions: convertedActions } : convertedActions[0];
473
+ let lastError;
474
+ for (let attempt = 0; attempt < 2; attempt++) {
475
+ try {
476
+ const controller = new AbortController();
477
+ const timeoutId = setTimeout(() => {
478
+ controller.abort();
479
+ }, timeout);
480
+ const response = await fetch(endpoint, {
481
+ method: "POST",
482
+ headers: {
483
+ "Content-Type": "application/json",
484
+ "X-Multicorn-Key": config.apiKey
485
+ },
486
+ body: JSON.stringify(payload),
487
+ signal: controller.signal
488
+ });
489
+ clearTimeout(timeoutId);
490
+ if (response.ok) {
491
+ return;
492
+ }
493
+ if (response.status >= 400 && response.status < 500) {
494
+ const body = await response.text().catch(() => "");
495
+ throw new Error(
496
+ `[ActionLogger] Client error (${String(response.status)}): ${response.statusText}. Response: ${body}. Check your API key and payload format.`
497
+ );
498
+ }
499
+ if (response.status >= 500 && attempt === 0) {
500
+ lastError = new Error(
501
+ `[ActionLogger] Server error (${String(response.status)}): ${response.statusText}. Retrying once...`
502
+ );
503
+ await sleep(100 * Math.pow(2, attempt));
504
+ continue;
505
+ }
506
+ throw new Error(
507
+ `[ActionLogger] Server error (${String(response.status)}) after retry: ${response.statusText}. Multicorn API may be experiencing issues.`
508
+ );
509
+ } catch (error) {
510
+ if (error instanceof Error) {
511
+ if (error.name === "AbortError") {
512
+ lastError = new Error(
513
+ `[ActionLogger] Request timeout after ${String(timeout)}ms. Increase the 'timeout' config option or check your network connection.`
514
+ );
515
+ } else if (error.message.includes("Client error") || error.message.includes("Server error")) {
516
+ lastError = error;
517
+ } else {
518
+ lastError = new Error(
519
+ `[ActionLogger] Network error: ${error.message}. Check your network connection and API endpoint.`
520
+ );
521
+ }
522
+ } else {
523
+ lastError = new Error(`[ActionLogger] Unknown error: ${String(error)}`);
524
+ }
525
+ if (attempt === 0 && !lastError.message.includes("Client error")) {
526
+ await sleep(100 * Math.pow(2, attempt));
527
+ continue;
528
+ }
529
+ break;
530
+ }
531
+ }
532
+ if (lastError) {
533
+ if (config.onError) {
534
+ config.onError(lastError);
535
+ }
536
+ }
537
+ }
538
+ async function flushQueue() {
539
+ if (queue.length === 0) return;
540
+ const actions = queue.map((item) => item.payload);
541
+ queue.length = 0;
542
+ await sendActions(actions);
543
+ }
544
+ function startFlushTimer() {
545
+ if (flushTimer !== void 0) return;
546
+ flushTimer = setInterval(() => {
547
+ flushQueue().catch(() => {
548
+ });
549
+ }, flushInterval);
550
+ const timer = flushTimer;
551
+ if (typeof timer.unref === "function") {
552
+ timer.unref();
553
+ }
554
+ }
555
+ function stopFlushTimer() {
556
+ if (flushTimer) {
557
+ clearInterval(flushTimer);
558
+ flushTimer = void 0;
559
+ }
560
+ }
561
+ if (batchEnabled) {
562
+ startFlushTimer();
563
+ }
564
+ return {
565
+ logAction(action) {
566
+ if (isShutdown) {
567
+ throw new Error(
568
+ "[ActionLogger] Cannot log action after shutdown. Create a new logger instance."
569
+ );
570
+ }
571
+ if (action.agent.trim().length === 0) {
572
+ throw new Error("[ActionLogger] Action must have a non-empty 'agent' field.");
573
+ }
574
+ if (action.service.trim().length === 0) {
575
+ throw new Error("[ActionLogger] Action must have a non-empty 'service' field.");
576
+ }
577
+ if (action.actionType.trim().length === 0) {
578
+ throw new Error("[ActionLogger] Action must have a non-empty 'actionType' field.");
579
+ }
580
+ if (action.status.trim().length === 0) {
581
+ throw new Error("[ActionLogger] Action must have a non-empty 'status' field.");
582
+ }
583
+ if (batchEnabled) {
584
+ queue.push({ payload: action, timestamp: Date.now() });
585
+ if (queue.length >= maxBatchSize) {
586
+ flushQueue().catch(() => {
587
+ });
588
+ }
589
+ } else {
590
+ sendActions([action]).catch(() => {
591
+ });
592
+ }
593
+ return Promise.resolve();
594
+ },
595
+ async flush() {
596
+ if (!batchEnabled) return;
597
+ await flushQueue();
598
+ },
599
+ async shutdown() {
600
+ if (isShutdown) return;
601
+ isShutdown = true;
602
+ stopFlushTimer();
603
+ if (batchEnabled) {
604
+ await flushQueue();
605
+ }
606
+ }
607
+ };
608
+ }
609
+ function sleep(ms) {
610
+ return new Promise((resolve) => setTimeout(resolve, ms));
611
+ }
612
+
440
613
  exports.ShieldAuthError = ShieldAuthError;
441
614
  exports.buildAuthErrorResponse = buildAuthErrorResponse;
442
615
  exports.buildBlockedResponse = buildBlockedResponse;
443
616
  exports.buildInternalErrorResponse = buildInternalErrorResponse;
444
617
  exports.buildServiceUnreachableResponse = buildServiceUnreachableResponse;
445
618
  exports.buildSpendingBlockedResponse = buildSpendingBlockedResponse;
619
+ exports.createActionLogger = createActionLogger;
446
620
  exports.createLogger = createLogger;
447
621
  exports.deriveDashboardUrl = deriveDashboardUrl;
448
622
  exports.extractActionFromToolName = extractActionFromToolName;
package/dist/proxy.d.cts CHANGED
@@ -68,6 +68,23 @@ declare const PERMISSION_LEVELS: {
68
68
  readonly Create: "create";
69
69
  };
70
70
  type PermissionLevel = (typeof PERMISSION_LEVELS)[keyof typeof PERMISSION_LEVELS];
71
+ /**
72
+ * Lifecycle states for an action processed by the policy engine.
73
+ *
74
+ * - `approved`: action passed policy checks and was executed
75
+ * - `blocked`: action was denied by policy
76
+ * - `pending`: action is awaiting human approval
77
+ * - `flagged`: action was executed but flagged for review
78
+ * - `requires_approval`: action requires content review before execution
79
+ */
80
+ declare const ACTION_STATUSES: {
81
+ readonly Approved: "approved";
82
+ readonly Blocked: "blocked";
83
+ readonly Pending: "pending";
84
+ readonly Flagged: "flagged";
85
+ readonly RequiresApproval: "requires_approval";
86
+ };
87
+ type ActionStatus = (typeof ACTION_STATUSES)[keyof typeof ACTION_STATUSES];
71
88
  /**
72
89
  * A single permission scope binding a service to an access level.
73
90
  *
@@ -232,4 +249,214 @@ interface McpToolScopeMapping {
232
249
  */
233
250
  declare function mapMcpToolToScope(toolName: string): McpToolScopeMapping;
234
251
 
235
- export { type AgentRecord, type JsonRpcError, type JsonRpcRequest, type JsonRpcResponse, type LogLevel, type ProxyLogger, ShieldAuthError, type ToolCallParams, buildAuthErrorResponse, buildBlockedResponse, buildInternalErrorResponse, buildServiceUnreachableResponse, buildSpendingBlockedResponse, createLogger, deriveDashboardUrl, extractActionFromToolName, extractServiceFromToolName, extractToolCallParams, fetchGrantedScopes, findAgentByName, isValidLogLevel, mapMcpToolToScope, parseJsonRpcLine, registerAgent, validateScopeAccess };
252
+ /**
253
+ * Action logging client for Multicorn Shield.
254
+ *
255
+ * Sends structured action events to the Multicorn hosted API, providing the
256
+ * observability backbone for Shield. Every agent action is logged with
257
+ * metadata, cost information, and status tracking.
258
+ *
259
+ * **Design principles:**
260
+ * - **Fire-and-forget**: Logging failures MUST NOT block the agent's action.
261
+ * - **Security (Jordan persona)**: API keys never logged, HTTPS enforced.
262
+ * - **Clear errors (Yuki persona)**: Descriptive messages for all failure modes.
263
+ * - **Clean async (The Team persona)**: Proper promise handling, no hanging requests.
264
+ *
265
+ * @module logger/action-logger
266
+ */
267
+
268
+ /**
269
+ * Configuration options for the action logger client.
270
+ *
271
+ * @example
272
+ * ```ts
273
+ * const config: ActionLoggerConfig = {
274
+ * apiKey: process.env.MULTICORN_API_KEY,
275
+ * baseUrl: "https://api.multicorn.ai",
276
+ * timeout: 5000,
277
+ * batchMode: {
278
+ * enabled: true,
279
+ * maxSize: 10,
280
+ * flushIntervalMs: 5000,
281
+ * },
282
+ * };
283
+ * ```
284
+ */
285
+ interface ActionLoggerConfig {
286
+ /**
287
+ * Multicorn API key for authentication.
288
+ * Passed as `X-Multicorn-Key` header. Never logged.
289
+ */
290
+ readonly apiKey: string;
291
+ /**
292
+ * Base URL for the Multicorn API.
293
+ * @default "https://api.multicorn.ai"
294
+ */
295
+ readonly baseUrl?: string;
296
+ /**
297
+ * Request timeout in milliseconds.
298
+ * @default 5000
299
+ */
300
+ readonly timeout?: number;
301
+ /**
302
+ * Optional batch mode configuration.
303
+ * When enabled, actions are queued and flushed periodically.
304
+ */
305
+ readonly batchMode?: BatchModeConfig;
306
+ /**
307
+ * Optional error handler for logging failures.
308
+ * Called asynchronously. Does not block the main action flow.
309
+ */
310
+ readonly onError?: (error: Error) => void;
311
+ }
312
+ /**
313
+ * Batch mode configuration.
314
+ *
315
+ * Actions are flushed when **either** condition is met:
316
+ * - The queue reaches `maxSize` actions, OR
317
+ * - `flushIntervalMs` milliseconds have elapsed since the last flush.
318
+ */
319
+ interface BatchModeConfig {
320
+ /** Whether batch mode is enabled. */
321
+ readonly enabled: boolean;
322
+ /**
323
+ * Maximum number of actions to queue before forcing a flush.
324
+ * @default 10
325
+ */
326
+ readonly maxSize?: number;
327
+ /**
328
+ * Maximum time (ms) between flushes.
329
+ * @default 5000
330
+ */
331
+ readonly flushIntervalMs?: number;
332
+ }
333
+ /**
334
+ * A single action event to be logged.
335
+ *
336
+ * @example
337
+ * ```ts
338
+ * const action: ActionPayload = {
339
+ * agent: "inbox-assistant",
340
+ * service: "gmail",
341
+ * actionType: "send_email",
342
+ * status: "approved",
343
+ * cost: 0.002,
344
+ * metadata: {
345
+ * recipient: "user@example.com",
346
+ * subject: "Weekly report",
347
+ * },
348
+ * };
349
+ * ```
350
+ */
351
+ interface ActionPayload {
352
+ /** Agent identifier (e.g. "inbox-assistant"). */
353
+ readonly agent: string;
354
+ /** Service being accessed (e.g. "gmail", "slack"). */
355
+ readonly service: string;
356
+ /** Type of action performed (e.g. "send_email", "read_message"). */
357
+ readonly actionType: string;
358
+ /** Lifecycle status of the action. */
359
+ readonly status: ActionStatus;
360
+ /**
361
+ * Optional cost in USD incurred by this action.
362
+ * Present only for actions with usage-based pricing.
363
+ */
364
+ readonly cost?: number;
365
+ /**
366
+ * Optional structured metadata for additional context.
367
+ * Keys and values must be serializable to JSON.
368
+ */
369
+ readonly metadata?: Readonly<Record<string, string | number | boolean>>;
370
+ }
371
+ /**
372
+ * HTTP client for logging agent actions to the Multicorn Shield API.
373
+ *
374
+ * Supports both immediate and batched delivery modes. All network failures
375
+ * are handled gracefully to ensure logging never blocks the agent's execution.
376
+ *
377
+ * @example Basic usage (immediate mode)
378
+ * ```ts
379
+ * const logger = createActionLogger({
380
+ * apiKey: process.env.MULTICORN_API_KEY!,
381
+ * });
382
+ *
383
+ * await logger.logAction({
384
+ * agent: "email-assistant",
385
+ * service: "gmail",
386
+ * actionType: "send_email",
387
+ * status: "approved",
388
+ * cost: 0.002,
389
+ * });
390
+ * ```
391
+ *
392
+ * @example Batch mode with error handling
393
+ * ```ts
394
+ * const logger = createActionLogger({
395
+ * apiKey: process.env.MULTICORN_API_KEY!,
396
+ * batchMode: { enabled: true, maxSize: 10, flushIntervalMs: 5000 },
397
+ * onError: (err) => console.error("[ActionLogger]", err.message),
398
+ * });
399
+ *
400
+ * // Actions are queued and flushed automatically
401
+ * logger.logAction({
402
+ * agent: "inbox-assistant",
403
+ * service: "gmail",
404
+ * actionType: "read_message",
405
+ * status: "approved",
406
+ * });
407
+ *
408
+ * // Force immediate flush
409
+ * await logger.flush();
410
+ *
411
+ * // Clean up resources
412
+ * logger.shutdown();
413
+ * ```
414
+ */
415
+ interface ActionLogger {
416
+ /**
417
+ * Log a single action event.
418
+ *
419
+ * In immediate mode, sends the action to the API right away (non-blocking).
420
+ * In batch mode, queues the action and flushes when thresholds are met.
421
+ *
422
+ * @param action - The action event to log.
423
+ * @returns A promise that resolves when the action is sent (immediate mode)
424
+ * or queued (batch mode). Rejects only on validation errors, not
425
+ * network failures (those are passed to `onError`).
426
+ */
427
+ logAction(action: ActionPayload): Promise<void>;
428
+ /**
429
+ * Flush all queued actions immediately (batch mode only).
430
+ *
431
+ * In immediate mode, this is a no-op.
432
+ *
433
+ * @returns A promise that resolves when all queued actions have been sent.
434
+ */
435
+ flush(): Promise<void>;
436
+ /**
437
+ * Shut down the logger and clean up resources.
438
+ *
439
+ * Stops the flush timer (if in batch mode) and flushes any remaining actions.
440
+ * After calling `shutdown()`, the logger should not be used.
441
+ */
442
+ shutdown(): Promise<void>;
443
+ }
444
+ /**
445
+ * Create a new action logger client.
446
+ *
447
+ * @param config - Configuration options.
448
+ * @returns An {@link ActionLogger} instance.
449
+ * @throws {Error} If the API key is missing or the base URL is not HTTPS.
450
+ *
451
+ * @example
452
+ * ```ts
453
+ * const logger = createActionLogger({
454
+ * apiKey: process.env.MULTICORN_API_KEY!,
455
+ * baseUrl: "https://api.multicorn.ai",
456
+ * timeout: 3000,
457
+ * });
458
+ * ```
459
+ */
460
+ declare function createActionLogger(config: ActionLoggerConfig): ActionLogger;
461
+
462
+ export { type ActionLogger, type ActionLoggerConfig, type ActionPayload, type AgentRecord, type JsonRpcError, type JsonRpcRequest, type JsonRpcResponse, type LogLevel, type PermissionLevel, type ProxyLogger, type Scope, ShieldAuthError, type ToolCallParams, buildAuthErrorResponse, buildBlockedResponse, buildInternalErrorResponse, buildServiceUnreachableResponse, buildSpendingBlockedResponse, createActionLogger, createLogger, deriveDashboardUrl, extractActionFromToolName, extractServiceFromToolName, extractToolCallParams, fetchGrantedScopes, findAgentByName, isValidLogLevel, mapMcpToolToScope, parseJsonRpcLine, registerAgent, validateScopeAccess };