diff --git a/README.md b/README.md index 9987d3b..05f510b 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,63 @@ planeteer list 3. **Refine** — Navigate the task tree, edit details, or type refinement requests (e.g., "split the auth task into login and signup"). Press `s` to save, `x` to execute. 4. **Execute** — Tasks are dispatched to Copilot agents in parallel batches that respect the dependency graph. Progress is shown in real time. +### Environment Variables + +Planeteer supports configuring environment variables that are available to Copilot agents during task execution. This is useful when agents need access to external services via MCP (Model Context Protocol) tools that require API keys, database URLs, or other configuration. + +#### Global Environment Variables + +Global environment variables apply to all tasks in all plans. Configure them in `.planeteer/settings.json`: + +```json +{ + "model": "claude-sonnet-4", + "globalEnv": { + "DATABASE_URL": "postgresql://localhost:5432/dev", + "LOG_LEVEL": "info" + } +} +``` + +#### Task-Specific Environment Variables + +Individual tasks can have their own environment variables. These override global variables with the same name. To configure task-specific env vars: + +1. In the Refine screen, press `/` then `e` to edit a task +2. Navigate to the "Environment Variables" field +3. Press Enter to edit +4. Enter variables as comma-separated `KEY=VALUE` pairs: + ``` + API_KEY=sk-test-123, REGION=us-west-2 + ``` + +Task-specific environment variables are saved in the plan JSON file. + +#### Security Considerations + +⚠️ **Important**: Environment variables containing sensitive data (API keys, passwords, tokens) are stored in plain text in plan files. + +**Best practices:** +- Use global env vars in `.planeteer/settings.json` for sensitive values (add `.planeteer/` to `.gitignore`) +- For production deployments, use environment variables set at the system level instead of storing them in plans +- Planeteer will warn you when saving plans with environment variables that appear sensitive (contain "key", "token", "password", etc.) +- Sensitive values are masked with `***` in the task editor UI + +#### How It Works + +When a task executes: +1. Global environment variables from settings are loaded +2. Task-specific environment variables override globals with the same name +3. All variables are set in `process.env` before creating the Copilot agent session +4. MCP servers spawned by the Copilot CLI inherit these environment variables (requires Copilot SDK 0.1.25+ with `envValueMode: direct` support) + +#### Example Use Cases + +- **Database access**: Pass `DATABASE_URL` to agents that need to query or migrate databases +- **API integration**: Provide `API_KEY` for agents using external APIs via MCP tools +- **Multi-environment**: Use different `ENV=development|staging|production` values per task +- **Cloud providers**: Pass `AWS_REGION`, `AZURE_SUBSCRIPTION_ID`, etc. for cloud infrastructure tasks + ### Keyboard Shortcuts | Key | Action | @@ -181,6 +238,7 @@ src/ │ └── plan.ts # Types: Plan, Task, ChatMessage └── utils/ ├── dependency-graph.ts # Topological sort & cycle detection + ├── env-validation.ts # Environment variable security checks └── markdown.ts # Plan → Markdown renderer ``` diff --git a/package-lock.json b/package-lock.json index 90ab401..aff08b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "planeteer", "version": "0.1.0", "dependencies": { - "@github/copilot-sdk": "^0.1.0", + "@github/copilot-sdk": "^0.1.25", "ink": "^5.1.0", "ink-select-input": "^6.0.0", "ink-spinner": "^5.0.0", @@ -642,26 +642,26 @@ } }, "node_modules/@github/copilot": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.403.tgz", - "integrity": "sha512-v5jUdtGJReLmE1rmff/LZf+50nzmYQYAaSRNtVNr9g0j0GkCd/noQExe31i1+PudvWU0ZJjltR0B8pUfDRdA9Q==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.411.tgz", + "integrity": "sha512-I3/7gw40Iu1O+kTyNPKJHNqDRyOebjsUW6wJsvSVrOpT0TNa3/lfm8xdS2XUuJWkp+PgEG/PRwF7u3DVNdP7bQ==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "0.0.403", - "@github/copilot-darwin-x64": "0.0.403", - "@github/copilot-linux-arm64": "0.0.403", - "@github/copilot-linux-x64": "0.0.403", - "@github/copilot-win32-arm64": "0.0.403", - "@github/copilot-win32-x64": "0.0.403" + "@github/copilot-darwin-arm64": "0.0.411", + "@github/copilot-darwin-x64": "0.0.411", + "@github/copilot-linux-arm64": "0.0.411", + "@github/copilot-linux-x64": "0.0.411", + "@github/copilot-win32-arm64": "0.0.411", + "@github/copilot-win32-x64": "0.0.411" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.403.tgz", - "integrity": "sha512-dOw8IleA0d1soHnbr/6wc6vZiYWNTKMgfTe/NET1nCfMzyKDt/0F0I7PT5y+DLujJknTla/ZeEmmBUmliTW4Cg==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.411.tgz", + "integrity": "sha512-dtr+iHxTS4f8HlV2JT9Fp0FFoxuiPWCnU3XGmrHK+rY6bX5okPC2daU5idvs77WKUGcH8yHTZtfbKYUiMxKosw==", "cpu": [ "arm64" ], @@ -675,9 +675,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.403.tgz", - "integrity": "sha512-aK2jSNWgY8eiZ+TmrvGhssMCPDTKArc0ip6Ul5OaslpytKks8hyXoRbxGD0N9sKioSUSbvKUf+1AqavbDpJO+w==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.411.tgz", + "integrity": "sha512-zhdbQCbPi1L4iHClackSLx8POfklA+NX9RQLuS48HlKi/0KI/JlaDA/bdbIeMR79wjif5t9gnc/m+RTVmHlRtA==", "cpu": [ "x64" ], @@ -691,9 +691,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.403.tgz", - "integrity": "sha512-KhoR2iR70O6vCkzf0h8/K+p82qAgOvMTgAPm9bVEHvbdGFR7Py9qL5v03bMbPxsA45oNaZAkzDhfTAqWhIAZsQ==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.411.tgz", + "integrity": "sha512-oZYZ7oX/7O+jzdTUcHkfD1A8YnNRW6mlUgdPjUg+5rXC43bwIdyatAnc0ObY21m9h8ghxGqholoLhm5WnGv1LQ==", "cpu": [ "arm64" ], @@ -707,9 +707,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.403.tgz", - "integrity": "sha512-eoswUc9vo4TB+/9PgFJLVtzI4dPjkpJXdCsAioVuoqPdNxHxlIHFe9HaVcqMRZxUNY1YHEBZozy+IpUEGjgdfQ==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.411.tgz", + "integrity": "sha512-nnXrKANmmGnkwa3ROlKdAhVNOx8daeMSE8Xh0o3ybKckFv4s38blhKdcxs0RJQRxgAk4p7XXGlDDKNRhurqF1g==", "cpu": [ "x64" ], @@ -723,23 +723,23 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "0.1.22", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.1.22.tgz", - "integrity": "sha512-ZGOEBmYOfu/vLXKjjoiw4lO3Cb8QBUuAWXcW/qzmPPsM9+Qe00qVr2AuDTU/Gft9Dm/yZcPK2QuTZc7LVeom9w==", + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.1.25.tgz", + "integrity": "sha512-hIgYLPXzWw9bNgrsD5BLKmgVH20ow5Or5UyVXfVe3YgeiaTgFxC4jWSAVHLGB6ufHZUrvbjppcq2dWK63FmDRA==", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.403", + "@github/copilot": "^0.0.411", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@github/copilot-win32-arm64": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.403.tgz", - "integrity": "sha512-djWjzCsp2xPNafMyOZ/ivU328/WvWhdroGie/DugiJBTgQL2SP0quWW1fhTlDwE81a3g9CxfJonaRgOpFTJTcg==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.411.tgz", + "integrity": "sha512-h+Bovb2YVCQSeELZOO7zxv8uht45XHcvAkFbRsc1gf9dl109sSUJIcB4KAhs8Aznk28qksxz7kvdSgUWyQBlIA==", "cpu": [ "arm64" ], @@ -753,9 +753,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.403.tgz", - "integrity": "sha512-lju8cHy2E6Ux7R7tWyLZeksYC2MVZu9i9ocjiBX/qfG2/pNJs7S5OlkwKJ0BSXSbZEHQYq7iHfEWp201bVfk9A==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.411.tgz", + "integrity": "sha512-xmOgi1lGvUBHQJWmq5AK1EP95+Y8xR4TFoK9OCSOaGbQ+LFcX2jF7iavnMolfWwddabew/AMQjsEHlXvbgMG8Q==", "cpu": [ "x64" ], @@ -1215,7 +1215,6 @@ "integrity": "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1233,7 +1232,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1360,7 +1358,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1833,7 +1830,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2224,7 +2220,6 @@ "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.1.3", "ansi-escapes": "^7.0.0", @@ -2730,7 +2725,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2811,7 +2805,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, diff --git a/package.json b/package.json index f6e9673..43e9428 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "test:watch": "vitest" }, "dependencies": { - "@github/copilot-sdk": "^0.1.0", + "@github/copilot-sdk": "^0.1.25", "ink": "^5.1.0", "ink-select-input": "^6.0.0", "ink-spinner": "^5.0.0", diff --git a/src/components/task-editor.tsx b/src/components/task-editor.tsx index 26b737a..58aecf3 100644 --- a/src/components/task-editor.tsx +++ b/src/components/task-editor.tsx @@ -3,13 +3,14 @@ import { Box, Text, useInput } from 'ink'; import TextInput from 'ink-text-input'; import type { Task } from '../models/plan.js'; -type EditField = 'title' | 'description' | 'acceptance' | 'dependsOn'; +type EditField = 'title' | 'description' | 'acceptance' | 'dependsOn' | 'env'; const FIELDS: { key: EditField; label: string }[] = [ { key: 'title', label: 'Title' }, { key: 'description', label: 'Description' }, { key: 'acceptance', label: 'Acceptance Criteria' }, { key: 'dependsOn', label: 'Dependencies' }, + { key: 'env', label: 'Environment Variables' }, ]; interface TaskEditorProps { @@ -29,6 +30,7 @@ export default function TaskEditor({ task, allTaskIds, onSave, onCancel }: TaskE const [description, setDescription] = useState(task.description); const [acceptanceCriteria, setAcceptanceCriteria] = useState([...task.acceptanceCriteria]); const [dependsOn, setDependsOn] = useState([...task.dependsOn]); + const [env, setEnv] = useState>(task.env || {}); // For acceptance criteria editing const [acIndex, setAcIndex] = useState(0); @@ -66,6 +68,11 @@ export default function TaskEditor({ task, allTaskIds, onSave, onCancel }: TaskE } else if (field === 'dependsOn') { setEditValue(dependsOn.join(', ')); setEditing(true); + } else if (field === 'env') { + // Format env vars as KEY=VALUE pairs, one per line + const envStr = Object.entries(env).map(([k, v]) => `${k}=${v}`).join(', '); + setEditValue(envStr); + setEditing(true); } return; } @@ -108,6 +115,7 @@ export default function TaskEditor({ task, allTaskIds, onSave, onCancel }: TaskE let newDescription = description; let newAcceptanceCriteria = acceptanceCriteria; let newDependsOn = dependsOn; + let newEnv = env; if (field === 'title') { newTitle = value; @@ -137,6 +145,21 @@ export default function TaskEditor({ task, allTaskIds, onSave, onCancel }: TaskE .filter((d) => d && allTaskIds.includes(d) && d !== task.id); newDependsOn = deps; setDependsOn(deps); + } else if (field === 'env') { + // Parse KEY=VALUE pairs separated by commas + const pairs = value + .split(',') + .map((pair) => pair.trim()) + .filter((pair) => pair.includes('=')); + const parsed: Record = {}; + for (const pair of pairs) { + const [key, ...valueParts] = pair.split('='); + if (key && valueParts.length > 0) { + parsed[key.trim()] = valueParts.join('=').trim(); + } + } + newEnv = parsed; + setEnv(parsed); } setEditing(false); @@ -149,6 +172,7 @@ export default function TaskEditor({ task, allTaskIds, onSave, onCancel }: TaskE description: newDescription, acceptanceCriteria: newAcceptanceCriteria, dependsOn: newDependsOn, + env: Object.keys(newEnv).length > 0 ? newEnv : undefined, }); }; @@ -245,6 +269,37 @@ export default function TaskEditor({ task, allTaskIds, onSave, onCancel }: TaskE ); } + if (field.key === 'env') { + // Helper to mask sensitive values + const maskValue = (key: string, value: string): string => { + const sensitiveKeys = ['key', 'token', 'password', 'secret', 'auth', 'credential']; + const isSensitive = sensitiveKeys.some(k => key.toLowerCase().includes(k)); + return isSensitive ? '***' : value; + }; + + const envEntries = Object.entries(env); + return ( + + {indicator} + {field.label}: + {editing && isActive ? ( + + ) : ( + + {envEntries.length > 0 + ? envEntries.map(([k, v]) => `${k}=${maskValue(k, v)}`).join(', ') + : '(none)'} + + )} + + ); + } + return null; })} diff --git a/src/models/plan.ts b/src/models/plan.ts index d015e8a..056377c 100644 --- a/src/models/plan.ts +++ b/src/models/plan.ts @@ -8,6 +8,7 @@ export interface Task { dependsOn: string[]; status: TaskStatus; agentResult?: string; + env?: Record; } export interface Plan { diff --git a/src/services/copilot.test.ts b/src/services/copilot.test.ts new file mode 100644 index 0000000..df5afd8 --- /dev/null +++ b/src/services/copilot.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { getGlobalEnv, setGlobalEnv } from './copilot.js'; + +describe('Environment Variable Management', () => { + let originalEnv: Record; + + beforeEach(() => { + // Save original global env + originalEnv = getGlobalEnv(); + }); + + afterEach(() => { + // Restore original global env + setGlobalEnv(originalEnv); + }); + + it('should get and set global environment variables', () => { + const testEnv = { TEST_VAR: 'test_value', ANOTHER_VAR: 'another_value' }; + setGlobalEnv(testEnv); + const retrieved = getGlobalEnv(); + expect(retrieved).toEqual(testEnv); + }); + + it('should return a copy of global env, not the original', () => { + const testEnv = { TEST_VAR: 'test_value' }; + setGlobalEnv(testEnv); + const retrieved = getGlobalEnv(); + retrieved.MODIFIED = 'new_value'; + const secondRetrieval = getGlobalEnv(); + expect(secondRetrieval).toEqual(testEnv); + expect(secondRetrieval).not.toHaveProperty('MODIFIED'); + }); + + it('should handle empty environment', () => { + setGlobalEnv({}); + const retrieved = getGlobalEnv(); + expect(retrieved).toEqual({}); + }); +}); diff --git a/src/services/copilot.ts b/src/services/copilot.ts index b691e58..991889b 100644 --- a/src/services/copilot.ts +++ b/src/services/copilot.ts @@ -8,6 +8,7 @@ const SETTINGS_PATH = join(process.cwd(), '.planeteer', 'settings.json'); interface Settings { model?: string; + globalEnv?: Record; } async function loadSettings(): Promise { @@ -59,6 +60,20 @@ export function setModel(model: string): void { saveSettings({ model }).catch(() => {}); } +let globalEnv: Record = {}; + +export function getGlobalEnv(): Record { + return { ...globalEnv }; +} + +export function setGlobalEnv(env: Record): void { + globalEnv = { ...env }; + // Load current settings and merge with new env + loadSettings().then(settings => { + saveSettings({ ...settings, globalEnv: env }).catch(() => {}); + }).catch(() => {}); +} + /** Load persisted model preference. Call once at startup. */ export async function loadModelPreference(): Promise { if (settingsLoaded) return; @@ -67,6 +82,9 @@ export async function loadModelPreference(): Promise { if (settings.model) { currentModel = settings.model; } + if (settings.globalEnv) { + globalEnv = settings.globalEnv; + } } export function getModelLabel(): string { @@ -110,11 +128,24 @@ export interface StreamCallbacks { onError: (error: Error) => void; } +export interface SendPromptOptions { + env?: Record; +} + export async function sendPrompt( systemPrompt: string, messages: ChatMessage[], callbacks: StreamCallbacks, + options?: SendPromptOptions, ): Promise { + // Set environment variables for this session if provided + // These will be available to MCP servers spawned by the Copilot CLI + if (options?.env) { + for (const [key, value] of Object.entries(options.env)) { + process.env[key] = value; + } + } + let copilot: CopilotClient; try { copilot = await getClient(); @@ -176,7 +207,7 @@ export async function sendPrompt( export async function sendPromptSync( systemPrompt: string, messages: ChatMessage[], - options?: { timeoutMs?: number; onDelta?: (delta: string, fullText: string) => void }, + options?: { timeoutMs?: number; onDelta?: (delta: string, fullText: string) => void; env?: Record }, ): Promise { const idleTimeoutMs = options?.timeoutMs ?? 120_000; const onDelta = options?.onDelta; @@ -235,6 +266,6 @@ export async function sendPromptSync( reject(err); } }, - }); + }, options?.env ? { env: options.env } : undefined); }); } diff --git a/src/services/executor.ts b/src/services/executor.ts index 1edf492..2be30f4 100644 --- a/src/services/executor.ts +++ b/src/services/executor.ts @@ -1,6 +1,7 @@ import type { Plan, Task } from '../models/plan.js'; import { sendPromptSync } from './copilot.js'; import { getReadyTasks } from '../utils/dependency-graph.js'; +import { getGlobalEnv } from './copilot.js'; export interface ExecutionCallbacks { onTaskStart: (taskId: string) => void; @@ -106,12 +107,17 @@ export function executePlan( try { const prompt = buildTaskPrompt(task, updatedPlan, codebaseContext); + + // Merge global env vars with task-specific env vars (task-specific takes precedence) + const env = { ...getGlobalEnv(), ...task.env }; + const result = await sendPromptSync(EXECUTOR_SYSTEM_PROMPT, [ { role: 'user', content: prompt }, ], { onDelta: (delta, fullText) => { callbacks.onTaskDelta(task.id, delta, fullText); }, + env: Object.keys(env).length > 0 ? env : undefined, }); taskInPlan.status = 'done'; taskInPlan.agentResult = result; diff --git a/src/services/persistence.ts b/src/services/persistence.ts index 78e59c6..1db0742 100644 --- a/src/services/persistence.ts +++ b/src/services/persistence.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; import { existsSync } from 'node:fs'; import type { Plan } from '../models/plan.js'; import { planToMarkdown, planToSummaryMarkdown } from '../utils/markdown.js'; +import { validateEnvVars } from '../utils/env-validation.js'; const PLAN_DIR = '.planeteer'; @@ -18,8 +19,25 @@ export async function savePlan(plan: Plan): Promise { const dir = await ensureDir(); plan.updatedAt = new Date().toISOString(); + // Check for sensitive environment variables + const allEnvWarnings: string[] = []; + for (const task of plan.tasks) { + if (task.env) { + const warnings = validateEnvVars(task.env); + allEnvWarnings.push(...warnings); + } + } + + // Create a plan object with optional warning comment + const planWithWarning = allEnvWarnings.length > 0 + ? { + _warning: 'This plan contains environment variables. Do not commit sensitive values (API keys, passwords, tokens) to version control.', + ...plan, + } + : plan; + const jsonPath = join(dir, `${plan.id}.json`); - await writeFile(jsonPath, JSON.stringify(plan, null, 2), 'utf-8'); + await writeFile(jsonPath, JSON.stringify(planWithWarning, null, 2), 'utf-8'); const mdPath = join(dir, `${plan.id}.md`); await writeFile(mdPath, planToMarkdown(plan), 'utf-8'); diff --git a/src/utils/env-validation.test.ts b/src/utils/env-validation.test.ts new file mode 100644 index 0000000..55570d6 --- /dev/null +++ b/src/utils/env-validation.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { isSensitiveEnvKey, validateEnvVars } from './env-validation.js'; + +describe('isSensitiveEnvKey', () => { + it('should detect sensitive keys', () => { + expect(isSensitiveEnvKey('API_KEY')).toBe(true); + expect(isSensitiveEnvKey('api_key')).toBe(true); + expect(isSensitiveEnvKey('PASSWORD')).toBe(true); + expect(isSensitiveEnvKey('DB_PASSWORD')).toBe(true); + expect(isSensitiveEnvKey('AUTH_TOKEN')).toBe(true); + expect(isSensitiveEnvKey('SECRET_KEY')).toBe(true); + expect(isSensitiveEnvKey('PRIVATE_KEY')).toBe(true); + }); + + it('should not flag non-sensitive keys', () => { + expect(isSensitiveEnvKey('DATABASE_URL')).toBe(false); + expect(isSensitiveEnvKey('LOG_LEVEL')).toBe(false); + expect(isSensitiveEnvKey('PORT')).toBe(false); + expect(isSensitiveEnvKey('NODE_ENV')).toBe(false); + }); +}); + +describe('validateEnvVars', () => { + it('should return warnings for sensitive keys', () => { + const env = { + API_KEY: 'sk-123', + DATABASE_URL: 'postgresql://localhost', + }; + const warnings = validateEnvVars(env); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain('API_KEY'); + expect(warnings[0]).toContain('sensitive'); + }); + + it('should return no warnings for non-sensitive keys', () => { + const env = { + LOG_LEVEL: 'info', + PORT: '3000', + }; + const warnings = validateEnvVars(env); + expect(warnings.length).toBe(0); + }); + + it('should handle empty env object', () => { + const warnings = validateEnvVars({}); + expect(warnings.length).toBe(0); + }); +}); diff --git a/src/utils/env-validation.ts b/src/utils/env-validation.ts new file mode 100644 index 0000000..e969756 --- /dev/null +++ b/src/utils/env-validation.ts @@ -0,0 +1,35 @@ +/** + * Checks if an environment variable key appears to contain sensitive information. + * Used to warn users when they might be storing secrets in plan files. + */ +export function isSensitiveEnvKey(key: string): boolean { + const sensitivePatterns = [ + 'key', + 'token', + 'password', + 'secret', + 'auth', + 'credential', + 'api_key', + 'apikey', + 'private', + 'pass', + ]; + const lowerKey = key.toLowerCase(); + return sensitivePatterns.some(pattern => lowerKey.includes(pattern)); +} + +/** + * Validates environment variable configuration and returns warnings if any. + */ +export function validateEnvVars(env: Record): string[] { + const warnings: string[] = []; + + for (const key of Object.keys(env)) { + if (isSensitiveEnvKey(key)) { + warnings.push(`Environment variable "${key}" appears to contain sensitive data. Avoid committing secrets to version control.`); + } + } + + return warnings; +}