@vess-id/vess 0.0.0-dev-20260902121045
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/LICENSE +64 -0
- package/README.md +215 -0
- package/bin/vess.js +2 -0
- package/dist/__mocks__/@napi-rs/keyring.d.ts +9 -0
- package/dist/__mocks__/@napi-rs/keyring.d.ts.map +1 -0
- package/dist/__mocks__/@napi-rs/keyring.js +33 -0
- package/dist/__mocks__/@napi-rs/keyring.js.map +1 -0
- package/dist/__mocks__/node-mac-auth.d.ts +8 -0
- package/dist/__mocks__/node-mac-auth.d.ts.map +1 -0
- package/dist/__mocks__/node-mac-auth.js +29 -0
- package/dist/__mocks__/node-mac-auth.js.map +1 -0
- package/dist/adapter/mcp/http-transport.d.ts +40 -0
- package/dist/adapter/mcp/http-transport.d.ts.map +1 -0
- package/dist/adapter/mcp/http-transport.js +228 -0
- package/dist/adapter/mcp/http-transport.js.map +1 -0
- package/dist/adapter/mcp/mcp-adapter.d.ts +43 -0
- package/dist/adapter/mcp/mcp-adapter.d.ts.map +1 -0
- package/dist/adapter/mcp/mcp-adapter.js +65 -0
- package/dist/adapter/mcp/mcp-adapter.js.map +1 -0
- package/dist/adapter/mcp/mcp-server.factory.d.ts +44 -0
- package/dist/adapter/mcp/mcp-server.factory.d.ts.map +1 -0
- package/dist/adapter/mcp/mcp-server.factory.js +311 -0
- package/dist/adapter/mcp/mcp-server.factory.js.map +1 -0
- package/dist/adapter/mcp/stdio-transport.d.ts +7 -0
- package/dist/adapter/mcp/stdio-transport.d.ts.map +1 -0
- package/dist/adapter/mcp/stdio-transport.js +13 -0
- package/dist/adapter/mcp/stdio-transport.js.map +1 -0
- package/dist/adapter/mcp/transport.d.ts +10 -0
- package/dist/adapter/mcp/transport.d.ts.map +1 -0
- package/dist/adapter/mcp/transport.js +14 -0
- package/dist/adapter/mcp/transport.js.map +1 -0
- package/dist/approval/approval-token.d.ts +23 -0
- package/dist/approval/approval-token.d.ts.map +1 -0
- package/dist/approval/approval-token.js +81 -0
- package/dist/approval/approval-token.js.map +1 -0
- package/dist/audit/audit-dto-mapper.d.ts +29 -0
- package/dist/audit/audit-dto-mapper.d.ts.map +1 -0
- package/dist/audit/audit-dto-mapper.js +61 -0
- package/dist/audit/audit-dto-mapper.js.map +1 -0
- package/dist/audit/audit-logger.d.ts +35 -0
- package/dist/audit/audit-logger.d.ts.map +1 -0
- package/dist/audit/audit-logger.js +67 -0
- package/dist/audit/audit-logger.js.map +1 -0
- package/dist/audit/audit-sync.d.ts +12 -0
- package/dist/audit/audit-sync.d.ts.map +1 -0
- package/dist/audit/audit-sync.js +65 -0
- package/dist/audit/audit-sync.js.map +1 -0
- package/dist/auth/user-authenticator.d.ts +51 -0
- package/dist/auth/user-authenticator.d.ts.map +1 -0
- package/dist/auth/user-authenticator.js +155 -0
- package/dist/auth/user-authenticator.js.map +1 -0
- package/dist/bootstrap/sdk-precheck.d.ts +9 -0
- package/dist/bootstrap/sdk-precheck.d.ts.map +1 -0
- package/dist/bootstrap/sdk-precheck.js +103 -0
- package/dist/bootstrap/sdk-precheck.js.map +1 -0
- package/dist/cli/claude-config-path.d.ts +17 -0
- package/dist/cli/claude-config-path.d.ts.map +1 -0
- package/dist/cli/claude-config-path.js +59 -0
- package/dist/cli/claude-config-path.js.map +1 -0
- package/dist/cli/cli-db.d.ts +12 -0
- package/dist/cli/cli-db.d.ts.map +1 -0
- package/dist/cli/cli-db.js +20 -0
- package/dist/cli/cli-db.js.map +1 -0
- package/dist/cli/cli-utils.d.ts +14 -0
- package/dist/cli/cli-utils.d.ts.map +1 -0
- package/dist/cli/cli-utils.js +57 -0
- package/dist/cli/cli-utils.js.map +1 -0
- package/dist/cli/daemon-utils.d.ts +30 -0
- package/dist/cli/daemon-utils.d.ts.map +1 -0
- package/dist/cli/daemon-utils.js +131 -0
- package/dist/cli/daemon-utils.js.map +1 -0
- package/dist/cli/daemon.d.ts +13 -0
- package/dist/cli/daemon.d.ts.map +1 -0
- package/dist/cli/daemon.js +207 -0
- package/dist/cli/daemon.js.map +1 -0
- package/dist/cli/doctor.d.ts +6 -0
- package/dist/cli/doctor.d.ts.map +1 -0
- package/dist/cli/doctor.js +136 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/env-delete.d.ts +6 -0
- package/dist/cli/env-delete.d.ts.map +1 -0
- package/dist/cli/env-delete.js +80 -0
- package/dist/cli/env-delete.js.map +1 -0
- package/dist/cli/env-list.d.ts +5 -0
- package/dist/cli/env-list.d.ts.map +1 -0
- package/dist/cli/env-list.js +42 -0
- package/dist/cli/env-list.js.map +1 -0
- package/dist/cli/env-post-integration.d.ts +21 -0
- package/dist/cli/env-post-integration.d.ts.map +1 -0
- package/dist/cli/env-post-integration.js +300 -0
- package/dist/cli/env-post-integration.js.map +1 -0
- package/dist/cli/env-restore.d.ts +15 -0
- package/dist/cli/env-restore.d.ts.map +1 -0
- package/dist/cli/env-restore.js +130 -0
- package/dist/cli/env-restore.js.map +1 -0
- package/dist/cli/env.d.ts +14 -0
- package/dist/cli/env.d.ts.map +1 -0
- package/dist/cli/env.js +182 -0
- package/dist/cli/env.js.map +1 -0
- package/dist/cli/error-handlers.d.ts +13 -0
- package/dist/cli/error-handlers.d.ts.map +1 -0
- package/dist/cli/error-handlers.js +32 -0
- package/dist/cli/error-handlers.js.map +1 -0
- package/dist/cli/hook-check-env.d.ts +12 -0
- package/dist/cli/hook-check-env.d.ts.map +1 -0
- package/dist/cli/hook-check-env.js +117 -0
- package/dist/cli/hook-check-env.js.map +1 -0
- package/dist/cli/hook-session-project.d.ts +10 -0
- package/dist/cli/hook-session-project.d.ts.map +1 -0
- package/dist/cli/hook-session-project.js +206 -0
- package/dist/cli/hook-session-project.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +361 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/init-guard.d.ts +13 -0
- package/dist/cli/init-guard.d.ts.map +1 -0
- package/dist/cli/init-guard.js +62 -0
- package/dist/cli/init-guard.js.map +1 -0
- package/dist/cli/init.d.ts +19 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +440 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/install.d.ts +14 -0
- package/dist/cli/install.d.ts.map +1 -0
- package/dist/cli/install.js +183 -0
- package/dist/cli/install.js.map +1 -0
- package/dist/cli/login.d.ts +6 -0
- package/dist/cli/login.d.ts.map +1 -0
- package/dist/cli/login.js +76 -0
- package/dist/cli/login.js.map +1 -0
- package/dist/cli/logs.d.ts +32 -0
- package/dist/cli/logs.d.ts.map +1 -0
- package/dist/cli/logs.js +147 -0
- package/dist/cli/logs.js.map +1 -0
- package/dist/cli/otel-headers.d.ts +12 -0
- package/dist/cli/otel-headers.d.ts.map +1 -0
- package/dist/cli/otel-headers.js +38 -0
- package/dist/cli/otel-headers.js.map +1 -0
- package/dist/cli/project.d.ts +8 -0
- package/dist/cli/project.d.ts.map +1 -0
- package/dist/cli/project.js +102 -0
- package/dist/cli/project.js.map +1 -0
- package/dist/cli/reset.d.ts +8 -0
- package/dist/cli/reset.d.ts.map +1 -0
- package/dist/cli/reset.js +137 -0
- package/dist/cli/reset.js.map +1 -0
- package/dist/cli/run.d.ts +22 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +103 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/cli/start.d.ts +2 -0
- package/dist/cli/start.d.ts.map +1 -0
- package/dist/cli/start.js +29 -0
- package/dist/cli/start.js.map +1 -0
- package/dist/cli/status.d.ts +12 -0
- package/dist/cli/status.d.ts.map +1 -0
- package/dist/cli/status.js +131 -0
- package/dist/cli/status.js.map +1 -0
- package/dist/cli/telemetry-setup.d.ts +27 -0
- package/dist/cli/telemetry-setup.d.ts.map +1 -0
- package/dist/cli/telemetry-setup.js +157 -0
- package/dist/cli/telemetry-setup.js.map +1 -0
- package/dist/cli/uninstall.d.ts +8 -0
- package/dist/cli/uninstall.d.ts.map +1 -0
- package/dist/cli/uninstall.js +110 -0
- package/dist/cli/uninstall.js.map +1 -0
- package/dist/cli/version.d.ts +2 -0
- package/dist/cli/version.d.ts.map +1 -0
- package/dist/cli/version.js +10 -0
- package/dist/cli/version.js.map +1 -0
- package/dist/config/config.d.ts +10 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +64 -0
- package/dist/config/config.js.map +1 -0
- package/dist/config/constants.d.ts +11 -0
- package/dist/config/constants.d.ts.map +1 -0
- package/dist/config/constants.js +14 -0
- package/dist/config/constants.js.map +1 -0
- package/dist/config/paths.d.ts +9 -0
- package/dist/config/paths.d.ts.map +1 -0
- package/dist/config/paths.js +58 -0
- package/dist/config/paths.js.map +1 -0
- package/dist/config/version.d.ts +3 -0
- package/dist/config/version.d.ts.map +1 -0
- package/dist/config/version.js +80 -0
- package/dist/config/version.js.map +1 -0
- package/dist/core/execution-engine.d.ts +289 -0
- package/dist/core/execution-engine.d.ts.map +1 -0
- package/dist/core/execution-engine.js +1931 -0
- package/dist/core/execution-engine.js.map +1 -0
- package/dist/core/runtime.d.ts +43 -0
- package/dist/core/runtime.d.ts.map +1 -0
- package/dist/core/runtime.js +147 -0
- package/dist/core/runtime.js.map +1 -0
- package/dist/core/sync-scheduler.d.ts +42 -0
- package/dist/core/sync-scheduler.d.ts.map +1 -0
- package/dist/core/sync-scheduler.js +131 -0
- package/dist/core/sync-scheduler.js.map +1 -0
- package/dist/core/types.d.ts +106 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +7 -0
- package/dist/core/types.js.map +1 -0
- package/dist/daemon/service-manager.d.ts +68 -0
- package/dist/daemon/service-manager.d.ts.map +1 -0
- package/dist/daemon/service-manager.js +303 -0
- package/dist/daemon/service-manager.js.map +1 -0
- package/dist/env/env-classifier.d.ts +14 -0
- package/dist/env/env-classifier.d.ts.map +1 -0
- package/dist/env/env-classifier.js +94 -0
- package/dist/env/env-classifier.js.map +1 -0
- package/dist/env/env-parser.d.ts +13 -0
- package/dist/env/env-parser.d.ts.map +1 -0
- package/dist/env/env-parser.js +33 -0
- package/dist/env/env-parser.js.map +1 -0
- package/dist/env/env-profile-store.d.ts +15 -0
- package/dist/env/env-profile-store.d.ts.map +1 -0
- package/dist/env/env-profile-store.js +35 -0
- package/dist/env/env-profile-store.js.map +1 -0
- package/dist/env/env-reference.d.ts +10 -0
- package/dist/env/env-reference.d.ts.map +1 -0
- package/dist/env/env-reference.js +33 -0
- package/dist/env/env-reference.js.map +1 -0
- package/dist/env/env-resolver.d.ts +18 -0
- package/dist/env/env-resolver.d.ts.map +1 -0
- package/dist/env/env-resolver.js +48 -0
- package/dist/env/env-resolver.js.map +1 -0
- package/dist/env/fs-utils.d.ts +9 -0
- package/dist/env/fs-utils.d.ts.map +1 -0
- package/dist/env/fs-utils.js +59 -0
- package/dist/env/fs-utils.js.map +1 -0
- package/dist/env/secret-backend.d.ts +15 -0
- package/dist/env/secret-backend.d.ts.map +1 -0
- package/dist/env/secret-backend.js +24 -0
- package/dist/env/secret-backend.js.map +1 -0
- package/dist/executor/executor-registry.d.ts +22 -0
- package/dist/executor/executor-registry.d.ts.map +1 -0
- package/dist/executor/executor-registry.js +42 -0
- package/dist/executor/executor-registry.js.map +1 -0
- package/dist/executor/process-launcher.d.ts +26 -0
- package/dist/executor/process-launcher.d.ts.map +1 -0
- package/dist/executor/process-launcher.js +98 -0
- package/dist/executor/process-launcher.js.map +1 -0
- package/dist/executor/secret-file.d.ts +28 -0
- package/dist/executor/secret-file.d.ts.map +1 -0
- package/dist/executor/secret-file.js +127 -0
- package/dist/executor/secret-file.js.map +1 -0
- package/dist/gateway/auth.d.ts +26 -0
- package/dist/gateway/auth.d.ts.map +1 -0
- package/dist/gateway/auth.js +66 -0
- package/dist/gateway/auth.js.map +1 -0
- package/dist/gateway/gateway-client.d.ts +363 -0
- package/dist/gateway/gateway-client.d.ts.map +1 -0
- package/dist/gateway/gateway-client.js +611 -0
- package/dist/gateway/gateway-client.js.map +1 -0
- package/dist/identity/agent-identity.d.ts +29 -0
- package/dist/identity/agent-identity.d.ts.map +1 -0
- package/dist/identity/agent-identity.js +54 -0
- package/dist/identity/agent-identity.js.map +1 -0
- package/dist/identity/did-manager.d.ts +17 -0
- package/dist/identity/did-manager.d.ts.map +1 -0
- package/dist/identity/did-manager.js +29 -0
- package/dist/identity/did-manager.js.map +1 -0
- package/dist/identity/key-manager.d.ts +21 -0
- package/dist/identity/key-manager.d.ts.map +1 -0
- package/dist/identity/key-manager.js +126 -0
- package/dist/identity/key-manager.js.map +1 -0
- package/dist/identity/session-key.d.ts +13 -0
- package/dist/identity/session-key.d.ts.map +1 -0
- package/dist/identity/session-key.js +17 -0
- package/dist/identity/session-key.js.map +1 -0
- package/dist/policy/policy-evaluator.d.ts +63 -0
- package/dist/policy/policy-evaluator.d.ts.map +1 -0
- package/dist/policy/policy-evaluator.js +266 -0
- package/dist/policy/policy-evaluator.js.map +1 -0
- package/dist/policy/policy-loader.d.ts +10 -0
- package/dist/policy/policy-loader.d.ts.map +1 -0
- package/dist/policy/policy-loader.js +71 -0
- package/dist/policy/policy-loader.js.map +1 -0
- package/dist/policy/types.d.ts +21 -0
- package/dist/policy/types.d.ts.map +1 -0
- package/dist/policy/types.js +3 -0
- package/dist/policy/types.js.map +1 -0
- package/dist/telemetry/claude-settings.d.ts +67 -0
- package/dist/telemetry/claude-settings.d.ts.map +1 -0
- package/dist/telemetry/claude-settings.js +191 -0
- package/dist/telemetry/claude-settings.js.map +1 -0
- package/dist/telemetry/otel-headers.d.ts +27 -0
- package/dist/telemetry/otel-headers.d.ts.map +1 -0
- package/dist/telemetry/otel-headers.js +33 -0
- package/dist/telemetry/otel-headers.js.map +1 -0
- package/dist/telemetry/project-key.d.ts +12 -0
- package/dist/telemetry/project-key.d.ts.map +1 -0
- package/dist/telemetry/project-key.js +113 -0
- package/dist/telemetry/project-key.js.map +1 -0
- package/dist/utils/credential-errors.d.ts +73 -0
- package/dist/utils/credential-errors.d.ts.map +1 -0
- package/dist/utils/credential-errors.js +90 -0
- package/dist/utils/credential-errors.js.map +1 -0
- package/dist/utils/resource-canonicalizer.d.ts +19 -0
- package/dist/utils/resource-canonicalizer.d.ts.map +1 -0
- package/dist/utils/resource-canonicalizer.js +100 -0
- package/dist/utils/resource-canonicalizer.js.map +1 -0
- package/dist/utils/vc-utils.d.ts +23 -0
- package/dist/utils/vc-utils.d.ts.map +1 -0
- package/dist/utils/vc-utils.js +53 -0
- package/dist/utils/vc-utils.js.map +1 -0
- package/dist/wallet/sqlite.d.ts +4 -0
- package/dist/wallet/sqlite.d.ts.map +1 -0
- package/dist/wallet/sqlite.js +158 -0
- package/dist/wallet/sqlite.js.map +1 -0
- package/dist/wallet/vp-builder.d.ts +26 -0
- package/dist/wallet/vp-builder.d.ts.map +1 -0
- package/dist/wallet/vp-builder.js +55 -0
- package/dist/wallet/vp-builder.js.map +1 -0
- package/dist/wallet/wallet.d.ts +64 -0
- package/dist/wallet/wallet.d.ts.map +1 -0
- package/dist/wallet/wallet.js +179 -0
- package/dist/wallet/wallet.js.map +1 -0
- package/package.json +71 -0
- package/required-sdk-symbols.json +15 -0
|
@@ -0,0 +1,1931 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.ExecutionEngine = void 0;
|
|
37
|
+
/**
|
|
38
|
+
* Execution Control Runtime — the core of agentd.
|
|
39
|
+
*
|
|
40
|
+
* All execution control logic lives here:
|
|
41
|
+
* - Action normalization
|
|
42
|
+
* - Resource canonicalization
|
|
43
|
+
* - Policy evaluation (local + org)
|
|
44
|
+
* - VC acquisition (4-step flow, spec §6.1)
|
|
45
|
+
* - VP creation + Gateway verification (spec §7.4)
|
|
46
|
+
* - Execution routing (local vs gateway)
|
|
47
|
+
* - Offline fallback (spec §7.6)
|
|
48
|
+
* - Audit logging
|
|
49
|
+
*
|
|
50
|
+
* This module is adapter-independent. MCP, IDE plugins, or SDK clients
|
|
51
|
+
* all call ExecutionEngine.execute() through their respective thin adapters.
|
|
52
|
+
*/
|
|
53
|
+
const ai_identity_1 = require("@vess-id/ai-identity");
|
|
54
|
+
const os_1 = require("os");
|
|
55
|
+
const path = __importStar(require("path"));
|
|
56
|
+
const fs = __importStar(require("fs"));
|
|
57
|
+
const wallet_1 = require("../wallet/wallet");
|
|
58
|
+
const gateway_client_1 = require("../gateway/gateway-client");
|
|
59
|
+
const resource_canonicalizer_1 = require("../utils/resource-canonicalizer");
|
|
60
|
+
const vc_utils_1 = require("../utils/vc-utils");
|
|
61
|
+
const credential_errors_1 = require("../utils/credential-errors");
|
|
62
|
+
const executor_registry_1 = require("../executor/executor-registry");
|
|
63
|
+
const env_resolver_1 = require("../env/env-resolver");
|
|
64
|
+
/** Policy cache staleness TTL (spec §7.6: 30 minutes) */
|
|
65
|
+
const POLICY_SYNC_TTL_MS = 30 * 60 * 1000;
|
|
66
|
+
/** Fallback OOB approval expiration (seconds) when the API doesn't return a value.
|
|
67
|
+
* Kept short (5 min) so the agent doesn't wait on a possibly-expired request. */
|
|
68
|
+
const OOB_APPROVAL_DEFAULT_EXPIRES_IN_SECONDS = 300;
|
|
69
|
+
/** Default TTL for OOB-approved credentials (10 minutes) */
|
|
70
|
+
const OOB_CREDENTIAL_DEFAULT_TTL_MS = 10 * 60 * 1000;
|
|
71
|
+
class ExecutionEngine {
|
|
72
|
+
policyEvaluator;
|
|
73
|
+
wallet;
|
|
74
|
+
vpBuilder;
|
|
75
|
+
auditLogger;
|
|
76
|
+
gatewayClient;
|
|
77
|
+
context;
|
|
78
|
+
tokenService;
|
|
79
|
+
secretBackend;
|
|
80
|
+
lastPolicySyncTime = null;
|
|
81
|
+
clientInfo;
|
|
82
|
+
agentRegistered = false;
|
|
83
|
+
/**
|
|
84
|
+
* Wall-clock timestamp (ms) of the most recent registerAgent attempt, used
|
|
85
|
+
* to throttle retries during a sustained outage so we don't hammer the API
|
|
86
|
+
* with one POST per tool call. See REGISTRATION_RETRY_COOLDOWN_MS.
|
|
87
|
+
*/
|
|
88
|
+
lastRegistrationAttemptAt = 0;
|
|
89
|
+
/**
|
|
90
|
+
* In-flight registerAgent promise, exposed via `registrationInFlight` for
|
|
91
|
+
* deterministic test waits. `null` when no fetch is pending. Tests await
|
|
92
|
+
* this to flush the fire-and-forget catch handler without resorting to
|
|
93
|
+
* arbitrary setTimeout sleeps that race the microtask queue.
|
|
94
|
+
*/
|
|
95
|
+
inFlightRegistration = null;
|
|
96
|
+
recentOOBRequests = new Map();
|
|
97
|
+
static OOB_COOLDOWN_MS = 60_000;
|
|
98
|
+
static OOB_MAP_MAX_SIZE = 1000;
|
|
99
|
+
/**
|
|
100
|
+
* Minimum interval between registerAgent attempts when the flag is unset
|
|
101
|
+
* (i.e., the previous attempt failed non-409). 5s is large enough to avoid
|
|
102
|
+
* a request storm against `POST /v1/agents/create` during a sustained
|
|
103
|
+
* outage (ALB rolling, DB connection storm), and small enough that a
|
|
104
|
+
* recovered API picks the agent back up within a couple of tool calls.
|
|
105
|
+
*/
|
|
106
|
+
static REGISTRATION_RETRY_COOLDOWN_MS = 5_000;
|
|
107
|
+
constructor(policyEvaluator, wallet, vpBuilder, auditLogger, gatewayClient, context, tokenService, secretBackend) {
|
|
108
|
+
this.policyEvaluator = policyEvaluator;
|
|
109
|
+
this.wallet = wallet;
|
|
110
|
+
this.vpBuilder = vpBuilder;
|
|
111
|
+
this.auditLogger = auditLogger;
|
|
112
|
+
this.gatewayClient = gatewayClient;
|
|
113
|
+
this.context = context;
|
|
114
|
+
this.tokenService = tokenService;
|
|
115
|
+
this.secretBackend = secretBackend;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Record a successful policy sync timestamp (called by SyncScheduler).
|
|
119
|
+
*/
|
|
120
|
+
setLastPolicySyncTime(time) {
|
|
121
|
+
this.lastPolicySyncTime = time;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Set MCP client info (from oninitialized callback).
|
|
125
|
+
* Used to derive a meaningful agent name for deferred registration.
|
|
126
|
+
*/
|
|
127
|
+
setClientInfo(info) {
|
|
128
|
+
this.clientInfo = info;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Ensure the agent is registered in the API.
|
|
132
|
+
* Called lazily on first tool execution (fire-and-forget).
|
|
133
|
+
* Uses clientInfo from MCP initialize handshake if available.
|
|
134
|
+
*
|
|
135
|
+
* Retry semantics (audit fix for audit_events vs /agents divergence):
|
|
136
|
+
* - Optimistic flag: mark `agentRegistered = true` BEFORE the fetch so
|
|
137
|
+
* concurrent `execute()` calls don't all fire duplicate registrations.
|
|
138
|
+
* - On 409 (GatewayHttpError with status 409): leave the flag true — the
|
|
139
|
+
* agent row already exists in the DB. We type-check via the typed
|
|
140
|
+
* `GatewayHttpError.status` field rather than substring-matching the
|
|
141
|
+
* message, so a future change to the error wording can't accidentally
|
|
142
|
+
* reclassify "already exists" 409s as 5xx-style retries (or vice versa).
|
|
143
|
+
* A string-match fallback (`'409' / 'already exists' / 'duplicate'`)
|
|
144
|
+
* remains for older/non-typed Error instances thrown from other layers
|
|
145
|
+
* (e.g. middleware / network helpers that haven't been converted).
|
|
146
|
+
* - On any other failure (5xx, network, etc.): reset the flag to false so
|
|
147
|
+
* the next `execute()` retries — subject to the retry cool-down below.
|
|
148
|
+
* - Cool-down: at most one POST every REGISTRATION_RETRY_COOLDOWN_MS while
|
|
149
|
+
* the flag stays false. Without this, a sustained 5xx (e.g. ALB rolling,
|
|
150
|
+
* DB connection storm) would result in one POST per tool call.
|
|
151
|
+
* - Retry horizon: there is intentionally NO upper bound on the number of
|
|
152
|
+
* cool-down cycles. agentd is a long-lived daemon, so for a sustained
|
|
153
|
+
* outage we want it to keep retrying (once per cool-down) and self-heal
|
|
154
|
+
* the moment the gateway recovers — a hard cap would permanently brick
|
|
155
|
+
* registration after N failures, which is strictly worse. Tool execution
|
|
156
|
+
* itself does not block on registration (it is fire-and-forget), so an
|
|
157
|
+
* unregistered agent degrades gracefully rather than deadlocking.
|
|
158
|
+
* - No stuck-flag deadlock: `inFlightRegistration` is reset to `null` in
|
|
159
|
+
* the `.finally()` below on EVERY settle (success, 409, or 5xx), so the
|
|
160
|
+
* `agentRegistered=false` + in-flight-guard combination can never wedge
|
|
161
|
+
* permanently — the next post-cool-down `execute()` always re-attempts.
|
|
162
|
+
*/
|
|
163
|
+
ensureAgentRegistered() {
|
|
164
|
+
// IMPORTANT: This method MUST remain synchronous from entry through the
|
|
165
|
+
// optimistic `this.agentRegistered = true` assignment below. Inserting an
|
|
166
|
+
// `await` between the flag check and the optimistic set would re-introduce
|
|
167
|
+
// the race that the optimistic pattern is specifically designed to prevent
|
|
168
|
+
// (two concurrent `execute()` calls both observing the flag as false and
|
|
169
|
+
// both firing duplicate POST /v1/agents/create). Node's single-threaded
|
|
170
|
+
// event loop guarantees that synchronous code in this critical section
|
|
171
|
+
// runs atomically with respect to other JS execution — but only while
|
|
172
|
+
// there is no `await` to yield the microtask queue. The fetch itself is
|
|
173
|
+
// launched without await (fire-and-forget via `.then()/.catch()`), which
|
|
174
|
+
// preserves this invariant.
|
|
175
|
+
if (this.agentRegistered)
|
|
176
|
+
return;
|
|
177
|
+
// Defense-in-depth dedup: if a prior call already kicked off a
|
|
178
|
+
// registerAgent fetch that is still in flight, don't fire another one
|
|
179
|
+
// even if `agentRegistered` was momentarily cleared by the catch handler
|
|
180
|
+
// (e.g. transient 5xx) and the cool-down has since elapsed. The
|
|
181
|
+
// optimistic-flag-before-fetch pattern above already covers the common
|
|
182
|
+
// case, but this guard makes the intent explicit and protects against
|
|
183
|
+
// future refactors that change the ordering.
|
|
184
|
+
if (this.inFlightRegistration)
|
|
185
|
+
return;
|
|
186
|
+
const publicKey = this.context.agentPublicKey;
|
|
187
|
+
if (!publicKey || !this.context.projectId)
|
|
188
|
+
return; // Pre-conditions not met; do NOT mark registered
|
|
189
|
+
// Retry cool-down: skip if we just attempted and failed. The next tool
|
|
190
|
+
// call after the cool-down window elapses will trigger another attempt.
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
if (this.lastRegistrationAttemptAt > 0 &&
|
|
193
|
+
now - this.lastRegistrationAttemptAt < ExecutionEngine.REGISTRATION_RETRY_COOLDOWN_MS) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
this.lastRegistrationAttemptAt = now;
|
|
197
|
+
const agentName = this.clientInfo
|
|
198
|
+
? `${this.clientInfo.name}-agent`
|
|
199
|
+
: 'vess-agentd';
|
|
200
|
+
// OPTIMISTIC: mark registered BEFORE the fetch to dedupe concurrent
|
|
201
|
+
// execute() calls. If registerAgent fails, the catch handler clears
|
|
202
|
+
// the flag so the next tool execution retries.
|
|
203
|
+
this.agentRegistered = true;
|
|
204
|
+
this.inFlightRegistration = this.gatewayClient.registerAgent({
|
|
205
|
+
agentDid: this.context.agentDid,
|
|
206
|
+
name: agentName,
|
|
207
|
+
type: 'assistant',
|
|
208
|
+
publicKey,
|
|
209
|
+
projectId: this.context.projectId,
|
|
210
|
+
deviceInfo: {
|
|
211
|
+
platform: process.platform,
|
|
212
|
+
hostname: (0, os_1.hostname)(),
|
|
213
|
+
runtime: this.clientInfo?.name || 'unknown',
|
|
214
|
+
},
|
|
215
|
+
})
|
|
216
|
+
.then(() => { })
|
|
217
|
+
.catch((err) => {
|
|
218
|
+
// Prefer typed status check; fall back to string match for non-typed
|
|
219
|
+
// errors so legacy throw sites (or middleware) don't silently
|
|
220
|
+
// misclassify 409s as transient failures.
|
|
221
|
+
const status = err instanceof gateway_client_1.GatewayHttpError ? err.status : undefined;
|
|
222
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
223
|
+
const isConflict = status === 409 ||
|
|
224
|
+
(status === undefined && (/\b409\b/.test(msg) ||
|
|
225
|
+
msg.includes('already exists') ||
|
|
226
|
+
msg.includes('duplicate')));
|
|
227
|
+
if (isConflict) {
|
|
228
|
+
// 409 is idempotent success — agent already exists. Leave flag true.
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
// Other failures: clear the flag so the next execute() retries.
|
|
232
|
+
// This unblocks transient failures (5xx, network) without burning
|
|
233
|
+
// a retry budget on every single tool call (the cool-down above
|
|
234
|
+
// bounds the actual retry rate).
|
|
235
|
+
this.agentRegistered = false;
|
|
236
|
+
process.stderr.write(`[vess] Agent registration failed (will retry on next tool call): ${msg}\n`);
|
|
237
|
+
})
|
|
238
|
+
.finally(() => {
|
|
239
|
+
// Always clear the in-flight handle, on every settle path (success,
|
|
240
|
+
// 409, or transient 5xx). This is what guarantees no permanent
|
|
241
|
+
// deadlock: even when the catch handler left agentRegistered=false,
|
|
242
|
+
// the in-flight guard (line ~167) is released here so the next
|
|
243
|
+
// execute() after the cool-down can retry. inFlightRegistration is
|
|
244
|
+
// `Promise | null` — never a stuck boolean.
|
|
245
|
+
this.inFlightRegistration = null;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Test-only accessor for the in-flight registerAgent promise.
|
|
250
|
+
*
|
|
251
|
+
* Awaiting this yields a deterministic wait for the fire-and-forget
|
|
252
|
+
* `.catch()` handler to finish updating internal state (the
|
|
253
|
+
* `agentRegistered` flag and the cool-down timestamp). Tests use this in
|
|
254
|
+
* place of arbitrary `setTimeout` sleeps, which would race the microtask
|
|
255
|
+
* queue and flake under load.
|
|
256
|
+
*
|
|
257
|
+
* Returns `null` when no registration is in flight.
|
|
258
|
+
*/
|
|
259
|
+
get registrationInFlight() {
|
|
260
|
+
return this.inFlightRegistration;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Execute an action request.
|
|
264
|
+
* This is the single entry point for all adapters (MCP, future IDE, SDK).
|
|
265
|
+
*/
|
|
266
|
+
async execute(request) {
|
|
267
|
+
// Deferred agent registration: fire-and-forget on first tool execution
|
|
268
|
+
this.ensureAgentRegistered();
|
|
269
|
+
// Mutual exclusion: pendingRequestId and approval are incompatible
|
|
270
|
+
if (request.pendingRequestId && request.approval) {
|
|
271
|
+
return { success: false, error: 'Cannot provide both pendingRequestId and approval in the same request' };
|
|
272
|
+
}
|
|
273
|
+
const { action, input } = request;
|
|
274
|
+
// Task 20 — validate parameter SHAPE against the registry input_schema
|
|
275
|
+
// BEFORE the approval / VC steps (parity with remote-mcp). A malformed
|
|
276
|
+
// call (e.g. inbox.respond invoked with { inboxId, accepted, selectedSlot }
|
|
277
|
+
// instead of { id, action }) must NOT mint an approval request: doing so
|
|
278
|
+
// consumes a human approval, shows an empty target prefill, and fails on
|
|
279
|
+
// invoke → re-approval loop. Failing fast here returns a self-correcting
|
|
280
|
+
// diagnostic so the agent fixes the shape and only the CORRECT call
|
|
281
|
+
// reaches approval. Unknown actions / actions without an input_schema
|
|
282
|
+
// (incl. local os.* actions) pass through unchanged. Skipped on the
|
|
283
|
+
// approval-retry / pending-approval paths, which carry the already
|
|
284
|
+
// validated original input plus an approval token / request id.
|
|
285
|
+
if (!request.approval && !request.pendingRequestId) {
|
|
286
|
+
const inputCheck = (0, ai_identity_1.validateActionInput)(action, input ?? {});
|
|
287
|
+
if (!inputCheck.valid) {
|
|
288
|
+
return { success: false, error: inputCheck.errors.join(' ') };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// 1. Extract provider from action (e.g., 'os' from 'os.secret.read')
|
|
292
|
+
const provider = action.split('.')[0];
|
|
293
|
+
// 2. Resource canonicalization
|
|
294
|
+
const requestedResource = input.file_path || (action === 'os.process.run' ? input.command?.join(' ') : undefined);
|
|
295
|
+
let normalizedResource;
|
|
296
|
+
let resourceFingerprint;
|
|
297
|
+
if ((0, executor_registry_1.isLocalAction)(action) && input.file_path) {
|
|
298
|
+
normalizedResource = (0, resource_canonicalizer_1.canonicalizePath)(input.file_path);
|
|
299
|
+
resourceFingerprint = (0, resource_canonicalizer_1.computeFingerprint)(normalizedResource, this.context.rootDid);
|
|
300
|
+
}
|
|
301
|
+
// 3. Policy evaluation (local + org deny check)
|
|
302
|
+
const policyResult = this.policyEvaluator.evaluate(provider, action.replace(`${provider}.`, ''), {
|
|
303
|
+
type: this.getResourceType(action),
|
|
304
|
+
pattern: normalizedResource || input.file_path,
|
|
305
|
+
});
|
|
306
|
+
if (!policyResult.allowed) {
|
|
307
|
+
this.logAudit({
|
|
308
|
+
action, provider,
|
|
309
|
+
decision: 'deny',
|
|
310
|
+
enforcementType: 'local',
|
|
311
|
+
decisionSource: 'local_policy',
|
|
312
|
+
decisionReason: policyResult.reason,
|
|
313
|
+
requestedResource,
|
|
314
|
+
normalizedResource,
|
|
315
|
+
resourceFingerprint,
|
|
316
|
+
executionType: (0, executor_registry_1.isLocalAction)(action) ? 'local' : 'gateway',
|
|
317
|
+
});
|
|
318
|
+
return { success: false, error: `Action denied by policy: ${policyResult.reason}` };
|
|
319
|
+
}
|
|
320
|
+
// Handle OOB approval polling (pendingRequestId)
|
|
321
|
+
if (request.pendingRequestId) {
|
|
322
|
+
return this.handlePendingApproval(request);
|
|
323
|
+
}
|
|
324
|
+
// Handle 2nd call with approval decision
|
|
325
|
+
if (request.approval) {
|
|
326
|
+
// Security guard: block in-band approval for high/critical risk actions
|
|
327
|
+
const riskLevel = this.getActionRiskLevel(request.action);
|
|
328
|
+
if (riskLevel === 'high' || riskLevel === 'critical') {
|
|
329
|
+
return {
|
|
330
|
+
success: false,
|
|
331
|
+
error: `High-risk action "${request.action}" requires browser approval (Out-of-Band). In-band approval tokens are not accepted.`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
return this.handleApprovalResponse(request, provider, normalizedResource, resourceFingerprint, requestedResource);
|
|
335
|
+
}
|
|
336
|
+
// 4. Determine execution routing
|
|
337
|
+
const routing = (0, executor_registry_1.determineRouting)(provider, action);
|
|
338
|
+
// 5. Execute based on routing
|
|
339
|
+
if (routing.executor === 'local') {
|
|
340
|
+
return this.executeLocal(action, input, provider, normalizedResource, resourceFingerprint, requestedResource);
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
return this.executeViaGateway(provider, action, input);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// ===========================================================================
|
|
347
|
+
// 4-step VC acquisition (spec §6.1)
|
|
348
|
+
// ===========================================================================
|
|
349
|
+
/**
|
|
350
|
+
* Ensure a valid VC exists for the requested action.
|
|
351
|
+
* Follows the 4-step acquisition flow from spec §6.1:
|
|
352
|
+
*
|
|
353
|
+
* Step 1: Check wallet for valid VC (verify revocation via Gateway using VC jti)
|
|
354
|
+
* Step 2: Check Gateway for approved requests
|
|
355
|
+
* Step 3: Try auto-issue via Gateway
|
|
356
|
+
* Step 4: Terminal approval prompt -> quick-approve via Gateway
|
|
357
|
+
*
|
|
358
|
+
* @param approvalContext — Cedar 一元化 Step 3.5 retry path. When the agent
|
|
359
|
+
* re-invokes ensureVC after the user clicked 承認 in the approval UI, the
|
|
360
|
+
* caller threads the ledger token + approval outcome ids here so Step 3's
|
|
361
|
+
* `autoIssueVC` call carries them to the API. The API consumes the
|
|
362
|
+
* single-use token and injects `context.approval.granted = true` into
|
|
363
|
+
* Cedar evaluation, flipping the prior `auth_required` to `permit`.
|
|
364
|
+
*/
|
|
365
|
+
async ensureVC(action, provider, resourceInfo, input = {}, canonicalResourceId, approvalContext) {
|
|
366
|
+
let approvedRequests = null;
|
|
367
|
+
// Step 1: Check wallet for existing valid VC
|
|
368
|
+
if (canonicalResourceId) {
|
|
369
|
+
// Step 1a: Exact resource match
|
|
370
|
+
const scopedVC = this.wallet.findCredential(this.context.agentDid, action, this.context.projectId, canonicalResourceId);
|
|
371
|
+
if (scopedVC) {
|
|
372
|
+
const verified = await this.verifyWalletVC(scopedVC);
|
|
373
|
+
if (verified)
|
|
374
|
+
return { credential: verified, decisionSource: 'cached_vc' };
|
|
375
|
+
}
|
|
376
|
+
// Step 1b: Legacy VC without resource scope (Gateway will validate)
|
|
377
|
+
const unscopedVC = this.wallet.findUnscopedCredential(this.context.agentDid, action, this.context.projectId);
|
|
378
|
+
if (unscopedVC) {
|
|
379
|
+
const verified = await this.verifyWalletVC(unscopedVC);
|
|
380
|
+
if (verified)
|
|
381
|
+
return { credential: verified, decisionSource: 'cached_vc' };
|
|
382
|
+
}
|
|
383
|
+
// No matching VC — fall through to Step 2-4
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
// No resource context — use original findCredential
|
|
387
|
+
const walletVC = this.wallet.findCredential(this.context.agentDid, action, this.context.projectId);
|
|
388
|
+
if (walletVC) {
|
|
389
|
+
const verified = await this.verifyWalletVC(walletVC);
|
|
390
|
+
if (verified)
|
|
391
|
+
return { credential: verified, decisionSource: 'cached_vc' };
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// Step 2: Check Gateway for approved requests, then claim VC
|
|
395
|
+
try {
|
|
396
|
+
approvedRequests = await this.gatewayClient.findApprovedRequests(this.context.agentDid);
|
|
397
|
+
const matching = approvedRequests.find(r => r.actions?.includes(action));
|
|
398
|
+
if (matching) {
|
|
399
|
+
try {
|
|
400
|
+
const claimed = await this.gatewayClient.claimVC(matching.id, this.context.agentDid);
|
|
401
|
+
const stored = this.wallet.storeCredential({
|
|
402
|
+
holderDid: this.context.agentDid,
|
|
403
|
+
projectId: this.context.projectId,
|
|
404
|
+
credentialJwt: claimed.credential.jwt,
|
|
405
|
+
actions: claimed.actions,
|
|
406
|
+
provider,
|
|
407
|
+
expiresAt: claimed.credential.expiresAt,
|
|
408
|
+
normalizedResourceKey: resourceInfo?.normalizedResource,
|
|
409
|
+
resourceFingerprint: resourceInfo?.fingerprint,
|
|
410
|
+
resources: (0, wallet_1.validateResources)(claimed.resources),
|
|
411
|
+
});
|
|
412
|
+
return { credential: stored, decisionSource: 'claimed_from_approval' };
|
|
413
|
+
}
|
|
414
|
+
catch (claimErr) {
|
|
415
|
+
const msg = claimErr instanceof Error ? claimErr.message : String(claimErr);
|
|
416
|
+
if (msg === 'CLAIM_CONSUMED') {
|
|
417
|
+
process.stderr.write('[vess] claimVC: request already consumed, falling through to step 3\n');
|
|
418
|
+
}
|
|
419
|
+
else if (msg === 'CLAIM_EXPIRED') {
|
|
420
|
+
process.stderr.write('[vess] claimVC: request expired, falling through to step 3\n');
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
process.stderr.write(`[vess] claimVC failed: ${msg}, falling through to step 3\n`);
|
|
424
|
+
}
|
|
425
|
+
// Fall through to Step 3
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// Gateway unreachable for step 2 — continue to step 3
|
|
431
|
+
}
|
|
432
|
+
// Step 3: Try auto-issue via Gateway. On the Cedar 一元化 Step 3.5 retry
|
|
433
|
+
// path, thread `approval_context` through so the API can consume the
|
|
434
|
+
// single-use approval token against the ledger and flip Cedar's prior
|
|
435
|
+
// `auth_required` to `permit` (spec §6 / §11.1).
|
|
436
|
+
//
|
|
437
|
+
// 動作確認 fix-forward (spec rev 5) — also thread the raw tool input
|
|
438
|
+
// through `parameters` so the API can bind `context.recipient.address`
|
|
439
|
+
// for templates like `recipient.address like "*@vess.id"`.
|
|
440
|
+
try {
|
|
441
|
+
const autoResult = await this.gatewayClient.autoIssueVC({
|
|
442
|
+
subjectDid: this.context.agentDid,
|
|
443
|
+
projectId: this.context.projectId,
|
|
444
|
+
actions: [action],
|
|
445
|
+
...(approvalContext ? { approval_context: approvalContext } : {}),
|
|
446
|
+
...(input && Object.keys(input).length > 0 ? { parameters: input } : {}),
|
|
447
|
+
});
|
|
448
|
+
if (autoResult?.autoIssued && autoResult.credential) {
|
|
449
|
+
const stored = this.wallet.storeCredential({
|
|
450
|
+
holderDid: this.context.agentDid,
|
|
451
|
+
projectId: this.context.projectId,
|
|
452
|
+
credentialJwt: autoResult.credential.jwt,
|
|
453
|
+
actions: autoResult.actions || [action],
|
|
454
|
+
provider,
|
|
455
|
+
expiresAt: autoResult.credential.expiresAt,
|
|
456
|
+
normalizedResourceKey: resourceInfo?.normalizedResource,
|
|
457
|
+
resourceFingerprint: resourceInfo?.fingerprint,
|
|
458
|
+
resources: (0, wallet_1.validateResources)(autoResult.resources),
|
|
459
|
+
metadata: autoResult.metadata,
|
|
460
|
+
});
|
|
461
|
+
return { credential: stored, decisionSource: 'cached_vc' };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
catch (err) {
|
|
465
|
+
// A Cedar forbid (403 policy_forbidden) is terminal — approval cannot
|
|
466
|
+
// lift it. Do NOT fall through to Step 4's risk-based approval routing
|
|
467
|
+
// (that would mint an un-redeemable OOB/in-band approval). Return a
|
|
468
|
+
// terminal denial so executeLocal/executeViaGateway surface
|
|
469
|
+
// `Permission denied: ...` with no approval prompt.
|
|
470
|
+
if (this.isPolicyForbiddenError(err)) {
|
|
471
|
+
const reason = this.policyForbiddenReason(err);
|
|
472
|
+
console.warn(`ensureVC: auto-issue forbidden by policy (terminal): ${reason}`);
|
|
473
|
+
return { denied: true, reason };
|
|
474
|
+
}
|
|
475
|
+
// Gateway unreachable for step 3 — continue to step 4
|
|
476
|
+
}
|
|
477
|
+
// Step 4: Risk-based approval routing
|
|
478
|
+
const riskLevel = this.getActionRiskLevel(action);
|
|
479
|
+
if (riskLevel === 'high' || riskLevel === 'critical') {
|
|
480
|
+
try {
|
|
481
|
+
return await this.createOOBApprovalRequest(action, provider, riskLevel, resourceInfo, input);
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
return {
|
|
485
|
+
denied: true,
|
|
486
|
+
reason: `Failed to create out-of-band approval request: ${err instanceof Error ? err.message : String(err)}`,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
return this.createInBandApprovalResponse(action, provider, riskLevel, approvedRequests, resourceInfo, input);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* True when an error is a Cedar forbid 403 (policy_forbidden) from the gateway.
|
|
496
|
+
*
|
|
497
|
+
* Primary path: `gatewayClient.autoIssueVC` throws `GatewayHttpError` on any
|
|
498
|
+
* non-ok, carrying the 403 status + body — that's the branch that fires today.
|
|
499
|
+
* The plain-`Error` message fallback is intentionally retained (not dead): it
|
|
500
|
+
* is cheap defensive coverage for a future caller that surfaces the forbid as
|
|
501
|
+
* a wrapped/plain Error, and keeps parity with remote-mcp's message-only
|
|
502
|
+
* `isPolicyForbiddenError`. The `policy_forbidden` token is snake_case and
|
|
503
|
+
* unlikely to collide with unrelated error prose.
|
|
504
|
+
*/
|
|
505
|
+
isPolicyForbiddenError(err) {
|
|
506
|
+
if (err instanceof gateway_client_1.GatewayHttpError) {
|
|
507
|
+
return err.status === 403 && (err.body?.includes('policy_forbidden') ?? false);
|
|
508
|
+
}
|
|
509
|
+
return err instanceof Error && err.message.includes('policy_forbidden');
|
|
510
|
+
}
|
|
511
|
+
policyForbiddenReason(err) {
|
|
512
|
+
const fallback = 'This action is forbidden by policy and cannot be approved ' +
|
|
513
|
+
'(e.g. outside the allowed time window). Adjust the policy if it should be allowed.';
|
|
514
|
+
if (err instanceof gateway_client_1.GatewayHttpError && err.body) {
|
|
515
|
+
try {
|
|
516
|
+
const parsed = JSON.parse(err.body);
|
|
517
|
+
if (parsed.message)
|
|
518
|
+
return parsed.message;
|
|
519
|
+
}
|
|
520
|
+
catch { /* body not JSON — use fallback */ }
|
|
521
|
+
}
|
|
522
|
+
return fallback;
|
|
523
|
+
}
|
|
524
|
+
// ===========================================================================
|
|
525
|
+
// Local execution with VP verification (spec §7.4)
|
|
526
|
+
// ===========================================================================
|
|
527
|
+
async executeLocal(action, params, provider, normalizedResource, resourceFingerprint, requestedResource) {
|
|
528
|
+
const resourceInfo = normalizedResource && resourceFingerprint
|
|
529
|
+
? { normalizedResource, fingerprint: resourceFingerprint }
|
|
530
|
+
: undefined;
|
|
531
|
+
// 1. Ensure VC (4-step acquisition)
|
|
532
|
+
const vcResult = await this.ensureVC(action, provider, resourceInfo, params);
|
|
533
|
+
if ('approvalRequired' in vcResult) {
|
|
534
|
+
return { success: false, approvalRequired: vcResult.approvalRequired };
|
|
535
|
+
}
|
|
536
|
+
if ('waitingForApproval' in vcResult) {
|
|
537
|
+
return { success: false, waitingForApproval: vcResult.waitingForApproval };
|
|
538
|
+
}
|
|
539
|
+
if ('denied' in vcResult) {
|
|
540
|
+
this.logAudit({
|
|
541
|
+
action, provider,
|
|
542
|
+
decision: 'deny',
|
|
543
|
+
enforcementType: 'local',
|
|
544
|
+
decisionSource: 'local_policy',
|
|
545
|
+
decisionReason: vcResult.reason,
|
|
546
|
+
requestedResource,
|
|
547
|
+
normalizedResource,
|
|
548
|
+
resourceFingerprint,
|
|
549
|
+
executionType: 'local',
|
|
550
|
+
});
|
|
551
|
+
return { success: false, error: `Permission denied: ${vcResult.reason}` };
|
|
552
|
+
}
|
|
553
|
+
// 2. Execute with the acquired VC
|
|
554
|
+
return this.executeLocalWithVC(action, params, provider, vcResult, normalizedResource, resourceFingerprint, requestedResource);
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Execute locally with an already-acquired VC (skips ensureVC).
|
|
558
|
+
* Used by both the normal path (after ensureVC) and handleApprovalResponse (after quickApprove).
|
|
559
|
+
*/
|
|
560
|
+
async executeLocalWithVC(action, params, provider, vcResult, normalizedResource, resourceFingerprint, requestedResource, retryCount = 0) {
|
|
561
|
+
const executor = (0, executor_registry_1.getLocalExecutor)(action);
|
|
562
|
+
if (!executor) {
|
|
563
|
+
return { success: false, error: `No local executor for action: ${action}` };
|
|
564
|
+
}
|
|
565
|
+
// Try Gateway VP verification (online path)
|
|
566
|
+
let gatewayReachable = true;
|
|
567
|
+
try {
|
|
568
|
+
const { nonce } = await this.gatewayClient.issueNonce(this.context.agentDid);
|
|
569
|
+
let vpJwt;
|
|
570
|
+
let authResult;
|
|
571
|
+
try {
|
|
572
|
+
vpJwt = await this.vpBuilder.buildVP({
|
|
573
|
+
credentialJwt: vcResult.credential.credentialJwt,
|
|
574
|
+
signerDid: this.context.agentDid,
|
|
575
|
+
signerPrivateKeyJwk: this.context.agentPrivateKeyJwk,
|
|
576
|
+
nonce,
|
|
577
|
+
domain: this.context.gatewayUrl,
|
|
578
|
+
});
|
|
579
|
+
authResult = await this.gatewayClient.verifyAndAuthorize(vpJwt, nonce, this.context.gatewayUrl, action, this.context.agentDid);
|
|
580
|
+
}
|
|
581
|
+
catch (buildErr) {
|
|
582
|
+
// VPBuilder.buildVP fails fast on an expired parent VC
|
|
583
|
+
// ("VC has expired") instead of producing a malformed presentation.
|
|
584
|
+
// Treat that as equivalent to the gateway returning a
|
|
585
|
+
// credential-invalid verdict, so the same retry / OOB fallback path
|
|
586
|
+
// below handles it. Without this, the throw would escape the outer
|
|
587
|
+
// try/catch (which only swallows GatewayNetworkError) and surface as
|
|
588
|
+
// a raw stack trace to the user.
|
|
589
|
+
if (buildErr instanceof gateway_client_1.GatewayNetworkError)
|
|
590
|
+
throw buildErr;
|
|
591
|
+
const buildErrMsg = buildErr instanceof Error ? buildErr.message : String(buildErr);
|
|
592
|
+
if (!(0, credential_errors_1.isCredentialInvalidError)(buildErrMsg))
|
|
593
|
+
throw buildErr;
|
|
594
|
+
authResult = { authorized: false, reason: buildErrMsg };
|
|
595
|
+
}
|
|
596
|
+
if (!authResult.authorized) {
|
|
597
|
+
// Check if the failure is due to credential expiration — attempt reissuance (max 1 retry)
|
|
598
|
+
if ((0, credential_errors_1.isCredentialInvalidError)(authResult.reason) && retryCount < 1) {
|
|
599
|
+
this.wallet.revokeCredential(vcResult.credential.id);
|
|
600
|
+
const resourceInfo = normalizedResource && resourceFingerprint
|
|
601
|
+
? { normalizedResource, fingerprint: resourceFingerprint }
|
|
602
|
+
: undefined;
|
|
603
|
+
const newVCResult = await this.ensureVC(action, provider, resourceInfo, params);
|
|
604
|
+
if ('credential' in newVCResult) {
|
|
605
|
+
return this.executeLocalWithVC(action, params, provider, newVCResult, normalizedResource, resourceFingerprint, requestedResource, retryCount + 1);
|
|
606
|
+
}
|
|
607
|
+
// ensureVC returned approval flow — pass through to caller
|
|
608
|
+
if ('waitingForApproval' in newVCResult) {
|
|
609
|
+
return { success: false, waitingForApproval: newVCResult.waitingForApproval };
|
|
610
|
+
}
|
|
611
|
+
if ('approvalRequired' in newVCResult) {
|
|
612
|
+
return { success: false, approvalRequired: newVCResult.approvalRequired };
|
|
613
|
+
}
|
|
614
|
+
// ensureVC denied — return denial reason instead of original auth error
|
|
615
|
+
if ('denied' in newVCResult) {
|
|
616
|
+
return { success: false, error: `VC reissuance denied: ${newVCResult.reason}` };
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
// After retry exhaustion, if still credential-invalid, fall back to OOB approval
|
|
620
|
+
// so the user gets an actionable re-authorization URL instead of a raw error.
|
|
621
|
+
if ((0, credential_errors_1.isCredentialInvalidError)(authResult.reason) && retryCount >= 1) {
|
|
622
|
+
const resourceInfoForOOB = normalizedResource && resourceFingerprint
|
|
623
|
+
? { normalizedResource, fingerprint: resourceFingerprint }
|
|
624
|
+
: undefined;
|
|
625
|
+
const oobFallback = await this.tryOOBFallbackAfterRetryExhaustion(action, provider, vcResult.credential.id, params, resourceInfoForOOB, 'local');
|
|
626
|
+
if (oobFallback)
|
|
627
|
+
return oobFallback;
|
|
628
|
+
}
|
|
629
|
+
this.logAudit({
|
|
630
|
+
action, provider,
|
|
631
|
+
decision: 'deny',
|
|
632
|
+
enforcementType: 'gateway_verified_local',
|
|
633
|
+
decisionSource: vcResult.decisionSource,
|
|
634
|
+
decisionReason: authResult.reason,
|
|
635
|
+
requestedResource,
|
|
636
|
+
normalizedResource,
|
|
637
|
+
resourceFingerprint,
|
|
638
|
+
executionType: 'local',
|
|
639
|
+
grantId: authResult.grantId,
|
|
640
|
+
});
|
|
641
|
+
return { success: false, error: `Authorization denied: ${authResult.reason}` };
|
|
642
|
+
}
|
|
643
|
+
// Consume one-time grant BEFORE execution
|
|
644
|
+
const effectiveGrantId = authResult.grantId || vcResult.grantId;
|
|
645
|
+
if (effectiveGrantId && authResult.approvalMode === 'one_time') {
|
|
646
|
+
const consumeResult = await this.gatewayClient.consumeGrant(effectiveGrantId);
|
|
647
|
+
if (!consumeResult.consumed) {
|
|
648
|
+
this.logAudit({
|
|
649
|
+
action, provider,
|
|
650
|
+
decision: 'deny',
|
|
651
|
+
enforcementType: 'gateway_verified_local',
|
|
652
|
+
decisionSource: vcResult.decisionSource,
|
|
653
|
+
decisionReason: `Grant consumption failed: ${consumeResult.reason}`,
|
|
654
|
+
requestedResource,
|
|
655
|
+
normalizedResource,
|
|
656
|
+
resourceFingerprint,
|
|
657
|
+
executionType: 'local',
|
|
658
|
+
grantId: effectiveGrantId,
|
|
659
|
+
});
|
|
660
|
+
return { success: false, error: `Grant consumption failed: ${consumeResult.reason}` };
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
// Execute locally — build context based on action type
|
|
664
|
+
let result;
|
|
665
|
+
if (action === 'os.process.run') {
|
|
666
|
+
const resolvedEnv = await this.resolveProcessEnv(params);
|
|
667
|
+
result = executor(params, { resolvedEnv });
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
result = executor(params, {
|
|
671
|
+
rootDid: this.context.rootDid,
|
|
672
|
+
approvedResource: vcResult.credential.normalizedResourceKey,
|
|
673
|
+
approvedFingerprint: vcResult.credential.resourceFingerprint,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
this.logAudit({
|
|
677
|
+
action, provider,
|
|
678
|
+
decision: result.success ? 'allow' : 'deny',
|
|
679
|
+
enforcementType: 'gateway_verified_local',
|
|
680
|
+
decisionSource: vcResult.decisionSource,
|
|
681
|
+
requestedResource,
|
|
682
|
+
normalizedResource,
|
|
683
|
+
resourceFingerprint: result.resourceFingerprint || resourceFingerprint,
|
|
684
|
+
executionType: 'local',
|
|
685
|
+
grantId: effectiveGrantId || authResult.grantId,
|
|
686
|
+
credentialId: vcResult.credential.id,
|
|
687
|
+
approvalMode: vcResult.approvalMode,
|
|
688
|
+
approvalNonce: vcResult.approvalNonce,
|
|
689
|
+
});
|
|
690
|
+
return { success: result.success, data: result.data, error: result.error };
|
|
691
|
+
}
|
|
692
|
+
catch (err) {
|
|
693
|
+
if (!(err instanceof gateway_client_1.GatewayNetworkError))
|
|
694
|
+
throw err;
|
|
695
|
+
gatewayReachable = false;
|
|
696
|
+
}
|
|
697
|
+
if (!gatewayReachable) {
|
|
698
|
+
return this.executeLocalOffline(action, params, provider, vcResult, normalizedResource, resourceFingerprint, requestedResource);
|
|
699
|
+
}
|
|
700
|
+
return { success: false, error: `Unexpected execution state for ${action}` };
|
|
701
|
+
}
|
|
702
|
+
// ===========================================================================
|
|
703
|
+
// Offline fallback (spec §7.6)
|
|
704
|
+
// ===========================================================================
|
|
705
|
+
/**
|
|
706
|
+
* Execute locally when Gateway is unreachable.
|
|
707
|
+
* All 4 conditions from spec §7.6 must be true:
|
|
708
|
+
* 1. Valid cached VC exists
|
|
709
|
+
* 2. VC not expired
|
|
710
|
+
* 3. Synced org policy not stale (30 min TTL)
|
|
711
|
+
* 4. Local policy does not deny
|
|
712
|
+
*/
|
|
713
|
+
async executeLocalOffline(action, params, provider, vcResult, normalizedResource, resourceFingerprint, requestedResource) {
|
|
714
|
+
// Condition 1: Valid cached VC exists (already guaranteed by ensureVC)
|
|
715
|
+
if (!vcResult.credential) {
|
|
716
|
+
return { success: false, error: 'Cannot execute offline: no cached VC' };
|
|
717
|
+
}
|
|
718
|
+
// Condition 2: VC not expired
|
|
719
|
+
if (vcResult.credential.expiresAt && vcResult.credential.expiresAt < Date.now()) {
|
|
720
|
+
this.logAudit({
|
|
721
|
+
action, provider,
|
|
722
|
+
decision: 'deny',
|
|
723
|
+
enforcementType: 'cached_verified_local',
|
|
724
|
+
decisionSource: 'cached_vc',
|
|
725
|
+
decisionReason: 'VC expired during offline execution',
|
|
726
|
+
requestedResource,
|
|
727
|
+
normalizedResource,
|
|
728
|
+
resourceFingerprint,
|
|
729
|
+
executionType: 'local',
|
|
730
|
+
});
|
|
731
|
+
return { success: false, error: 'Cannot execute offline: cached VC expired' };
|
|
732
|
+
}
|
|
733
|
+
// Condition 3: Synced org policy not stale (30 min TTL)
|
|
734
|
+
if (!this.lastPolicySyncTime || (Date.now() - this.lastPolicySyncTime) > POLICY_SYNC_TTL_MS) {
|
|
735
|
+
this.logAudit({
|
|
736
|
+
action, provider,
|
|
737
|
+
decision: 'deny',
|
|
738
|
+
enforcementType: 'cached_verified_local',
|
|
739
|
+
decisionSource: 'org_policy',
|
|
740
|
+
decisionReason: 'Org policy cache is stale or missing for offline execution',
|
|
741
|
+
requestedResource,
|
|
742
|
+
normalizedResource,
|
|
743
|
+
resourceFingerprint,
|
|
744
|
+
executionType: 'local',
|
|
745
|
+
});
|
|
746
|
+
return { success: false, error: 'Cannot execute offline: org policy cache is stale or has never been synced' };
|
|
747
|
+
}
|
|
748
|
+
// Condition 4: Local policy does not deny
|
|
749
|
+
const offlinePolicyCheck = this.policyEvaluator.evaluate(provider, action.replace(`${provider}.`, ''), {
|
|
750
|
+
type: this.getResourceType(action),
|
|
751
|
+
pattern: normalizedResource,
|
|
752
|
+
});
|
|
753
|
+
if (!offlinePolicyCheck.allowed) {
|
|
754
|
+
this.logAudit({
|
|
755
|
+
action, provider,
|
|
756
|
+
decision: 'deny',
|
|
757
|
+
enforcementType: 'cached_verified_local',
|
|
758
|
+
decisionSource: 'local_policy',
|
|
759
|
+
decisionReason: offlinePolicyCheck.reason,
|
|
760
|
+
requestedResource,
|
|
761
|
+
normalizedResource,
|
|
762
|
+
resourceFingerprint,
|
|
763
|
+
executionType: 'local',
|
|
764
|
+
});
|
|
765
|
+
return { success: false, error: `Cannot execute offline: local policy denies: ${offlinePolicyCheck.reason}` };
|
|
766
|
+
}
|
|
767
|
+
// All conditions met — execute with cached_verified_local enforcement
|
|
768
|
+
const executor = (0, executor_registry_1.getLocalExecutor)(action);
|
|
769
|
+
if (!executor) {
|
|
770
|
+
return { success: false, error: `No local executor for action: ${action}` };
|
|
771
|
+
}
|
|
772
|
+
let result;
|
|
773
|
+
if (action === 'os.process.run') {
|
|
774
|
+
const resolvedEnv = await this.resolveProcessEnv(params);
|
|
775
|
+
result = executor(params, { resolvedEnv });
|
|
776
|
+
}
|
|
777
|
+
else {
|
|
778
|
+
result = executor(params, {
|
|
779
|
+
rootDid: this.context.rootDid,
|
|
780
|
+
approvedResource: vcResult.credential.normalizedResourceKey,
|
|
781
|
+
approvedFingerprint: vcResult.credential.resourceFingerprint,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
this.logAudit({
|
|
785
|
+
action, provider,
|
|
786
|
+
decision: result.success ? 'allow' : 'deny',
|
|
787
|
+
enforcementType: 'cached_verified_local',
|
|
788
|
+
decisionSource: 'cached_vc',
|
|
789
|
+
requestedResource,
|
|
790
|
+
normalizedResource,
|
|
791
|
+
resourceFingerprint: result.resourceFingerprint || resourceFingerprint,
|
|
792
|
+
executionType: 'local',
|
|
793
|
+
credentialId: vcResult.credential.id,
|
|
794
|
+
});
|
|
795
|
+
return { success: result.success, data: result.data, error: result.error };
|
|
796
|
+
}
|
|
797
|
+
// ===========================================================================
|
|
798
|
+
// SaaS execution via Gateway (spec §7.4)
|
|
799
|
+
// ===========================================================================
|
|
800
|
+
async executeViaGateway(provider, action, params) {
|
|
801
|
+
// NEW: Resolve resource identifier to canonical ID
|
|
802
|
+
const extractedId = this.extractResourceIdFromInput(action, params);
|
|
803
|
+
let canonicalResourceId = extractedId;
|
|
804
|
+
if (extractedId) {
|
|
805
|
+
try {
|
|
806
|
+
const resolved = await this.gatewayClient.resolveResource({
|
|
807
|
+
provider,
|
|
808
|
+
resourceType: this.getResourceType(action),
|
|
809
|
+
input: extractedId,
|
|
810
|
+
projectId: this.context.projectId,
|
|
811
|
+
});
|
|
812
|
+
if (resolved.resolved) {
|
|
813
|
+
canonicalResourceId = resolved.canonicalId;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
catch {
|
|
817
|
+
// Resolution failed — continue with original input (best-effort)
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
// 1. Ensure VC (4-step acquisition) with resource context
|
|
821
|
+
const vcResult = await this.ensureVC(action, provider, undefined, params, canonicalResourceId);
|
|
822
|
+
if ('approvalRequired' in vcResult) {
|
|
823
|
+
return { success: false, approvalRequired: vcResult.approvalRequired };
|
|
824
|
+
}
|
|
825
|
+
if ('waitingForApproval' in vcResult) {
|
|
826
|
+
return { success: false, waitingForApproval: vcResult.waitingForApproval };
|
|
827
|
+
}
|
|
828
|
+
if ('denied' in vcResult) {
|
|
829
|
+
this.logAudit({
|
|
830
|
+
action, provider,
|
|
831
|
+
decision: 'deny',
|
|
832
|
+
enforcementType: 'gateway',
|
|
833
|
+
decisionSource: 'local_policy',
|
|
834
|
+
decisionReason: vcResult.reason,
|
|
835
|
+
executionType: 'gateway',
|
|
836
|
+
});
|
|
837
|
+
return { success: false, error: `Permission denied: ${vcResult.reason}` };
|
|
838
|
+
}
|
|
839
|
+
// 2. Execute with the acquired VC
|
|
840
|
+
return this.executeViaGatewayWithVC(provider, action, params, vcResult, 0, canonicalResourceId);
|
|
841
|
+
}
|
|
842
|
+
async executeViaGatewayWithVC(provider, action, params, vcResult, retryCount = 0, canonicalResourceId) {
|
|
843
|
+
try {
|
|
844
|
+
const { nonce } = await this.gatewayClient.issueNonce(this.context.agentDid);
|
|
845
|
+
let result;
|
|
846
|
+
try {
|
|
847
|
+
const vpJwt = await this.vpBuilder.buildVP({
|
|
848
|
+
credentialJwt: vcResult.credential.credentialJwt,
|
|
849
|
+
signerDid: this.context.agentDid,
|
|
850
|
+
signerPrivateKeyJwk: this.context.agentPrivateKeyJwk,
|
|
851
|
+
nonce,
|
|
852
|
+
domain: this.context.gatewayUrl,
|
|
853
|
+
});
|
|
854
|
+
result = await this.gatewayClient.invokeTool({
|
|
855
|
+
action,
|
|
856
|
+
parameters: params,
|
|
857
|
+
holderDid: this.context.agentDid,
|
|
858
|
+
vpJwt,
|
|
859
|
+
vpChallenge: nonce,
|
|
860
|
+
vpDomain: this.context.gatewayUrl,
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
catch (buildErr) {
|
|
864
|
+
// VPBuilder.buildVP fails fast on an expired parent VC
|
|
865
|
+
// ("VC has expired") instead of producing a malformed presentation.
|
|
866
|
+
// Synthesize a CREDENTIAL_INVALID InvokeToolResult so the existing
|
|
867
|
+
// retry / OOB fallback below handles it identically — without this,
|
|
868
|
+
// the throw would escape into the outer catch (which classifies any
|
|
869
|
+
// throw as "Gateway unreachable") and surface a misleading error.
|
|
870
|
+
if (buildErr instanceof gateway_client_1.GatewayNetworkError)
|
|
871
|
+
throw buildErr;
|
|
872
|
+
const buildErrMsg = buildErr instanceof Error ? buildErr.message : String(buildErr);
|
|
873
|
+
if (!(0, credential_errors_1.isCredentialInvalidError)(buildErrMsg))
|
|
874
|
+
throw buildErr;
|
|
875
|
+
result = {
|
|
876
|
+
success: false,
|
|
877
|
+
error: buildErrMsg,
|
|
878
|
+
errorCode: ai_identity_1.GATEWAY_ERROR_CODE.CREDENTIAL_INVALID,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
// Phase 2c-B — the api signaled a revoked/expired OAuth token. Do not
|
|
882
|
+
// retry: the VC is still valid locally but the upstream SaaS token is
|
|
883
|
+
// gone and won't return until the user clicks the authUrl. Surface the
|
|
884
|
+
// reauth payload so the CLI can render it.
|
|
885
|
+
//
|
|
886
|
+
// Scope note: reauth only originates here (the gateway path) because
|
|
887
|
+
// local actions (`os.*`) don't touch SaaS OAuth tokens. If a future
|
|
888
|
+
// local executor ever starts proxying SaaS calls, mirror this branch
|
|
889
|
+
// in `executeLocalWithVC` with `enforcementType: 'local'`.
|
|
890
|
+
if (!result.success && result.errorCode === ai_identity_1.GATEWAY_ERROR_CODE.REAUTH_REQUIRED && result.reauthRequired) {
|
|
891
|
+
this.logAudit({
|
|
892
|
+
action, provider,
|
|
893
|
+
decision: 'deny',
|
|
894
|
+
enforcementType: 'gateway',
|
|
895
|
+
decisionSource: 'gateway_execution',
|
|
896
|
+
decisionReason: 'oauth_token_revoked',
|
|
897
|
+
executionType: 'gateway',
|
|
898
|
+
credentialId: vcResult.credential.id,
|
|
899
|
+
grantId: vcResult.grantId,
|
|
900
|
+
approvalMode: vcResult.approvalMode,
|
|
901
|
+
});
|
|
902
|
+
return {
|
|
903
|
+
success: false,
|
|
904
|
+
error: result.error,
|
|
905
|
+
reauthRequired: {
|
|
906
|
+
provider: result.reauthRequired.provider,
|
|
907
|
+
reason: result.reauthRequired.reason,
|
|
908
|
+
message: result.reauthRequired.message,
|
|
909
|
+
authUrl: result.reauthRequired.authUrl,
|
|
910
|
+
projectId: result.reauthRequired.projectId,
|
|
911
|
+
},
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
// Cedar forbid — terminal. No re-approval, no retry (unlike
|
|
915
|
+
// CREDENTIAL_INVALID / RESOURCE_MISMATCH). Mirrors the api 403.
|
|
916
|
+
//
|
|
917
|
+
// TODO(audit-parity): this runtime path logs the structured
|
|
918
|
+
// `decisionReason: 'cedar_policy_forbidden'`, but a forbid surfaced via
|
|
919
|
+
// the Step-3 autoIssue path flows through the shared `{ denied: true }`
|
|
920
|
+
// sink in executeLocal/executeViaGateway, which logs the raw `reason`
|
|
921
|
+
// string instead. A log query keyed on `cedar_policy_forbidden` won't
|
|
922
|
+
// match Step-3 forbids. Unifying requires threading a structured source
|
|
923
|
+
// through the shared denied sink (used by ALL denials, not just forbid),
|
|
924
|
+
// so it's deferred rather than churned here.
|
|
925
|
+
if (!result.success && result.errorCode === ai_identity_1.GATEWAY_ERROR_CODE.POLICY_FORBIDDEN) {
|
|
926
|
+
this.logAudit({
|
|
927
|
+
action, provider,
|
|
928
|
+
decision: 'deny',
|
|
929
|
+
enforcementType: 'gateway',
|
|
930
|
+
decisionSource: 'gateway_execution',
|
|
931
|
+
decisionReason: 'cedar_policy_forbidden',
|
|
932
|
+
executionType: 'gateway',
|
|
933
|
+
credentialId: vcResult.credential.id,
|
|
934
|
+
grantId: vcResult.grantId,
|
|
935
|
+
approvalMode: vcResult.approvalMode,
|
|
936
|
+
});
|
|
937
|
+
return {
|
|
938
|
+
success: false,
|
|
939
|
+
error: result.error ||
|
|
940
|
+
'This action is forbidden by policy and cannot be approved. Adjust the policy if it should be allowed.',
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
// Invoke-time Cedar RequireApproval (HTTP 202 auth_required). The VC was
|
|
944
|
+
// already issued (so ensureVC's Step-4 approval routing was skipped), but
|
|
945
|
+
// the per-request evaluation still needs approval — e.g. a new recipient
|
|
946
|
+
// that fails the internal-domain fold. Unlike POLICY_FORBIDDEN this is
|
|
947
|
+
// lift-able: mint an OOB approval URL so the user can approve and retry.
|
|
948
|
+
// Reuses createOOBApprovalRequest (the same helper ensureVC Step-4 uses);
|
|
949
|
+
// resourceInfo is omitted because gateway resource resolution is
|
|
950
|
+
// server-side (mirrors tryOOBFallbackAfterRetryExhaustion). The api's 202
|
|
951
|
+
// carries no approvalUrl, which is why we create the request here rather
|
|
952
|
+
// than forwarding one.
|
|
953
|
+
// Structure mirrors handleResourceMismatch (the other OOB-minting branch):
|
|
954
|
+
// throttle guard first, audit BEFORE the try (so the denial is recorded
|
|
955
|
+
// whether or not OOB creation succeeds), then mint → track.
|
|
956
|
+
if (!result.success && result.errorCode === ai_identity_1.GATEWAY_ERROR_CODE.APPROVAL_REQUIRED) {
|
|
957
|
+
const extractedId = this.extractResourceIdFromInput(action, params);
|
|
958
|
+
// Dedup: a buggy server that returns 202 on every call would otherwise
|
|
959
|
+
// mint a fresh approval request each time. Same guard as the
|
|
960
|
+
// resource-mismatch reapproval path.
|
|
961
|
+
if (this.isRecentOOBRequest(action, extractedId)) {
|
|
962
|
+
return {
|
|
963
|
+
success: false,
|
|
964
|
+
error: 'An approval request was recently created. Please approve it or wait before retrying.',
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
this.logAudit({
|
|
968
|
+
action, provider,
|
|
969
|
+
decision: 'deny',
|
|
970
|
+
enforcementType: 'gateway',
|
|
971
|
+
decisionSource: 'gateway_execution',
|
|
972
|
+
decisionReason: 'cedar_auth_required',
|
|
973
|
+
executionType: 'gateway',
|
|
974
|
+
credentialId: vcResult.credential.id,
|
|
975
|
+
grantId: vcResult.grantId,
|
|
976
|
+
approvalMode: vcResult.approvalMode,
|
|
977
|
+
});
|
|
978
|
+
const riskLevel = this.getActionRiskLevel(action);
|
|
979
|
+
try {
|
|
980
|
+
const oob = await this.createOOBApprovalRequest(action, provider, riskLevel, undefined, params);
|
|
981
|
+
this.trackOOBRequest(action, extractedId);
|
|
982
|
+
return { success: false, waitingForApproval: oob.waitingForApproval };
|
|
983
|
+
}
|
|
984
|
+
catch (oobErr) {
|
|
985
|
+
// OOB creation failed — surface the auth_required without a URL
|
|
986
|
+
// rather than crashing (mirrors the callTool fall-through in
|
|
987
|
+
// remote-mcp). Prepend the failure context instead of `||`-ing it:
|
|
988
|
+
// the api always supplies `result.error` ("Approval required …"),
|
|
989
|
+
// which would otherwise mask WHY no clickable URL appeared.
|
|
990
|
+
const base = result.error || `Approval required for ${action}`;
|
|
991
|
+
return {
|
|
992
|
+
success: false,
|
|
993
|
+
error: `${base} — creating the approval request failed: ` +
|
|
994
|
+
`${oobErr instanceof Error ? oobErr.message : String(oobErr)}`,
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
// Detect resource mismatch from structured error code
|
|
999
|
+
if (!result.success && result.errorCode === ai_identity_1.GATEWAY_ERROR_CODE.RESOURCE_MISMATCH) {
|
|
1000
|
+
return this.handleResourceMismatch(action, provider, params, vcResult);
|
|
1001
|
+
}
|
|
1002
|
+
// Detect credential-invalid errors and attempt VC reissuance (max 1 retry)
|
|
1003
|
+
if (!result.success && result.errorCode === ai_identity_1.GATEWAY_ERROR_CODE.CREDENTIAL_INVALID && retryCount < 1) {
|
|
1004
|
+
this.wallet.revokeCredential(vcResult.credential.id);
|
|
1005
|
+
const newVCResult = await this.ensureVC(action, provider, undefined, params, canonicalResourceId);
|
|
1006
|
+
if ('credential' in newVCResult) {
|
|
1007
|
+
return this.executeViaGatewayWithVC(provider, action, params, newVCResult, retryCount + 1, canonicalResourceId);
|
|
1008
|
+
}
|
|
1009
|
+
// ensureVC returned approval flow — pass through to caller
|
|
1010
|
+
if ('waitingForApproval' in newVCResult) {
|
|
1011
|
+
return { success: false, waitingForApproval: newVCResult.waitingForApproval };
|
|
1012
|
+
}
|
|
1013
|
+
if ('approvalRequired' in newVCResult) {
|
|
1014
|
+
return { success: false, approvalRequired: newVCResult.approvalRequired };
|
|
1015
|
+
}
|
|
1016
|
+
// ensureVC denied — return denial reason
|
|
1017
|
+
if ('denied' in newVCResult) {
|
|
1018
|
+
return { success: false, error: `VC reissuance denied: ${newVCResult.reason}` };
|
|
1019
|
+
}
|
|
1020
|
+
// Could not re-acquire VC — return the original error
|
|
1021
|
+
return { success: false, error: result.error };
|
|
1022
|
+
}
|
|
1023
|
+
// After retry exhaustion, if still CREDENTIAL_INVALID, fall back to OOB approval
|
|
1024
|
+
// so the user gets an actionable re-authorization URL instead of a raw error.
|
|
1025
|
+
if (!result.success && result.errorCode === ai_identity_1.GATEWAY_ERROR_CODE.CREDENTIAL_INVALID && retryCount >= 1) {
|
|
1026
|
+
// Gateway path does not have normalizedResource/resourceFingerprint in scope
|
|
1027
|
+
// (resource resolution happens server-side), so resourceInfo is omitted.
|
|
1028
|
+
const oobFallback = await this.tryOOBFallbackAfterRetryExhaustion(action, provider, vcResult.credential.id, params);
|
|
1029
|
+
if (oobFallback)
|
|
1030
|
+
return oobFallback;
|
|
1031
|
+
}
|
|
1032
|
+
this.logAudit({
|
|
1033
|
+
action, provider,
|
|
1034
|
+
decision: result.success ? 'allow' : 'deny',
|
|
1035
|
+
enforcementType: 'gateway',
|
|
1036
|
+
decisionSource: vcResult.decisionSource,
|
|
1037
|
+
decisionReason: result.error,
|
|
1038
|
+
executionType: 'gateway',
|
|
1039
|
+
credentialId: vcResult.credential.id,
|
|
1040
|
+
grantId: vcResult.grantId,
|
|
1041
|
+
approvalMode: vcResult.approvalMode,
|
|
1042
|
+
approvalNonce: vcResult.approvalNonce,
|
|
1043
|
+
});
|
|
1044
|
+
return { success: result.success, data: result.data, error: result.error };
|
|
1045
|
+
}
|
|
1046
|
+
catch (err) {
|
|
1047
|
+
this.logAudit({
|
|
1048
|
+
action, provider,
|
|
1049
|
+
decision: 'deny',
|
|
1050
|
+
enforcementType: 'gateway',
|
|
1051
|
+
decisionSource: 'gateway_execution',
|
|
1052
|
+
decisionReason: `Gateway unreachable: ${err instanceof Error ? err.message : String(err)}`,
|
|
1053
|
+
executionType: 'gateway',
|
|
1054
|
+
});
|
|
1055
|
+
return {
|
|
1056
|
+
success: false,
|
|
1057
|
+
error: `Gateway required for SaaS operations but unreachable: ${err instanceof Error ? err.message : String(err)}`,
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
// ===========================================================================
|
|
1062
|
+
// OOB approval methods
|
|
1063
|
+
// ===========================================================================
|
|
1064
|
+
/**
|
|
1065
|
+
* Handle a re-invocation with pendingRequestId (OOB approval polling).
|
|
1066
|
+
*/
|
|
1067
|
+
async handlePendingApproval(request) {
|
|
1068
|
+
const { action, pendingRequestId } = request;
|
|
1069
|
+
if (!pendingRequestId) {
|
|
1070
|
+
return { success: false, error: 'No pendingRequestId provided' };
|
|
1071
|
+
}
|
|
1072
|
+
let statusResult;
|
|
1073
|
+
try {
|
|
1074
|
+
statusResult = await this.gatewayClient.getApprovalStatus(pendingRequestId);
|
|
1075
|
+
}
|
|
1076
|
+
catch (err) {
|
|
1077
|
+
return { success: false, error: `Failed to check approval status: ${err instanceof Error ? err.message : String(err)}` };
|
|
1078
|
+
}
|
|
1079
|
+
// Security: verify that the re-invoked action matches what was originally approved
|
|
1080
|
+
if (statusResult.actions && !statusResult.actions.includes(action)) {
|
|
1081
|
+
return {
|
|
1082
|
+
success: false,
|
|
1083
|
+
error: `Action mismatch: approval was for [${statusResult.actions.join(', ')}], but re-invoked with "${action}"`,
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
// Security: verify that the re-invoked resource matches what was originally approved
|
|
1087
|
+
if (statusResult.resources?.length) {
|
|
1088
|
+
const extractedId = this.extractResourceIdFromInput(action, request.input);
|
|
1089
|
+
if (extractedId) {
|
|
1090
|
+
// For local actions, the approval stores canonicalized paths — canonicalize the input too
|
|
1091
|
+
const normalizedId = (0, executor_registry_1.isLocalAction)(action) && request.input.file_path
|
|
1092
|
+
? (0, resource_canonicalizer_1.canonicalizePath)(request.input.file_path)
|
|
1093
|
+
: extractedId;
|
|
1094
|
+
const approvedResources = statusResult.resources;
|
|
1095
|
+
// 🪤 The SAME predicate exists at the post-claim re-validation below
|
|
1096
|
+
// (~:1310). Both must agree: this one runs on `request.input`, that one
|
|
1097
|
+
// on the stored `actionParams`, and the live 2026-07-30 calendar bug
|
|
1098
|
+
// reached BOTH. Keep them in lock-step via `approvedResourceMatches`.
|
|
1099
|
+
const resourceMatch = this.approvedResourceMatches(approvedResources, extractedId, normalizedId);
|
|
1100
|
+
if (!resourceMatch) {
|
|
1101
|
+
return {
|
|
1102
|
+
success: false,
|
|
1103
|
+
error: `Resource mismatch: approval was for [${approvedResources.map((r) => r.id || r.pattern).join(', ')}], but re-invoked with "${extractedId}"`,
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
if (statusResult.status === 'denied') {
|
|
1109
|
+
const provider = action.split('.')[0];
|
|
1110
|
+
this.logAudit({
|
|
1111
|
+
action, provider,
|
|
1112
|
+
decision: 'deny',
|
|
1113
|
+
enforcementType: 'gateway',
|
|
1114
|
+
decisionSource: 'org_policy',
|
|
1115
|
+
decisionReason: 'User denied the OOB approval request',
|
|
1116
|
+
executionType: (0, executor_registry_1.isLocalAction)(action) ? 'local' : 'gateway',
|
|
1117
|
+
});
|
|
1118
|
+
return { success: false, error: 'Approval request was denied by the user' };
|
|
1119
|
+
}
|
|
1120
|
+
if (statusResult.status === 'expired') {
|
|
1121
|
+
return { success: false, error: 'Approval request has expired. Please try again.' };
|
|
1122
|
+
}
|
|
1123
|
+
if (statusResult.status === 'consumed') {
|
|
1124
|
+
return { success: false, error: 'Approval was already claimed by another session. Please request a new approval.' };
|
|
1125
|
+
}
|
|
1126
|
+
if (statusResult.status === 'pending') {
|
|
1127
|
+
const rawExpiry = statusResult.requestExpiresAt
|
|
1128
|
+
? Math.floor((new Date(statusResult.requestExpiresAt).getTime() - Date.now()) / 1000)
|
|
1129
|
+
: NaN;
|
|
1130
|
+
const expiresIn = Number.isNaN(rawExpiry) ? OOB_APPROVAL_DEFAULT_EXPIRES_IN_SECONDS : Math.max(0, rawExpiry);
|
|
1131
|
+
return {
|
|
1132
|
+
success: false,
|
|
1133
|
+
waitingForApproval: {
|
|
1134
|
+
status: 'waiting_for_approval',
|
|
1135
|
+
approvalUrl: statusResult.approvalUrl || 'Use the URL from your initial request',
|
|
1136
|
+
requestId: pendingRequestId,
|
|
1137
|
+
action,
|
|
1138
|
+
riskLevel: this.getActionRiskLevel(action),
|
|
1139
|
+
expiresIn,
|
|
1140
|
+
message: 'Still waiting for approval. Please approve in your browser.',
|
|
1141
|
+
},
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
// approved — claim VC, store, and execute directly
|
|
1145
|
+
if (statusResult.status === 'approved') {
|
|
1146
|
+
const provider = action.split('.')[0];
|
|
1147
|
+
let claimed;
|
|
1148
|
+
try {
|
|
1149
|
+
claimed = await this.gatewayClient.claimVC(pendingRequestId, this.context.agentDid);
|
|
1150
|
+
}
|
|
1151
|
+
catch (claimErr) {
|
|
1152
|
+
const msg = claimErr instanceof Error ? claimErr.message : String(claimErr);
|
|
1153
|
+
if (msg === 'CLAIM_CONSUMED') {
|
|
1154
|
+
return { success: false, error: 'Approval was already claimed by another session' };
|
|
1155
|
+
}
|
|
1156
|
+
if (msg === 'CLAIM_EXPIRED') {
|
|
1157
|
+
return { success: false, error: 'Approval request has expired. Please try again.' };
|
|
1158
|
+
}
|
|
1159
|
+
return { success: false, error: `Failed to claim VC: ${msg}` };
|
|
1160
|
+
}
|
|
1161
|
+
// Use stored actionParams from approval request (approved for these exact params).
|
|
1162
|
+
// Fall back to request.input only for legacy requests where actionParams is unavailable.
|
|
1163
|
+
const rawStoredParams = claimed.actionParams;
|
|
1164
|
+
const storedParams = rawStoredParams && Object.keys(rawStoredParams).length > 0
|
|
1165
|
+
? rawStoredParams
|
|
1166
|
+
: undefined;
|
|
1167
|
+
const effectiveInput = storedParams
|
|
1168
|
+
?? (Object.keys(request.input).length > 0 ? request.input : null);
|
|
1169
|
+
if (!effectiveInput) {
|
|
1170
|
+
return { success: false, error: 'Action parameters could not be restored. Please retry with explicit parameters.' };
|
|
1171
|
+
}
|
|
1172
|
+
// Security: re-validate resource constraint using effectiveInput (defense-in-depth)
|
|
1173
|
+
// The pre-claim check (line 838) may have been skipped when request.input was empty.
|
|
1174
|
+
if (statusResult.resources?.length) {
|
|
1175
|
+
const extractedId = this.extractResourceIdFromInput(action, effectiveInput);
|
|
1176
|
+
if (extractedId) {
|
|
1177
|
+
const normalizedId = (0, executor_registry_1.isLocalAction)(action) && effectiveInput.file_path
|
|
1178
|
+
? (0, resource_canonicalizer_1.canonicalizePath)(effectiveInput.file_path)
|
|
1179
|
+
: extractedId;
|
|
1180
|
+
const approvedResources = statusResult.resources;
|
|
1181
|
+
// Same predicate as the pre-claim check (~:1213) — see the note there.
|
|
1182
|
+
const resourceMatch = this.approvedResourceMatches(approvedResources, extractedId, normalizedId);
|
|
1183
|
+
if (!resourceMatch) {
|
|
1184
|
+
return {
|
|
1185
|
+
success: false,
|
|
1186
|
+
error: `Resource mismatch: approval was for [${approvedResources.map((r) => r.id || r.pattern).join(', ')}], but effective input resolves to "${extractedId}"`,
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
const stored = this.wallet.storeCredential({
|
|
1192
|
+
holderDid: this.context.agentDid,
|
|
1193
|
+
projectId: this.context.projectId,
|
|
1194
|
+
credentialJwt: claimed.credential.jwt,
|
|
1195
|
+
actions: claimed.actions.length ? claimed.actions : [action],
|
|
1196
|
+
provider,
|
|
1197
|
+
expiresAt: claimed.credential.expiresAt,
|
|
1198
|
+
resources: (0, wallet_1.validateResources)(claimed.resources),
|
|
1199
|
+
});
|
|
1200
|
+
// Build VCAcquired and execute directly (skip re-enter via this.execute)
|
|
1201
|
+
const vcAcquired = {
|
|
1202
|
+
credential: stored,
|
|
1203
|
+
approvalMode: 'one_time',
|
|
1204
|
+
decisionSource: 'claimed_from_approval',
|
|
1205
|
+
};
|
|
1206
|
+
const routing = (0, executor_registry_1.determineRouting)(provider, action);
|
|
1207
|
+
let executeResult;
|
|
1208
|
+
const normalizedResource = (0, executor_registry_1.isLocalAction)(action) && effectiveInput.file_path
|
|
1209
|
+
? (0, resource_canonicalizer_1.canonicalizePath)(effectiveInput.file_path)
|
|
1210
|
+
: undefined;
|
|
1211
|
+
const resourceFingerprint = normalizedResource
|
|
1212
|
+
? (0, resource_canonicalizer_1.computeFingerprint)(normalizedResource, this.context.rootDid)
|
|
1213
|
+
: undefined;
|
|
1214
|
+
try {
|
|
1215
|
+
if (routing.executor === 'local') {
|
|
1216
|
+
executeResult = await this.executeLocalWithVC(action, effectiveInput, provider, vcAcquired, normalizedResource, resourceFingerprint, effectiveInput.file_path);
|
|
1217
|
+
}
|
|
1218
|
+
else {
|
|
1219
|
+
executeResult = await this.executeViaGatewayWithVC(provider, action, effectiveInput, vcAcquired);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
finally {
|
|
1223
|
+
// Wallet cleanup for OOB one-time credentials
|
|
1224
|
+
if (stored?.id) {
|
|
1225
|
+
this.wallet.revokeCredential(stored.id);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return executeResult;
|
|
1229
|
+
}
|
|
1230
|
+
return { success: false, error: `Unexpected approval status: ${statusResult.status}` };
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Create an OOB (out-of-band) approval request.
|
|
1234
|
+
* Returns a URL for the user to approve in their browser.
|
|
1235
|
+
*
|
|
1236
|
+
* Called in two contexts:
|
|
1237
|
+
* - ensureVC Step 4: for high/critical risk actions that require browser approval.
|
|
1238
|
+
* - tryOOBFallbackAfterRetryExhaustion: for ANY risk level when a VC expires
|
|
1239
|
+
* mid-execution and retry is exhausted. In this case OOB is used as a fallback
|
|
1240
|
+
* because the normal in-band flow cannot recover an expired credential — the
|
|
1241
|
+
* user must re-authorize via browser regardless of risk level.
|
|
1242
|
+
*/
|
|
1243
|
+
async createOOBApprovalRequest(action, provider, riskLevel, resourceInfo, input) {
|
|
1244
|
+
const resourceType = this.getResourceType(action);
|
|
1245
|
+
const extractedId = this.extractResourceIdFromInput(action, input);
|
|
1246
|
+
const resources = resourceInfo
|
|
1247
|
+
? [{ provider, type: resourceType, pattern: resourceInfo.normalizedResource }]
|
|
1248
|
+
: extractedId
|
|
1249
|
+
? [{ provider, type: resourceType, id: extractedId }]
|
|
1250
|
+
: [{ provider, type: resourceType }];
|
|
1251
|
+
try {
|
|
1252
|
+
const result = await this.gatewayClient.createApprovalRequest({
|
|
1253
|
+
subjectDid: this.context.agentDid,
|
|
1254
|
+
projectId: this.context.projectId,
|
|
1255
|
+
actions: [action],
|
|
1256
|
+
resources,
|
|
1257
|
+
actionParams: input,
|
|
1258
|
+
});
|
|
1259
|
+
const rawExpiry = result.expiresAt
|
|
1260
|
+
? Math.floor((new Date(result.expiresAt).getTime() - Date.now()) / 1000)
|
|
1261
|
+
: NaN;
|
|
1262
|
+
const expiresIn = Number.isNaN(rawExpiry) ? OOB_APPROVAL_DEFAULT_EXPIRES_IN_SECONDS : Math.max(0, rawExpiry);
|
|
1263
|
+
return {
|
|
1264
|
+
waitingForApproval: {
|
|
1265
|
+
status: 'waiting_for_approval',
|
|
1266
|
+
approvalUrl: result.approvalUrl,
|
|
1267
|
+
requestId: result.requestId,
|
|
1268
|
+
action,
|
|
1269
|
+
resource: resourceInfo?.normalizedResource ?? extractedId,
|
|
1270
|
+
riskLevel,
|
|
1271
|
+
expiresIn,
|
|
1272
|
+
message: `This ${riskLevel}-risk action requires browser approval (login required). Open the URL to approve.`,
|
|
1273
|
+
},
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
catch (err) {
|
|
1277
|
+
throw new Error(`Failed to create OOB approval request: ${err instanceof Error ? err.message : String(err)}`);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Revoke the stale credential and attempt to create an OOB approval request
|
|
1282
|
+
* as a fallback after retry exhaustion. Returns an ExecuteResult with
|
|
1283
|
+
* waitingForApproval on success, or null if OOB creation fails (caller falls
|
|
1284
|
+
* through to the original error).
|
|
1285
|
+
*/
|
|
1286
|
+
async tryOOBFallbackAfterRetryExhaustion(action, provider, credentialId, params, resourceInfo, executionType = 'gateway') {
|
|
1287
|
+
const enforcementType = executionType === 'local' ? 'gateway_verified_local' : 'gateway';
|
|
1288
|
+
this.wallet.revokeCredential(credentialId);
|
|
1289
|
+
try {
|
|
1290
|
+
const riskLevel = this.getActionRiskLevel(action);
|
|
1291
|
+
const oobResult = await this.createOOBApprovalRequest(action, provider, riskLevel, resourceInfo, params);
|
|
1292
|
+
this.logAudit({
|
|
1293
|
+
action, provider,
|
|
1294
|
+
decision: 'deny',
|
|
1295
|
+
enforcementType,
|
|
1296
|
+
decisionSource: 'gateway_execution',
|
|
1297
|
+
decisionReason: `Credential expired after retry — OOB approval requested (requestId=${oobResult.waitingForApproval.requestId})`,
|
|
1298
|
+
executionType,
|
|
1299
|
+
credentialId,
|
|
1300
|
+
});
|
|
1301
|
+
return { success: false, waitingForApproval: oobResult.waitingForApproval };
|
|
1302
|
+
}
|
|
1303
|
+
catch (err) {
|
|
1304
|
+
this.logAudit({
|
|
1305
|
+
action, provider,
|
|
1306
|
+
decision: 'deny',
|
|
1307
|
+
enforcementType,
|
|
1308
|
+
decisionSource: 'gateway_execution',
|
|
1309
|
+
decisionReason: `OOB approval fallback failed after retry exhaustion: ${err instanceof Error ? err.message : String(err)}`,
|
|
1310
|
+
executionType,
|
|
1311
|
+
credentialId,
|
|
1312
|
+
});
|
|
1313
|
+
return null;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Create an in-band approval response for low/medium risk actions.
|
|
1318
|
+
* Generates an approval token for the MCP client to present back.
|
|
1319
|
+
*/
|
|
1320
|
+
createInBandApprovalResponse(action, _provider, riskLevel, approvedRequests, resourceInfo, input = {}) {
|
|
1321
|
+
const defaultTTL = this.getDefaultVCTTL(riskLevel);
|
|
1322
|
+
// Determine context using cached result from Step 2
|
|
1323
|
+
let approvalContext = 'new_approval';
|
|
1324
|
+
if (approvedRequests && approvedRequests.some(r => r.actions?.includes(action))) {
|
|
1325
|
+
approvalContext = 'reconfirmation';
|
|
1326
|
+
}
|
|
1327
|
+
// Narrow resource: use normalized path for local actions, or extract resource ID for SaaS actions
|
|
1328
|
+
const extractedResource = this.extractResourceIdFromInput(action, input);
|
|
1329
|
+
const tokenResource = resourceInfo?.normalizedResource ?? extractedResource;
|
|
1330
|
+
const approvalToken = this.tokenService.generate({
|
|
1331
|
+
action,
|
|
1332
|
+
scope: action,
|
|
1333
|
+
resource: tokenResource,
|
|
1334
|
+
context: approvalContext,
|
|
1335
|
+
});
|
|
1336
|
+
return {
|
|
1337
|
+
approvalRequired: {
|
|
1338
|
+
status: 'approval_required',
|
|
1339
|
+
action,
|
|
1340
|
+
description: `Execute ${action}`,
|
|
1341
|
+
scope: action,
|
|
1342
|
+
resource: tokenResource,
|
|
1343
|
+
riskLevel,
|
|
1344
|
+
options: ['one_time', 'persistent', 'deny_once', 'deny_persistent'],
|
|
1345
|
+
suggestedTTL: defaultTTL,
|
|
1346
|
+
maxTTL: { minutes: 1440, label: '24 hours' },
|
|
1347
|
+
approvalToken,
|
|
1348
|
+
context: approvalContext,
|
|
1349
|
+
},
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
// ===========================================================================
|
|
1353
|
+
// Utility methods
|
|
1354
|
+
// ===========================================================================
|
|
1355
|
+
/**
|
|
1356
|
+
* Pre-request permissions for multiple actions (batch).
|
|
1357
|
+
* Triggers VC acquisition for each action without executing.
|
|
1358
|
+
* This allows the user to approve multiple actions at once.
|
|
1359
|
+
*/
|
|
1360
|
+
async requestPermissions(actions) {
|
|
1361
|
+
const approved = [];
|
|
1362
|
+
const denied = [];
|
|
1363
|
+
const pending = [];
|
|
1364
|
+
for (const action of actions) {
|
|
1365
|
+
const provider = action.split('.')[0];
|
|
1366
|
+
// Policy check
|
|
1367
|
+
const policyResult = this.policyEvaluator.evaluate(provider, action.replace(`${provider}.`, ''), { type: this.getResourceType(action) });
|
|
1368
|
+
if (!policyResult.allowed) {
|
|
1369
|
+
denied.push(action);
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
// Try VC acquisition (steps 1-3 only, no terminal prompt for batch)
|
|
1373
|
+
try {
|
|
1374
|
+
const walletVC = this.wallet.findCredential(this.context.agentDid, action, this.context.projectId);
|
|
1375
|
+
if (walletVC) {
|
|
1376
|
+
approved.push(action);
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
// Try auto-issue
|
|
1380
|
+
const autoResult = await this.gatewayClient.autoIssueVC({
|
|
1381
|
+
subjectDid: this.context.agentDid,
|
|
1382
|
+
projectId: this.context.projectId,
|
|
1383
|
+
actions: [action],
|
|
1384
|
+
});
|
|
1385
|
+
if (autoResult?.autoIssued && autoResult.credential) {
|
|
1386
|
+
this.wallet.storeCredential({
|
|
1387
|
+
holderDid: this.context.agentDid,
|
|
1388
|
+
projectId: this.context.projectId,
|
|
1389
|
+
credentialJwt: autoResult.credential.jwt,
|
|
1390
|
+
actions: autoResult.actions || [action],
|
|
1391
|
+
provider,
|
|
1392
|
+
expiresAt: autoResult.credential.expiresAt,
|
|
1393
|
+
resources: (0, wallet_1.validateResources)(autoResult.resources),
|
|
1394
|
+
metadata: autoResult.metadata,
|
|
1395
|
+
});
|
|
1396
|
+
approved.push(action);
|
|
1397
|
+
continue;
|
|
1398
|
+
}
|
|
1399
|
+
pending.push(action);
|
|
1400
|
+
}
|
|
1401
|
+
catch {
|
|
1402
|
+
pending.push(action);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
const allApproved = denied.length === 0 && pending.length === 0;
|
|
1406
|
+
// Create approval URL for pending actions (matches remote-mcp behavior)
|
|
1407
|
+
if (pending.length > 0) {
|
|
1408
|
+
try {
|
|
1409
|
+
const approvalResult = await this.gatewayClient.createApprovalRequest({
|
|
1410
|
+
subjectDid: this.context.agentDid,
|
|
1411
|
+
projectId: this.context.projectId,
|
|
1412
|
+
actions: pending,
|
|
1413
|
+
});
|
|
1414
|
+
if (!approvalResult.approvalUrl) {
|
|
1415
|
+
throw new Error('Gateway returned empty approval URL');
|
|
1416
|
+
}
|
|
1417
|
+
return {
|
|
1418
|
+
success: true,
|
|
1419
|
+
data: {
|
|
1420
|
+
approved,
|
|
1421
|
+
denied,
|
|
1422
|
+
pending,
|
|
1423
|
+
requestId: approvalResult.requestId,
|
|
1424
|
+
approvalUrl: approvalResult.approvalUrl,
|
|
1425
|
+
expiresAt: approvalResult.expiresAt,
|
|
1426
|
+
status: 'pending',
|
|
1427
|
+
message: approved.length > 0
|
|
1428
|
+
? `Approved: [${approved.join(', ')}]. Approval required for: [${pending.join(', ')}]. See approvalUrl field.`
|
|
1429
|
+
: `Permission required for: [${pending.join(', ')}]. See approvalUrl field.`,
|
|
1430
|
+
},
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
catch (err) {
|
|
1434
|
+
// Fallback: return pending without URL if approval request creation fails
|
|
1435
|
+
return {
|
|
1436
|
+
success: true,
|
|
1437
|
+
data: {
|
|
1438
|
+
approved,
|
|
1439
|
+
denied,
|
|
1440
|
+
pending,
|
|
1441
|
+
status: 'partial',
|
|
1442
|
+
message: `Approved: [${approved.join(', ')}]. Pending: [${pending.join(', ')}]. Denied: [${denied.join(', ')}]. Failed to create approval URL: ${err instanceof Error ? err.message : String(err)}. Use aidentity_call_tool for pending actions — you will be prompted to approve.`,
|
|
1443
|
+
},
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
return {
|
|
1448
|
+
success: true,
|
|
1449
|
+
data: {
|
|
1450
|
+
approved,
|
|
1451
|
+
denied,
|
|
1452
|
+
pending,
|
|
1453
|
+
status: allApproved ? 'approved' : 'partial',
|
|
1454
|
+
message: allApproved
|
|
1455
|
+
? 'All requested permissions have been granted. You can now use aidentity_call_tool.'
|
|
1456
|
+
: `Approved: [${approved.join(', ')}]. Denied: [${denied.join(', ')}].`,
|
|
1457
|
+
},
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
async listAvailableTools() {
|
|
1461
|
+
const tools = ai_identity_1.ACTION_REGISTRY.actions.map(a => ({
|
|
1462
|
+
action: a.action,
|
|
1463
|
+
resource_type: a.resource_type,
|
|
1464
|
+
risk: a.risk,
|
|
1465
|
+
input_schema: a.input_schema,
|
|
1466
|
+
}));
|
|
1467
|
+
return { success: true, data: { tools, count: tools.length } };
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Resolve env vars for os.process.run from .env file.
|
|
1471
|
+
* Returns empty record if no env_profile specified or .env not found.
|
|
1472
|
+
*/
|
|
1473
|
+
async resolveProcessEnv(params) {
|
|
1474
|
+
if (!this.secretBackend || !params.env_profile) {
|
|
1475
|
+
return {};
|
|
1476
|
+
}
|
|
1477
|
+
const envPath = path.join(params.working_directory || process.cwd(), '.env');
|
|
1478
|
+
try {
|
|
1479
|
+
const content = fs.readFileSync(envPath, 'utf-8');
|
|
1480
|
+
const envResolver = new env_resolver_1.EnvResolver(this.secretBackend);
|
|
1481
|
+
const { env, warnings } = await envResolver.resolveEnvContentWithWarnings(content);
|
|
1482
|
+
for (const w of warnings) {
|
|
1483
|
+
process.stderr.write(`[vess] Warning: ${w}\n`);
|
|
1484
|
+
}
|
|
1485
|
+
return env;
|
|
1486
|
+
}
|
|
1487
|
+
catch (err) {
|
|
1488
|
+
if (err?.code === 'ENOENT') {
|
|
1489
|
+
process.stderr.write(`[vess] Warning: env_profile "${params.env_profile}" requested but .env not found at ${envPath}\n`);
|
|
1490
|
+
return {};
|
|
1491
|
+
}
|
|
1492
|
+
// Resolution errors (keychain failures, etc.) should propagate
|
|
1493
|
+
throw err;
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
getActionRiskLevel(action) {
|
|
1497
|
+
const actionDef = ai_identity_1.ACTION_REGISTRY.actions.find(a => a.action === action);
|
|
1498
|
+
if (!actionDef) {
|
|
1499
|
+
process.stderr.write(`[vess] WARNING: action "${action}" not found in ACTION_REGISTRY, defaulting to high risk\n`);
|
|
1500
|
+
return 'high';
|
|
1501
|
+
}
|
|
1502
|
+
return actionDef.risk || 'high';
|
|
1503
|
+
}
|
|
1504
|
+
getDefaultVCTTL(riskLevel) {
|
|
1505
|
+
const defaults = {
|
|
1506
|
+
low: { minutes: 60, label: '1 hour (low risk default)' },
|
|
1507
|
+
medium: { minutes: 30, label: '30 minutes (medium risk default)' },
|
|
1508
|
+
high: { minutes: 10, label: '10 minutes (high risk default)' },
|
|
1509
|
+
critical: { minutes: 5, label: '5 minutes (critical risk default)' },
|
|
1510
|
+
};
|
|
1511
|
+
return defaults[riskLevel];
|
|
1512
|
+
}
|
|
1513
|
+
async handleApprovalResponse(request, provider, normalizedResource, resourceFingerprint, requestedResource) {
|
|
1514
|
+
const { action, approval } = request;
|
|
1515
|
+
if (!approval)
|
|
1516
|
+
return { success: false, error: 'No approval provided' };
|
|
1517
|
+
// Verify token (with resource binding for both local and SaaS actions)
|
|
1518
|
+
const extractedResource = this.extractResourceIdFromInput(action, request.input);
|
|
1519
|
+
const expectedResource = normalizedResource ?? extractedResource;
|
|
1520
|
+
const tokenResult = this.tokenService.verify(approval.token, action, expectedResource);
|
|
1521
|
+
if (!tokenResult.valid) {
|
|
1522
|
+
return { success: false, error: `Invalid approval token: ${tokenResult.error}` };
|
|
1523
|
+
}
|
|
1524
|
+
// Validate choice
|
|
1525
|
+
const validChoices = ['one_time', 'persistent', 'deny_once', 'deny_persistent'];
|
|
1526
|
+
if (!validChoices.includes(approval.choice)) {
|
|
1527
|
+
return { success: false, error: `Invalid approval choice: ${approval.choice}` };
|
|
1528
|
+
}
|
|
1529
|
+
// Handle deny choices
|
|
1530
|
+
if (approval.choice === 'deny_once') {
|
|
1531
|
+
this.logAudit({
|
|
1532
|
+
action, provider,
|
|
1533
|
+
decision: 'deny',
|
|
1534
|
+
enforcementType: 'local',
|
|
1535
|
+
decisionSource: 'local_policy',
|
|
1536
|
+
decisionReason: 'User denied the action (deny_once)',
|
|
1537
|
+
requestedResource, normalizedResource, resourceFingerprint,
|
|
1538
|
+
executionType: (0, executor_registry_1.isLocalAction)(action) ? 'local' : 'gateway',
|
|
1539
|
+
approvalNonce: tokenResult.nonce,
|
|
1540
|
+
});
|
|
1541
|
+
return { success: false, error: 'User denied the action (this time only)' };
|
|
1542
|
+
}
|
|
1543
|
+
if (approval.choice === 'deny_persistent') {
|
|
1544
|
+
this.policyEvaluator.addDenyRule({
|
|
1545
|
+
actions: [action],
|
|
1546
|
+
effect: 'deny',
|
|
1547
|
+
source: 'user',
|
|
1548
|
+
});
|
|
1549
|
+
this.logAudit({
|
|
1550
|
+
action, provider,
|
|
1551
|
+
decision: 'deny',
|
|
1552
|
+
enforcementType: 'local',
|
|
1553
|
+
decisionSource: 'local_policy',
|
|
1554
|
+
decisionReason: 'User denied the action (deny_persistent)',
|
|
1555
|
+
requestedResource, normalizedResource, resourceFingerprint,
|
|
1556
|
+
executionType: (0, executor_registry_1.isLocalAction)(action) ? 'local' : 'gateway',
|
|
1557
|
+
approvalNonce: tokenResult.nonce,
|
|
1558
|
+
});
|
|
1559
|
+
return { success: false, error: 'User denied the action (persistent deny rule added)' };
|
|
1560
|
+
}
|
|
1561
|
+
// Approved (one_time or persistent) — call quickApprove
|
|
1562
|
+
const approvalMode = approval.choice === 'one_time' ? 'one_time' : 'persistent';
|
|
1563
|
+
const resourceInfo = normalizedResource && resourceFingerprint
|
|
1564
|
+
? { normalizedResource, fingerprint: resourceFingerprint }
|
|
1565
|
+
: undefined;
|
|
1566
|
+
// Cedar 一元化 Step 3.5 retry path (client side): if the approval payload
|
|
1567
|
+
// carries the full set of `ApprovalContext` fields (request_id +
|
|
1568
|
+
// outcome_id + granted_at + granted_by), re-invoke `ensureVC` with the
|
|
1569
|
+
// context threaded so `autoIssueVC` consumes the ledger token and Cedar
|
|
1570
|
+
// flips `auth_required` → `permit`. If `autoIssueVC` returns a
|
|
1571
|
+
// credential, we execute directly with it; otherwise we fall back to the
|
|
1572
|
+
// legacy `quickApprove` path below so non-Cedar grants continue to work.
|
|
1573
|
+
if (approval.request_id &&
|
|
1574
|
+
approval.outcome_id &&
|
|
1575
|
+
approval.granted_at &&
|
|
1576
|
+
approval.granted_by) {
|
|
1577
|
+
try {
|
|
1578
|
+
const approvalContext = {
|
|
1579
|
+
token: approval.token,
|
|
1580
|
+
request_id: approval.request_id,
|
|
1581
|
+
outcome_id: approval.outcome_id,
|
|
1582
|
+
granted: true,
|
|
1583
|
+
granted_at: approval.granted_at,
|
|
1584
|
+
granted_by: approval.granted_by,
|
|
1585
|
+
};
|
|
1586
|
+
const cedarResult = await this.ensureVC(action, provider, resourceInfo, request.input,
|
|
1587
|
+
// Cedar retry path assumes no usable wallet VC (user just approved
|
|
1588
|
+
// an auth_required action); skip the scoped wallet lookup (Step 1a)
|
|
1589
|
+
// by passing canonicalResourceId=undefined and let ensureVC fall
|
|
1590
|
+
// through to the autoIssueVC path with approval_context injected.
|
|
1591
|
+
undefined, approvalContext);
|
|
1592
|
+
if ('credential' in cedarResult) {
|
|
1593
|
+
const routing = (0, executor_registry_1.determineRouting)(provider, action);
|
|
1594
|
+
try {
|
|
1595
|
+
if (routing.executor === 'local') {
|
|
1596
|
+
return await this.executeLocalWithVC(action, request.input, provider, cedarResult, normalizedResource, resourceFingerprint, requestedResource);
|
|
1597
|
+
}
|
|
1598
|
+
return await this.executeViaGatewayWithVC(provider, action, request.input, cedarResult);
|
|
1599
|
+
}
|
|
1600
|
+
finally {
|
|
1601
|
+
if (approvalMode === 'one_time' && cedarResult.credential.id) {
|
|
1602
|
+
this.wallet.revokeCredential(cedarResult.credential.id);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
// ensureVC did not produce a credential (e.g. legacy grant without
|
|
1607
|
+
// Cedar policy_ref) — fall through to the quickApprove path.
|
|
1608
|
+
}
|
|
1609
|
+
catch (err) {
|
|
1610
|
+
// Cedar retry path failed (network error etc.) — log and fall through
|
|
1611
|
+
// to the legacy quickApprove fallback so the user still gets a usable
|
|
1612
|
+
// VC path. The retry-with-approval_context is best-effort; the legacy
|
|
1613
|
+
// quickApprove is the resilient fallback that does not require Cedar
|
|
1614
|
+
// policy_ref. This keeps UX intact when the gateway has a transient
|
|
1615
|
+
// failure between approval and VC issuance.
|
|
1616
|
+
console.warn(`[ExecutionEngine] Cedar retry failed, falling back to quickApprove: ${err instanceof Error ? err.message : String(err)}`);
|
|
1617
|
+
// fall through to quickApprove
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
// Determine VC TTL
|
|
1621
|
+
const riskLevel = this.getActionRiskLevel(action);
|
|
1622
|
+
const defaultTTL = this.getDefaultVCTTL(riskLevel);
|
|
1623
|
+
const vcTTLMinutes = approval.vcTTLMinutes
|
|
1624
|
+
? Math.max(5, Math.min(1440, approval.vcTTLMinutes))
|
|
1625
|
+
: defaultTTL.minutes;
|
|
1626
|
+
const expiresInHours = vcTTLMinutes / 60;
|
|
1627
|
+
// Build resources: for local actions use pattern, for gateway actions extract resource ID from parameters
|
|
1628
|
+
const resourceType = this.getResourceType(action);
|
|
1629
|
+
let resources;
|
|
1630
|
+
if (resourceInfo) {
|
|
1631
|
+
resources = [{ type: resourceType, pattern: resourceInfo.normalizedResource }];
|
|
1632
|
+
}
|
|
1633
|
+
else {
|
|
1634
|
+
const extractedId = this.extractResourceIdFromInput(action, request.input);
|
|
1635
|
+
// 'critical' refuses wildcard like 'high' (PR #931 review — same tier
|
|
1636
|
+
// handling as the checks at lines ~350/~515 above; a critical action
|
|
1637
|
+
// slipping through here would bake an unbound delegate that the api's
|
|
1638
|
+
// Gate-2 strict arm then deterministically denies).
|
|
1639
|
+
const wildcardRefuseRisk = this.getActionRiskLevel(action);
|
|
1640
|
+
if (!extractedId && (wildcardRefuseRisk === 'high' || wildcardRefuseRisk === 'critical')) {
|
|
1641
|
+
const actionDef = ai_identity_1.ACTION_REGISTRY.actions.find(a => a.action === action);
|
|
1642
|
+
const paramName = actionDef?.target_bindings?.resource_id?.param || 'resource ID';
|
|
1643
|
+
return {
|
|
1644
|
+
success: false,
|
|
1645
|
+
error: `High-risk action "${action}" requires a specific ${paramName}. Cannot approve with wildcard resource scope.`,
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
resources = extractedId
|
|
1649
|
+
? [{ type: resourceType, id: extractedId }]
|
|
1650
|
+
: [{ type: resourceType }];
|
|
1651
|
+
}
|
|
1652
|
+
try {
|
|
1653
|
+
const quickResult = await this.gatewayClient.quickApprove({
|
|
1654
|
+
actions: [action],
|
|
1655
|
+
resources,
|
|
1656
|
+
normalizedResource: resourceInfo?.normalizedResource,
|
|
1657
|
+
resourceFingerprint: resourceInfo?.fingerprint,
|
|
1658
|
+
subjectDid: this.context.agentDid,
|
|
1659
|
+
projectId: this.context.projectId,
|
|
1660
|
+
issueVC: true,
|
|
1661
|
+
approvalMode,
|
|
1662
|
+
approvalNonce: tokenResult.nonce,
|
|
1663
|
+
expiresInHours,
|
|
1664
|
+
});
|
|
1665
|
+
const stored = this.wallet.storeCredential({
|
|
1666
|
+
holderDid: this.context.agentDid,
|
|
1667
|
+
projectId: this.context.projectId,
|
|
1668
|
+
credentialJwt: quickResult.credential,
|
|
1669
|
+
actions: quickResult.grant.actions,
|
|
1670
|
+
provider,
|
|
1671
|
+
expiresAt: new Date(quickResult.expiresAt).getTime(),
|
|
1672
|
+
normalizedResourceKey: resourceInfo?.normalizedResource,
|
|
1673
|
+
resourceFingerprint: resourceInfo?.fingerprint,
|
|
1674
|
+
resources: (0, wallet_1.validateResources)(quickResult.resources),
|
|
1675
|
+
});
|
|
1676
|
+
// Build VCAcquired from quickApprove result
|
|
1677
|
+
const vcAcquired = {
|
|
1678
|
+
credential: stored,
|
|
1679
|
+
grantId: quickResult.grant.id,
|
|
1680
|
+
approvalMode: approvalMode,
|
|
1681
|
+
approvalNonce: tokenResult.nonce,
|
|
1682
|
+
decisionSource: 'new_vc_from_quick_approve',
|
|
1683
|
+
};
|
|
1684
|
+
// Now execute with the freshly acquired VC (skips double ensureVC)
|
|
1685
|
+
const routing = (0, executor_registry_1.determineRouting)(provider, action);
|
|
1686
|
+
let executeResult;
|
|
1687
|
+
try {
|
|
1688
|
+
if (routing.executor === 'local') {
|
|
1689
|
+
executeResult = await this.executeLocalWithVC(action, request.input, provider, vcAcquired, normalizedResource, resourceFingerprint, requestedResource);
|
|
1690
|
+
}
|
|
1691
|
+
else {
|
|
1692
|
+
executeResult = await this.executeViaGatewayWithVC(provider, action, request.input, vcAcquired);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
finally {
|
|
1696
|
+
// Wallet cleanup for one-time grants (regardless of execution success — spec requirement)
|
|
1697
|
+
if (approvalMode === 'one_time' && stored.id) {
|
|
1698
|
+
this.wallet.revokeCredential(stored.id);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
return executeResult;
|
|
1702
|
+
}
|
|
1703
|
+
catch (err) {
|
|
1704
|
+
return {
|
|
1705
|
+
success: false,
|
|
1706
|
+
error: `Quick-approve failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
getResourceType(action) {
|
|
1711
|
+
const actionDef = ai_identity_1.ACTION_REGISTRY.actions.find(a => a.action === action);
|
|
1712
|
+
if (actionDef) {
|
|
1713
|
+
const parts = actionDef.resource_type.split(':');
|
|
1714
|
+
return parts.length > 1 ? parts[1] : parts[0];
|
|
1715
|
+
}
|
|
1716
|
+
return '*';
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* Extract specific resource ID from action input using ACTION_REGISTRY target_bindings.
|
|
1720
|
+
* For gateway (SaaS) actions, the resource ID (e.g., channel ID) is in the input parameters.
|
|
1721
|
+
* Returns undefined for actions without param-sourced bindings or when the param is absent
|
|
1722
|
+
* (e.g., target_bindings with required: false).
|
|
1723
|
+
*/
|
|
1724
|
+
extractResourceIdFromInput(action, input) {
|
|
1725
|
+
// #884 Task 4b — single canonical normalizer so agentd-issued VCs carry the
|
|
1726
|
+
// SAME resource id the gateway Gate-2 enforces (github → owner/repo, not bare
|
|
1727
|
+
// repo; jira → RAW issue key, downstream resolvers own derivation).
|
|
1728
|
+
const value = (0, ai_identity_1.canonicalResourceId)(action, input);
|
|
1729
|
+
if (value == null)
|
|
1730
|
+
return undefined;
|
|
1731
|
+
// Defense-in-depth: reject control characters and excessively long values.
|
|
1732
|
+
const MAX_RESOURCE_ID_LENGTH = 500;
|
|
1733
|
+
if (value.length > MAX_RESOURCE_ID_LENGTH || /[\x00-\x1f]/.test(value))
|
|
1734
|
+
return undefined;
|
|
1735
|
+
return value;
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* #1009 — this OOB re-invocation check is agentd's own defense-in-depth
|
|
1739
|
+
* resource re-validation, separate from Gate-2's Cedar resolver in
|
|
1740
|
+
* packages/api. #893 taught Gate-2's JiraResourceResolver that a
|
|
1741
|
+
* project-scoped grant (e.g. "AIDENTITY") legitimately covers an
|
|
1742
|
+
* issue-scoped request (e.g. "AIDENTITY-127"), but that fix never touched
|
|
1743
|
+
* this agentd-side check, which stayed a plain string comparison. A grant
|
|
1744
|
+
* that Gate-2 correctly approved would then be rejected here on re-invoke,
|
|
1745
|
+
* permanently: the approval never resolves. Mirror the same hierarchy
|
|
1746
|
+
* check so both enforcement paths agree.
|
|
1747
|
+
*
|
|
1748
|
+
* TODO(provider-generic): this is Jira-only by construction —
|
|
1749
|
+
* `extractProjectKey` parses the Jira `KEY-123` shape, and the sole caller
|
|
1750
|
+
* gates on `r.provider === 'jira'` (~:1216). packages/api expresses the same
|
|
1751
|
+
* idea through `ResourceResolverRegistry` + `supportsHierarchicalMatch()`,
|
|
1752
|
+
* so a second provider that needs hierarchy (e.g. GitHub org → repo) would
|
|
1753
|
+
* have to be added in BOTH places, in two different shapes.
|
|
1754
|
+
*
|
|
1755
|
+
* Deliberately not abstracted yet: agentd is a local daemon that does not
|
|
1756
|
+
* share the API's DI container or resolver registry, so mirroring that
|
|
1757
|
+
* abstraction here means either duplicating the registry or extracting it
|
|
1758
|
+
* into the SDK — real work that one provider does not justify. Jira is
|
|
1759
|
+
* currently the only registered hierarchical resolver.
|
|
1760
|
+
*
|
|
1761
|
+
* When a second one appears, extract the resolver interface into the SDK
|
|
1762
|
+
* (alongside `extractProjectKey`, which already lives there) and have both
|
|
1763
|
+
* enforcement paths consume it, rather than adding a second `provider ===`
|
|
1764
|
+
* branch here. 🪤 The hazard this guards against is silent divergence: if
|
|
1765
|
+
* the two paths disagree about what a grant covers, Gate-2 approves and
|
|
1766
|
+
* agentd rejects on re-invoke, and the approval never resolves — exactly
|
|
1767
|
+
* the #1009 bug this method was written to fix.
|
|
1768
|
+
*/
|
|
1769
|
+
resourceIdHierarchicallyMatches(approvedId, candidateId) {
|
|
1770
|
+
// exact-or-hierarchical, mirrors JiraResourceResolver.matches() (see #1014)
|
|
1771
|
+
if (approvedId === candidateId)
|
|
1772
|
+
return true;
|
|
1773
|
+
const projectKey = (0, ai_identity_1.extractProjectKey)(candidateId);
|
|
1774
|
+
return projectKey !== null && projectKey === approvedId;
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
1777
|
+
* The single predicate deciding "does this approval cover this resource?".
|
|
1778
|
+
*
|
|
1779
|
+
* 🔴 Extracted because the check exists at TWO call sites — the pre-claim
|
|
1780
|
+
* check (~:1213, over `request.input`) and the post-claim re-validation
|
|
1781
|
+
* (~:1310, over the stored `actionParams`). They were byte-identical copies,
|
|
1782
|
+
* and the live 2026-07-30 calendar bug hit BOTH: fixing only one produced a
|
|
1783
|
+
* test that still failed, from the other site's near-identical message.
|
|
1784
|
+
* Two independent copies of one security decision is how they silently drift
|
|
1785
|
+
* (#1232 is the same lesson for terminal-status sets living in 3 places).
|
|
1786
|
+
*/
|
|
1787
|
+
approvedResourceMatches(approvedResources, extractedId, normalizedId) {
|
|
1788
|
+
return approvedResources.some(r => r.id === extractedId || r.id === normalizedId ||
|
|
1789
|
+
r.pattern === extractedId || r.pattern === normalizedId ||
|
|
1790
|
+
(r.id !== undefined && r.provider === 'jira' && this.resourceIdHierarchicallyMatches(r.id, extractedId)) ||
|
|
1791
|
+
(r.id !== undefined && this.calendarPrimaryAliasMatches(r.id, extractedId)));
|
|
1792
|
+
}
|
|
1793
|
+
/**
|
|
1794
|
+
* `primary` is Google Calendar's alias for the token owner's OWN calendar,
|
|
1795
|
+
* so `primary` and that owner's calendar address name one resource. The
|
|
1796
|
+
* approval surfaces deliberately substitute the address for the alias —
|
|
1797
|
+
* `public/js/pages/approve.js:514-521` and
|
|
1798
|
+
* `slack-approve.service.ts:224-238` both do it, because asking a human to
|
|
1799
|
+
* approve a write to "primary" tells them nothing about which calendar.
|
|
1800
|
+
*
|
|
1801
|
+
* But nothing rewrites the request's stored actionParams to match, and
|
|
1802
|
+
* `canonicalResourceId` has no notion of the alias (resolving it needs the
|
|
1803
|
+
* Google OAuth token, i.e. a DB read that agentd and remote-mcp cannot do —
|
|
1804
|
+
* INV-1). So re-invocation compares `"owner@gmail.com"` against `"primary"`,
|
|
1805
|
+
* they differ byte-wise, and the approval can NEVER be satisfied. Reported
|
|
1806
|
+
* live 2026-07-30: the user approved, retried, and was rejected forever.
|
|
1807
|
+
*
|
|
1808
|
+
* 🔴 THIS IS NOT A WILDCARD. The alias only matches an id that looks like a
|
|
1809
|
+
* calendar ADDRESS, and only in the alias→address direction. A named shared
|
|
1810
|
+
* calendar (`team@group.calendar.google.com`) is a different resource and
|
|
1811
|
+
* still fails, which is what the third case in
|
|
1812
|
+
* `oob-approval-calendar-primary-alias.test.ts` pins — without that bound
|
|
1813
|
+
* this check would degrade into "any calendar approval covers any calendar".
|
|
1814
|
+
*
|
|
1815
|
+
* 🪤 Same failure SHAPE as #1009/#893 (Jira): Gate-2 approves, agentd's plain
|
|
1816
|
+
* `===` rejects on re-invoke, and the approval never resolves. That one got
|
|
1817
|
+
* `resourceIdHierarchicallyMatches`; this is the alias-equivalence sibling.
|
|
1818
|
+
* When a third case appears, extract a resolver interface into the SDK so
|
|
1819
|
+
* both enforcement paths consume one definition (see the TODO above) rather
|
|
1820
|
+
* than growing a third branch here.
|
|
1821
|
+
*/
|
|
1822
|
+
calendarPrimaryAliasMatches(approvedId, candidateId) {
|
|
1823
|
+
// Only the alias needs reconciling; anything else is handled by `===`.
|
|
1824
|
+
if (candidateId !== 'primary')
|
|
1825
|
+
return false;
|
|
1826
|
+
// A Google calendar address, but NOT a named/shared calendar — those are
|
|
1827
|
+
// genuinely different resources and must keep failing.
|
|
1828
|
+
if (approvedId.endsWith('.calendar.google.com'))
|
|
1829
|
+
return false;
|
|
1830
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(approvedId);
|
|
1831
|
+
}
|
|
1832
|
+
isRecentOOBRequest(action, resourceId) {
|
|
1833
|
+
const key = `${action}:${resourceId || '*'}`;
|
|
1834
|
+
const lastTime = this.recentOOBRequests.get(key);
|
|
1835
|
+
return !!lastTime && (Date.now() - lastTime) < ExecutionEngine.OOB_COOLDOWN_MS;
|
|
1836
|
+
}
|
|
1837
|
+
trackOOBRequest(action, resourceId) {
|
|
1838
|
+
const key = `${action}:${resourceId || '*'}`;
|
|
1839
|
+
this.recentOOBRequests.set(key, Date.now());
|
|
1840
|
+
if (this.recentOOBRequests.size > ExecutionEngine.OOB_MAP_MAX_SIZE) {
|
|
1841
|
+
const now = Date.now();
|
|
1842
|
+
for (const [k, t] of this.recentOOBRequests) {
|
|
1843
|
+
if (now - t > ExecutionEngine.OOB_COOLDOWN_MS) {
|
|
1844
|
+
this.recentOOBRequests.delete(k);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
/** Verify a wallet VC: check revocation via Gateway, handle expiry. Returns the VC if valid, null if invalid. */
|
|
1850
|
+
async verifyWalletVC(vc) {
|
|
1851
|
+
// Check local expiration first — avoids sending expired VCs to the API
|
|
1852
|
+
// Check both wallet expiresAt and JWT exp claim (JWT exp is in seconds, convert to ms)
|
|
1853
|
+
const jwtExp = (0, vc_utils_1.extractExpFromVC)(vc.credentialJwt);
|
|
1854
|
+
const effectiveExpiresAt = vc.expiresAt ?? (jwtExp !== null ? jwtExp * 1000 : null);
|
|
1855
|
+
if (effectiveExpiresAt && effectiveExpiresAt <= Date.now()) {
|
|
1856
|
+
this.wallet.revokeCredential(vc.id);
|
|
1857
|
+
return null;
|
|
1858
|
+
}
|
|
1859
|
+
const jti = (0, vc_utils_1.extractJtiFromVC)(vc.credentialJwt);
|
|
1860
|
+
if (jti) {
|
|
1861
|
+
try {
|
|
1862
|
+
const status = await this.gatewayClient.checkVCStatus(jti);
|
|
1863
|
+
if (!status.valid) {
|
|
1864
|
+
this.wallet.revokeCredential(vc.id);
|
|
1865
|
+
return null;
|
|
1866
|
+
}
|
|
1867
|
+
return vc;
|
|
1868
|
+
}
|
|
1869
|
+
catch {
|
|
1870
|
+
// Gateway unreachable — use cached VC if not expired (already checked above)
|
|
1871
|
+
return vc;
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
// No jti extractable — backward compat
|
|
1875
|
+
return vc;
|
|
1876
|
+
}
|
|
1877
|
+
async handleResourceMismatch(action, provider, params, vcResult) {
|
|
1878
|
+
const extractedId = this.extractResourceIdFromInput(action, params);
|
|
1879
|
+
if (this.isRecentOOBRequest(action, extractedId)) {
|
|
1880
|
+
return {
|
|
1881
|
+
success: false,
|
|
1882
|
+
error: 'Resource access denied. An approval request was recently created. Please approve it or wait before retrying.',
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
this.logAudit({
|
|
1886
|
+
action, provider,
|
|
1887
|
+
decision: 'deny',
|
|
1888
|
+
enforcementType: 'gateway',
|
|
1889
|
+
decisionSource: 'gateway_execution',
|
|
1890
|
+
decisionReason: 'Resource mismatch — VC scope does not cover target resource',
|
|
1891
|
+
executionType: 'gateway',
|
|
1892
|
+
credentialId: vcResult.credential.id,
|
|
1893
|
+
});
|
|
1894
|
+
try {
|
|
1895
|
+
const riskLevel = this.getActionRiskLevel(action);
|
|
1896
|
+
const oobResult = await this.createOOBApprovalRequest(action, provider, riskLevel, undefined, params);
|
|
1897
|
+
this.trackOOBRequest(action, extractedId);
|
|
1898
|
+
return { success: false, waitingForApproval: oobResult.waitingForApproval };
|
|
1899
|
+
}
|
|
1900
|
+
catch (err) {
|
|
1901
|
+
return {
|
|
1902
|
+
success: false,
|
|
1903
|
+
error: `Resource mismatch detected but failed to create approval request: ${err instanceof Error ? err.message : String(err)}`,
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
logAudit(params) {
|
|
1908
|
+
this.auditLogger?.logEvent({
|
|
1909
|
+
action: params.action,
|
|
1910
|
+
provider: params.provider,
|
|
1911
|
+
executionType: params.executionType,
|
|
1912
|
+
decision: params.decision,
|
|
1913
|
+
enforcementType: params.enforcementType,
|
|
1914
|
+
decisionSource: params.decisionSource,
|
|
1915
|
+
decisionReason: params.decisionReason,
|
|
1916
|
+
rootDid: this.context.rootDid,
|
|
1917
|
+
agentDid: this.context.agentDid,
|
|
1918
|
+
presenterDid: this.context.agentDid,
|
|
1919
|
+
projectId: this.context.projectId,
|
|
1920
|
+
requestedResource: params.requestedResource,
|
|
1921
|
+
normalizedResource: params.normalizedResource,
|
|
1922
|
+
resourceFingerprint: params.resourceFingerprint,
|
|
1923
|
+
grantId: params.grantId,
|
|
1924
|
+
credentialId: params.credentialId,
|
|
1925
|
+
approvalMode: params.approvalMode,
|
|
1926
|
+
approvalNonce: params.approvalNonce,
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
exports.ExecutionEngine = ExecutionEngine;
|
|
1931
|
+
//# sourceMappingURL=execution-engine.js.map
|