@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.
Files changed (202) hide show
  1. package/ARCHITECTURE.md +155 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/docs/architecture/integrations.md +7 -11
  5. package/docs/architecture/security.md +80 -0
  6. package/package.json +1 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -0
  8. package/src/__tests__/approval-primitive.test.ts +540 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  10. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  11. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  12. package/src/__tests__/call-controller.test.ts +605 -104
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/checker.test.ts +60 -0
  15. package/src/__tests__/cli.test.ts +42 -1
  16. package/src/__tests__/config-schema.test.ts +11 -127
  17. package/src/__tests__/config-watcher.test.ts +0 -8
  18. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  19. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  20. package/src/__tests__/diff.test.ts +22 -0
  21. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  22. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +779 -0
  23. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  24. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  25. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  26. package/src/__tests__/guardian-dispatch.test.ts +185 -1
  27. package/src/__tests__/guardian-grant-minting.test.ts +532 -0
  28. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  29. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  30. package/src/__tests__/ipc-snapshot.test.ts +58 -0
  31. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  32. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  33. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  34. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  35. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  36. package/src/__tests__/scoped-grant-security-matrix.test.ts +444 -0
  37. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  38. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  39. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  40. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  41. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  42. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  43. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  44. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  45. package/src/__tests__/system-prompt.test.ts +1 -1
  46. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  47. package/src/__tests__/terminal-tools.test.ts +2 -93
  48. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  49. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  50. package/src/__tests__/trust-store.test.ts +2 -0
  51. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  52. package/src/__tests__/voice-scoped-grant-consumer.test.ts +533 -0
  53. package/src/agent/loop.ts +36 -1
  54. package/src/approvals/approval-primitive.ts +381 -0
  55. package/src/approvals/guardian-decision-primitive.ts +191 -0
  56. package/src/calls/call-controller.ts +276 -212
  57. package/src/calls/call-domain.ts +56 -6
  58. package/src/calls/guardian-dispatch.ts +56 -0
  59. package/src/calls/relay-server.ts +13 -0
  60. package/src/calls/types.ts +1 -1
  61. package/src/calls/voice-session-bridge.ts +59 -4
  62. package/src/cli/core-commands.ts +0 -4
  63. package/src/cli.ts +76 -34
  64. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  65. package/src/config/assistant-feature-flags.ts +162 -0
  66. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  67. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  68. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  69. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  70. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  71. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  72. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  73. package/src/config/core-schema.ts +1 -1
  74. package/src/config/env-registry.ts +10 -0
  75. package/src/config/feature-flag-registry.json +61 -0
  76. package/src/config/loader.ts +22 -1
  77. package/src/config/sandbox-schema.ts +0 -39
  78. package/src/config/schema.ts +12 -2
  79. package/src/config/skill-state.ts +34 -0
  80. package/src/config/skills-schema.ts +26 -0
  81. package/src/config/skills.ts +9 -0
  82. package/src/config/system-prompt.ts +110 -46
  83. package/src/config/templates/SOUL.md +1 -1
  84. package/src/config/types.ts +19 -1
  85. package/src/config/vellum-skills/catalog.json +1 -1
  86. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  87. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  88. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -1
  89. package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -3
  90. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  91. package/src/daemon/config-watcher.ts +0 -1
  92. package/src/daemon/daemon-control.ts +1 -1
  93. package/src/daemon/guardian-invite-intent.ts +124 -0
  94. package/src/daemon/handlers/avatar.ts +68 -0
  95. package/src/daemon/handlers/browser.ts +2 -2
  96. package/src/daemon/handlers/config-channels.ts +18 -0
  97. package/src/daemon/handlers/guardian-actions.ts +120 -0
  98. package/src/daemon/handlers/index.ts +4 -0
  99. package/src/daemon/handlers/sessions.ts +19 -0
  100. package/src/daemon/handlers/shared.ts +3 -1
  101. package/src/daemon/handlers/skills.ts +45 -2
  102. package/src/daemon/install-cli-launchers.ts +58 -13
  103. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  104. package/src/daemon/ipc-contract/sessions.ts +8 -2
  105. package/src/daemon/ipc-contract/settings.ts +25 -2
  106. package/src/daemon/ipc-contract/skills.ts +1 -0
  107. package/src/daemon/ipc-contract-inventory.json +10 -0
  108. package/src/daemon/ipc-contract.ts +4 -0
  109. package/src/daemon/lifecycle.ts +6 -2
  110. package/src/daemon/main.ts +1 -0
  111. package/src/daemon/server.ts +1 -0
  112. package/src/daemon/session-lifecycle.ts +52 -7
  113. package/src/daemon/session-memory.ts +45 -0
  114. package/src/daemon/session-process.ts +260 -422
  115. package/src/daemon/session-runtime-assembly.ts +12 -0
  116. package/src/daemon/session-skill-tools.ts +14 -1
  117. package/src/daemon/session-tool-setup.ts +5 -0
  118. package/src/daemon/session.ts +11 -0
  119. package/src/daemon/tool-side-effects.ts +35 -9
  120. package/src/index.ts +0 -2
  121. package/src/memory/conversation-display-order-migration.ts +44 -0
  122. package/src/memory/conversation-queries.ts +2 -0
  123. package/src/memory/conversation-store.ts +91 -0
  124. package/src/memory/db-init.ts +13 -1
  125. package/src/memory/embedding-local.ts +22 -8
  126. package/src/memory/guardian-action-store.ts +133 -2
  127. package/src/memory/guardian-verification.ts +1 -1
  128. package/src/memory/ingress-invite-store.ts +95 -1
  129. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  130. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  131. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  132. package/src/memory/migrations/index.ts +3 -0
  133. package/src/memory/schema.ts +35 -1
  134. package/src/memory/scoped-approval-grants.ts +518 -0
  135. package/src/messaging/providers/slack/client.ts +12 -0
  136. package/src/messaging/providers/slack/types.ts +5 -0
  137. package/src/notifications/decision-engine.ts +49 -12
  138. package/src/notifications/emit-signal.ts +7 -0
  139. package/src/notifications/signal.ts +7 -0
  140. package/src/notifications/thread-seed-composer.ts +2 -1
  141. package/src/permissions/checker.ts +27 -0
  142. package/src/runtime/channel-approval-types.ts +16 -6
  143. package/src/runtime/channel-approvals.ts +19 -15
  144. package/src/runtime/channel-invite-transport.ts +85 -0
  145. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  146. package/src/runtime/guardian-action-grant-minter.ts +154 -0
  147. package/src/runtime/guardian-action-message-composer.ts +30 -0
  148. package/src/runtime/guardian-decision-types.ts +91 -0
  149. package/src/runtime/http-server.ts +23 -1
  150. package/src/runtime/ingress-service.ts +22 -0
  151. package/src/runtime/invite-redemption-service.ts +181 -0
  152. package/src/runtime/invite-redemption-templates.ts +39 -0
  153. package/src/runtime/routes/call-routes.ts +2 -1
  154. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  155. package/src/runtime/routes/guardian-approval-interception.ts +66 -74
  156. package/src/runtime/routes/inbound-message-handler.ts +568 -409
  157. package/src/runtime/routes/pairing-routes.ts +4 -0
  158. package/src/security/encrypted-store.ts +31 -17
  159. package/src/security/keychain.ts +176 -2
  160. package/src/security/secure-keys.ts +97 -0
  161. package/src/security/tool-approval-digest.ts +67 -0
  162. package/src/skills/remote-skill-policy.ts +131 -0
  163. package/src/tools/browser/browser-execution.ts +2 -2
  164. package/src/tools/browser/browser-manager.ts +46 -32
  165. package/src/tools/browser/browser-screencast.ts +2 -2
  166. package/src/tools/calls/call-start.ts +1 -1
  167. package/src/tools/executor.ts +22 -17
  168. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  169. package/src/tools/skills/load.ts +22 -8
  170. package/src/tools/system/avatar-generator.ts +119 -0
  171. package/src/tools/system/navigate-settings.ts +65 -0
  172. package/src/tools/system/open-system-settings.ts +75 -0
  173. package/src/tools/system/voice-config.ts +121 -32
  174. package/src/tools/terminal/backends/native.ts +40 -19
  175. package/src/tools/terminal/backends/types.ts +3 -3
  176. package/src/tools/terminal/parser.ts +1 -1
  177. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  178. package/src/tools/terminal/sandbox.ts +1 -12
  179. package/src/tools/terminal/shell.ts +3 -31
  180. package/src/tools/tool-approval-handler.ts +141 -3
  181. package/src/tools/tool-manifest.ts +6 -0
  182. package/src/tools/types.ts +6 -0
  183. package/src/util/diff.ts +36 -13
  184. package/Dockerfile.sandbox +0 -5
  185. package/src/__tests__/doordash-client.test.ts +0 -187
  186. package/src/__tests__/doordash-session.test.ts +0 -154
  187. package/src/__tests__/signup-e2e.test.ts +0 -354
  188. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  189. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  190. package/src/cli/doordash.ts +0 -1057
  191. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  192. package/src/config/templates/LOOKS.md +0 -25
  193. package/src/doordash/cart-queries.ts +0 -787
  194. package/src/doordash/client.ts +0 -1016
  195. package/src/doordash/order-queries.ts +0 -85
  196. package/src/doordash/queries.ts +0 -13
  197. package/src/doordash/query-extractor.ts +0 -94
  198. package/src/doordash/search-queries.ts +0 -203
  199. package/src/doordash/session.ts +0 -84
  200. package/src/doordash/store-queries.ts +0 -246
  201. package/src/doordash/types.ts +0 -367
  202. 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 (safe to create)
97
+ * - `null`: file does not exist or was corrupt (backed up and removed)
98
98
  * - `StoreFile`: successfully parsed
99
- * - throws: file exists but cannot be parsed (corrupt/invalid)
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
- const parsed = JSON.parse(raw);
107
- if (parsed.version !== 1 || typeof parsed.salt !== 'string' || typeof parsed.entries !== 'object') {
108
- throw new Error('Encrypted store has invalid format');
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 path = getStorePath();
127
- if (pathExists(path)) {
128
- // File exists — must be parseable, otherwise fail to prevent data loss
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 = {
@@ -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
- * All operations are synchronous to match the config loader's sync API.
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(1280 / result.vw, 960 / result.vh);
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);