@vellumai/assistant 0.10.9-dev.202607161044.1a55a2d → 0.10.9-dev.202607161241.f69e16e
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/package.json +1 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/plugin-api-resolve-credential.test.ts +140 -0
- package/src/plugin-api/index.ts +10 -0
- package/src/plugin-api/resolve-credential.ts +75 -0
- package/src/plugins/pipeline.ts +9 -1
- package/src/plugins/plugin-execution-context.ts +44 -0
- package/src/runtime/routes/__tests__/migration-vellum-metadata-reconcile.test.ts +7 -0
- package/src/tools/executor.ts +14 -1
package/package.json
CHANGED
|
@@ -231,6 +231,7 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
|
|
|
231
231
|
"tools/network/web-fetch.ts", // Firecrawl /scrape BYOK fetch provider API key lookup (firecrawl provider key)
|
|
232
232
|
"workspace/default-provider-ensure.ts", // legacy anthropic echo disambiguation (vault key presence check)
|
|
233
233
|
"providers/inference/connection-availability.ts", // shared (provider, connection) availability status (credential presence check only; value never leaves the helper)
|
|
234
|
+
"plugin-api/resolve-credential.ts", // plugin-facing resolveCredential — reveal-equivalent plaintext read, scoped to the in-context plugin's own field
|
|
234
235
|
]);
|
|
235
236
|
|
|
236
237
|
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
afterAll,
|
|
7
|
+
afterEach,
|
|
8
|
+
beforeEach,
|
|
9
|
+
describe,
|
|
10
|
+
expect,
|
|
11
|
+
spyOn,
|
|
12
|
+
test,
|
|
13
|
+
} from "bun:test";
|
|
14
|
+
|
|
15
|
+
import { credentialKey } from "@vellumai/credential-storage";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
CredentialResolutionError,
|
|
19
|
+
resolveCredential,
|
|
20
|
+
} from "../plugin-api/resolve-credential.js";
|
|
21
|
+
import { runInPluginContext } from "../plugins/plugin-execution-context.js";
|
|
22
|
+
import * as secureKeys from "../security/secure-keys.js";
|
|
23
|
+
import {
|
|
24
|
+
_setMetadataPath,
|
|
25
|
+
upsertCredentialMetadata,
|
|
26
|
+
} from "../tools/credentials/metadata-store.js";
|
|
27
|
+
|
|
28
|
+
// Real metadata store backed by a temp file (no module mocking — a mock.module
|
|
29
|
+
// on metadata-store / secure-keys would replace the whole module namespace and
|
|
30
|
+
// leak into other test files in the same process). Only the secure backend read
|
|
31
|
+
// is intercepted, via a restorable spy.
|
|
32
|
+
|
|
33
|
+
const TEST_DIR = join(
|
|
34
|
+
tmpdir(),
|
|
35
|
+
`vellum-plugin-resolvecred-${randomBytes(4).toString("hex")}`,
|
|
36
|
+
);
|
|
37
|
+
const META_PATH = join(TEST_DIR, "metadata.json");
|
|
38
|
+
|
|
39
|
+
let secureStore: Map<string, string>;
|
|
40
|
+
let unreachable: boolean;
|
|
41
|
+
let getSpy: ReturnType<typeof spyOn>;
|
|
42
|
+
|
|
43
|
+
function seedCredential(service: string, field: string, value: string): string {
|
|
44
|
+
const meta = upsertCredentialMetadata(service, field, {});
|
|
45
|
+
secureStore.set(credentialKey(service, field), value);
|
|
46
|
+
return meta.credentialId;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
if (existsSync(TEST_DIR)) {
|
|
51
|
+
rmSync(TEST_DIR, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
54
|
+
_setMetadataPath(META_PATH);
|
|
55
|
+
|
|
56
|
+
secureStore = new Map();
|
|
57
|
+
unreachable = false;
|
|
58
|
+
getSpy = spyOn(secureKeys, "getSecureKeyResultAsync").mockImplementation(
|
|
59
|
+
async (key: string) => ({
|
|
60
|
+
value: secureStore.get(key),
|
|
61
|
+
unreachable: unreachable && !secureStore.has(key),
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
getSpy.mockRestore();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterAll(() => {
|
|
71
|
+
_setMetadataPath(null);
|
|
72
|
+
if (existsSync(TEST_DIR)) {
|
|
73
|
+
rmSync(TEST_DIR, { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("resolveCredential", () => {
|
|
78
|
+
test("resolves plaintext by service/field ref when no plugin is in context", async () => {
|
|
79
|
+
seedCredential("openai", "api_key", "sk-secret");
|
|
80
|
+
await expect(resolveCredential("openai/api_key")).resolves.toBe(
|
|
81
|
+
"sk-secret",
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("resolves plaintext by credential UUID", async () => {
|
|
86
|
+
const id = seedCredential("stripe", "acme", "stripe-secret");
|
|
87
|
+
await expect(resolveCredential(id)).resolves.toBe("stripe-secret");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("throws not found for an unknown ref", async () => {
|
|
91
|
+
await expect(resolveCredential("nope/missing")).rejects.toBeInstanceOf(
|
|
92
|
+
CredentialResolutionError,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("throws when the store is unreachable", async () => {
|
|
97
|
+
upsertCredentialMetadata("openai", "api_key", {});
|
|
98
|
+
unreachable = true;
|
|
99
|
+
await expect(resolveCredential("openai/api_key")).rejects.toThrow(
|
|
100
|
+
/unreachable/,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("plugin scoping", () => {
|
|
105
|
+
test("allows a plugin to resolve a credential whose field matches its name", async () => {
|
|
106
|
+
seedCredential("openai", "acme", "scoped-secret");
|
|
107
|
+
const value = await runInPluginContext("acme", () =>
|
|
108
|
+
resolveCredential("openai/acme"),
|
|
109
|
+
);
|
|
110
|
+
expect(value).toBe("scoped-secret");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("blocks a plugin from resolving a credential whose field differs from its name", async () => {
|
|
114
|
+
seedCredential("openai", "api_key", "sk-secret");
|
|
115
|
+
await expect(
|
|
116
|
+
runInPluginContext("acme", () => resolveCredential("openai/api_key")),
|
|
117
|
+
).rejects.toThrow(/out of scope/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("does not read the secure backend when out of scope", async () => {
|
|
121
|
+
seedCredential("openai", "api_key", "sk-secret");
|
|
122
|
+
getSpy.mockClear();
|
|
123
|
+
await expect(
|
|
124
|
+
runInPluginContext("acme", () => resolveCredential("openai/api_key")),
|
|
125
|
+
).rejects.toThrow(CredentialResolutionError);
|
|
126
|
+
expect(getSpy).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("scoping applies by field only, across any service", async () => {
|
|
130
|
+
seedCredential("stripe", "acme", "stripe-secret");
|
|
131
|
+
seedCredential("openai", "acme", "openai-secret");
|
|
132
|
+
await expect(
|
|
133
|
+
runInPluginContext("acme", () => resolveCredential("stripe/acme")),
|
|
134
|
+
).resolves.toBe("stripe-secret");
|
|
135
|
+
await expect(
|
|
136
|
+
runInPluginContext("acme", () => resolveCredential("openai/acme")),
|
|
137
|
+
).resolves.toBe("openai-secret");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
package/src/plugin-api/index.ts
CHANGED
|
@@ -150,6 +150,16 @@ export { getModelProfiles } from "./model-profiles.js";
|
|
|
150
150
|
// looks up the model catalog's `supportsVision` flag (mix profiles are
|
|
151
151
|
// vision-capable if any arm is). Returns false when nothing resolves.
|
|
152
152
|
export { doesSupportVision } from "./vision-support.js";
|
|
153
|
+
// Resolve a stored credential to its plaintext value — the same value
|
|
154
|
+
// `assistant credentials reveal` prints — from a UUID or a "service/field"
|
|
155
|
+
// reference. When a plugin is in context, resolution is scoped to credentials
|
|
156
|
+
// whose `field` matches the plugin's manifest name; outside any plugin it is
|
|
157
|
+
// unscoped. Throws CredentialResolutionError when the ref does not resolve, the
|
|
158
|
+
// store is unreachable, or the credential is out of the plugin's scope.
|
|
159
|
+
export {
|
|
160
|
+
CredentialResolutionError,
|
|
161
|
+
resolveCredential,
|
|
162
|
+
} from "./resolve-credential.js";
|
|
153
163
|
// Resolve a provider for a call site (optionally overriding the profile) so a
|
|
154
164
|
// plugin can run inference through the workspace's configured profiles and
|
|
155
165
|
// credentials — managed-proxy or BYOK — without supplying its own API key.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin-facing credential resolution.
|
|
3
|
+
*
|
|
4
|
+
* {@link resolveCredential} returns a stored credential's plaintext value — the
|
|
5
|
+
* same value `assistant credentials reveal` prints — resolving a reference that
|
|
6
|
+
* is either a credential UUID or a `"service/field"` string, exactly as the
|
|
7
|
+
* reveal path does (see {@link ../tools/credentials/resolve.resolveCredentialRef}
|
|
8
|
+
* and the `credentials/reveal` route).
|
|
9
|
+
*
|
|
10
|
+
* ## Plugin scoping
|
|
11
|
+
*
|
|
12
|
+
* When a plugin is in context — its hook or tool is executing, tracked by
|
|
13
|
+
* {@link ../plugins/plugin-execution-context.getCurrentPluginName} — resolution
|
|
14
|
+
* is restricted: the plugin may only resolve credentials whose `field` equals
|
|
15
|
+
* the plugin's own manifest name. A plugin named `acme` can therefore read
|
|
16
|
+
* `openai/acme` or `stripe/acme` but not `openai/api_key`. Outside any plugin
|
|
17
|
+
* context (host-internal callers, CLI, tests) the resolver is unscoped and
|
|
18
|
+
* behaves like a direct reveal.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { getCurrentPluginName } from "../plugins/plugin-execution-context.js";
|
|
22
|
+
import { getSecureKeyResultAsync } from "../security/secure-keys.js";
|
|
23
|
+
import { resolveCredentialRef } from "../tools/credentials/resolve.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Raised when a credential cannot be resolved: the reference does not match a
|
|
27
|
+
* stored credential, the store is unreachable, or the calling plugin is not
|
|
28
|
+
* permitted to resolve the requested credential.
|
|
29
|
+
*/
|
|
30
|
+
export class CredentialResolutionError extends Error {
|
|
31
|
+
constructor(message: string) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "CredentialResolutionError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve a credential reference to its plaintext value.
|
|
39
|
+
*
|
|
40
|
+
* @param ref A credential UUID or a `"service/field"` string.
|
|
41
|
+
* @returns The plaintext credential value.
|
|
42
|
+
* @throws {CredentialResolutionError} when the reference does not resolve, the
|
|
43
|
+
* store is unreachable, or a plugin in context is not scoped to the credential.
|
|
44
|
+
*/
|
|
45
|
+
export async function resolveCredential(ref: string): Promise<string> {
|
|
46
|
+
const resolved = resolveCredentialRef(ref);
|
|
47
|
+
if (!resolved) {
|
|
48
|
+
throw new CredentialResolutionError(`Credential not found: ${ref}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Scope the resolution to the plugin in context, if any. The field-name gate
|
|
52
|
+
// is enforced before the plaintext is read so an out-of-scope plugin never
|
|
53
|
+
// touches the secure backend.
|
|
54
|
+
const pluginName = getCurrentPluginName();
|
|
55
|
+
if (pluginName !== undefined && resolved.field !== pluginName) {
|
|
56
|
+
throw new CredentialResolutionError(
|
|
57
|
+
`Plugin "${pluginName}" may only resolve credentials whose field matches its name; ` +
|
|
58
|
+
`"${resolved.service}/${resolved.field}" is out of scope.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const { value, unreachable } = await getSecureKeyResultAsync(
|
|
63
|
+
resolved.storageKey,
|
|
64
|
+
);
|
|
65
|
+
if (value == null || value.length === 0) {
|
|
66
|
+
if (unreachable) {
|
|
67
|
+
throw new CredentialResolutionError(
|
|
68
|
+
"Credential store is unreachable — ensure the assistant is running",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
throw new CredentialResolutionError(`Credential not found: ${ref}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return value;
|
|
75
|
+
}
|
package/src/plugins/pipeline.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { getHookEntriesFor } from "../hooks/registry.js";
|
|
|
25
25
|
import type { BaseHookContext } from "../hooks/types.js";
|
|
26
26
|
import { type HookName, HOOKS } from "../plugin-api/constants.js";
|
|
27
27
|
import { getLogger } from "../util/logger.js";
|
|
28
|
+
import { runInPluginContext } from "./plugin-execution-context.js";
|
|
28
29
|
import type { HookEntry } from "./types.js";
|
|
29
30
|
|
|
30
31
|
// ─── Hook runner ────────────────────────────────────────────────────────────
|
|
@@ -352,8 +353,15 @@ export async function runHook<TInput extends object>(
|
|
|
352
353
|
broadcast: makeHookBroadcast({ conversationId, hookName: name, owner }),
|
|
353
354
|
};
|
|
354
355
|
try {
|
|
356
|
+
// Mark the contributing plugin as in context so host APIs the hook
|
|
357
|
+
// reaches (e.g. resolveCredential) can scope to it. Standalone workspace
|
|
358
|
+
// hooks are not plugins and establish no context.
|
|
359
|
+
const invokeHook =
|
|
360
|
+
owner.kind === "plugin"
|
|
361
|
+
? () => runInPluginContext(owner.id, () => fn(draft))
|
|
362
|
+
: () => fn(draft);
|
|
355
363
|
const result = await callWithTimeout(
|
|
356
|
-
|
|
364
|
+
invokeHook,
|
|
357
365
|
HOOK_TIMEOUT_MS,
|
|
358
366
|
`plugin hook '${name}' (${owner.id}) timed out after ${HOOK_TIMEOUT_MS}ms`,
|
|
359
367
|
);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin execution context — tracks which plugin's code is currently running.
|
|
3
|
+
*
|
|
4
|
+
* Host APIs exposed to plugins through `@vellumai/plugin-api` sometimes need to
|
|
5
|
+
* know *which* plugin is calling them so they can scope their behavior to that
|
|
6
|
+
* plugin (e.g. {@link ../plugin-api/resolve-credential.resolveCredential} limits
|
|
7
|
+
* a plugin to its own credentials). A plugin's manifest name is not threaded
|
|
8
|
+
* through every host call, so the pipeline that invokes a plugin's hook — and
|
|
9
|
+
* the tool executor that runs a plugin's tool — mark the plugin as "in context"
|
|
10
|
+
* for the duration of that invocation via an {@link AsyncLocalStorage}. Host
|
|
11
|
+
* APIs read {@link getCurrentPluginName} to recover it.
|
|
12
|
+
*
|
|
13
|
+
* The store propagates across `await` boundaries, so a plugin that awaits a
|
|
14
|
+
* host API deep inside its hook/tool body is still seen as in context. When no
|
|
15
|
+
* plugin is in context (host-internal callers, the CLI, tests), the store is
|
|
16
|
+
* empty and scoped APIs fall back to their unscoped behavior.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
20
|
+
|
|
21
|
+
export interface PluginExecutionContext {
|
|
22
|
+
/** Manifest name of the plugin whose hook or tool is currently executing. */
|
|
23
|
+
pluginName: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const storage = new AsyncLocalStorage<PluginExecutionContext>();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run `fn` with `pluginName` marked as the plugin currently in context. The
|
|
30
|
+
* returned value (including a promise) carries the context across its async
|
|
31
|
+
* continuations, so callers pass the promise straight to a timeout wrapper
|
|
32
|
+
* without losing the binding.
|
|
33
|
+
*/
|
|
34
|
+
export function runInPluginContext<T>(pluginName: string, fn: () => T): T {
|
|
35
|
+
return storage.run({ pluginName }, fn);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Name of the plugin whose hook or tool is currently executing, or `undefined`
|
|
40
|
+
* when no plugin is in context.
|
|
41
|
+
*/
|
|
42
|
+
export function getCurrentPluginName(): string | undefined {
|
|
43
|
+
return storage.getStore()?.pluginName;
|
|
44
|
+
}
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
29
29
|
|
|
30
30
|
import { credentialKey } from "../../../security/credential-key.js";
|
|
31
|
+
import * as actualMetadataStore from "../../../tools/credentials/metadata-store.js";
|
|
31
32
|
|
|
32
33
|
type MetadataRecord = {
|
|
33
34
|
credentialId: string;
|
|
@@ -80,7 +81,13 @@ mock.module("../../../security/secure-keys.js", () => ({
|
|
|
80
81
|
_resetBackend: () => {},
|
|
81
82
|
}));
|
|
82
83
|
|
|
84
|
+
// Spread the real module so every export the reconcile handler's transitive
|
|
85
|
+
// graph links (e.g. getCredentialMetadataById, listCredentialMetadata pulled in
|
|
86
|
+
// via the plugin-api hub) stays present; only the two functions under test are
|
|
87
|
+
// overridden. A partial mock replaces the whole namespace and fails to link the
|
|
88
|
+
// unlisted exports.
|
|
83
89
|
mock.module("../../../tools/credentials/metadata-store.js", () => ({
|
|
90
|
+
...actualMetadataStore,
|
|
84
91
|
getCredentialMetadata: (service: string, field: string) =>
|
|
85
92
|
metadataStore.get(`${service}:${field}`),
|
|
86
93
|
upsertCredentialMetadata: (
|
package/src/tools/executor.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { getConfig } from "../config/loader.js";
|
|
4
4
|
import { PermissionPrompter } from "../permissions/prompter.js";
|
|
5
5
|
import { RiskLevel } from "../permissions/types.js";
|
|
6
|
+
import { runInPluginContext } from "../plugins/plugin-execution-context.js";
|
|
6
7
|
import { TokenExpiredError } from "../security/token-manager.js";
|
|
7
8
|
import {
|
|
8
9
|
recordToolError,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
import { getWorkflowRunManager } from "../workflows/run-manager.js";
|
|
20
21
|
import { executeWithTimeout, safeTimeoutMs } from "./execution-timeout.js";
|
|
21
22
|
import { PermissionChecker } from "./permission-checker.js";
|
|
23
|
+
import { getToolOwner } from "./registry.js";
|
|
22
24
|
import { extractAndSanitize } from "./sensitive-output-placeholders.js";
|
|
23
25
|
import { applyEdit } from "./shared/filesystem/edit-engine.js";
|
|
24
26
|
import { sandboxPolicy } from "./shared/filesystem/path-policy.js";
|
|
@@ -223,8 +225,19 @@ export class ToolExecutor {
|
|
|
223
225
|
const toolTimeoutMs = computePerToolTimeoutMs(name, input);
|
|
224
226
|
const execContext = context;
|
|
225
227
|
|
|
228
|
+
// Mark the owning plugin as in context (via AsyncLocalStorage) so host
|
|
229
|
+
// APIs the tool reaches — e.g. resolveCredential — can scope to it. The
|
|
230
|
+
// context must be established around the `execute()` call itself so the
|
|
231
|
+
// returned promise carries the binding across its awaits. Non-plugin
|
|
232
|
+
// tools (default/skill/mcp/workspace) establish no context.
|
|
233
|
+
const owner = getToolOwner(name);
|
|
234
|
+
const execPromise =
|
|
235
|
+
owner?.kind === "plugin"
|
|
236
|
+
? runInPluginContext(owner.id, () => tool.execute(input, execContext))
|
|
237
|
+
: tool.execute(input, execContext);
|
|
238
|
+
|
|
226
239
|
let execResult: ToolExecutionResult = await executeWithTimeout(
|
|
227
|
-
|
|
240
|
+
execPromise,
|
|
228
241
|
toolTimeoutMs,
|
|
229
242
|
name,
|
|
230
243
|
);
|