diff --git a/.claude/commands/analysis.md b/.claude/commands/analysis.md index bda6b75b0..f886e1fa5 100644 --- a/.claude/commands/analysis.md +++ b/.claude/commands/analysis.md @@ -1 +1 @@ -Review the most recent contents of /tmp/posthog-wizard.log. Look for \ No newline at end of file +Review the most recent contents of /tmp/posthog-wizard.log. The log may be quite substantial, so prepare to tail or search it. Look for \ No newline at end of file diff --git a/src/lib/agent-interface.ts b/src/lib/agent-interface.ts index 2c315fedc..8c36715cf 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent-interface.ts @@ -14,6 +14,7 @@ import { } from './constants'; import { getLlmGatewayUrlFromHost } from '../utils/urls'; import { LINTING_TOOLS } from './safe-tools'; +import { createEnvFileServer, ENV_FILE_TOOL_NAMES } from './env-file-tools'; // Dynamic import cache for ESM module let _sdkModule: any = null; @@ -191,7 +192,38 @@ export function wizardCanUseTool( ): | { behavior: 'allow'; updatedInput: Record } | { behavior: 'deny'; message: string } { - // Allow all non-Bash tools + // Block direct reads/writes of .env files — use env-file-tools MCP instead + if (toolName === 'Read' || toolName === 'Write' || toolName === 'Edit') { + const filePath = typeof input.file_path === 'string' ? input.file_path : ''; + const basename = path.basename(filePath); + if (basename.startsWith('.env')) { + logToFile(`Denying ${toolName} on env file: ${filePath}`); + return { + behavior: 'deny', + message: `Direct ${toolName} of ${basename} is not allowed. Use the env-file-tools MCP server (check_env_keys / set_env_values) to read or modify environment variables.`, + }; + } + return { behavior: 'allow', updatedInput: input }; + } + + // Block Grep when it directly targets a .env file. + // Note: ripgrep skips dotfiles (like .env*) by default during directory traversal, + // so broad searches like `Grep { path: "." }` are already safe. + if (toolName === 'Grep') { + const grepPath = typeof input.path === 'string' ? input.path : ''; + if (grepPath && path.basename(grepPath).startsWith('.env')) { + logToFile(`Denying Grep on env file: ${grepPath}`); + return { + behavior: 'deny', + message: `Grep on ${path.basename( + grepPath, + )} is not allowed. Use the env-file-tools MCP server (check_env_keys) to check environment variables.`, + }; + } + return { behavior: 'allow', updatedInput: input }; + } + + // Allow all other non-Bash tools if (toolName !== 'Bash') { return { behavior: 'allow', updatedInput: input }; } @@ -291,10 +323,10 @@ export function wizardCanUseTool( /** * Initialize agent configuration for the LLM gateway */ -export function initializeAgent( +export async function initializeAgent( config: AgentConfig, options: WizardOptions, -): AgentRunConfig { +): Promise { // Initialize log file for this run initLogFile(); logToFile('Agent initialization starting'); @@ -330,6 +362,10 @@ export function initializeAgent( ), }; + // Add in-process env file tools (secret values never leave the machine) + const envFileServer = await createEnvFileServer(config.workingDirectory); + mcpServers['env-file-tools'] = envFileServer; + const agentRunConfig: AgentRunConfig = { workingDirectory: config.workingDirectory, mcpServers, @@ -485,6 +521,7 @@ export async function runAgent( 'Bash', 'ListMcpResourcesTool', 'Skill', + ...ENV_FILE_TOOL_NAMES, ]; const response = query({ diff --git a/src/lib/agent-runner.ts b/src/lib/agent-runner.ts index d319df22e..46ed98bc4 100644 --- a/src/lib/agent-runner.ts +++ b/src/lib/agent-runner.ts @@ -85,7 +85,7 @@ export async function runAgentWizard( } clack.log.info( - `🧙 The wizard has chosen you to try the next-generation agent integration for ${config.metadata.name}.\n\nStand by for the good stuff, and let the robot minders know how it goes:\n\nwizard@posthog.com`, + `We're about to read your project using our LLM gateway.\n\n.env* file contents will not leave your machine.\n\nOther files will be read and edited to provide a fully-custom PostHog integration.`, ); const aiConsent = await askForAIConsent(options); @@ -182,7 +182,7 @@ export async function runAgentWizard( ? 'https://mcp-eu.posthog.com/mcp' : 'https://mcp.posthog.com/mcp'); - const agent = initializeAgent( + const agent = await initializeAgent( { workingDirectory: options.installDir, posthogMcpUrl: mcpUrl, @@ -404,9 +404,12 @@ STEP 4: Load the installed skill's SKILL.md file to understand what references a STEP 5: Follow the skill's workflow files in sequence. Look for numbered workflow files in the references (e.g., files with patterns like "1.0-", "1.1-", "1.2-"). Start with the first one and proceed through each step until completion. Each workflow file will tell you what to do and which file comes next. -STEP 6: Set up environment variables for PostHog in a .env file with the API key and host provided above, using the appropriate naming convention for ${ - config.metadata.name - }. Make sure to use these environment variables in the code files you create instead of hardcoding the API key and host. +STEP 6: Set up environment variables for PostHog using the env-file-tools MCP server (this runs locally — secret values never leave the machine): + - Use check_env_keys to see which keys already exist in the project's .env file (e.g. .env.local or .env). + - Use set_env_values to create or update the PostHog API key and host, using the appropriate naming convention for ${ + config.metadata.name + }. The tool will also ensure .gitignore coverage. Don't assume the presence of keys means the value is up to date. Write the correct value each time. + - Reference these environment variables in the code files you create instead of hardcoding the API key and host. Important: Look for lockfiles (pnpm-lock.yaml, package-lock.json, yarn.lock, bun.lockb) to determine the package manager (excluding the contents of node_modules). Do not manually edit package.json. Always install packages as a background task. Don't await completion; proceed with other work immediately after starting the installation. You must read a file immediately before attempting to write it, even if you have previously read it; failure to do so will cause a tool failure. diff --git a/src/lib/env-file-tools.ts b/src/lib/env-file-tools.ts new file mode 100644 index 000000000..6087b7866 --- /dev/null +++ b/src/lib/env-file-tools.ts @@ -0,0 +1,191 @@ +/** + * In-process MCP server that reads/writes .env files locally. + * Secret values never leave the machine. + */ + +import path from 'path'; +import fs from 'fs'; +import { z } from 'zod'; +import { logToFile } from '../utils/debug'; + +// Dynamic import cache for ESM module (same pattern as agent-interface.ts) +let _sdkModule: any = null; +async function getSDKModule(): Promise { + if (!_sdkModule) { + _sdkModule = await import('@anthropic-ai/claude-agent-sdk'); + } + return _sdkModule; +} + +/** + * Resolve filePath relative to workingDirectory, rejecting path traversal. + */ +function resolveEnvPath(workingDirectory: string, filePath: string): string { + const resolved = path.resolve(workingDirectory, filePath); + if ( + !resolved.startsWith(workingDirectory + path.sep) && + resolved !== workingDirectory + ) { + throw new Error( + `Path traversal rejected: "${filePath}" resolves outside working directory`, + ); + } + return resolved; +} + +/** + * Ensure the given env file basename is covered by .gitignore in the working directory. + * Creates .gitignore if it doesn't exist; appends the entry if missing. + */ +function ensureGitignoreCoverage( + workingDirectory: string, + envFileName: string, +): void { + const gitignorePath = path.join(workingDirectory, '.gitignore'); + + if (fs.existsSync(gitignorePath)) { + const content = fs.readFileSync(gitignorePath, 'utf8'); + // Check if the file (or a glob covering it) is already listed + if (content.split('\n').some((line) => line.trim() === envFileName)) { + return; + } + const newContent = content.endsWith('\n') + ? `${content}${envFileName}\n` + : `${content}\n${envFileName}\n`; + fs.writeFileSync(gitignorePath, newContent, 'utf8'); + } else { + fs.writeFileSync(gitignorePath, `${envFileName}\n`, 'utf8'); + } +} + +/** + * Create an in-process MCP server with env file tools. + * Must be called asynchronously because the SDK is an ESM module loaded via dynamic import. + */ +export async function createEnvFileServer(workingDirectory: string) { + const sdk = await getSDKModule(); + const { tool, createSdkMcpServer } = sdk; + + const checkEnvKeys = tool( + 'check_env_keys', + 'Check which environment variable keys are present or missing in a .env file. Never reveals values.', + { + filePath: z + .string() + .describe('Path to the .env file, relative to the project root'), + keys: z + .array(z.string()) + .describe('Environment variable key names to check'), + }, + (args: { filePath: string; keys: string[] }) => { + const resolved = resolveEnvPath(workingDirectory, args.filePath); + logToFile(`check_env_keys: ${resolved}, keys: ${args.keys.join(', ')}`); + + const existingKeys: Set = new Set(); + if (fs.existsSync(resolved)) { + const content = fs.readFileSync(resolved, 'utf8'); + for (const line of content.split('\n')) { + const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/); + if (match) { + existingKeys.add(match[1]); + } + } + } + + const results: Record = {}; + for (const key of args.keys) { + results[key] = existingKeys.has(key) ? 'present' : 'missing'; + } + + return { + content: [ + { type: 'text' as const, text: JSON.stringify(results, null, 2) }, + ], + }; + }, + ); + + 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.', + { + 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'), + }, + (args: { filePath: string; values: Record }) => { + const resolved = resolveEnvPath(workingDirectory, args.filePath); + logToFile( + `set_env_values: ${resolved}, keys: ${Object.keys(args.values).join( + ', ', + )}`, + ); + + let content = ''; + if (fs.existsSync(resolved)) { + content = fs.readFileSync(resolved, 'utf8'); + } + + const updatedKeys = new Set(); + + for (const [key, value] of Object.entries(args.values)) { + const regex = new RegExp(`^(\\s*${key}\\s*=).*$`, 'm'); + if (regex.test(content)) { + content = content.replace(regex, `$1${value}`); + updatedKeys.add(key); + } + } + + // Append keys that weren't already in the file + const newKeys = Object.entries(args.values).filter( + ([key]) => !updatedKeys.has(key), + ); + if (newKeys.length > 0) { + if (content.length > 0 && !content.endsWith('\n')) { + content += '\n'; + } + for (const [key, value] of newKeys) { + content += `${key}=${value}\n`; + } + } + + // Ensure parent directory exists + const dir = path.dirname(resolved); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(resolved, content, 'utf8'); + + // Ensure .gitignore coverage for this env file + const envFileName = path.basename(resolved); + ensureGitignoreCoverage(workingDirectory, envFileName); + + return { + content: [ + { + type: 'text' as const, + text: `Updated ${Object.keys(args.values).length} key(s) in ${ + args.filePath + }`, + }, + ], + }; + }, + ); + + return createSdkMcpServer({ + name: 'env-file-tools', + version: '1.0.0', + tools: [checkEnvKeys, setEnvValues], + }); +} + +/** Tool names exposed by the env file server, for use in allowedTools */ +export const ENV_FILE_TOOL_NAMES = [ + 'env-file-tools:check_env_keys', + 'env-file-tools:set_env_values', +]; diff --git a/src/utils/clack-utils.ts b/src/utils/clack-utils.ts index 05c045e8b..394344c72 100644 --- a/src/utils/clack-utils.ts +++ b/src/utils/clack-utils.ts @@ -100,12 +100,6 @@ export function printWelcome(options: { // eslint-disable-next-line no-console console.log(''); clack.intro(chalk.inverse(` ${options.wizardName} `)); - - const welcomeText = - options.message || - `The ${options.wizardName} will help you set up PostHog for your application.\nThank you for using PostHog :)`; - - clack.note(welcomeText); } export async function confirmContinueIfNoOrDirtyGitRepo(