multicorn-shield 0.9.0 → 0.11.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/dist/index.d.ts CHANGED
@@ -652,6 +652,42 @@ declare function validateAllScopesAccess(grantedScopes: readonly Scope[], reques
652
652
  */
653
653
  declare function hasScope(grantedScopes: readonly Scope[], requested: Scope): boolean;
654
654
 
655
+ /**
656
+ * Detects if a scope requires content review before execution.
657
+ *
658
+ * Public content scopes that require review:
659
+ * - `publish:web` - publishing content to the web
660
+ * - `create:public_content` - creating public-facing content
661
+ *
662
+ * @module scopes/content-review-detector
663
+ */
664
+
665
+ /**
666
+ * Check if a scope requires content review.
667
+ *
668
+ * @param scope - The scope to check
669
+ * @returns `true` if the scope requires content review
670
+ *
671
+ * @example
672
+ * ```ts
673
+ * requiresContentReview({ service: "web", permissionLevel: "publish" }); // true
674
+ * requiresContentReview({ service: "public_content", permissionLevel: "create" }); // true
675
+ * requiresContentReview({ service: "gmail", permissionLevel: "execute" }); // false
676
+ * ```
677
+ */
678
+ declare function requiresContentReview(scope: Scope): boolean;
679
+ /**
680
+ * Check if a tool name/action indicates public content creation.
681
+ *
682
+ * This is a helper for cases where the service might not be explicitly
683
+ * "web" or "public_content" but the action name indicates public content.
684
+ *
685
+ * @param toolName - The tool name to check
686
+ * @param service - The service name
687
+ * @returns `true` if the tool/action indicates public content
688
+ */
689
+ declare function isPublicContentAction(toolName: string, service: string): boolean;
690
+
655
691
  /**
656
692
  * Custom element tag name for the consent component.
657
693
  */
@@ -1033,6 +1069,29 @@ interface FocusTrap {
1033
1069
  */
1034
1070
  declare function createFocusTrap(container: HTMLElement, initialFocus?: HTMLElement | null): FocusTrap;
1035
1071
 
1072
+ /**
1073
+ * `<multicorn-badge>`: small embeddable trust badge (Shadow DOM).
1074
+ * Implemented as a native custom element to keep the CDN `badge.js` under the
1075
+ * size budget. Styling and tokens align with the Lit-based consent screen.
1076
+ *
1077
+ * @module badge/multicorn-badge
1078
+ */
1079
+ /** Custom element tag for the trust badge. */
1080
+ declare const BADGE_ELEMENT_TAG: "multicorn-badge";
1081
+ declare class MulticornBadge extends HTMLElement {
1082
+ #private;
1083
+ private ensureShadow;
1084
+ static get observedAttributes(): string[];
1085
+ connectedCallback(): void;
1086
+ attributeChangedCallback(): void;
1087
+ private render;
1088
+ }
1089
+ declare global {
1090
+ interface HTMLElementTagNameMap {
1091
+ [BADGE_ELEMENT_TAG]: MulticornBadge;
1092
+ }
1093
+ }
1094
+
1036
1095
  /**
1037
1096
  * Action logging client for Multicorn Shield.
1038
1097
  *
@@ -1775,6 +1834,11 @@ interface McpAdapterConfig {
1775
1834
  * Used with baseUrl to fetch auto-approval status if checkAutoApprove is not provided.
1776
1835
  */
1777
1836
  readonly apiKey?: string;
1837
+ /**
1838
+ * When `true`, blocks until the content review completes (or times out) and forwards the tool call if approved.
1839
+ * Requires `baseUrl` and `apiKey`. Default `false` preserves the existing fast block with `requires_approval` logging only.
1840
+ */
1841
+ readonly waitForReviewDecision?: boolean;
1778
1842
  }
1779
1843
  /**
1780
1844
  * The MCP adapter produced by {@link createMcpAdapter}.
@@ -2179,4 +2243,67 @@ declare class MulticornShield {
2179
2243
  destroy(): void;
2180
2244
  }
2181
2245
 
2182
- export { ACTION_STATUSES, AGENT_STATUSES, type Action, type ActionInput, type ActionLogger, type ActionLoggerConfig, type ActionPayload, type ActionStatus, type Agent, type AgentStatus, type ApiError, BUILT_IN_SERVICES, type BatchModeConfig, type BuiltInServiceName, CONSENT_ELEMENT_TAG, type ConsentDecision, type ConsentDeniedEventDetail, type ConsentEventDetail, type ConsentEventMap, type ConsentEventName, type ConsentGrantedEventDetail, type ConsentOptions, type ConsentPartialEventDetail, type FocusTrap, type McpAdapter, type McpAdapterConfig, type McpAdapterResult, type McpBlockedResult, type McpToolCall, type McpToolHandler, type McpToolResult, MulticornConsent, MulticornShield, type MulticornShieldConfig, PERMISSION_LEVELS, type Permission, type PermissionLevel, type RemainingBudget, SERVICE_NAME_PATTERN, type Scope, ScopeParseError, type ScopeParseResult, type ScopeRegistry, type ScopeRequest, type ServiceDefinition, type SpendCheckResult, type SpendingCheckResult, type SpendingChecker, type SpendingLimit, type SpendingLimits, type SpendingTrackerConfig, type ValidationResult, centsToDollars, createActionLogger, createFocusTrap, createMcpAdapter, createScopeRegistry, createSpendingChecker, dollarsToCents, formatScope, getPermissionLabel, getScopeLabel, getScopeShortLabel, getServiceDisplayName, getServiceIcon, hasScope, isBlockedResult, isValidScopeString, parseScope, parseScopes, tryParseScope, validateAllScopesAccess, validateScopeAccess };
2246
+ /**
2247
+ * Local type definitions for the OpenClaw Plugin SDK.
2248
+ *
2249
+ * Extracted from openclaw/dist/plugin-sdk/plugins/types.d.ts (v2026.2.26).
2250
+ * We define only the subset needed for the Shield plugin so there's no
2251
+ * build-time dependency on the OpenClaw package itself.
2252
+ *
2253
+ * @module openclaw/plugin-sdk.types
2254
+ */
2255
+ interface PluginLogger {
2256
+ debug?: (message: string) => void;
2257
+ info: (message: string) => void;
2258
+ warn: (message: string) => void;
2259
+ error: (message: string) => void;
2260
+ }
2261
+
2262
+ /**
2263
+ * HTTP client for communicating with the Multicorn Shield API.
2264
+ *
2265
+ * Handles agent registration, permission fetching, and action logging.
2266
+ * Follows the same patterns as the MCP proxy client but is self-contained
2267
+ * so the hook has no runtime dependency on proxy internals.
2268
+ *
2269
+ * Security: the API key is passed as a parameter and sent only via the
2270
+ * `X-Multicorn-Key` header over HTTPS. It is never logged or written
2271
+ * to disk.
2272
+ *
2273
+ * @module openclaw/shield-client
2274
+ */
2275
+
2276
+ /**
2277
+ * `data` shape from GET /api/v1/content-reviews/:id/status when `success` is true.
2278
+ */
2279
+ interface ContentReviewStatusResponse {
2280
+ readonly id: string;
2281
+ readonly status: "pending" | "approved" | "blocked" | "timeout";
2282
+ }
2283
+ /**
2284
+ * Result of {@link requestContentReview} or {@link pollContentReviewStatus}.
2285
+ */
2286
+ interface ContentReviewResult {
2287
+ readonly status: "approved" | "blocked" | "timeout";
2288
+ readonly reviewId?: string;
2289
+ readonly reason?: string;
2290
+ }
2291
+ /**
2292
+ * Payload for {@link requestContentReview}. `cost` is optional; the POST body always includes `cost`, defaulting to `0` when omitted (matches {@link LogActionRequest} in the service).
2293
+ */
2294
+ interface ContentReviewRequestPayload {
2295
+ readonly agent: string;
2296
+ readonly service: string;
2297
+ readonly actionType: string;
2298
+ /** Defaults to `0` when omitted. Must be >= 0 if set. */
2299
+ readonly cost?: number;
2300
+ readonly metadata?: Readonly<Record<string, string | number | boolean>>;
2301
+ }
2302
+ /**
2303
+ * Create a content-review request via POST /api/v1/actions with `status: "requires_approval"`, then poll until decided.
2304
+ *
2305
+ * Wire format: response `data.content_review_id` (snake_case) per service Jackson naming.
2306
+ */
2307
+ declare function requestContentReview(payload: ContentReviewRequestPayload, apiKey: string, baseUrl: string, logger?: PluginLogger): Promise<ContentReviewResult>;
2308
+
2309
+ export { ACTION_STATUSES, AGENT_STATUSES, type Action, type ActionInput, type ActionLogger, type ActionLoggerConfig, type ActionPayload, type ActionStatus, type Agent, type AgentStatus, type ApiError, BUILT_IN_SERVICES, type BatchModeConfig, type BuiltInServiceName, CONSENT_ELEMENT_TAG, type ConsentDecision, type ConsentDeniedEventDetail, type ConsentEventDetail, type ConsentEventMap, type ConsentEventName, type ConsentGrantedEventDetail, type ConsentOptions, type ConsentPartialEventDetail, type ContentReviewRequestPayload, type ContentReviewResult, type ContentReviewStatusResponse, type FocusTrap, type McpAdapter, type McpAdapterConfig, type McpAdapterResult, type McpBlockedResult, type McpToolCall, type McpToolHandler, type McpToolResult, MulticornBadge, MulticornConsent, MulticornShield, type MulticornShieldConfig, PERMISSION_LEVELS, type Permission, type PermissionLevel, type RemainingBudget, SERVICE_NAME_PATTERN, type Scope, ScopeParseError, type ScopeParseResult, type ScopeRegistry, type ScopeRequest, type ServiceDefinition, type SpendCheckResult, type SpendingCheckResult, type SpendingChecker, type SpendingLimit, type SpendingLimits, type SpendingTrackerConfig, type ValidationResult, centsToDollars, createActionLogger, createFocusTrap, createMcpAdapter, createScopeRegistry, createSpendingChecker, dollarsToCents, formatScope, getPermissionLabel, getScopeLabel, getScopeShortLabel, getServiceDisplayName, getServiceIcon, hasScope, isBlockedResult, isPublicContentAction, isValidScopeString, parseScope, parseScopes, requestContentReview, requiresContentReview, tryParseScope, validateAllScopesAccess, validateScopeAccess };
package/dist/index.js CHANGED
@@ -285,6 +285,39 @@ function hasScope(grantedScopes, requested) {
285
285
  );
286
286
  }
287
287
 
288
+ // src/scopes/content-review-detector.ts
289
+ function requiresContentReview(scope) {
290
+ if (scope.service === "web" && scope.permissionLevel === "publish") {
291
+ return true;
292
+ }
293
+ if (scope.service === "public_content" && scope.permissionLevel === "create") {
294
+ return true;
295
+ }
296
+ return false;
297
+ }
298
+ function isPublicContentAction(toolName, service) {
299
+ const lowerToolName = toolName.toLowerCase();
300
+ const lowerService = service.toLowerCase();
301
+ if (lowerService === "web" || lowerService === "public_content") {
302
+ return true;
303
+ }
304
+ const publicContentIndicators = [
305
+ "publish",
306
+ "public",
307
+ "web",
308
+ "blog",
309
+ "post",
310
+ "article",
311
+ "social",
312
+ "twitter",
313
+ "facebook",
314
+ "linkedin",
315
+ "github_pages",
316
+ "deploy"
317
+ ];
318
+ return publicContentIndicators.some((indicator) => lowerToolName.includes(indicator));
319
+ }
320
+
288
321
  // src/consent/scope-labels.ts
289
322
  var SERVICE_DISPLAY_NAMES = {
290
323
  gmail: "Gmail",
@@ -397,6 +430,8 @@ function getScopeWarning(scopeString) {
397
430
  const metadata = getScopeMetadata(scopeString);
398
431
  return metadata?.warningMessage;
399
432
  }
433
+
434
+ // src/shared/shield-tokens.ts
400
435
  var SHIELD_COLORS = {
401
436
  bg: "#0d0d14",
402
437
  surface: "#14141f",
@@ -417,6 +452,8 @@ var SHIELD_COLORS = {
417
452
  red: "#ef4444",
418
453
  redDim: "rgba(239, 68, 68, 0.12)"
419
454
  };
455
+
456
+ // src/consent/consent-styles.ts
420
457
  var consentStyles = css`
421
458
  :host {
422
459
  display: block;
@@ -1564,6 +1601,144 @@ MulticornConsent = __decorateClass([
1564
1601
  customElement(CONSENT_ELEMENT_TAG)
1565
1602
  ], MulticornConsent);
1566
1603
 
1604
+ // src/badge/badge-styles.ts
1605
+ var LIGHT_TEXT = "#0f172a";
1606
+ var LIGHT_SURFACE = "#f8fafc";
1607
+ var LIGHT_SURFACE_HOVER = "#f1f5f9";
1608
+ var LIGHT_BORDER = "#e2e8f0";
1609
+ function getBadgeStyleText() {
1610
+ return `
1611
+ :host { display: inline-block; line-height: 0; }
1612
+ .badge {
1613
+ display: inline-flex;
1614
+ align-items: center;
1615
+ justify-content: center;
1616
+ box-sizing: border-box;
1617
+ gap: 6px;
1618
+ min-height: 28px;
1619
+ padding: 4px 10px 4px 8px;
1620
+ border-radius: 9999px;
1621
+ text-decoration: none;
1622
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
1623
+ font-size: 12px;
1624
+ font-weight: 500;
1625
+ border: 1px solid ${SHIELD_COLORS.border};
1626
+ background: ${SHIELD_COLORS.surface};
1627
+ color: ${SHIELD_COLORS.text};
1628
+ transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
1629
+ }
1630
+ :host([theme="light"]) .badge {
1631
+ border-color: ${LIGHT_BORDER};
1632
+ background: ${LIGHT_SURFACE};
1633
+ color: ${LIGHT_TEXT};
1634
+ }
1635
+ .badge:hover {
1636
+ background: ${SHIELD_COLORS.surfaceHover};
1637
+ border-color: ${SHIELD_COLORS.accent};
1638
+ box-shadow: 0 0 0 1px ${SHIELD_COLORS.accentDim};
1639
+ }
1640
+ :host([theme="light"]) .badge:hover {
1641
+ background: ${LIGHT_SURFACE_HOVER};
1642
+ border-color: ${SHIELD_COLORS.accent};
1643
+ }
1644
+ .badge:focus-visible {
1645
+ outline: 2px solid ${SHIELD_COLORS.accent};
1646
+ outline-offset: 2px;
1647
+ }
1648
+ .icon { flex-shrink: 0; display: block; }
1649
+ .text { white-space: nowrap; }
1650
+ :host([size="compact"]) .text { display: none; }
1651
+ :host([size="compact"]) .badge { padding: 4px 6px; }
1652
+ @media (prefers-reduced-motion: reduce) { .badge { transition: none; } }
1653
+ `.trim();
1654
+ }
1655
+
1656
+ // src/badge/multicorn-badge.ts
1657
+ var VERIFY_BASE = "https://multicorn.ai/verify/";
1658
+ var BADGE_ELEMENT_TAG = "multicorn-badge";
1659
+ var SHIELD_PATH = "M12 1L3 5v6c0 5.55 3.84 9.95 9 12 5.16-2.05 9-6.45 9-12V5l-9-4z";
1660
+ function parseOptionalCount(raw) {
1661
+ if (raw == null || raw === "") {
1662
+ return void 0;
1663
+ }
1664
+ const n = Number(raw);
1665
+ return Number.isNaN(n) ? void 0 : n;
1666
+ }
1667
+ var MulticornBadge = class extends HTMLElement {
1668
+ #didInjectStyle = false;
1669
+ ensureShadow() {
1670
+ if (this.shadowRoot != null) {
1671
+ return this.shadowRoot;
1672
+ }
1673
+ return this.attachShadow({ mode: "open" });
1674
+ }
1675
+ static get observedAttributes() {
1676
+ return ["agent-id", "size", "theme", "action-count"];
1677
+ }
1678
+ connectedCallback() {
1679
+ this.render();
1680
+ }
1681
+ attributeChangedCallback() {
1682
+ this.render();
1683
+ }
1684
+ render() {
1685
+ const root = this.ensureShadow();
1686
+ if (this.#didInjectStyle) ; else {
1687
+ const style = document.createElement("style");
1688
+ style.textContent = getBadgeStyleText();
1689
+ root.appendChild(style);
1690
+ this.#didInjectStyle = true;
1691
+ }
1692
+ const agentId = (this.getAttribute("agent-id") ?? "").trim();
1693
+ const actionCount = parseOptionalCount(this.getAttribute("action-count"));
1694
+ const prior = root.querySelector("a.badge");
1695
+ if (prior) {
1696
+ prior.remove();
1697
+ }
1698
+ if (agentId === "") {
1699
+ return;
1700
+ }
1701
+ let labelSuffix;
1702
+ let ariaLabel;
1703
+ if (actionCount == null) {
1704
+ labelSuffix = "Secured by Multicorn";
1705
+ ariaLabel = "Secured by Multicorn, verify this agent";
1706
+ } else {
1707
+ const count = actionCount;
1708
+ const countText = String(count);
1709
+ labelSuffix = "Secured by Multicorn \xB7 " + countText + " actions secured";
1710
+ ariaLabel = "Secured by Multicorn \xB7 " + countText + " actions secured, verify this agent";
1711
+ }
1712
+ const href = `${VERIFY_BASE}${encodeURIComponent(agentId)}`;
1713
+ const a = document.createElement("a");
1714
+ a.className = "badge";
1715
+ a.href = href;
1716
+ a.target = "_blank";
1717
+ a.rel = "noopener noreferrer";
1718
+ a.setAttribute("aria-label", ariaLabel);
1719
+ const svgNs = "http://www.w3.org/2000/svg";
1720
+ const svg = document.createElementNS(svgNs, "svg");
1721
+ svg.setAttribute("class", "icon");
1722
+ svg.setAttribute("width", "16");
1723
+ svg.setAttribute("height", "16");
1724
+ svg.setAttribute("viewBox", "0 0 24 24");
1725
+ svg.setAttribute("aria-hidden", "true");
1726
+ const path = document.createElementNS(svgNs, "path");
1727
+ path.setAttribute("d", SHIELD_PATH);
1728
+ path.setAttribute("fill", SHIELD_COLORS.accent);
1729
+ svg.appendChild(path);
1730
+ a.appendChild(svg);
1731
+ const text = document.createElement("span");
1732
+ text.className = "text";
1733
+ text.textContent = labelSuffix;
1734
+ a.appendChild(text);
1735
+ root.appendChild(a);
1736
+ }
1737
+ };
1738
+ if (customElements.get(BADGE_ELEMENT_TAG) === void 0) {
1739
+ customElements.define(BADGE_ELEMENT_TAG, MulticornBadge);
1740
+ }
1741
+
1567
1742
  // src/logger/action-logger.ts
1568
1743
  function createActionLogger(config) {
1569
1744
  if (!config.apiKey || config.apiKey.trim().length === 0) {
@@ -1876,37 +2051,215 @@ function centsToDollars(cents) {
1876
2051
  })}`;
1877
2052
  }
1878
2053
 
1879
- // src/scopes/content-review-detector.ts
1880
- function requiresContentReview(scope) {
1881
- if (scope.service === "web" && scope.permissionLevel === "publish") {
1882
- return true;
2054
+ // src/openclaw/shield-client.ts
2055
+ var REQUEST_TIMEOUT_MS = 5e3;
2056
+ var AUTH_HEADER = "X-Multicorn-Key";
2057
+ var POLL_INTERVAL_MS = 3e3;
2058
+ var MAX_POLLS = 100;
2059
+ var POLL_TIMEOUT_MS = POLL_INTERVAL_MS * MAX_POLLS;
2060
+ var authErrorLogged = false;
2061
+ function isApiSuccess(value) {
2062
+ if (typeof value !== "object" || value === null) return false;
2063
+ const obj = value;
2064
+ return obj["success"] === true;
2065
+ }
2066
+ function isContentReviewStatusResponse(value) {
2067
+ if (typeof value !== "object" || value === null) return false;
2068
+ const obj = value;
2069
+ return typeof obj["id"] === "string" && typeof obj["status"] === "string" && ["pending", "approved", "blocked", "timeout"].includes(obj["status"]);
2070
+ }
2071
+ function readApiErrorCode(body) {
2072
+ if (typeof body !== "object" || body === null) return void 0;
2073
+ const err = body["error"];
2074
+ if (typeof err !== "object" || err === null) return void 0;
2075
+ const code = err["code"];
2076
+ return typeof code === "string" ? code : void 0;
2077
+ }
2078
+ function isPlanTierInsufficientError(status, body) {
2079
+ return status === 403 && readApiErrorCode(body) === "PLAN_TIER_INSUFFICIENT";
2080
+ }
2081
+ function handleHttpError(status, logger, retryDelaySeconds) {
2082
+ if (status === 401 || status === 403) {
2083
+ if (!authErrorLogged) {
2084
+ authErrorLogged = true;
2085
+ const errorMsg = "[multicorn-shield] ERROR: Authentication failed. Your MULTICORN_API_KEY is invalid or expired. Check the key in your OpenClaw config (~/.openclaw/openclaw.json \u2192 plugins.entries.multicorn-shield.env.MULTICORN_API_KEY). Get a valid key from your Multicorn dashboard (Settings \u2192 API Keys).";
2086
+ logger?.error(errorMsg);
2087
+ process.stderr.write(`${errorMsg}
2088
+ `);
2089
+ }
2090
+ return { shouldBlock: true };
2091
+ }
2092
+ if (status === 429) {
2093
+ if (retryDelaySeconds !== void 0) {
2094
+ const rateLimitMsg = `[multicorn-shield] Rate limited by Shield API. Retrying in ${String(retryDelaySeconds)}s.`;
2095
+ logger?.warn(rateLimitMsg);
2096
+ process.stderr.write(`${rateLimitMsg}
2097
+ `);
2098
+ } else {
2099
+ const rateLimitMsg = "[multicorn-shield] Rate limited by Shield API. Action blocked: Shield cannot verify permissions.";
2100
+ logger?.warn(rateLimitMsg);
2101
+ process.stderr.write(`${rateLimitMsg}
2102
+ `);
2103
+ }
2104
+ return { shouldBlock: true };
1883
2105
  }
1884
- if (scope.service === "public_content" && scope.permissionLevel === "create") {
1885
- return true;
2106
+ if (status >= 500 && status < 600) {
2107
+ const serverErrorMsg = `[multicorn-shield] Shield API error (${String(status)}). Action blocked: Shield cannot verify permissions.`;
2108
+ logger?.warn(serverErrorMsg);
2109
+ process.stderr.write(`${serverErrorMsg}
2110
+ `);
2111
+ return { shouldBlock: true };
1886
2112
  }
1887
- return false;
2113
+ return { shouldBlock: true };
1888
2114
  }
1889
- function isPublicContentAction(toolName, service) {
1890
- const lowerToolName = toolName.toLowerCase();
1891
- const lowerService = service.toLowerCase();
1892
- if (lowerService === "web" || lowerService === "public_content") {
1893
- return true;
2115
+ async function pollContentReviewStatus(reviewId, apiKey, baseUrl, logger) {
2116
+ const startTime = Date.now();
2117
+ const logDebug = logger?.debug?.bind(logger);
2118
+ for (let pollCount = 0; pollCount < MAX_POLLS; pollCount++) {
2119
+ if (Date.now() - startTime >= POLL_TIMEOUT_MS) {
2120
+ return { status: "timeout", reason: "decision_window_exceeded", reviewId };
2121
+ }
2122
+ let row = null;
2123
+ for (let retry = 0; retry < 3; retry++) {
2124
+ try {
2125
+ const response = await fetch(`${baseUrl}/api/v1/content-reviews/${reviewId}/status`, {
2126
+ headers: { [AUTH_HEADER]: apiKey },
2127
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
2128
+ });
2129
+ if (!response.ok) {
2130
+ if (response.status === 404) {
2131
+ return { status: "blocked", reason: "review_not_found", reviewId };
2132
+ }
2133
+ const errBody = await response.json().catch(() => null);
2134
+ if (isPlanTierInsufficientError(response.status, errBody)) {
2135
+ return { status: "blocked", reason: "plan_tier_insufficient", reviewId };
2136
+ }
2137
+ if (response.status === 401 || response.status === 403) {
2138
+ handleHttpError(response.status, logger);
2139
+ return { status: "blocked", reason: "auth_error", reviewId };
2140
+ }
2141
+ if (response.status === 429 || response.status >= 500 && response.status < 600) {
2142
+ const retryDelay = retry < 2 ? Math.pow(2, retry) : void 0;
2143
+ handleHttpError(response.status, logger, retryDelay);
2144
+ }
2145
+ logDebug?.(
2146
+ `Content review poll ${String(pollCount + 1)} failed: HTTP ${String(response.status)}. Retrying...`
2147
+ );
2148
+ if (retry < 2) {
2149
+ await new Promise((resolve) => setTimeout(resolve, 1e3 * Math.pow(2, retry)));
2150
+ }
2151
+ continue;
2152
+ }
2153
+ const body = await response.json();
2154
+ if (!isApiSuccess(body)) {
2155
+ logDebug?.(
2156
+ `Content review poll ${String(pollCount + 1)} failed: invalid response format. Retrying...`
2157
+ );
2158
+ if (retry < 2) {
2159
+ await new Promise((resolve) => setTimeout(resolve, 1e3 * Math.pow(2, retry)));
2160
+ }
2161
+ continue;
2162
+ }
2163
+ const statusData = body.data;
2164
+ if (!isContentReviewStatusResponse(statusData)) {
2165
+ logDebug?.(
2166
+ `Content review poll ${String(pollCount + 1)} failed: invalid status data. Retrying...`
2167
+ );
2168
+ if (retry < 2) {
2169
+ await new Promise((resolve) => setTimeout(resolve, 1e3 * Math.pow(2, retry)));
2170
+ }
2171
+ continue;
2172
+ }
2173
+ row = statusData;
2174
+ break;
2175
+ } catch (error) {
2176
+ const errorMessage = error instanceof Error ? error.message : String(error);
2177
+ logDebug?.(
2178
+ `Content review poll ${String(pollCount + 1)} failed: ${errorMessage}. Retrying...`
2179
+ );
2180
+ if (retry < 2) {
2181
+ await new Promise((resolve) => setTimeout(resolve, 1e3 * Math.pow(2, retry)));
2182
+ }
2183
+ }
2184
+ }
2185
+ if (row !== null) {
2186
+ if (row.status === "approved") {
2187
+ return { status: "approved", reviewId };
2188
+ }
2189
+ if (row.status === "blocked") {
2190
+ return { status: "blocked", reason: "blocked_by_reviewer", reviewId };
2191
+ }
2192
+ if (row.status === "timeout") {
2193
+ return { status: "timeout", reason: "decision_window_exceeded", reviewId };
2194
+ }
2195
+ if (pollCount < MAX_POLLS - 1) {
2196
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
2197
+ }
2198
+ } else {
2199
+ logDebug?.(
2200
+ `All retries failed for content review poll ${String(pollCount + 1)}. Continuing...`
2201
+ );
2202
+ if (pollCount < MAX_POLLS - 1) {
2203
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
2204
+ }
2205
+ }
2206
+ }
2207
+ return { status: "timeout", reason: "decision_window_exceeded", reviewId };
2208
+ }
2209
+ async function requestContentReview(payload, apiKey, baseUrl, logger) {
2210
+ try {
2211
+ const response = await fetch(`${baseUrl}/api/v1/actions`, {
2212
+ method: "POST",
2213
+ headers: {
2214
+ "Content-Type": "application/json",
2215
+ [AUTH_HEADER]: apiKey
2216
+ },
2217
+ body: JSON.stringify({
2218
+ agent: payload.agent,
2219
+ service: payload.service,
2220
+ actionType: payload.actionType,
2221
+ status: "requires_approval",
2222
+ cost: payload.cost ?? 0,
2223
+ metadata: payload.metadata
2224
+ }),
2225
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
2226
+ });
2227
+ const body = await response.json().catch(() => null);
2228
+ if (isPlanTierInsufficientError(response.status, body)) {
2229
+ return { status: "blocked", reason: "plan_tier_insufficient" };
2230
+ }
2231
+ if (response.status === 401 || response.status === 403) {
2232
+ handleHttpError(response.status, logger);
2233
+ return { status: "blocked", reason: "auth_error" };
2234
+ }
2235
+ if (response.status === 429 || response.status >= 500 && response.status < 600) {
2236
+ handleHttpError(response.status, logger);
2237
+ return { status: "blocked", reason: "service_unavailable" };
2238
+ }
2239
+ if (response.status === 202) {
2240
+ if (!isApiSuccess(body)) {
2241
+ return { status: "blocked", reason: "no_review_id" };
2242
+ }
2243
+ const data = body.data;
2244
+ if (typeof data !== "object" || data === null) {
2245
+ return { status: "blocked", reason: "no_review_id" };
2246
+ }
2247
+ const record = data;
2248
+ const rid = record["content_review_id"];
2249
+ const reviewId = typeof rid === "string" ? rid : void 0;
2250
+ if (reviewId === void 0) {
2251
+ return { status: "blocked", reason: "no_review_id" };
2252
+ }
2253
+ const polled = await pollContentReviewStatus(reviewId, apiKey, baseUrl, logger);
2254
+ return { ...polled, reviewId };
2255
+ }
2256
+ if (response.status === 201) {
2257
+ return { status: "blocked", reason: "no_review_id" };
2258
+ }
2259
+ return { status: "blocked", reason: "service_unavailable" };
2260
+ } catch {
2261
+ return { status: "blocked", reason: "network_error" };
1894
2262
  }
1895
- const publicContentIndicators = [
1896
- "publish",
1897
- "public",
1898
- "web",
1899
- "blog",
1900
- "post",
1901
- "article",
1902
- "social",
1903
- "twitter",
1904
- "facebook",
1905
- "linkedin",
1906
- "github_pages",
1907
- "deploy"
1908
- ];
1909
- return publicContentIndicators.some((indicator) => lowerToolName.includes(indicator));
1910
2263
  }
1911
2264
 
1912
2265
  // src/mcp/mcp-adapter.ts
@@ -1983,6 +2336,28 @@ function createMcpAdapter(config) {
1983
2336
  status
1984
2337
  });
1985
2338
  }
2339
+ function mapContentReviewReasonToUserMessage(reason) {
2340
+ switch (reason) {
2341
+ case "plan_tier_insufficient":
2342
+ return "Content review requires an Enterprise plan. Upgrade at app.multicorn.ai/settings.";
2343
+ case "review_not_found":
2344
+ return "Content review no longer exists. It may have been deleted or timed out.";
2345
+ case "decision_window_exceeded":
2346
+ return "Content review timed out before a decision was made.";
2347
+ case "blocked_by_reviewer":
2348
+ return "Content review was blocked by a reviewer.";
2349
+ case "auth_error":
2350
+ return "Content review failed: please verify your Multicorn API key.";
2351
+ case "service_unavailable":
2352
+ return "Content review failed: service unavailable.";
2353
+ case "network_error":
2354
+ return "Content review failed: network error.";
2355
+ case "no_review_id":
2356
+ return "Content review failed: could not start review.";
2357
+ default:
2358
+ return "Content review failed.";
2359
+ }
2360
+ }
1986
2361
  async function checkAutoApproveStatus() {
1987
2362
  if (config.checkAutoApprove !== void 0) {
1988
2363
  const result = config.checkAutoApprove(config.agentId);
@@ -2052,6 +2427,38 @@ function createMcpAdapter(config) {
2052
2427
  arguments: JSON.stringify(toolCall.arguments),
2053
2428
  requiresReview: true
2054
2429
  };
2430
+ if (config.waitForReviewDecision === true) {
2431
+ if (config.baseUrl === void 0 || config.apiKey === void 0) {
2432
+ return {
2433
+ blocked: true,
2434
+ reason: "waitForReviewDecision requires baseUrl and apiKey to poll the content review.",
2435
+ toolName: toolCall.toolName,
2436
+ service: mappedService,
2437
+ action
2438
+ };
2439
+ }
2440
+ const review = await requestContentReview(
2441
+ {
2442
+ agent: config.agentId,
2443
+ service: mappedService,
2444
+ actionType: action,
2445
+ metadata
2446
+ },
2447
+ config.apiKey,
2448
+ config.baseUrl
2449
+ );
2450
+ if (review.status === "approved") {
2451
+ await recordAction(mappedService, action, ACTION_STATUSES.Approved);
2452
+ return handler(toolCall);
2453
+ }
2454
+ return {
2455
+ blocked: true,
2456
+ reason: mapContentReviewReasonToUserMessage(review.reason),
2457
+ toolName: toolCall.toolName,
2458
+ service: mappedService,
2459
+ action
2460
+ };
2461
+ }
2055
2462
  if (config.logger) {
2056
2463
  await config.logger.logAction({
2057
2464
  agent: config.agentId,
@@ -2478,4 +2885,4 @@ function validateApiKey(apiKey) {
2478
2885
  }
2479
2886
  }
2480
2887
 
2481
- export { ACTION_STATUSES, AGENT_STATUSES, BUILT_IN_SERVICES, CONSENT_ELEMENT_TAG, MulticornConsent, MulticornShield, PERMISSION_LEVELS, SERVICE_NAME_PATTERN, ScopeParseError, centsToDollars, createActionLogger, createFocusTrap, createMcpAdapter, createScopeRegistry, createSpendingChecker, dollarsToCents, formatScope, getPermissionLabel, getScopeLabel, getScopeShortLabel, getServiceDisplayName, getServiceIcon, hasScope, isBlockedResult, isValidScopeString, parseScope, parseScopes, tryParseScope, validateAllScopesAccess, validateScopeAccess };
2888
+ export { ACTION_STATUSES, AGENT_STATUSES, BUILT_IN_SERVICES, CONSENT_ELEMENT_TAG, MulticornBadge, MulticornConsent, MulticornShield, PERMISSION_LEVELS, SERVICE_NAME_PATTERN, ScopeParseError, centsToDollars, createActionLogger, createFocusTrap, createMcpAdapter, createScopeRegistry, createSpendingChecker, dollarsToCents, formatScope, getPermissionLabel, getScopeLabel, getScopeShortLabel, getServiceDisplayName, getServiceIcon, hasScope, isBlockedResult, isPublicContentAction, isValidScopeString, parseScope, parseScopes, requestContentReview, requiresContentReview, tryParseScope, validateAllScopesAccess, validateScopeAccess };
@@ -884,7 +884,9 @@ var plugin = {
884
884
  if (config.agentName !== null) {
885
885
  pinnedAgentName = config.agentName;
886
886
  }
887
- console.error("[SHIELD-DIAG] cachedMulticornConfig: " + JSON.stringify(cachedMulticornConfig));
887
+ api.logger.info(
888
+ `Multicorn Shield config loaded: hasApiKey=${String((cachedMulticornConfig?.apiKey ?? "").length > 0)} baseUrl=${cachedMulticornConfig?.baseUrl ?? "default"} agentName=${cachedMulticornConfig?.agentName ?? "unset"} defaultAgent=${cachedMulticornConfig?.defaultAgent ?? "unset"} agents=${String(cachedMulticornConfig?.agents?.length ?? 0)}`
889
+ );
888
890
  api.on("before_tool_call", beforeToolCall, { priority: 10 });
889
891
  api.on("after_tool_call", afterToolCall);
890
892
  api.logger.info("Multicorn Shield plugin registered.");