multicorn-shield 0.9.0 → 0.10.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.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",
@@ -1876,37 +1909,215 @@ function centsToDollars(cents) {
1876
1909
  })}`;
1877
1910
  }
1878
1911
 
1879
- // src/scopes/content-review-detector.ts
1880
- function requiresContentReview(scope) {
1881
- if (scope.service === "web" && scope.permissionLevel === "publish") {
1882
- return true;
1912
+ // src/openclaw/shield-client.ts
1913
+ var REQUEST_TIMEOUT_MS = 5e3;
1914
+ var AUTH_HEADER = "X-Multicorn-Key";
1915
+ var POLL_INTERVAL_MS = 3e3;
1916
+ var MAX_POLLS = 100;
1917
+ var POLL_TIMEOUT_MS = POLL_INTERVAL_MS * MAX_POLLS;
1918
+ var authErrorLogged = false;
1919
+ function isApiSuccess(value) {
1920
+ if (typeof value !== "object" || value === null) return false;
1921
+ const obj = value;
1922
+ return obj["success"] === true;
1923
+ }
1924
+ function isContentReviewStatusResponse(value) {
1925
+ if (typeof value !== "object" || value === null) return false;
1926
+ const obj = value;
1927
+ return typeof obj["id"] === "string" && typeof obj["status"] === "string" && ["pending", "approved", "blocked", "timeout"].includes(obj["status"]);
1928
+ }
1929
+ function readApiErrorCode(body) {
1930
+ if (typeof body !== "object" || body === null) return void 0;
1931
+ const err = body["error"];
1932
+ if (typeof err !== "object" || err === null) return void 0;
1933
+ const code = err["code"];
1934
+ return typeof code === "string" ? code : void 0;
1935
+ }
1936
+ function isPlanTierInsufficientError(status, body) {
1937
+ return status === 403 && readApiErrorCode(body) === "PLAN_TIER_INSUFFICIENT";
1938
+ }
1939
+ function handleHttpError(status, logger, retryDelaySeconds) {
1940
+ if (status === 401 || status === 403) {
1941
+ if (!authErrorLogged) {
1942
+ authErrorLogged = true;
1943
+ 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).";
1944
+ logger?.error(errorMsg);
1945
+ process.stderr.write(`${errorMsg}
1946
+ `);
1947
+ }
1948
+ return { shouldBlock: true };
1949
+ }
1950
+ if (status === 429) {
1951
+ if (retryDelaySeconds !== void 0) {
1952
+ const rateLimitMsg = `[multicorn-shield] Rate limited by Shield API. Retrying in ${String(retryDelaySeconds)}s.`;
1953
+ logger?.warn(rateLimitMsg);
1954
+ process.stderr.write(`${rateLimitMsg}
1955
+ `);
1956
+ } else {
1957
+ const rateLimitMsg = "[multicorn-shield] Rate limited by Shield API. Action blocked: Shield cannot verify permissions.";
1958
+ logger?.warn(rateLimitMsg);
1959
+ process.stderr.write(`${rateLimitMsg}
1960
+ `);
1961
+ }
1962
+ return { shouldBlock: true };
1883
1963
  }
1884
- if (scope.service === "public_content" && scope.permissionLevel === "create") {
1885
- return true;
1964
+ if (status >= 500 && status < 600) {
1965
+ const serverErrorMsg = `[multicorn-shield] Shield API error (${String(status)}). Action blocked: Shield cannot verify permissions.`;
1966
+ logger?.warn(serverErrorMsg);
1967
+ process.stderr.write(`${serverErrorMsg}
1968
+ `);
1969
+ return { shouldBlock: true };
1886
1970
  }
1887
- return false;
1971
+ return { shouldBlock: true };
1888
1972
  }
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;
1973
+ async function pollContentReviewStatus(reviewId, apiKey, baseUrl, logger) {
1974
+ const startTime = Date.now();
1975
+ const logDebug = logger?.debug?.bind(logger);
1976
+ for (let pollCount = 0; pollCount < MAX_POLLS; pollCount++) {
1977
+ if (Date.now() - startTime >= POLL_TIMEOUT_MS) {
1978
+ return { status: "timeout", reason: "decision_window_exceeded", reviewId };
1979
+ }
1980
+ let row = null;
1981
+ for (let retry = 0; retry < 3; retry++) {
1982
+ try {
1983
+ const response = await fetch(`${baseUrl}/api/v1/content-reviews/${reviewId}/status`, {
1984
+ headers: { [AUTH_HEADER]: apiKey },
1985
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
1986
+ });
1987
+ if (!response.ok) {
1988
+ if (response.status === 404) {
1989
+ return { status: "blocked", reason: "review_not_found", reviewId };
1990
+ }
1991
+ const errBody = await response.json().catch(() => null);
1992
+ if (isPlanTierInsufficientError(response.status, errBody)) {
1993
+ return { status: "blocked", reason: "plan_tier_insufficient", reviewId };
1994
+ }
1995
+ if (response.status === 401 || response.status === 403) {
1996
+ handleHttpError(response.status, logger);
1997
+ return { status: "blocked", reason: "auth_error", reviewId };
1998
+ }
1999
+ if (response.status === 429 || response.status >= 500 && response.status < 600) {
2000
+ const retryDelay = retry < 2 ? Math.pow(2, retry) : void 0;
2001
+ handleHttpError(response.status, logger, retryDelay);
2002
+ }
2003
+ logDebug?.(
2004
+ `Content review poll ${String(pollCount + 1)} failed: HTTP ${String(response.status)}. Retrying...`
2005
+ );
2006
+ if (retry < 2) {
2007
+ await new Promise((resolve) => setTimeout(resolve, 1e3 * Math.pow(2, retry)));
2008
+ }
2009
+ continue;
2010
+ }
2011
+ const body = await response.json();
2012
+ if (!isApiSuccess(body)) {
2013
+ logDebug?.(
2014
+ `Content review poll ${String(pollCount + 1)} failed: invalid response format. Retrying...`
2015
+ );
2016
+ if (retry < 2) {
2017
+ await new Promise((resolve) => setTimeout(resolve, 1e3 * Math.pow(2, retry)));
2018
+ }
2019
+ continue;
2020
+ }
2021
+ const statusData = body.data;
2022
+ if (!isContentReviewStatusResponse(statusData)) {
2023
+ logDebug?.(
2024
+ `Content review poll ${String(pollCount + 1)} failed: invalid status data. Retrying...`
2025
+ );
2026
+ if (retry < 2) {
2027
+ await new Promise((resolve) => setTimeout(resolve, 1e3 * Math.pow(2, retry)));
2028
+ }
2029
+ continue;
2030
+ }
2031
+ row = statusData;
2032
+ break;
2033
+ } catch (error) {
2034
+ const errorMessage = error instanceof Error ? error.message : String(error);
2035
+ logDebug?.(
2036
+ `Content review poll ${String(pollCount + 1)} failed: ${errorMessage}. Retrying...`
2037
+ );
2038
+ if (retry < 2) {
2039
+ await new Promise((resolve) => setTimeout(resolve, 1e3 * Math.pow(2, retry)));
2040
+ }
2041
+ }
2042
+ }
2043
+ if (row !== null) {
2044
+ if (row.status === "approved") {
2045
+ return { status: "approved", reviewId };
2046
+ }
2047
+ if (row.status === "blocked") {
2048
+ return { status: "blocked", reason: "blocked_by_reviewer", reviewId };
2049
+ }
2050
+ if (row.status === "timeout") {
2051
+ return { status: "timeout", reason: "decision_window_exceeded", reviewId };
2052
+ }
2053
+ if (pollCount < MAX_POLLS - 1) {
2054
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
2055
+ }
2056
+ } else {
2057
+ logDebug?.(
2058
+ `All retries failed for content review poll ${String(pollCount + 1)}. Continuing...`
2059
+ );
2060
+ if (pollCount < MAX_POLLS - 1) {
2061
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
2062
+ }
2063
+ }
2064
+ }
2065
+ return { status: "timeout", reason: "decision_window_exceeded", reviewId };
2066
+ }
2067
+ async function requestContentReview(payload, apiKey, baseUrl, logger) {
2068
+ try {
2069
+ const response = await fetch(`${baseUrl}/api/v1/actions`, {
2070
+ method: "POST",
2071
+ headers: {
2072
+ "Content-Type": "application/json",
2073
+ [AUTH_HEADER]: apiKey
2074
+ },
2075
+ body: JSON.stringify({
2076
+ agent: payload.agent,
2077
+ service: payload.service,
2078
+ actionType: payload.actionType,
2079
+ status: "requires_approval",
2080
+ cost: payload.cost ?? 0,
2081
+ metadata: payload.metadata
2082
+ }),
2083
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
2084
+ });
2085
+ const body = await response.json().catch(() => null);
2086
+ if (isPlanTierInsufficientError(response.status, body)) {
2087
+ return { status: "blocked", reason: "plan_tier_insufficient" };
2088
+ }
2089
+ if (response.status === 401 || response.status === 403) {
2090
+ handleHttpError(response.status, logger);
2091
+ return { status: "blocked", reason: "auth_error" };
2092
+ }
2093
+ if (response.status === 429 || response.status >= 500 && response.status < 600) {
2094
+ handleHttpError(response.status, logger);
2095
+ return { status: "blocked", reason: "service_unavailable" };
2096
+ }
2097
+ if (response.status === 202) {
2098
+ if (!isApiSuccess(body)) {
2099
+ return { status: "blocked", reason: "no_review_id" };
2100
+ }
2101
+ const data = body.data;
2102
+ if (typeof data !== "object" || data === null) {
2103
+ return { status: "blocked", reason: "no_review_id" };
2104
+ }
2105
+ const record = data;
2106
+ const rid = record["content_review_id"];
2107
+ const reviewId = typeof rid === "string" ? rid : void 0;
2108
+ if (reviewId === void 0) {
2109
+ return { status: "blocked", reason: "no_review_id" };
2110
+ }
2111
+ const polled = await pollContentReviewStatus(reviewId, apiKey, baseUrl, logger);
2112
+ return { ...polled, reviewId };
2113
+ }
2114
+ if (response.status === 201) {
2115
+ return { status: "blocked", reason: "no_review_id" };
2116
+ }
2117
+ return { status: "blocked", reason: "service_unavailable" };
2118
+ } catch {
2119
+ return { status: "blocked", reason: "network_error" };
1894
2120
  }
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
2121
  }
1911
2122
 
1912
2123
  // src/mcp/mcp-adapter.ts
@@ -1983,6 +2194,28 @@ function createMcpAdapter(config) {
1983
2194
  status
1984
2195
  });
1985
2196
  }
2197
+ function mapContentReviewReasonToUserMessage(reason) {
2198
+ switch (reason) {
2199
+ case "plan_tier_insufficient":
2200
+ return "Content review requires an Enterprise plan. Upgrade at app.multicorn.ai/settings.";
2201
+ case "review_not_found":
2202
+ return "Content review no longer exists. It may have been deleted or timed out.";
2203
+ case "decision_window_exceeded":
2204
+ return "Content review timed out before a decision was made.";
2205
+ case "blocked_by_reviewer":
2206
+ return "Content review was blocked by a reviewer.";
2207
+ case "auth_error":
2208
+ return "Content review failed: please verify your Multicorn API key.";
2209
+ case "service_unavailable":
2210
+ return "Content review failed: service unavailable.";
2211
+ case "network_error":
2212
+ return "Content review failed: network error.";
2213
+ case "no_review_id":
2214
+ return "Content review failed: could not start review.";
2215
+ default:
2216
+ return "Content review failed.";
2217
+ }
2218
+ }
1986
2219
  async function checkAutoApproveStatus() {
1987
2220
  if (config.checkAutoApprove !== void 0) {
1988
2221
  const result = config.checkAutoApprove(config.agentId);
@@ -2052,6 +2285,38 @@ function createMcpAdapter(config) {
2052
2285
  arguments: JSON.stringify(toolCall.arguments),
2053
2286
  requiresReview: true
2054
2287
  };
2288
+ if (config.waitForReviewDecision === true) {
2289
+ if (config.baseUrl === void 0 || config.apiKey === void 0) {
2290
+ return {
2291
+ blocked: true,
2292
+ reason: "waitForReviewDecision requires baseUrl and apiKey to poll the content review.",
2293
+ toolName: toolCall.toolName,
2294
+ service: mappedService,
2295
+ action
2296
+ };
2297
+ }
2298
+ const review = await requestContentReview(
2299
+ {
2300
+ agent: config.agentId,
2301
+ service: mappedService,
2302
+ actionType: action,
2303
+ metadata
2304
+ },
2305
+ config.apiKey,
2306
+ config.baseUrl
2307
+ );
2308
+ if (review.status === "approved") {
2309
+ await recordAction(mappedService, action, ACTION_STATUSES.Approved);
2310
+ return handler(toolCall);
2311
+ }
2312
+ return {
2313
+ blocked: true,
2314
+ reason: mapContentReviewReasonToUserMessage(review.reason),
2315
+ toolName: toolCall.toolName,
2316
+ service: mappedService,
2317
+ action
2318
+ };
2319
+ }
2055
2320
  if (config.logger) {
2056
2321
  await config.logger.logAction({
2057
2322
  agent: config.agentId,
@@ -2478,4 +2743,4 @@ function validateApiKey(apiKey) {
2478
2743
  }
2479
2744
  }
2480
2745
 
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 };
2746
+ 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, isPublicContentAction, isValidScopeString, parseScope, parseScopes, requestContentReview, requiresContentReview, tryParseScope, validateAllScopesAccess, validateScopeAccess };
@@ -22359,7 +22359,7 @@ async function writeExtensionBackup(claudeDesktopConfigPath, mcpServers) {
22359
22359
 
22360
22360
  // package.json
22361
22361
  var package_default = {
22362
- version: "0.9.0"};
22362
+ version: "0.10.0"};
22363
22363
 
22364
22364
  // src/package-meta.ts
22365
22365
  var PACKAGE_VERSION = package_default.version;
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "multicorn-shield",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "The control layer for AI agents: permissions, consent, spending limits, and audit logging.",
5
5
  "license": "MIT",
6
+ "author": "Multicorn AI Pty Ltd",
6
7
  "type": "module",
7
8
  "main": "./dist/index.cjs",
8
9
  "module": "./dist/index.js",
@@ -37,8 +38,13 @@
37
38
  "dist",
38
39
  "plugins/windsurf",
39
40
  "LICENSE",
40
- "README.md"
41
+ "README.md",
42
+ "CHANGELOG.md"
41
43
  ],
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "provenance": true
47
+ },
42
48
  "sideEffects": false,
43
49
  "engines": {
44
50
  "node": ">=20"