@vellumai/assistant 0.3.18 → 0.3.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +155 -15
- package/Dockerfile +1 -0
- package/README.md +40 -4
- package/docs/architecture/integrations.md +7 -11
- package/docs/architecture/security.md +80 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -0
- package/src/__tests__/approval-primitive.test.ts +540 -0
- package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
- package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
- package/src/__tests__/call-controller.test.ts +605 -104
- package/src/__tests__/channel-invite-transport.test.ts +264 -0
- package/src/__tests__/checker.test.ts +60 -0
- package/src/__tests__/cli.test.ts +42 -1
- package/src/__tests__/config-schema.test.ts +11 -127
- package/src/__tests__/config-watcher.test.ts +0 -8
- package/src/__tests__/daemon-lifecycle.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +8 -2
- package/src/__tests__/diff.test.ts +22 -0
- package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +779 -0
- package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
- package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
- package/src/__tests__/guardian-dispatch.test.ts +185 -1
- package/src/__tests__/guardian-grant-minting.test.ts +532 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
- package/src/__tests__/invite-redemption-service.test.ts +306 -0
- package/src/__tests__/ipc-snapshot.test.ts +58 -0
- package/src/__tests__/notification-decision-fallback.test.ts +88 -0
- package/src/__tests__/remote-skill-policy.test.ts +215 -0
- package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
- package/src/__tests__/sandbox-host-parity.test.ts +6 -13
- package/src/__tests__/scoped-approval-grants.test.ts +521 -0
- package/src/__tests__/scoped-grant-security-matrix.test.ts +444 -0
- package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
- package/src/__tests__/session-load-history-repair.test.ts +169 -2
- package/src/__tests__/session-runtime-assembly.test.ts +33 -5
- package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
- package/src/__tests__/skill-feature-flags.test.ts +188 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
- package/src/__tests__/skill-mirror-parity.test.ts +1 -0
- package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
- package/src/__tests__/system-prompt.test.ts +1 -1
- package/src/__tests__/terminal-sandbox.test.ts +142 -9
- package/src/__tests__/terminal-tools.test.ts +2 -93
- package/src/__tests__/thread-seed-composer.test.ts +18 -0
- package/src/__tests__/tool-approval-handler.test.ts +350 -0
- package/src/__tests__/trust-store.test.ts +2 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +533 -0
- package/src/agent/loop.ts +36 -1
- package/src/approvals/approval-primitive.ts +381 -0
- package/src/approvals/guardian-decision-primitive.ts +191 -0
- package/src/calls/call-controller.ts +276 -212
- package/src/calls/call-domain.ts +56 -6
- package/src/calls/guardian-dispatch.ts +56 -0
- package/src/calls/relay-server.ts +13 -0
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +59 -4
- package/src/cli/core-commands.ts +0 -4
- package/src/cli.ts +76 -34
- package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
- package/src/config/assistant-feature-flags.ts +162 -0
- package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
- package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
- package/src/config/bundled-skills/notifications/SKILL.md +18 -0
- package/src/config/bundled-skills/reminder/SKILL.md +49 -2
- package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
- package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
- package/src/config/core-schema.ts +1 -1
- package/src/config/env-registry.ts +10 -0
- package/src/config/feature-flag-registry.json +61 -0
- package/src/config/loader.ts +22 -1
- package/src/config/sandbox-schema.ts +0 -39
- package/src/config/schema.ts +12 -2
- package/src/config/skill-state.ts +34 -0
- package/src/config/skills-schema.ts +26 -0
- package/src/config/skills.ts +9 -0
- package/src/config/system-prompt.ts +110 -46
- package/src/config/templates/SOUL.md +1 -1
- package/src/config/types.ts +19 -1
- package/src/config/vellum-skills/catalog.json +1 -1
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -3
- package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/config-watcher.ts +0 -1
- package/src/daemon/daemon-control.ts +1 -1
- package/src/daemon/guardian-invite-intent.ts +124 -0
- package/src/daemon/handlers/avatar.ts +68 -0
- package/src/daemon/handlers/browser.ts +2 -2
- package/src/daemon/handlers/config-channels.ts +18 -0
- package/src/daemon/handlers/guardian-actions.ts +120 -0
- package/src/daemon/handlers/index.ts +4 -0
- package/src/daemon/handlers/sessions.ts +19 -0
- package/src/daemon/handlers/shared.ts +3 -1
- package/src/daemon/handlers/skills.ts +45 -2
- package/src/daemon/install-cli-launchers.ts +58 -13
- package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
- package/src/daemon/ipc-contract/sessions.ts +8 -2
- package/src/daemon/ipc-contract/settings.ts +25 -2
- package/src/daemon/ipc-contract/skills.ts +1 -0
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +4 -0
- package/src/daemon/lifecycle.ts +6 -2
- package/src/daemon/main.ts +1 -0
- package/src/daemon/server.ts +1 -0
- package/src/daemon/session-lifecycle.ts +52 -7
- package/src/daemon/session-memory.ts +45 -0
- package/src/daemon/session-process.ts +260 -422
- package/src/daemon/session-runtime-assembly.ts +12 -0
- package/src/daemon/session-skill-tools.ts +14 -1
- package/src/daemon/session-tool-setup.ts +5 -0
- package/src/daemon/session.ts +11 -0
- package/src/daemon/tool-side-effects.ts +35 -9
- package/src/index.ts +0 -2
- package/src/memory/conversation-display-order-migration.ts +44 -0
- package/src/memory/conversation-queries.ts +2 -0
- package/src/memory/conversation-store.ts +91 -0
- package/src/memory/db-init.ts +13 -1
- package/src/memory/embedding-local.ts +22 -8
- package/src/memory/guardian-action-store.ts +133 -2
- package/src/memory/guardian-verification.ts +1 -1
- package/src/memory/ingress-invite-store.ts +95 -1
- package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
- package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
- package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
- package/src/memory/migrations/index.ts +3 -0
- package/src/memory/schema.ts +35 -1
- package/src/memory/scoped-approval-grants.ts +518 -0
- package/src/messaging/providers/slack/client.ts +12 -0
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/notifications/decision-engine.ts +49 -12
- package/src/notifications/emit-signal.ts +7 -0
- package/src/notifications/signal.ts +7 -0
- package/src/notifications/thread-seed-composer.ts +2 -1
- package/src/permissions/checker.ts +27 -0
- package/src/runtime/channel-approval-types.ts +16 -6
- package/src/runtime/channel-approvals.ts +19 -15
- package/src/runtime/channel-invite-transport.ts +85 -0
- package/src/runtime/channel-invite-transports/telegram.ts +105 -0
- package/src/runtime/guardian-action-grant-minter.ts +154 -0
- package/src/runtime/guardian-action-message-composer.ts +30 -0
- package/src/runtime/guardian-decision-types.ts +91 -0
- package/src/runtime/http-server.ts +23 -1
- package/src/runtime/ingress-service.ts +22 -0
- package/src/runtime/invite-redemption-service.ts +181 -0
- package/src/runtime/invite-redemption-templates.ts +39 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/guardian-action-routes.ts +206 -0
- package/src/runtime/routes/guardian-approval-interception.ts +66 -74
- package/src/runtime/routes/inbound-message-handler.ts +568 -409
- package/src/runtime/routes/pairing-routes.ts +4 -0
- package/src/security/encrypted-store.ts +31 -17
- package/src/security/keychain.ts +176 -2
- package/src/security/secure-keys.ts +97 -0
- package/src/security/tool-approval-digest.ts +67 -0
- package/src/skills/remote-skill-policy.ts +131 -0
- package/src/tools/browser/browser-execution.ts +2 -2
- package/src/tools/browser/browser-manager.ts +46 -32
- package/src/tools/browser/browser-screencast.ts +2 -2
- package/src/tools/calls/call-start.ts +1 -1
- package/src/tools/executor.ts +22 -17
- package/src/tools/network/script-proxy/session-manager.ts +1 -5
- package/src/tools/skills/load.ts +22 -8
- package/src/tools/system/avatar-generator.ts +119 -0
- package/src/tools/system/navigate-settings.ts +65 -0
- package/src/tools/system/open-system-settings.ts +75 -0
- package/src/tools/system/voice-config.ts +121 -32
- package/src/tools/terminal/backends/native.ts +40 -19
- package/src/tools/terminal/backends/types.ts +3 -3
- package/src/tools/terminal/parser.ts +1 -1
- package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
- package/src/tools/terminal/sandbox.ts +1 -12
- package/src/tools/terminal/shell.ts +3 -31
- package/src/tools/tool-approval-handler.ts +141 -3
- package/src/tools/tool-manifest.ts +6 -0
- package/src/tools/types.ts +6 -0
- package/src/util/diff.ts +36 -13
- package/Dockerfile.sandbox +0 -5
- package/src/__tests__/doordash-client.test.ts +0 -187
- package/src/__tests__/doordash-session.test.ts +0 -154
- package/src/__tests__/signup-e2e.test.ts +0 -354
- package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
- package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
- package/src/cli/doordash.ts +0 -1057
- package/src/config/bundled-skills/doordash/SKILL.md +0 -163
- package/src/config/templates/LOOKS.md +0 -25
- package/src/doordash/cart-queries.ts +0 -787
- package/src/doordash/client.ts +0 -1016
- package/src/doordash/order-queries.ts +0 -85
- package/src/doordash/queries.ts +0 -13
- package/src/doordash/query-extractor.ts +0 -94
- package/src/doordash/search-queries.ts +0 -203
- package/src/doordash/session.ts +0 -84
- package/src/doordash/store-queries.ts +0 -246
- package/src/doordash/types.ts +0 -367
- package/src/tools/terminal/backends/docker.ts +0 -379
|
@@ -17,6 +17,8 @@ const log = getLogger('runtime-http');
|
|
|
17
17
|
export interface PairingHandlerContext {
|
|
18
18
|
pairingStore: PairingStore;
|
|
19
19
|
bearerToken: string | undefined;
|
|
20
|
+
/** Feature-flag client token to include in pairing approval responses so iOS can PATCH flags. */
|
|
21
|
+
featureFlagToken: string | undefined;
|
|
20
22
|
pairingBroadcast?: (msg: ServerMessage) => void;
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -90,6 +92,7 @@ export async function handlePairingRequest(req: Request, ctx: PairingHandlerCont
|
|
|
90
92
|
bearerToken: ctx.bearerToken,
|
|
91
93
|
gatewayUrl: entry.gatewayUrl,
|
|
92
94
|
localLanUrl: entry.localLanUrl,
|
|
95
|
+
...(ctx.featureFlagToken ? { featureFlagToken: ctx.featureFlagToken } : {}),
|
|
93
96
|
});
|
|
94
97
|
}
|
|
95
98
|
|
|
@@ -138,6 +141,7 @@ export function handlePairingStatus(url: URL, ctx: PairingHandlerContext): Respo
|
|
|
138
141
|
bearerToken: entry.bearerToken,
|
|
139
142
|
gatewayUrl: entry.gatewayUrl,
|
|
140
143
|
localLanUrl: entry.localLanUrl,
|
|
144
|
+
...(ctx.featureFlagToken ? { featureFlagToken: ctx.featureFlagToken } : {}),
|
|
141
145
|
});
|
|
142
146
|
}
|
|
143
147
|
|
|
@@ -17,9 +17,9 @@ import {
|
|
|
17
17
|
pbkdf2Sync,
|
|
18
18
|
randomBytes,
|
|
19
19
|
} from 'node:crypto';
|
|
20
|
-
import { chmodSync,readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { chmodSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
21
21
|
import { hostname, userInfo } from 'node:os';
|
|
22
|
-
import { dirname,join } from 'node:path';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
23
|
|
|
24
24
|
import { ensureDir,pathExists } from '../util/fs.js';
|
|
25
25
|
import { getLogger } from '../util/logger.js';
|
|
@@ -94,24 +94,40 @@ function deriveKey(salt: Buffer): Buffer {
|
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
96
|
* Read result: distinguishes "file missing" from "file corrupt/unreadable".
|
|
97
|
-
* - `null`: file does not exist (
|
|
97
|
+
* - `null`: file does not exist or was corrupt (backed up and removed)
|
|
98
98
|
* - `StoreFile`: successfully parsed
|
|
99
|
-
* - throws:
|
|
99
|
+
* - throws: transient I/O error from readFileSync (EACCES, EMFILE, EIO, etc.)
|
|
100
100
|
*/
|
|
101
101
|
function readStore(): StoreFile | null {
|
|
102
102
|
const path = getStorePath();
|
|
103
103
|
if (!pathExists(path)) return null;
|
|
104
104
|
|
|
105
|
+
// Read outside the parse try/catch so transient filesystem errors (EACCES,
|
|
106
|
+
// EMFILE, EIO) propagate to callers instead of triggering corruption recovery.
|
|
105
107
|
const raw = readFileSync(path, 'utf-8');
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(raw);
|
|
111
|
+
if (parsed.version !== 1 || typeof parsed.salt !== 'string' || typeof parsed.entries !== 'object') {
|
|
112
|
+
throw new Error('Encrypted store has invalid format');
|
|
113
|
+
}
|
|
114
|
+
// Use null-prototype object for entries to prevent prototype pollution
|
|
115
|
+
const safeEntries: Record<string, EncryptedEntry> = Object.create(null);
|
|
116
|
+
Object.assign(safeEntries, parsed.entries);
|
|
117
|
+
parsed.entries = safeEntries;
|
|
118
|
+
return parsed as StoreFile;
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// Corrupted or invalid store file — back it up and start fresh so the
|
|
121
|
+
// daemon doesn't crash on every credential access.
|
|
122
|
+
const backupPath = `${path}.corrupt.${Date.now()}`;
|
|
123
|
+
log.error({ err, backupPath }, 'Encrypted store is corrupt — backing up and resetting');
|
|
124
|
+
try {
|
|
125
|
+
renameSync(path, backupPath);
|
|
126
|
+
} catch (renameErr) {
|
|
127
|
+
log.warn({ err: renameErr }, 'Failed to back up corrupt store file');
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
109
130
|
}
|
|
110
|
-
// Use null-prototype object for entries to prevent prototype pollution
|
|
111
|
-
const safeEntries: Record<string, EncryptedEntry> = Object.create(null);
|
|
112
|
-
Object.assign(safeEntries, parsed.entries);
|
|
113
|
-
parsed.entries = safeEntries;
|
|
114
|
-
return parsed as StoreFile;
|
|
115
131
|
}
|
|
116
132
|
|
|
117
133
|
function writeStore(store: StoreFile): void {
|
|
@@ -123,11 +139,9 @@ function writeStore(store: StoreFile): void {
|
|
|
123
139
|
}
|
|
124
140
|
|
|
125
141
|
function getOrCreateStore(): StoreFile {
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
return readStore()!;
|
|
130
|
-
}
|
|
142
|
+
const existing = readStore();
|
|
143
|
+
if (existing) return existing;
|
|
144
|
+
|
|
131
145
|
const salt = randomBytes(SALT_LENGTH);
|
|
132
146
|
const entries: Record<string, EncryptedEntry> = Object.create(null);
|
|
133
147
|
const store: StoreFile = {
|
package/src/security/keychain.ts
CHANGED
|
@@ -4,17 +4,22 @@
|
|
|
4
4
|
* - macOS: uses the `security` CLI to interact with Keychain
|
|
5
5
|
* - Linux: uses `secret-tool` (libsecret) for GNOME/KDE keyrings
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* Sync variants (getKey, setKey, deleteKey) match the config loader's sync API.
|
|
8
|
+
* Async variants (getKeyAsync, setKeyAsync, deleteKeyAsync) avoid blocking
|
|
9
|
+
* the event loop and should be preferred for non-startup code paths.
|
|
10
|
+
*
|
|
8
11
|
* Callers should check `isKeychainAvailable()` before use and fall back
|
|
9
12
|
* to encrypted-at-rest storage when the keychain is not accessible.
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
|
-
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
16
|
+
import { promisify } from 'node:util';
|
|
13
17
|
|
|
14
18
|
import { getLogger } from '../util/logger.js';
|
|
15
19
|
import { isLinux,isMacOS } from '../util/platform.js';
|
|
16
20
|
|
|
17
21
|
const log = getLogger('keychain');
|
|
22
|
+
const execFileAsync = promisify(execFile);
|
|
18
23
|
|
|
19
24
|
const SERVICE_NAME = 'vellum-assistant';
|
|
20
25
|
|
|
@@ -67,6 +72,29 @@ export function isKeychainAvailable(): boolean {
|
|
|
67
72
|
}
|
|
68
73
|
}
|
|
69
74
|
|
|
75
|
+
/** Async version of `isKeychainAvailable` — probes without blocking the event loop. */
|
|
76
|
+
export async function isKeychainAvailableAsync(): Promise<boolean> {
|
|
77
|
+
try {
|
|
78
|
+
if (deps.isMacOS()) {
|
|
79
|
+
await execFileAsync('security', ['list-keychains'], {
|
|
80
|
+
timeout: 5000,
|
|
81
|
+
});
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (deps.isLinux()) {
|
|
86
|
+
await execFileAsync('which', ['secret-tool'], {
|
|
87
|
+
timeout: 5000,
|
|
88
|
+
});
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return false;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
70
98
|
/**
|
|
71
99
|
* Retrieve a secret from the OS keychain.
|
|
72
100
|
* Returns `null` if the key doesn't exist.
|
|
@@ -251,3 +279,149 @@ function linuxDeleteKey(account: string): boolean {
|
|
|
251
279
|
return false;
|
|
252
280
|
}
|
|
253
281
|
}
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// Async variants — non-blocking alternatives to the sync functions above.
|
|
285
|
+
// Preferred for non-startup code paths to avoid blocking the event loop.
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Async version of `getKey` — retrieve a secret without blocking the event loop.
|
|
290
|
+
* Returns `null` if the key doesn't exist.
|
|
291
|
+
* Throws on runtime errors (keychain unavailable, locked, etc.).
|
|
292
|
+
*/
|
|
293
|
+
export async function getKeyAsync(account: string): Promise<string | null> {
|
|
294
|
+
if (deps.isMacOS()) return macosGetKeyAsync(account);
|
|
295
|
+
if (deps.isLinux()) return linuxGetKeyAsync(account);
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Async version of `setKey` — store a secret without blocking the event loop.
|
|
301
|
+
* Returns true on success, false on failure.
|
|
302
|
+
*/
|
|
303
|
+
export async function setKeyAsync(account: string, value: string): Promise<boolean> {
|
|
304
|
+
try {
|
|
305
|
+
if (deps.isMacOS()) return await macosSetKeyAsync(account, value);
|
|
306
|
+
if (deps.isLinux()) return await linuxSetKeyAsync(account, value);
|
|
307
|
+
return false;
|
|
308
|
+
} catch (err) {
|
|
309
|
+
log.warn({ err, account }, 'Failed to write to keychain');
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Async version of `deleteKey` — delete a secret without blocking the event loop.
|
|
316
|
+
* Returns true on success, false if not found or on failure.
|
|
317
|
+
*/
|
|
318
|
+
export async function deleteKeyAsync(account: string): Promise<boolean> {
|
|
319
|
+
try {
|
|
320
|
+
if (deps.isMacOS()) return await macosDeleteKeyAsync(account);
|
|
321
|
+
if (deps.isLinux()) return await linuxDeleteKeyAsync(account);
|
|
322
|
+
return false;
|
|
323
|
+
} catch (err) {
|
|
324
|
+
log.debug({ err, account }, 'Failed to delete from keychain');
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Async macOS Keychain
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
async function macosGetKeyAsync(account: string): Promise<string | null> {
|
|
334
|
+
try {
|
|
335
|
+
const { stdout } = await execFileAsync('security', [
|
|
336
|
+
'find-generic-password',
|
|
337
|
+
'-s', SERVICE_NAME,
|
|
338
|
+
'-a', account,
|
|
339
|
+
'-w',
|
|
340
|
+
], { timeout: 5000 });
|
|
341
|
+
return stdout.replace(/\n$/, '') || null;
|
|
342
|
+
} catch (err: unknown) {
|
|
343
|
+
// Exit code 44 = item not found — return null.
|
|
344
|
+
if (err && typeof err === 'object' && 'code' in err && (err as { code: number }).code === 44) {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
throw err;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function macosSetKeyAsync(account: string, value: string): Promise<boolean> {
|
|
352
|
+
try {
|
|
353
|
+
await execFileAsync('security', [
|
|
354
|
+
'add-generic-password',
|
|
355
|
+
'-s', SERVICE_NAME,
|
|
356
|
+
'-a', account,
|
|
357
|
+
'-w', value,
|
|
358
|
+
'-U',
|
|
359
|
+
], { timeout: 5000 });
|
|
360
|
+
return true;
|
|
361
|
+
} catch {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function macosDeleteKeyAsync(account: string): Promise<boolean> {
|
|
367
|
+
try {
|
|
368
|
+
await execFileAsync('security', [
|
|
369
|
+
'delete-generic-password',
|
|
370
|
+
'-s', SERVICE_NAME,
|
|
371
|
+
'-a', account,
|
|
372
|
+
], { timeout: 5000 });
|
|
373
|
+
return true;
|
|
374
|
+
} catch {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// Async Linux via `secret-tool`
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
async function linuxGetKeyAsync(account: string): Promise<string | null> {
|
|
384
|
+
try {
|
|
385
|
+
const { stdout } = await execFileAsync('secret-tool', [
|
|
386
|
+
'lookup',
|
|
387
|
+
'service', SERVICE_NAME,
|
|
388
|
+
'account', account,
|
|
389
|
+
], { timeout: 5000 });
|
|
390
|
+
return stdout.replace(/\n$/, '') || null;
|
|
391
|
+
} catch (err: unknown) {
|
|
392
|
+
if (err && typeof err === 'object' && 'code' in err && (err as { code: number }).code === 1) {
|
|
393
|
+
const stderr = String((err as { stderr?: unknown }).stderr ?? '').trim();
|
|
394
|
+
if (stderr.length > 0) throw err;
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
throw err;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function linuxSetKeyAsync(account: string, value: string): Promise<boolean> {
|
|
402
|
+
// secret-tool reads the secret from stdin
|
|
403
|
+
return new Promise<boolean>((resolve) => {
|
|
404
|
+
const child = execFile('secret-tool', [
|
|
405
|
+
'store',
|
|
406
|
+
'--label', `${SERVICE_NAME}: ${account}`,
|
|
407
|
+
'service', SERVICE_NAME,
|
|
408
|
+
'account', account,
|
|
409
|
+
], { timeout: 5000 }, (err) => {
|
|
410
|
+
resolve(!err);
|
|
411
|
+
});
|
|
412
|
+
child.stdin?.end(value);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function linuxDeleteKeyAsync(account: string): Promise<boolean> {
|
|
417
|
+
try {
|
|
418
|
+
await execFileAsync('secret-tool', [
|
|
419
|
+
'clear',
|
|
420
|
+
'service', SERVICE_NAME,
|
|
421
|
+
'account', account,
|
|
422
|
+
], { timeout: 5000 });
|
|
423
|
+
return true;
|
|
424
|
+
} catch {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
@@ -30,6 +30,19 @@ function getBackend(): Backend {
|
|
|
30
30
|
return resolvedBackend;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
async function getBackendAsync(): Promise<Backend> {
|
|
34
|
+
if (resolvedBackend !== undefined) return resolvedBackend;
|
|
35
|
+
|
|
36
|
+
if (await keychain.isKeychainAvailableAsync()) {
|
|
37
|
+
log.debug('Using OS keychain for secure key storage');
|
|
38
|
+
resolvedBackend = 'keychain';
|
|
39
|
+
} else {
|
|
40
|
+
log.debug('OS keychain unavailable, using encrypted file storage');
|
|
41
|
+
resolvedBackend = 'encrypted';
|
|
42
|
+
}
|
|
43
|
+
return resolvedBackend;
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
/**
|
|
34
47
|
* Try a keychain operation; on failure, permanently downgrade to encrypted
|
|
35
48
|
* backend and retry. This handles systems where the keychain CLI exists
|
|
@@ -167,6 +180,90 @@ export function isDowngradedFromKeychain(): boolean {
|
|
|
167
180
|
return downgradedFromKeychain;
|
|
168
181
|
}
|
|
169
182
|
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Async variants — non-blocking alternatives that avoid blocking the event
|
|
185
|
+
// loop during keychain operations. Preferred for non-startup code paths.
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Async version of `getSecureKey` — retrieve a secret without blocking.
|
|
190
|
+
*/
|
|
191
|
+
export async function getSecureKeyAsync(account: string): Promise<string | undefined> {
|
|
192
|
+
const backend = await getBackendAsync();
|
|
193
|
+
if (backend === 'keychain') {
|
|
194
|
+
try {
|
|
195
|
+
return (await keychain.getKeyAsync(account)) ?? undefined;
|
|
196
|
+
} catch {
|
|
197
|
+
log.warn('Keychain read failed at runtime, falling back to encrypted file storage');
|
|
198
|
+
resolvedBackend = 'encrypted';
|
|
199
|
+
downgradedFromKeychain = true;
|
|
200
|
+
return encryptedStore.getKey(account);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (backend === 'encrypted') {
|
|
204
|
+
const value = encryptedStore.getKey(account);
|
|
205
|
+
if (value === undefined && downgradedFromKeychain) {
|
|
206
|
+
try {
|
|
207
|
+
return (await keychain.getKeyAsync(account)) ?? undefined;
|
|
208
|
+
} catch {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return value;
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Async version of `setSecureKey` — store a secret without blocking.
|
|
219
|
+
*/
|
|
220
|
+
export async function setSecureKeyAsync(account: string, value: string): Promise<boolean> {
|
|
221
|
+
const backend = await getBackendAsync();
|
|
222
|
+
if (backend === 'encrypted') return encryptedStore.setKey(account, value);
|
|
223
|
+
if (backend !== 'keychain') return false;
|
|
224
|
+
|
|
225
|
+
const result = await keychain.setKeyAsync(account, value);
|
|
226
|
+
if (result === false) {
|
|
227
|
+
log.warn('Keychain operation failed at runtime, falling back to encrypted file storage');
|
|
228
|
+
resolvedBackend = 'encrypted';
|
|
229
|
+
downgradedFromKeychain = true;
|
|
230
|
+
return encryptedStore.setKey(account, value);
|
|
231
|
+
}
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Async version of `deleteSecureKey` — delete a secret without blocking.
|
|
237
|
+
*/
|
|
238
|
+
export async function deleteSecureKeyAsync(account: string): Promise<boolean> {
|
|
239
|
+
const backend = await getBackendAsync();
|
|
240
|
+
if (backend === 'encrypted') {
|
|
241
|
+
const result = encryptedStore.deleteKey(account);
|
|
242
|
+
if (downgradedFromKeychain) {
|
|
243
|
+
await keychain.deleteKeyAsync(account); // best-effort
|
|
244
|
+
}
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
if (backend !== 'keychain') return false;
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
if ((await keychain.getKeyAsync(account)) == null) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
// fall through
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const result = await keychain.deleteKeyAsync(account);
|
|
258
|
+
if (result === false) {
|
|
259
|
+
log.warn('Keychain operation failed at runtime, falling back to encrypted file storage');
|
|
260
|
+
resolvedBackend = 'encrypted';
|
|
261
|
+
downgradedFromKeychain = true;
|
|
262
|
+
return encryptedStore.deleteKey(account);
|
|
263
|
+
}
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
|
|
170
267
|
/** @internal Test-only: reset the cached backend so it's re-evaluated. */
|
|
171
268
|
export function _resetBackend(): void {
|
|
172
269
|
resolvedBackend = undefined;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON serialization and deterministic SHA-256 hash for tool
|
|
3
|
+
* approval signatures.
|
|
4
|
+
*
|
|
5
|
+
* Producers (grant creators) and consumers (grant matchers) must use the
|
|
6
|
+
* same serialization to ensure digest equality. The algorithm:
|
|
7
|
+
* 1. Sort object keys recursively (depth-first).
|
|
8
|
+
* 2. Convert to a canonical JSON string with no whitespace.
|
|
9
|
+
* 3. SHA-256 hash the UTF-8 bytes and return a lowercase hex digest.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Canonical JSON serialization
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Recursively sort all object keys and return a deterministic JSON string.
|
|
20
|
+
*
|
|
21
|
+
* Handles nested objects, arrays (element order preserved), and primitive
|
|
22
|
+
* values. `undefined` values inside objects are omitted (matching
|
|
23
|
+
* JSON.stringify semantics). `null` is preserved.
|
|
24
|
+
*/
|
|
25
|
+
export function canonicalJsonSerialize(value: unknown): string {
|
|
26
|
+
return JSON.stringify(sortKeysDeep(value));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sortKeysDeep(value: unknown): unknown {
|
|
30
|
+
if (value == null) return value;
|
|
31
|
+
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
return value.map(sortKeysDeep);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (typeof value === 'object') {
|
|
37
|
+
const sorted: Record<string, unknown> = {};
|
|
38
|
+
const keys = Object.keys(value as Record<string, unknown>).sort();
|
|
39
|
+
for (const key of keys) {
|
|
40
|
+
sorted[key] = sortKeysDeep((value as Record<string, unknown>)[key]);
|
|
41
|
+
}
|
|
42
|
+
return sorted;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Primitive — number, string, boolean
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Digest computation
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compute a deterministic SHA-256 hex digest over the canonical
|
|
55
|
+
* serialization of a tool invocation's name and input.
|
|
56
|
+
*
|
|
57
|
+
* The digest covers `{ toolName, input }` so that two invocations of the
|
|
58
|
+
* same tool with identical (deeply-equal) inputs always produce the same
|
|
59
|
+
* hash, regardless of key ordering in the original input object.
|
|
60
|
+
*/
|
|
61
|
+
export function computeToolApprovalDigest(
|
|
62
|
+
toolName: string,
|
|
63
|
+
input: Record<string, unknown>,
|
|
64
|
+
): string {
|
|
65
|
+
const payload = canonicalJsonSerialize({ input, toolName });
|
|
66
|
+
return createHash('sha256').update(payload, 'utf8').digest('hex');
|
|
67
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
export type RemoteSkillProvider = 'clawhub' | 'skillssh';
|
|
2
|
+
|
|
3
|
+
export type SkillsShRisk = 'safe' | 'low' | 'medium' | 'high' | 'critical' | 'unknown';
|
|
4
|
+
export type SkillsShRiskThreshold = Exclude<SkillsShRisk, 'unknown'>;
|
|
5
|
+
|
|
6
|
+
export interface RemoteSkillPolicy {
|
|
7
|
+
/**
|
|
8
|
+
* When true, suspicious skills are excluded from installable lists
|
|
9
|
+
* and blocked from installation.
|
|
10
|
+
*/
|
|
11
|
+
blockSuspicious: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* When true, malware-blocked skills are excluded from installable lists
|
|
14
|
+
* and blocked from installation.
|
|
15
|
+
*/
|
|
16
|
+
blockMalware: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Maximum allowed Skills.sh audit risk. Anything above this threshold is blocked.
|
|
19
|
+
*/
|
|
20
|
+
maxSkillsShRisk: SkillsShRiskThreshold;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ClawhubModerationState {
|
|
24
|
+
isSuspicious?: boolean;
|
|
25
|
+
isMalwareBlocked?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SkillsShAuditState {
|
|
29
|
+
risk?: SkillsShRisk | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface RemoteSkillCandidateBase {
|
|
33
|
+
provider: RemoteSkillProvider;
|
|
34
|
+
slug: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ClawhubRemoteSkillCandidate extends RemoteSkillCandidateBase {
|
|
38
|
+
provider: 'clawhub';
|
|
39
|
+
moderation?: ClawhubModerationState | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SkillsShRemoteSkillCandidate extends RemoteSkillCandidateBase {
|
|
43
|
+
provider: 'skillssh';
|
|
44
|
+
audit?: SkillsShAuditState | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type RemoteSkillCandidate =
|
|
48
|
+
| ClawhubRemoteSkillCandidate
|
|
49
|
+
| SkillsShRemoteSkillCandidate;
|
|
50
|
+
|
|
51
|
+
export type RemoteSkillDenyReason =
|
|
52
|
+
| 'clawhub_suspicious'
|
|
53
|
+
| 'clawhub_malware_blocked'
|
|
54
|
+
| 'clawhub_moderation_missing'
|
|
55
|
+
| 'skillssh_risk_exceeds_threshold';
|
|
56
|
+
|
|
57
|
+
export type RemoteSkillInstallDecision =
|
|
58
|
+
| { ok: true }
|
|
59
|
+
| { ok: false; reason: RemoteSkillDenyReason };
|
|
60
|
+
|
|
61
|
+
export const DEFAULT_REMOTE_SKILL_POLICY: Readonly<RemoteSkillPolicy> = Object.freeze({
|
|
62
|
+
blockSuspicious: true,
|
|
63
|
+
blockMalware: true,
|
|
64
|
+
maxSkillsShRisk: 'medium',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const SKILLS_SH_RISK_RANK: Record<SkillsShRisk, number> = {
|
|
68
|
+
safe: 0,
|
|
69
|
+
low: 1,
|
|
70
|
+
medium: 2,
|
|
71
|
+
high: 3,
|
|
72
|
+
critical: 4,
|
|
73
|
+
// Fail closed when risk is unknown.
|
|
74
|
+
unknown: 5,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function normalizeSkillsShRisk(audit: SkillsShAuditState | null | undefined): SkillsShRisk {
|
|
78
|
+
const risk = audit?.risk;
|
|
79
|
+
if (risk == null) return 'unknown';
|
|
80
|
+
// Coerce unrecognized risk labels to 'unknown' so we fail closed.
|
|
81
|
+
if (!Object.hasOwn(SKILLS_SH_RISK_RANK, risk)) return 'unknown';
|
|
82
|
+
return risk;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function exceedsSkillsShRiskThreshold(
|
|
86
|
+
audit: SkillsShAuditState | null | undefined,
|
|
87
|
+
threshold: SkillsShRiskThreshold,
|
|
88
|
+
): boolean {
|
|
89
|
+
const actualRisk = normalizeSkillsShRisk(audit);
|
|
90
|
+
return SKILLS_SH_RISK_RANK[actualRisk] > SKILLS_SH_RISK_RANK[threshold];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function evaluateRemoteSkillInstall(
|
|
94
|
+
candidate: RemoteSkillCandidate,
|
|
95
|
+
policy: RemoteSkillPolicy = DEFAULT_REMOTE_SKILL_POLICY,
|
|
96
|
+
): RemoteSkillInstallDecision {
|
|
97
|
+
if (candidate.provider === 'clawhub') {
|
|
98
|
+
// Fail closed: block Clawhub skills when moderation data is missing.
|
|
99
|
+
if (candidate.moderation == null) {
|
|
100
|
+
return { ok: false, reason: 'clawhub_moderation_missing' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (policy.blockMalware && candidate.moderation.isMalwareBlocked === true) {
|
|
104
|
+
return { ok: false, reason: 'clawhub_malware_blocked' };
|
|
105
|
+
}
|
|
106
|
+
if (policy.blockSuspicious && candidate.moderation.isSuspicious === true) {
|
|
107
|
+
return { ok: false, reason: 'clawhub_suspicious' };
|
|
108
|
+
}
|
|
109
|
+
return { ok: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (exceedsSkillsShRiskThreshold(candidate.audit, policy.maxSkillsShRisk)) {
|
|
113
|
+
return { ok: false, reason: 'skillssh_risk_exceeds_threshold' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { ok: true };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function isRemoteSkillInstallable(
|
|
120
|
+
candidate: RemoteSkillCandidate,
|
|
121
|
+
policy: RemoteSkillPolicy = DEFAULT_REMOTE_SKILL_POLICY,
|
|
122
|
+
): boolean {
|
|
123
|
+
return evaluateRemoteSkillInstall(candidate, policy).ok;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function filterInstallableRemoteSkills<T extends RemoteSkillCandidate>(
|
|
127
|
+
skills: T[],
|
|
128
|
+
policy: RemoteSkillPolicy = DEFAULT_REMOTE_SKILL_POLICY,
|
|
129
|
+
): T[] {
|
|
130
|
+
return skills.filter((skill) => isRemoteSkillInstallable(skill, policy));
|
|
131
|
+
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import type { ToolContext, ToolExecutionResult } from '../types.js';
|
|
13
13
|
import { detectAuthChallenge, detectCaptchaChallenge, formatAuthChallenge } from './auth-detector.js';
|
|
14
14
|
import type { PageResponse,RouteHandler } from './browser-manager.js';
|
|
15
|
-
import { browserManager } from './browser-manager.js';
|
|
15
|
+
import { browserManager, SCREENCAST_HEIGHT,SCREENCAST_WIDTH } from './browser-manager.js';
|
|
16
16
|
import {
|
|
17
17
|
ensureScreencast,
|
|
18
18
|
getElementBounds,
|
|
@@ -770,7 +770,7 @@ export async function executeBrowserScroll(
|
|
|
770
770
|
if (bounds) {
|
|
771
771
|
// Convert screencast coords back to page coords for mouse.move
|
|
772
772
|
const result = await page.evaluate(`(() => ({ vw: window.innerWidth, vh: window.innerHeight }))()`) as { vw: number; vh: number };
|
|
773
|
-
const scale = Math.min(
|
|
773
|
+
const scale = Math.min(SCREENCAST_WIDTH / result.vw, SCREENCAST_HEIGHT / result.vh);
|
|
774
774
|
const pageX = (bounds.x + bounds.w / 2) / scale;
|
|
775
775
|
const pageY = (bounds.y + bounds.h / 2) / scale;
|
|
776
776
|
await page.mouse.move(pageX, pageY);
|