Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/lib/__tests__/secret-vault.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { createSecretVault, isSecretRef } from '../secret-vault';

describe('createSecretVault', () => {
it('stores a value and returns a ref', () => {
const vault = createSecretVault();
const ref = vault.put('phx_super_secret', {
label: 'PostHog key',
source: 'test',
});

expect(isSecretRef(ref)).toBe(true);
expect(ref).toMatch(/^secret:[0-9a-f-]{36}$/);
expect(vault.has(ref)).toBe(true);
expect(vault.get(ref)).toBe('phx_super_secret');
});

it('mints a fresh ref per put even for the same value', () => {
const vault = createSecretVault();
const a = vault.put('same-value', { label: 'A', source: 'test' });
const b = vault.put('same-value', { label: 'B', source: 'test' });

expect(a).not.toBe(b);
expect(vault.get(a)).toBe('same-value');
expect(vault.get(b)).toBe('same-value');
});

it('returns undefined for unknown refs', () => {
const vault = createSecretVault();
expect(vault.get('secret:does-not-exist')).toBeUndefined();
expect(vault.has('secret:does-not-exist')).toBe(false);
});

it('list() returns metadata only, never values', () => {
const vault = createSecretVault();
vault.put('value-1', { label: 'one', source: 'src-a' });
vault.put('value-2', { label: 'two', source: 'src-b' });

const metas = vault.list();
expect(metas).toHaveLength(2);
expect(metas.map((m) => m.label).sort()).toEqual(['one', 'two']);
expect(metas.map((m) => m.source).sort()).toEqual(['src-a', 'src-b']);
// Ensure no `value` key bled into the metadata
for (const m of metas) {
expect(m).not.toHaveProperty('value');
}
});

it('clear() drops every secret', () => {
const vault = createSecretVault();
const ref = vault.put('gone', { label: 'temp', source: 'test' });
vault.clear();
expect(vault.has(ref)).toBe(false);
expect(vault.get(ref)).toBeUndefined();
expect(vault.list()).toEqual([]);
});

it('isolates vault instances from each other', () => {
const a = createSecretVault();
const b = createSecretVault();
const ref = a.put('only-in-a', { label: 'a-only', source: 'test' });

expect(a.has(ref)).toBe(true);
expect(b.has(ref)).toBe(false);
});

it('isSecretRef recognises refs and rejects garbage', () => {
expect(isSecretRef('secret:abc')).toBe(true);
expect(isSecretRef('not a ref')).toBe(false);
expect(isSecretRef('')).toBe(false);
expect(isSecretRef(null)).toBe(false);
expect(isSecretRef(undefined)).toBe(false);
expect(isSecretRef({ secretRef: 'secret:abc' })).toBe(false);
});
});
79 changes: 79 additions & 0 deletions src/lib/secret-vault.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Session-scoped secret vault.
*
* Tools that handle sensitive values (personal API keys pasted by the user
* or minted by the wizard, OAuth tokens, etc.) store them here and return
* an opaque `secret:<uuid>` ref to the agent. The agent passes the ref
* around to subsequent tools — `set_env_values` resolves it host-side
* before writing — but the raw value never enters the LLM conversation.
*
* The vault is created once per `createWizardToolsServer` call and lives
* for the duration of a single wizard run. There is no persistence and
* no cross-session sharing; refs minted in one run cannot be resolved in
* another.
*/

import { randomUUID } from 'crypto';

const REF_PREFIX = 'secret:';

export interface SecretMeta {
/** Opaque reference handed to the agent. */
ref: string;
/** Human-readable label shown to the user (e.g. "Personal API key"). */
label: string;
/** Where the secret came from (e.g. "wizard_ask"). */
source: string;
/** ms epoch when the secret was stored. */
createdAt: number;
}

export interface SecretVault {
/** Store a value and return its ref. */
put(value: string, meta: Omit<SecretMeta, 'ref' | 'createdAt'>): string;
/** Resolve a ref to its value, or undefined if unknown. */
get(ref: string): string | undefined;
/** Whether the vault knows about this ref. */
has(ref: string): boolean;
/** Metadata for every stored secret (never the values). */
list(): SecretMeta[];
/** Drop every secret. Call at session teardown. */
clear(): void;
}

/** True when the value looks like a secret ref. Does not assert resolvability. */
export function isSecretRef(value: unknown): value is string {
return typeof value === 'string' && value.startsWith(REF_PREFIX);
}

export function createSecretVault(): SecretVault {
const store = new Map<string, { value: string; meta: SecretMeta }>();

return {
put(value, meta) {
const ref = `${REF_PREFIX}${randomUUID()}`;
store.set(ref, {
value,
meta: {
ref,
label: meta.label,
source: meta.source,
createdAt: Date.now(),
},
});
return ref;
},
get(ref) {
return store.get(ref)?.value;
},
has(ref) {
return store.has(ref);
},
list() {
return [...store.values()].map((s) => s.meta);
},
clear() {
store.clear();
},
};
}
8 changes: 8 additions & 0 deletions src/lib/wizard-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ export interface AskQuestion {
options?: { label: string; value: string }[];
/** Defaults to true */
required?: boolean;
/**
* Only meaningful for kind='text'. When true, the wizard-tools `wizard_ask`
* tool stores the user's answer in the session secret vault and returns
* `{ secretRef }` to the agent instead of the plain string — so the value
* never enters the LLM conversation. The TUI may also mask input
* accordingly. See `secret-vault.ts`.
*/
sensitive?: boolean;
}

/** Map of question id → answer (string for single/text, string[] for multi). */
Expand Down
112 changes: 104 additions & 8 deletions src/lib/wizard-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type AuditStatus,
} from './workflows/audit/types';
import type { WizardAskBridge } from './wizard-ask-bridge';
import { createSecretVault, type SecretVault } from './secret-vault';

// ---------------------------------------------------------------------------
// SDK dynamic import (ESM module loaded once, cached)
Expand Down Expand Up @@ -192,6 +193,15 @@ export interface WizardToolsOptions {
* of this cap — see {@link ASK_BATCH_THRESHOLD}.
*/
askMaxQuestions?: number;

/**
* Optional secret vault. When provided, tools that handle sensitive
* values (wizard_ask with `sensitive: true`, set_env_values) route
* those values through the vault and return opaque refs to the agent
* instead of raw strings — so the LLM never sees them. When omitted
* (e.g. in unit tests), a fresh vault is created internally.
*/
secretVault?: SecretVault;
}

/** Default per-run cap on wizard_ask calls when no override is provided. */
Expand Down Expand Up @@ -494,6 +504,7 @@ export async function createWizardToolsServer(options: WizardToolsOptions) {
skillsBaseUrl,
askBridge,
askMaxQuestions = DEFAULT_ASK_MAX_QUESTIONS,
secretVault = createSecretVault(),
} = options;
const sdk = await getSDKModule();
const { tool, createSdkMcpServer } = sdk;
Expand Down Expand Up @@ -553,16 +564,24 @@ export async function createWizardToolsServer(options: WizardToolsOptions) {

const setEnvValues = tool(
'set_env_values',
'Create or update environment variable keys in a .env file. Creates the file if it does not exist. Ensures .gitignore coverage.',
'Create or update environment variable keys in a .env file. Creates the file if it does not exist. Ensures .gitignore coverage. Each value can be either a literal string or a secret reference of the form `{ "secretRef": "secret:..." }` returned by another tool (e.g. wizard_ask). Secret references are resolved locally — the actual value is written to the file but never returned to the agent.',
{
filePath: z
.string()
.describe('Path to the .env file, relative to the project root'),
values: z
.record(z.string(), z.string())
.describe('Key-value pairs to set'),
.record(
z.string(),
z.union([z.string(), z.object({ secretRef: z.string() })]),
)
.describe(
'Key → (literal string OR { secretRef } pointing to a vaulted secret)',
),
},
(args: { filePath: string; values: Record<string, string> }) => {
(args: {
filePath: string;
values: Record<string, string | { secretRef: string }>;
}) => {
// Block the wrong key name — the correct key is NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN or similar
const forbidden = Object.keys(args.values).find(
(k) => k.toUpperCase() === 'POSTHOG_KEY',
Expand All @@ -579,17 +598,45 @@ export async function createWizardToolsServer(options: WizardToolsOptions) {
};
}

// Resolve any secret refs from the vault before writing.
const resolvedValues: Record<string, string> = {};
const resolvedRefKeys: string[] = [];
for (const [key, val] of Object.entries(args.values)) {
if (typeof val === 'string') {
resolvedValues[key] = val;
} else {
const secret = secretVault.get(val.secretRef);
if (secret === undefined) {
return {
content: [
{
type: 'text' as const,
text: `Error: secret reference "${val.secretRef}" for key "${key}" is not known to the vault. The ref may have expired, been minted in a different run, or been mistyped.`,
},
],
isError: true,
};
}
resolvedValues[key] = secret;
resolvedRefKeys.push(key);
}
}

const resolved = resolveEnvPath(workingDirectory, args.filePath);
logToFile(
`set_env_values: ${resolved}, keys: ${Object.keys(args.values).join(
`set_env_values: ${resolved}, keys: ${Object.keys(resolvedValues).join(
', ',
)}`,
)}${
resolvedRefKeys.length > 0
? ` (secret refs: ${resolvedRefKeys.join(', ')})`
: ''
}`,
);

const existing = fs.existsSync(resolved)
? fs.readFileSync(resolved, 'utf8')
: '';
const content = mergeEnvValues(existing, args.values);
const content = mergeEnvValues(existing, resolvedValues);

// Ensure parent directory exists
const dir = path.dirname(resolved);
Expand Down Expand Up @@ -895,6 +942,12 @@ export async function createWizardToolsServer(options: WizardToolsOptions) {
.optional()
.describe('Required for kind=single|multi; ignored for kind=text'),
required: z.boolean().optional().describe('Defaults to true'),
sensitive: z
.boolean()
.optional()
.describe(
"Only valid for kind='text'. When true, the user's answer is stored in the wizard's secret vault and returned to you as { secretRef: 'secret:...' } instead of the raw string. Use for API keys, tokens, and any other secret the user types in.",
),
});

const wizardAsk = tool(
Expand All @@ -912,6 +965,7 @@ export async function createWizardToolsServer(options: WizardToolsOptions) {
kind: 'single' | 'multi' | 'text';
options?: { label: string; value: string }[];
required?: boolean;
sensitive?: boolean;
}>;
}) => {
if (!askBridge) {
Expand Down Expand Up @@ -956,6 +1010,17 @@ export async function createWizardToolsServer(options: WizardToolsOptions) {
isError: true,
};
}
if (q.sensitive && q.kind !== 'text') {
return {
content: [
{
type: 'text' as const,
text: `Error: question "${q.id}" sets sensitive=true but kind="${q.kind}". Only kind="text" answers can be vaulted as secrets.`,
},
],
isError: true,
};
}
}

const ids = new Set<string>();
Expand All @@ -978,6 +1043,37 @@ export async function createWizardToolsServer(options: WizardToolsOptions) {

try {
const answers = await askBridge.request({ questions: args.questions });

// For any question marked sensitive, move the raw answer into the
// vault and replace it with an opaque ref before returning to the
// agent — so the secret never enters the LLM conversation.
const sensitiveById = new Map(
args.questions
.filter((q) => q.sensitive)
.map((q) => [q.id, q.prompt]),
);
const sanitised: Record<
string,
string | string[] | { secretRef: string }
> = {};
for (const [id, answer] of Object.entries(answers)) {
const label = sensitiveById.get(id);
if (
label !== undefined &&
typeof answer === 'string' &&
answer !== '__cancelled__'
) {
const ref = secretVault.put(answer, {
label,
source: 'wizard_ask',
});
sanitised[id] = { secretRef: ref };
logToFile(`wizard_ask: vaulted answer for "${id}" as ${ref}`);
} else {
sanitised[id] = answer;
}
}

logToFile(
`wizard_ask: resolved ${Object.keys(answers).length} answer(s) for ${
args.questions.length
Expand All @@ -987,7 +1083,7 @@ export async function createWizardToolsServer(options: WizardToolsOptions) {
content: [
{
type: 'text' as const,
text: JSON.stringify({ answers }, null, 2),
text: JSON.stringify({ answers: sanitised }, null, 2),
},
],
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Source-maps learn-deck. Delegates to the generic skill deck — the
* workflow doesn't yet have content unique enough to justify a
* dedicated script.
*/

export { getContentBlocks } from '../../agent-skill/content/index.js';
Loading