/** * Server-side key substitution for automation tools. * * Resolves `${keys.NAME}` references in user-supplied strings (URLs, headers, * bodies, etc.) by looking up the named secret at tool-dispatch time. The * raw secret value NEVER enters the model's context — substitution happens * after the agent emits its tool call and before the request is dispatched. * * SECURITY — workspace-scope fallback (audit 05 H2): * * The user→workspace fallback is OPT-IN via the * `AGENT_NATIVE_KEYS_WORKSPACE_FALLBACK=1` env flag. Default OFF. * * When a user (any org member) writes a workspace-scoped `OPENAI_API_KEY`, * a default-on fallback would let every other org member's tools that * reference `${keys.OPENAI_API_KEY}` start using the malicious key * (key-skimming, mirror requests, billing hijack). The previous * fix-wave gated workspace-scope WRITES behind an org-admin check; this * file is the read-side defense-in-depth. * * When the env flag is unset, `resolveKeyReferences("user", scopeId)` * queries ONLY user-scope rows. Tools/automations that need shared * defaults must explicitly look up via `scope: "workspace"`. Most * installs benefit from the stricter default — opt in only after the * org-admin write-gate is verified to be active. */ import { resolveCredentialForScope } from "../credentials/index.js"; import { getRequestOrgId, getRequestUserEmail, } from "../server/request-context.js"; import type { SecretScope } from "./register.js"; import { readAppSecret, readAppSecretMeta } from "./storage.js"; const KEY_REFERENCE_REGEX = /\$\{keys\.([A-Za-z0-9_-]+)\}/g; function isWorkspaceFallbackEnabled(): boolean { const v = process.env.AGENT_NATIVE_KEYS_WORKSPACE_FALLBACK; if (!v) return false; const normalized = v.trim().toLowerCase(); return ( normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on" ); } export interface ResolveKeyReferencesResult { resolved: string; usedKeys: string[]; secretValues: string[]; resolvedKeys?: ResolvedKeyReference[]; } export interface ResolvedKeyReference { name: string; scope: SecretScope; scopeId: string; } /** * Resolve `${keys.NAME}` references in `text`. For each reference, looks up * the named secret at the given scope, falling back to workspace-scope when * the user-scope row doesn't exist. Throws when a referenced key is missing * so the agent receives a clear error rather than dispatching with the * literal placeholder. */ export async function resolveKeyReferences( text: string, scope: SecretScope, scopeId: string, ): Promise { const usedKeys: string[] = []; const matches = Array.from(text.matchAll(KEY_REFERENCE_REGEX)); if (matches.length === 0) { return { resolved: text, usedKeys, secretValues: [] }; } const resolutions = new Map(); const secretValues: string[] = []; const workspaceFallbackEnabled = isWorkspaceFallbackEnabled(); for (const match of matches) { const name = match[1]; if (resolutions.has(name)) continue; let result = await readAppSecret({ key: name, scope, scopeId }); // SECURITY (audit 05 H2): user→workspace fallback is opt-in. Default // off prevents one malicious org member from poisoning every other // member's `${keys.NAME}` resolution with a workspace-scoped value. if (!result && scope === "user" && workspaceFallbackEnabled) { result = await readAppSecret({ key: name, scope: "workspace", scopeId: getWorkspaceScopeId(scopeId), }); } if (!result) { throw new Error( `Referenced key "${name}" is not defined for scope "${scope}". Create it in the Dispatch Vault, app Settings, or via the secrets API before using this automation.`, ); } resolutions.set(name, result.value); usedKeys.push(name); if (result.value) secretValues.push(result.value); } const resolved = text.replace(KEY_REFERENCE_REGEX, (_, name: string) => { const value = resolutions.get(name); if (value === undefined) { throw new Error(`Referenced key "${name}" was not resolved`); } return value; }); return { resolved, usedKeys, secretValues }; } /** * Resolve `${keys.NAME}` for browser extension fetches and other request-bound * code paths that should honor the active workspace's shared credential store. * * Lookup order: * 1. user scope for personal overrides * 2. active org scope (Dispatch vault sync writes here for org workspaces) * 3. active org workspace scope (legacy shared rows) * 4. solo workspace scope when no org is active * 5. legacy app credential store user/org scopes */ export async function resolveKeyReferencesWithRequestScopes( text: string, userScopeId: string, ): Promise { const usedKeys: string[] = []; const matches = Array.from(text.matchAll(KEY_REFERENCE_REGEX)); if (matches.length === 0) { return { resolved: text, usedKeys, secretValues: [], resolvedKeys: [] }; } const resolutions = new Map(); const resolvedKeys: ResolvedKeyReference[] = []; const secretValues: string[] = []; for (const match of matches) { const name = match[1]; if (resolutions.has(name)) continue; const result = await readRequestScopedSecret(name, userScopeId); if (!result) { throw new Error( `Referenced key "${name}" is not defined for this user or active workspace. Create it in the Dispatch Vault or app Settings before using this extension.`, ); } resolutions.set(name, result.value); usedKeys.push(name); resolvedKeys.push(result.ref); if (result.value) secretValues.push(result.value); } const resolved = text.replace(KEY_REFERENCE_REGEX, (_, name: string) => { const value = resolutions.get(name); if (value === undefined) { throw new Error(`Referenced key "${name}" was not resolved`); } return value; }); return { resolved, usedKeys, secretValues, resolvedKeys }; } async function readRequestScopedSecret( name: string, userScopeId: string, ): Promise<{ value: string; ref: ResolvedKeyReference } | null> { const candidates = requestSecretCandidates(userScopeId); for (const ref of candidates) { const result = await readAppSecret({ key: name, ...ref }); if (result) return { value: result.value, ref: { name, ...ref } }; } const legacyCredential = await readLegacyCredential(name, userScopeId); if (legacyCredential) return legacyCredential; return null; } async function readLegacyCredential( name: string, userScopeId: string, ): Promise<{ value: string; ref: ResolvedKeyReference } | null> { const orgId = getRequestOrgId(); const userValue = await resolveCredentialForScope(name, { userEmail: userScopeId, orgId, scope: "user", }).catch(() => undefined); if (userValue) { return { value: userValue, ref: { name, scope: "user", scopeId: userScopeId }, }; } if (!orgId) return null; const orgValue = await resolveCredentialForScope(name, { userEmail: userScopeId, orgId, scope: "org", }).catch(() => undefined); if (!orgValue) return null; return { value: orgValue, ref: { name, scope: "org", scopeId: orgId }, }; } function requestSecretCandidates( userScopeId: string, ): Array<{ scope: SecretScope; scopeId: string }> { const orgId = getRequestOrgId(); if (orgId) { return [ { scope: "user", scopeId: userScopeId }, { scope: "org", scopeId: orgId }, { scope: "workspace", scopeId: orgId }, ]; } const email = getRequestUserEmail() || userScopeId; return [ { scope: "user", scopeId: userScopeId }, { scope: "workspace", scopeId: `solo:${email}` }, ]; } /** * Check if a URL is allowed by a key's URL allowlist. Returns true when no * allowlist is configured (permissive default — the allowlist is opt-in). * * Matching is exact on the URL's origin (scheme + host + port), so an entry * like `https://hooks.slack.com` blocks `https://evil.example.com` even if * the agent tries to redirect the request elsewhere. */ export function validateUrlAllowlist( url: string, allowlist: string[] | null, ): boolean { if (!allowlist || allowlist.length === 0) return true; let origin: string; try { origin = new URL(url).origin; } catch { return false; } return allowlist.some((entry) => { try { return new URL(entry).origin === origin; } catch { return false; } }); } /** * Convenience helper: look up a key's allowlist by name+scope. Returns null * when the key doesn't exist or has no allowlist configured. * * SECURITY: workspace fallback obeys the same opt-in flag as * `resolveKeyReferences` so the allowlist check stays consistent with the * resolved secret. If a future caller queries the allowlist for a key the * resolver wouldn't return, we'd risk allowing requests that the resolver * would refuse — keep them aligned. */ export async function getKeyAllowlist( name: string, scope: SecretScope, scopeId: string, ): Promise { let meta = await readAppSecretMeta({ key: name, scope, scopeId }); if (!meta && scope === "user" && isWorkspaceFallbackEnabled()) { meta = await readAppSecretMeta({ key: name, scope: "workspace", scopeId: getWorkspaceScopeId(scopeId), }); } return meta?.urlAllowlist ?? null; } export async function getResolvedKeyAllowlist( ref: ResolvedKeyReference, ): Promise { const meta = await readAppSecretMeta({ key: ref.name, scope: ref.scope, scopeId: ref.scopeId, }); return meta?.urlAllowlist ?? null; } function getWorkspaceScopeId(userScopeId: string): string { const orgId = getRequestOrgId(); if (orgId) return orgId; const email = getRequestUserEmail() || userScopeId; return `solo:${email}`; }