diff --git a/CLAUDE.md b/CLAUDE.md index 363086969d..a2b9d8d973 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,10 +198,6 @@ bun run cli workflow run implement --branch feature-auth "Add auth" # Opt out of isolation (run in live checkout) bun run cli workflow run quick-fix --no-worktree "Fix typo" -# Grant env-leak-gate consent during auto-registration (for repos whose .env -# contains sensitive keys). Audit-logged with actor: 'user-cli'. -bun run cli workflow run plan --cwd /path/to/leaky/repo --allow-env-keys "..." - # Show running workflows bun run cli workflow status @@ -768,8 +764,7 @@ Pattern: Use `classifyIsolationError()` (from `@archon/isolation`) to map git er **Codebases:** - `GET /api/codebases` / `GET /api/codebases/:id` - List / fetch codebases -- `POST /api/codebases` - Register a codebase (clone or local path); body accepts `allowEnvKeys` for the env-leak gate -- `PATCH /api/codebases/:id` - Flip the `allow_env_keys` consent bit; body: `{ allowEnvKeys: boolean }`. Audit-logged at `warn` level on every grant/revoke (`env_leak_consent_granted` / `env_leak_consent_revoked`) with `codebaseId`, `path`, `files`, `keys`, `scanStatus`, `actor` +- `POST /api/codebases` - Register a codebase (clone or local path) - `DELETE /api/codebases/:id` - Delete a codebase and clean up resources - `GET /api/codebases/:id/env` - List env var keys for a codebase (never returns values) - `PUT /api/codebases/:id/env` / `DELETE /api/codebases/:id/env/:key` - Upsert / delete a single codebase env var diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d7dedf4810..f64416369c 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -125,9 +125,6 @@ Options: --json Output machine-readable JSON (for workflow list) --workflow Workflow to run for 'continue' (default: archon-assist) --no-context Skip context injection for 'continue' - --allow-env-keys Grant env-key consent during auto-registration - (bypasses the env-leak gate for this codebase; - logs an audit entry) --port Override server port for 'serve' (default: 3090) --download-only Download web UI without starting the server @@ -207,7 +204,6 @@ async function main(): Promise { reason: { type: 'string' }, workflow: { type: 'string' }, 'no-context': { type: 'boolean' }, - 'allow-env-keys': { type: 'boolean' }, port: { type: 'string' }, 'download-only': { type: 'boolean' }, }, @@ -231,8 +227,6 @@ async function main(): Promise { const resumeFlag = values.resume as boolean | undefined; const spawnFlag = values.spawn as boolean | undefined; const jsonFlag = values.json as boolean | undefined; - const allowEnvKeysFlag = values['allow-env-keys'] as boolean | undefined; - // Handle help flag if (values.help) { printUsage(); @@ -344,7 +338,6 @@ async function main(): Promise { fromBranch, noWorktree, resume: resumeFlag, - allowEnvKeys: allowEnvKeysFlag, quiet: values.quiet as boolean | undefined, verbose: values.verbose as boolean | undefined, }; diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index 89dd5911e4..6ba31d1256 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -62,8 +62,6 @@ export interface WorkflowRunOptions { noWorktree?: boolean; resume?: boolean; codebaseId?: string; // Passed by resume/approve to skip path-based lookup - /** When true, skip the env-leak-gate during auto-registration. */ - allowEnvKeys?: boolean; quiet?: boolean; verbose?: boolean; /** Platform conversation ID (e.g. `cli-{ts}-{rand}`), NOT a DB UUID. */ @@ -325,7 +323,7 @@ export async function workflowRunCommand( const repoRoot = await git.findRepoRoot(cwd); if (repoRoot) { try { - const result = await registerRepository(repoRoot, options.allowEnvKeys, 'register-cli'); + const result = await registerRepository(repoRoot); codebase = await codebaseDb.getCodebase(result.codebaseId); if (!result.alreadyExisted) { getLog().info({ name: result.name }, 'cli.codebase_auto_registered'); diff --git a/packages/core/package.json b/packages/core/package.json index 4739c5328f..970b01e4d4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -22,7 +22,7 @@ "./state/*": "./src/state/*.ts" }, "scripts": { - "test": "bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/connection.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/utils/env-leak-scanner.test.ts src/config/ src/state/ && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts", + "test": "bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/connection.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/config/ src/state/ && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts", "type-check": "bun x tsc --noEmit", "build": "echo 'No build needed - Bun runs TypeScript directly'" }, diff --git a/packages/core/src/config/config-loader.ts b/packages/core/src/config/config-loader.ts index 8ee702c613..f0f51ba0a4 100644 --- a/packages/core/src/config/config-loader.ts +++ b/packages/core/src/config/config-loader.ts @@ -38,24 +38,6 @@ function getLog(): ReturnType { return cachedLog; } -/** - * Tracks which env-leak-gate-disabled sources have already warned in this - * process. `loadConfig()` is called once per pre-spawn check (per workflow - * step), so without this guard the warn would flood logs and break alert - * rate-limiting downstream. - */ -const envLeakGateDisabledWarnedSources = new Set<'global_config' | 'repo_config'>(); -function warnEnvLeakGateDisabledOnce(source: 'global_config' | 'repo_config'): void { - if (envLeakGateDisabledWarnedSources.has(source)) return; - envLeakGateDisabledWarnedSources.add(source); - getLog().warn({ source }, 'env_leak_gate_disabled'); -} - -// Test-only: reset the warn-once state so unit tests can re-trigger the log. -export function resetEnvLeakGateWarnedSourcesForTests(): void { - envLeakGateDisabledWarnedSources.clear(); -} - /** * Parse YAML using Bun's native YAML parser */ @@ -216,7 +198,6 @@ function getDefaults(): MergedConfig { loadDefaultCommands: true, loadDefaultWorkflows: true, }, - allowTargetRepoKeys: false, }; } @@ -321,12 +302,6 @@ function mergeGlobalConfig(defaults: MergedConfig, global: GlobalConfig): Merged result.concurrency.maxConversations = global.concurrency.maxConversations; } - // Env-leak gate bypass (global) - if (global.allow_target_repo_keys === true) { - result.allowTargetRepoKeys = true; - warnEnvLeakGateDisabledOnce('global_config'); - } - return result; } @@ -400,14 +375,6 @@ function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig { result.envVars = { ...result.envVars, ...repo.env }; } - // Repo-level env-leak gate override (wins over global) - if (repo.allow_target_repo_keys !== undefined) { - result.allowTargetRepoKeys = repo.allow_target_repo_keys; - if (repo.allow_target_repo_keys) { - warnEnvLeakGateDisabledOnce('repo_config'); - } - } - return result; } diff --git a/packages/core/src/config/config-types.ts b/packages/core/src/config/config-types.ts index 7dd74ac8ba..983720c13b 100644 --- a/packages/core/src/config/config-types.ts +++ b/packages/core/src/config/config-types.ts @@ -74,20 +74,6 @@ export interface GlobalConfig { */ maxConversations?: number; }; - - /** - * Bypass the env-leak gate globally. When true, Archon will not refuse to - * register or spawn subprocesses for codebases whose auto-loaded .env files - * contain sensitive keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc). - * - * WARNING: Weakens the env-leak gate. Keys in the target repo's .env will - * be auto-loaded by Bun subprocesses (Claude/Codex) and bypass Archon's - * env allowlist. Use only on trusted machines. - * - * YAML key: `allow_target_repo_keys` - * @default false - */ - allow_target_repo_keys?: boolean; } /** @@ -162,12 +148,6 @@ export interface RepoConfig { */ env?: Record; - /** - * Per-repo override for the env-leak gate bypass. Repo value wins over global. - * YAML key: `allow_target_repo_keys` - */ - allow_target_repo_keys?: boolean; - /** * Default commands/workflows configuration */ @@ -250,14 +230,6 @@ export interface MergedConfig { * Undefined when no env vars are configured. */ envVars?: Record; - - /** - * Effective value of the env-leak gate bypass. When true, the env scanner - * is skipped during registration and pre-spawn. Repo-level override wins - * over global (explicit `false` at repo level re-enables the gate). - * @default false - */ - allowTargetRepoKeys: boolean; } /** diff --git a/packages/core/src/db/adapters/sqlite.ts b/packages/core/src/db/adapters/sqlite.ts index 2864e4fc43..485706d040 100644 --- a/packages/core/src/db/adapters/sqlite.ts +++ b/packages/core/src/db/adapters/sqlite.ts @@ -215,22 +215,6 @@ export class SqliteAdapter implements IDatabase { } catch (e: unknown) { getLog().warn({ err: e as Error }, 'db.sqlite_migration_session_columns_failed'); } - - // Codebases columns (added in #983 — env-leak gate consent bit) - try { - const cbCols = this.db.prepare("PRAGMA table_info('remote_agent_codebases')").all() as { - name: string; - }[]; - const cbColNames = new Set(cbCols.map(c => c.name)); - - if (!cbColNames.has('allow_env_keys')) { - this.db.run( - 'ALTER TABLE remote_agent_codebases ADD COLUMN allow_env_keys INTEGER DEFAULT 0' - ); - } - } catch (e: unknown) { - getLog().warn({ err: e as Error }, 'db.sqlite_migration_codebases_columns_failed'); - } } /** @@ -252,7 +236,6 @@ export class SqliteAdapter implements IDatabase { default_cwd TEXT NOT NULL, default_branch TEXT DEFAULT 'main', ai_assistant_type TEXT DEFAULT 'claude', - allow_env_keys INTEGER DEFAULT 0, commands TEXT DEFAULT '{}', created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) diff --git a/packages/core/src/db/codebases.test.ts b/packages/core/src/db/codebases.test.ts index ec3c249d14..26c269a085 100644 --- a/packages/core/src/db/codebases.test.ts +++ b/packages/core/src/db/codebases.test.ts @@ -22,7 +22,6 @@ import { findCodebaseByDefaultCwd, findCodebaseByName, updateCodebase, - updateCodebaseAllowEnvKeys, deleteCodebase, } from './codebases'; @@ -37,7 +36,6 @@ describe('codebases', () => { repository_url: 'https://github.com/user/repo', default_cwd: '/workspace/test-project', ai_assistant_type: 'claude', - allow_env_keys: false, commands: { plan: { path: '.claude/commands/plan.md', description: 'Plan feature' } }, created_at: new Date(), updated_at: new Date(), @@ -56,8 +54,8 @@ describe('codebases', () => { expect(result).toEqual(mockCodebase); expect(mockQuery).toHaveBeenCalledWith( - 'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, ai_assistant_type, allow_env_keys) VALUES ($1, $2, $3, $4, $5) RETURNING *', - ['test-project', 'https://github.com/user/repo', '/workspace/test-project', 'claude', false] + 'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, ai_assistant_type) VALUES ($1, $2, $3, $4) RETURNING *', + ['test-project', 'https://github.com/user/repo', '/workspace/test-project', 'claude'] ); }); @@ -75,8 +73,8 @@ describe('codebases', () => { expect(result).toEqual(codebaseWithoutOptional); expect(mockQuery).toHaveBeenCalledWith( - 'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, ai_assistant_type, allow_env_keys) VALUES ($1, $2, $3, $4, $5) RETURNING *', - ['test-project', null, '/workspace/test-project', 'claude', false] + 'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, ai_assistant_type) VALUES ($1, $2, $3, $4) RETURNING *', + ['test-project', null, '/workspace/test-project', 'claude'] ); }); @@ -299,7 +297,6 @@ describe('codebases', () => { name: 'test-repo', default_cwd: '/workspace/test-repo', ai_assistant_type: 'claude', - allow_env_keys: false, repository_url: null, commands: {}, created_at: new Date(), @@ -399,26 +396,6 @@ describe('codebases', () => { }); }); - describe('updateCodebaseAllowEnvKeys', () => { - test('flips the consent bit', async () => { - mockQuery.mockResolvedValueOnce(createQueryResult([], 1)); - - await updateCodebaseAllowEnvKeys('codebase-123', true); - - expect(mockQuery).toHaveBeenCalledWith( - 'UPDATE remote_agent_codebases SET allow_env_keys = $1, updated_at = NOW() WHERE id = $2', - [true, 'codebase-123'] - ); - }); - - test('throws when codebase not found', async () => { - mockQuery.mockResolvedValueOnce(createQueryResult([], 0)); - await expect(updateCodebaseAllowEnvKeys('missing', false)).rejects.toThrow( - 'Codebase missing not found' - ); - }); - }); - describe('deleteCodebase', () => { test('should unlink sessions, conversations, and delete codebase', async () => { // First call: unlink sessions diff --git a/packages/core/src/db/codebases.ts b/packages/core/src/db/codebases.ts index b9f45578b6..f3947fb6c1 100644 --- a/packages/core/src/db/codebases.ts +++ b/packages/core/src/db/codebases.ts @@ -17,13 +17,11 @@ export async function createCodebase(data: { repository_url?: string; default_cwd: string; ai_assistant_type?: string; - allow_env_keys?: boolean; }): Promise { const assistantType = data.ai_assistant_type ?? 'claude'; - const allowEnvKeys = data.allow_env_keys ?? false; const result = await pool.query( - 'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, ai_assistant_type, allow_env_keys) VALUES ($1, $2, $3, $4, $5) RETURNING *', - [data.name, data.repository_url ?? null, data.default_cwd, assistantType, allowEnvKeys] + 'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, ai_assistant_type) VALUES ($1, $2, $3, $4) RETURNING *', + [data.name, data.repository_url ?? null, data.default_cwd, assistantType] ); if (!result.rows[0]) { throw new Error('Failed to create codebase: INSERT succeeded but no row returned'); @@ -158,21 +156,6 @@ export async function updateCodebase( } } -/** - * Flip the `allow_env_keys` consent bit for an existing codebase. - * Throws when the codebase does not exist. - */ -export async function updateCodebaseAllowEnvKeys(id: string, allowEnvKeys: boolean): Promise { - const dialect = getDialect(); - const result = await pool.query( - `UPDATE remote_agent_codebases SET allow_env_keys = $1, updated_at = ${dialect.now()} WHERE id = $2`, - [allowEnvKeys, id] - ); - if ((result.rowCount ?? 0) === 0) { - throw new Error(`Codebase ${id} not found`); - } -} - export async function listCodebases(): Promise { const result = await pool.query( 'SELECT * FROM remote_agent_codebases ORDER BY name ASC' diff --git a/packages/core/src/handlers/clone.test.ts b/packages/core/src/handlers/clone.test.ts index 7f948cfb33..c913c1a78c 100644 --- a/packages/core/src/handlers/clone.test.ts +++ b/packages/core/src/handlers/clone.test.ts @@ -20,7 +20,6 @@ const mockCreateCodebase = mock(() => repository_url: 'https://github.com/owner/repo', default_cwd: '/home/test/.archon/workspaces/owner/repo/source', ai_assistant_type: 'claude', - allow_env_keys: false, commands: {}, created_at: new Date(), updated_at: new Date(), @@ -67,20 +66,6 @@ mock.module('../utils/commands', () => ({ findMarkdownFilesRecursive: mockFindMarkdownFilesRecursive, })); -// ── env-leak-scanner mock ─────────────────────────────────────────────────── -class MockEnvLeakError extends Error { - constructor(public report: unknown) { - super('Cannot add codebase — /test/path contains keys that will leak into AI subprocesses'); - this.name = 'EnvLeakError'; - } -} - -const mockScanPathForSensitiveKeys = mock(() => ({ path: '', findings: [] })); -mock.module('../utils/env-leak-scanner', () => ({ - scanPathForSensitiveKeys: mockScanPathForSensitiveKeys, - EnvLeakError: MockEnvLeakError, -})); - // ── Import module under test AFTER mocks are registered ──────────────────── import { cloneRepository, registerRepository } from './clone'; @@ -118,7 +103,6 @@ function clearMocks(): void { mockFindCodebaseByName.mockReset(); mockUpdateCodebase.mockReset(); mockFindMarkdownFilesRecursive.mockReset(); - mockScanPathForSensitiveKeys.mockReset(); mockLogger.info.mockClear(); mockLogger.debug.mockClear(); mockLogger.warn.mockClear(); @@ -132,7 +116,6 @@ function clearMocks(): void { mockFindCodebaseByName.mockResolvedValue(null); mockUpdateCodebase.mockResolvedValue(undefined); mockFindMarkdownFilesRecursive.mockResolvedValue([]); - mockScanPathForSensitiveKeys.mockReturnValue({ path: '', findings: [] }); } afterAll(() => { @@ -157,7 +140,6 @@ function makeCodebase( repository_url: 'https://github.com/owner/repo', default_cwd: '/home/test/.archon/workspaces/owner/repo/source', ai_assistant_type: 'claude', - allow_env_keys: false, commands: {}, created_at: new Date(), updated_at: new Date(), @@ -948,33 +930,4 @@ describe('RegisterResult shape', () => { expect(result.alreadyExisted).toBe(true); expect(result.commandCount).toBe(0); }); - - describe('env leak gate', () => { - test('throws EnvLeakError when scanner finds sensitive keys and allowEnvKeys is false', async () => { - mockScanPathForSensitiveKeys.mockReturnValueOnce({ - path: '/home/test/.archon/workspaces/owner/repo/source', - findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }], - }); - - await expect(cloneRepository('https://github.com/owner/repo')).rejects.toThrow( - 'Cannot add codebase' - ); - }); - - test('does not throw when allowEnvKeys is true, even with scanner findings present', async () => { - mockCreateCodebase.mockResolvedValueOnce(makeCodebase() as ReturnType); - // Scanner is still called for the audit-log payload (files/keys), but the - // gate must NOT throw — the per-call grant is the bypass. - mockScanPathForSensitiveKeys.mockReturnValueOnce({ - path: '/home/test/.archon/workspaces/owner/repo/source', - findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }], - }); - - const result = await cloneRepository('https://github.com/owner/repo', true); - - expect(result.codebaseId).toBe('codebase-uuid-1'); - // Scanner is called once — for the audit log, not as a gate - expect(mockScanPathForSensitiveKeys).toHaveBeenCalledTimes(1); - }); - }); }); diff --git a/packages/core/src/handlers/clone.ts b/packages/core/src/handlers/clone.ts index 3dc96f499c..fe7e4d9570 100644 --- a/packages/core/src/handlers/clone.ts +++ b/packages/core/src/handlers/clone.ts @@ -16,12 +16,6 @@ import { parseOwnerRepo, } from '@archon/paths'; import { findMarkdownFilesRecursive } from '../utils/commands'; -import { - scanPathForSensitiveKeys, - EnvLeakError, - type LeakErrorContext, -} from '../utils/env-leak-scanner'; -import { loadConfig } from '../config/config-loader'; import { createLogger } from '@archon/paths'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ @@ -46,53 +40,8 @@ export interface RegisterResult { async function registerRepoAtPath( targetPath: string, name: string, - repositoryUrl: string | null, - allowEnvKeys = false, - context: LeakErrorContext = 'register-ui' + repositoryUrl: string | null ): Promise { - // Scan for sensitive keys in auto-loaded .env files before registering. - // Two bypass paths exist (in order of precedence): - // 1. Per-call `allowEnvKeys=true` (Web UI checkbox or CLI --allow-env-keys) - // 2. Config-level `allow_target_repo_keys: true` (global YAML) - // When the per-call bypass is used we still emit an audit-log entry so the - // grant has a permanent breadcrumb (parity with the PATCH route's - // `env_leak_consent_granted` log). - if (!allowEnvKeys) { - const merged = await loadConfig(targetPath); - if (!merged.allowTargetRepoKeys) { - const report = scanPathForSensitiveKeys(targetPath); - if (report.findings.length > 0) { - throw new EnvLeakError(report, context); - } - } - } else { - // Per-call grant — emit audit log mirroring the PATCH route shape so the - // CLI/UI add-with-consent paths leave the same breadcrumbs. - let files: string[] = []; - let keys: string[] = []; - let scanStatus: 'ok' | 'skipped' = 'ok'; - try { - const report = scanPathForSensitiveKeys(targetPath); - files = report.findings.map(f => f.file); - keys = Array.from(new Set(report.findings.flatMap(f => f.keys))); - } catch (scanErr) { - scanStatus = 'skipped'; - getLog().warn({ err: scanErr, path: targetPath }, 'env_leak_consent_scan_skipped'); - } - const actor = context === 'register-cli' ? 'user-cli' : 'user-ui'; - getLog().warn( - { - name, - path: targetPath, - files, - keys, - scanStatus, - actor, - }, - 'env_leak_consent_granted' - ); - } - // Auto-detect assistant type based on folder structure let suggestedAssistant = 'claude'; const codexFolder = join(targetPath, '.codex'); @@ -173,7 +122,6 @@ async function registerRepoAtPath( repository_url: repositoryUrl ?? undefined, default_cwd: targetPath, ai_assistant_type: suggestedAssistant, - allow_env_keys: allowEnvKeys, }); // Auto-load commands if found @@ -242,15 +190,11 @@ function normalizeRepoUrl(rawUrl: string): { * Local paths (starting with /, ~, or .) are delegated to registerRepository * to avoid wrong owner/repo naming. See #383 for broader rethink. */ -export async function cloneRepository( - repoUrl: string, - allowEnvKeys?: boolean, - context: LeakErrorContext = 'register-ui' -): Promise { +export async function cloneRepository(repoUrl: string): Promise { // Local paths should be registered (symlink), not cloned (copied) if (repoUrl.startsWith('/') || repoUrl.startsWith('~') || repoUrl.startsWith('.')) { const resolvedPath = repoUrl.startsWith('~') ? expandTilde(repoUrl) : resolve(repoUrl); - return registerRepository(resolvedPath, allowEnvKeys, context); + return registerRepository(resolvedPath); } const { workingUrl, ownerName, repoName, targetPath } = normalizeRepoUrl(repoUrl); @@ -331,13 +275,7 @@ export async function cloneRepository( await execFileAsync('git', ['config', '--global', '--add', 'safe.directory', targetPath]); getLog().debug({ path: targetPath }, 'safe_directory_added'); - const result = await registerRepoAtPath( - targetPath, - `${ownerName}/${repoName}`, - workingUrl, - allowEnvKeys, - context - ); + const result = await registerRepoAtPath(targetPath, `${ownerName}/${repoName}`, workingUrl); getLog().info({ url: workingUrl, targetPath }, 'clone_completed'); return result; } @@ -345,11 +283,7 @@ export async function cloneRepository( /** * Register an existing local repository in the database (no git clone). */ -export async function registerRepository( - localPath: string, - allowEnvKeys?: boolean, - context: LeakErrorContext = 'register-ui' -): Promise { +export async function registerRepository(localPath: string): Promise { // Validate path exists and is a git repo try { await execFileAsync('git', ['-C', localPath, 'rev-parse', '--git-dir']); @@ -415,5 +349,5 @@ export async function registerRepository( ); // default_cwd is the real local path (not the symlink) - return registerRepoAtPath(localPath, name, remoteUrl, allowEnvKeys, context); + return registerRepoAtPath(localPath, name, remoteUrl); } diff --git a/packages/core/src/handlers/command-handler.test.ts b/packages/core/src/handlers/command-handler.test.ts index 4f29e7247b..de6516cb98 100644 --- a/packages/core/src/handlers/command-handler.test.ts +++ b/packages/core/src/handlers/command-handler.test.ts @@ -511,7 +511,6 @@ describe('CommandHandler', () => { repository_url: 'https://github.com/user/my-repo', default_cwd: '/workspace/my-repo', ai_assistant_type: 'claude', - allow_env_keys: false, commands: {}, created_at: new Date(), updated_at: new Date(), @@ -567,7 +566,6 @@ describe('CommandHandler', () => { repository_url: 'https://github.com/owner/repo', default_cwd: '/workspace/repo', ai_assistant_type: 'claude', - allow_env_keys: false, commands: {}, created_at: new Date(), updated_at: new Date(), @@ -606,7 +604,6 @@ describe('CommandHandler', () => { repository_url: 'https://github.com/owner/orphaned-repo', default_cwd: '/workspace/orphaned-repo', ai_assistant_type: 'claude', - allow_env_keys: false, commands: {}, created_at: new Date(), updated_at: new Date(), @@ -721,7 +718,6 @@ describe('CommandHandler', () => { repository_url: 'https://github.com/user/my-repo', default_cwd: '/workspace/my-repo', ai_assistant_type: 'claude', - allow_env_keys: false, commands: {}, created_at: new Date(), updated_at: new Date(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a0c897481f..8c5e928a98 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -136,15 +136,6 @@ export { toError } from './utils/error'; // Credential sanitization export { sanitizeCredentials, sanitizeError } from './utils/credential-sanitizer'; -// Env leak scanner -export { - EnvLeakError, - scanPathForSensitiveKeys, - formatLeakError, - type LeakReport, - type LeakErrorContext, -} from './utils/env-leak-scanner'; - // GitHub GraphQL export { getLinkedIssueNumbers } from './utils/github-graphql'; diff --git a/packages/core/src/orchestrator/orchestrator-agent.test.ts b/packages/core/src/orchestrator/orchestrator-agent.test.ts index b1e155a8f8..dfde310bbd 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.test.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.test.ts @@ -182,7 +182,6 @@ function makeCodebase(name: string, id = `id-${name}`): Codebase { repository_url: null, default_cwd: `/repos/${name}`, ai_assistant_type: 'claude', - allow_env_keys: false, commands: {}, created_at: new Date(), updated_at: new Date(), @@ -806,7 +805,6 @@ function makeCodebaseForSync() { repository_url: 'https://github.com/test/repo', default_cwd: '/repos/test-repo', ai_assistant_type: 'claude', - allow_env_keys: false, commands: {}, created_at: new Date(), updated_at: new Date(), @@ -972,7 +970,6 @@ describe('workflow dispatch routing — interactive flag', () => { repository_url: null, default_cwd: '/repos/test-repo', ai_assistant_type: 'claude' as const, - allow_env_keys: false, commands: {}, created_at: new Date(), updated_at: new Date(), @@ -1073,7 +1070,6 @@ describe('natural-language approval routing', () => { repository_url: null, default_cwd: '/repos/test-repo', ai_assistant_type: 'claude' as const, - allow_env_keys: false, commands: {}, created_at: new Date(), updated_at: new Date(), diff --git a/packages/core/src/orchestrator/orchestrator-isolation.test.ts b/packages/core/src/orchestrator/orchestrator-isolation.test.ts index 6aabc41597..6bcbedb697 100644 --- a/packages/core/src/orchestrator/orchestrator-isolation.test.ts +++ b/packages/core/src/orchestrator/orchestrator-isolation.test.ts @@ -176,7 +176,6 @@ function makeCodebase(overrides?: Partial): Codebase { id: 'cb-1', name: 'test-repo', default_cwd: '/workspace/test-repo', - allow_env_keys: false, commands: {}, created_at: new Date(), updated_at: new Date(), diff --git a/packages/core/src/orchestrator/orchestrator.test.ts b/packages/core/src/orchestrator/orchestrator.test.ts index 8f99efff64..de4618ed15 100644 --- a/packages/core/src/orchestrator/orchestrator.test.ts +++ b/packages/core/src/orchestrator/orchestrator.test.ts @@ -216,7 +216,6 @@ const mockCodebase: Codebase = { repository_url: 'https://github.com/user/repo', default_cwd: '/workspace/test-project', ai_assistant_type: 'claude', - allow_env_keys: false, commands: {}, created_at: new Date(), updated_at: new Date(), diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index c847122c74..74966e3b2c 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -59,7 +59,6 @@ export interface Codebase { repository_url: string | null; default_cwd: string; ai_assistant_type: string; - allow_env_keys: boolean; commands: Record; created_at: Date; updated_at: Date; diff --git a/packages/core/src/utils/env-leak-scanner.test.ts b/packages/core/src/utils/env-leak-scanner.test.ts deleted file mode 100644 index 4d436bbc24..0000000000 --- a/packages/core/src/utils/env-leak-scanner.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; -import { writeFileSync, mkdirSync, rmSync } from 'fs'; -import { join } from 'path'; -import { - scanPathForSensitiveKeys, - EnvLeakError, - formatLeakError, - SENSITIVE_KEYS, - AUTOLOADED_FILES, -} from './env-leak-scanner'; - -describe('scanPathForSensitiveKeys', () => { - const tmpDir = '/tmp/archon-test-env-scan'; - - beforeEach(() => { - mkdirSync(tmpDir, { recursive: true }); - }); - afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('returns empty findings for clean directory', () => { - const report = scanPathForSensitiveKeys(tmpDir); - expect(report.findings).toHaveLength(0); - }); - - it('returns empty findings for non-existent directory', () => { - const report = scanPathForSensitiveKeys('/tmp/archon-test-nonexistent-dir'); - expect(report.findings).toHaveLength(0); - }); - - // Each sensitive key × each auto-loaded filename - for (const key of SENSITIVE_KEYS) { - for (const filename of AUTOLOADED_FILES) { - it(`detects ${key} in ${filename}`, () => { - writeFileSync(join(tmpDir, filename), `${key}=sk-test-value\nOTHER=safe\n`); - const report = scanPathForSensitiveKeys(tmpDir); - expect(report.findings).toHaveLength(1); - expect(report.findings[0].file).toBe(filename); - expect(report.findings[0].keys).toContain(key); - // Clean up for next iteration - rmSync(join(tmpDir, filename)); - }); - } - } - - it('ignores commented-out keys', () => { - writeFileSync(join(tmpDir, '.env'), '# ANTHROPIC_API_KEY=value\n'); - const report = scanPathForSensitiveKeys(tmpDir); - expect(report.findings).toHaveLength(0); - }); - - it('ignores lines without =', () => { - writeFileSync(join(tmpDir, '.env'), 'ANTHROPIC_API_KEY\n'); - const report = scanPathForSensitiveKeys(tmpDir); - expect(report.findings).toHaveLength(0); - }); - - it('reports multiple files with findings', () => { - writeFileSync(join(tmpDir, '.env'), 'ANTHROPIC_API_KEY=sk-1\n'); - writeFileSync(join(tmpDir, '.env.local'), 'OPENAI_API_KEY=sk-2\n'); - const report = scanPathForSensitiveKeys(tmpDir); - expect(report.findings).toHaveLength(2); - }); - - it('reports multiple keys in same file', () => { - writeFileSync(join(tmpDir, '.env'), 'ANTHROPIC_API_KEY=sk-1\nOPENAI_API_KEY=sk-2\n'); - const report = scanPathForSensitiveKeys(tmpDir); - expect(report.findings).toHaveLength(1); - expect(report.findings[0].keys).toHaveLength(2); - }); - - it('ignores non-autoloaded filenames', () => { - writeFileSync(join(tmpDir, '.env.secrets'), 'ANTHROPIC_API_KEY=sk-1\n'); - const report = scanPathForSensitiveKeys(tmpDir); - expect(report.findings).toHaveLength(0); - }); - - it('ignores safe keys', () => { - writeFileSync(join(tmpDir, '.env'), 'DATABASE_URL=postgres://localhost\nNODE_ENV=dev\n'); - const report = scanPathForSensitiveKeys(tmpDir); - expect(report.findings).toHaveLength(0); - }); -}); - -describe('EnvLeakError', () => { - it('is instanceof EnvLeakError and Error', () => { - const report = { path: '/tmp', findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }] }; - const err = new EnvLeakError(report); - expect(err).toBeInstanceOf(Error); - expect(err).toBeInstanceOf(EnvLeakError); - expect(err.name).toBe('EnvLeakError'); - expect(err.message).toContain('ANTHROPIC_API_KEY'); - expect(err.report).toBe(report); - }); - - it('defaults context to register-ui and stores it on the error', () => { - const report = { path: '/x', findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }] }; - const err = new EnvLeakError(report); - expect(err.context).toBe('register-ui'); - expect(err.message).toContain('Add Project'); - }); - - it('produces distinct remediation bodies per context', () => { - const report = { path: '/x', findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }] }; - const ui = formatLeakError(report, 'register-ui'); - const cli = formatLeakError(report, 'register-cli'); - const spawn = formatLeakError(report, 'spawn-existing'); - expect(ui).toContain('Add Project'); - expect(cli).toContain('--allow-env-keys'); - expect(cli).toContain('allow_target_repo_keys'); - expect(spawn).toContain('Settings'); - expect(spawn).toContain('already-registered'); - // headers differ between register and spawn - expect(ui).toContain('Cannot add codebase'); - expect(spawn).toContain('Cannot run workflow'); - }); - - it('formats multiple findings', () => { - const report = { - path: '/test', - findings: [ - { file: '.env', keys: ['ANTHROPIC_API_KEY'] }, - { file: '.env.local', keys: ['OPENAI_API_KEY', 'GEMINI_API_KEY'] }, - ], - }; - const err = new EnvLeakError(report); - expect(err.message).toContain('.env'); - expect(err.message).toContain('.env.local'); - expect(err.message).toContain('OPENAI_API_KEY'); - expect(err.message).toContain('GEMINI_API_KEY'); - }); -}); diff --git a/packages/core/src/utils/env-leak-scanner.ts b/packages/core/src/utils/env-leak-scanner.ts deleted file mode 100644 index 48edc2c6b7..0000000000 --- a/packages/core/src/utils/env-leak-scanner.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { readFileSync, existsSync } from 'fs'; -import { join } from 'path'; - -export const SENSITIVE_KEYS = new Set([ - 'ANTHROPIC_API_KEY', - 'ANTHROPIC_AUTH_TOKEN', - 'CLAUDE_API_KEY', - 'CLAUDE_CODE_OAUTH_TOKEN', - 'OPENAI_API_KEY', - 'CODEX_API_KEY', - 'GEMINI_API_KEY', -]); - -export const AUTOLOADED_FILES = [ - '.env', - '.env.local', - '.env.development', - '.env.production', - '.env.development.local', - '.env.production.local', -]; - -export interface LeakFinding { - file: string; - keys: string[]; -} - -export interface LeakReport { - path: string; - findings: LeakFinding[]; -} - -/** - * Context in which the env-leak error is being surfaced. Drives the remediation - * copy so users see guidance that matches how they hit the gate. - * - * - `register-ui`: Add-Project flow in the Web UI (checkbox is visible) - * - `register-cli`: CLI auto-register path (no Web UI) - * - `spawn-existing`: Pre-spawn check for an already-registered codebase - */ -export type LeakErrorContext = 'register-ui' | 'register-cli' | 'spawn-existing'; - -export class EnvLeakError extends Error { - public readonly context: LeakErrorContext; - constructor( - public readonly report: LeakReport, - context: LeakErrorContext = 'register-ui' - ) { - super(formatLeakError(report, context)); - this.name = 'EnvLeakError'; - this.context = context; - } -} - -/** - * Scan `dirPath` for auto-loaded .env files containing sensitive keys. - * Pure function — no side effects. - */ -export function scanPathForSensitiveKeys(dirPath: string): LeakReport { - const findings: LeakFinding[] = []; - - for (const filename of AUTOLOADED_FILES) { - const fullPath = join(dirPath, filename); - if (!existsSync(fullPath)) continue; - - let contents: string; - try { - contents = readFileSync(fullPath, 'utf8'); - } catch (err) { - // File exists but is unreadable — treat as a finding to avoid silently bypassing the gate - const code = (err as NodeJS.ErrnoException).code; - findings.push({ file: filename, keys: [`[unreadable — ${code ?? 'unknown error'}]`] }); - continue; - } - - const foundKeys: string[] = []; - for (const line of contents.split('\n')) { - const trimmed = line.trim(); - if (trimmed.startsWith('#') || !trimmed.includes('=')) continue; - const key = trimmed.split('=')[0].trim(); - if (SENSITIVE_KEYS.has(key)) { - foundKeys.push(key); - } - } - - if (foundKeys.length > 0) { - findings.push({ file: filename, keys: foundKeys }); - } - } - - return { path: dirPath, findings }; -} - -/** - * Exhaustive per-context consent remediation copy. Using `switch` with a - * `never` default means adding a new `LeakErrorContext` variant without - * handling it here is a compile error — important for a security-visible path. - */ -function consentCopy(context: LeakErrorContext): string { - switch (context) { - case 'register-cli': - return ` 3. Acknowledge the risk and allow this codebase to use its .env key: - Re-run the CLI command with --allow-env-keys, or set - 'allow_target_repo_keys: true' in ~/.archon/config.yaml to bypass this - gate globally.`; - case 'spawn-existing': - return ` 3. Acknowledge the risk for this already-registered codebase: - Open the Web UI (Settings → Projects), find this project, and toggle - "Allow env keys". Or set 'allow_target_repo_keys: true' in - ~/.archon/config.yaml to bypass this gate globally.`; - case 'register-ui': - return ` 3. Acknowledge the risk and allow this codebase to use its .env key: - Open the web UI (Settings → Projects → Add Project) and tick - "Allow env keys (I understand the risk)" when adding this project.`; - default: { - const exhaustive: never = context; - return exhaustive; - } - } -} - -export function formatLeakError( - report: LeakReport, - context: LeakErrorContext = 'register-ui' -): string { - const fileList = report.findings.map(f => ` ${f.file} — ${f.keys.join(', ')}`).join('\n'); - - const header = - context === 'spawn-existing' - ? `Cannot run workflow — ${report.path} contains keys that will leak into AI subprocesses` - : `Cannot add codebase — ${report.path} contains keys that will leak into AI subprocesses`; - - const consent = consentCopy(context); - - return `${header} - - Found: -${fileList} - - Why this matters: - Bun subprocesses auto-load .env from their working directory. Archon cleans - its own environment, but Claude/Codex subprocesses running with cwd= - will re-inject these keys at their own startup, bypassing archon's allowlist. - This can bill the wrong API account silently. - - Choose one: - 1. Remove the key from this repo's .env (recommended): - grep -v '^ANTHROPIC_API_KEY=' .env > .env.tmp && mv .env.tmp .env - - 2. Rename to a non-auto-loaded file: - mv .env .env.secrets - # update your app to load it explicitly - -${consent}`; -} diff --git a/packages/docs-web/src/content/docs/reference/api.md b/packages/docs-web/src/content/docs/reference/api.md index 0e2fa8aa37..511355e091 100644 --- a/packages/docs-web/src/content/docs/reference/api.md +++ b/packages/docs-web/src/content/docs/reference/api.md @@ -138,7 +138,6 @@ Performs a soft delete -- the conversation is hidden but not destroyed. | GET | `/api/codebases` | List registered codebases | | GET | `/api/codebases/{id}` | Get a single codebase | | POST | `/api/codebases` | Register a codebase (clone or local path) | -| PATCH | `/api/codebases/{id}` | Update env-key consent (`allowEnvKeys`) | | DELETE | `/api/codebases/{id}` | Delete a codebase and clean up resources | | GET | `/api/codebases/{id}/environments` | List isolation environments for a codebase | @@ -166,16 +165,6 @@ curl -X POST http://localhost:3090/api/codebases \ -d '{"path": "/home/user/projects/my-repo"}' ``` -### Update Env-Key Consent - -Flip the env-leak-gate consent bit (`allow_env_keys`) on an existing codebase. Audit-logged on every grant and revoke as `env_leak_consent_granted` / `env_leak_consent_revoked` (warn-level) including `codebaseId`, `path`, scanned `files`, matched `keys`, `scanStatus`, and `actor`. - -```bash -curl -X PATCH http://localhost:3090/api/codebases/{id} \ - -H "Content-Type: application/json" \ - -d '{"allowEnvKeys": true}' -``` - ### Delete a Codebase ```bash diff --git a/packages/docs-web/src/content/docs/reference/cli.md b/packages/docs-web/src/content/docs/reference/cli.md index 33f6436884..ff492962b3 100644 --- a/packages/docs-web/src/content/docs/reference/cli.md +++ b/packages/docs-web/src/content/docs/reference/cli.md @@ -122,7 +122,6 @@ Progress events (node start/complete/fail/skip, approval gates) are written to s | `--from `, `--from-branch ` | Override base branch (start-point for worktree) | | `--no-worktree` | Opt out of isolation -- run directly in live checkout | | `--resume` | Resume from last failed run at the working path (skips completed nodes) | -| `--allow-env-keys` | Grant env-leak-gate consent during auto-registration (bypasses the gate for this codebase). Audit-logged as `env_leak_consent_granted` with `actor: 'user-cli'`. See [security.md](/reference/security/#env-leak-gate-target-repo-env-keys). | | `--quiet`, `-q` | Suppress all progress output to stderr | | `--verbose`, `-v` | Also show tool-level events (tool name and duration) | diff --git a/packages/docs-web/src/content/docs/reference/configuration.md b/packages/docs-web/src/content/docs/reference/configuration.md index c126b968f1..1e8d867abe 100644 --- a/packages/docs-web/src/content/docs/reference/configuration.md +++ b/packages/docs-web/src/content/docs/reference/configuration.md @@ -83,11 +83,6 @@ paths: concurrency: maxConversations: 10 -# Env-leak gate bypass (last resort — weakens a security control) -# allow_target_repo_keys: false # Set true to skip the env-leak-gate - # globally for all codebases on this machine. - # `env_leak_gate_disabled` is logged once per - # process per source. See security.md. ``` ## Repository Configuration @@ -135,11 +130,6 @@ defaults: # MY_API_KEY: value # CUSTOM_ENDPOINT: https://... -# Per-repo override for the env-leak-gate bypass. -# Set to `false` to re-enable the gate for THIS repo even when the global -# config has `allow_target_repo_keys: true`. Set to `true` to grant the -# bypass for THIS repo only. Wins over the global flag in either direction. -# allow_target_repo_keys: false ``` ### Claude settingSources diff --git a/packages/docs-web/src/content/docs/reference/security.md b/packages/docs-web/src/content/docs/reference/security.md index 4a2907d855..b3d1696e04 100644 --- a/packages/docs-web/src/content/docs/reference/security.md +++ b/packages/docs-web/src/content/docs/reference/security.md @@ -124,36 +124,20 @@ The GitHub and Gitea adapters verify webhook signatures to ensure payloads origi - Per-codebase env vars configured via `codebase_env_vars` or `.archon/config.yaml` `env:` are merged on top at workflow execution time. - CWD `.env` keys are the **only** untrusted source. They belong to the target project, not to Archon. -### Env-leak gate (target repo `.env` keys) +### Target repo `.env` isolation -As a second layer of defense, Archon scans target repos for sensitive keys **before spawning** AI subprocesses. A Claude or Codex subprocess started with `cwd=/path/to/target/repo` inherits Bun's auto-loaded `.env` from that CWD — the env-leak gate catches this by scanning the target repo's `.env` files at registration and pre-spawn time. +Archon prevents target repo `.env` from leaking into subprocesses through structural protection: -**What Archon scans:** auto-loaded filenames `.env`, `.env.local`, `.env.development`, `.env.production`, `.env.development.local`, `.env.production.local`. +1. **Boot cleanup:** `stripCwdEnv()` removes Bun-auto-loaded CWD `.env` keys from `process.env` before any application code runs. +2. **Claude Code subprocess:** `executableArgs: ['--no-env-file']` prevents Bun from auto-loading `.env` in the Claude Code subprocess CWD. +3. **Bun script nodes:** `bun --no-env-file` prevents script node subprocesses from loading target repo `.env`. +4. **Bash nodes:** Not affected — bash does not auto-load `.env` files. -**Scanned keys:** `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, `OPENAI_API_KEY`, `CODEX_API_KEY`, `GEMINI_API_KEY`. +Archon's own env sources (`~/.archon/.env`, dev `.env`) are loaded after the CWD strip and pass through to subprocesses normally. -:::caution -Renaming the file to `.env.local`, `.env.development`, etc. **does not work** — Bun auto-loads those too. Only `.env.secrets` (or any non-auto-loaded name) is safe. -::: - -**Where the gate runs:** - -| Failure point | When | What you see | -| --- | --- | --- | -| Registration (Web UI) | Adding a project via Settings → Add Project | 422 with the "Allow env keys" checkbox shown inline | -| Registration (CLI) | First `archon workflow run --cwd ` auto-registers | Error message points at `--allow-env-keys` and the global config flag | -| Pre-spawn | Existing codebase, before each Claude/Codex query | Error message points at Settings → Projects → "Allow env keys" toggle | - -**Primary remediation (recommended):** -1. Remove the key from the target repo's `.env`, or -2. Rename the file to `.env.secrets` and load it explicitly from your app code. - -**Secondary remediation (consent grants):** -- **Web UI:** Settings → Projects → click "Allow env keys" on the row. Revoke from the same place. Each grant/revoke writes a `warn`-level audit log (`env_leak_consent_granted` / `env_leak_consent_revoked`) including `codebaseId`, `path`, scanned `files`, matched `keys`, `scanStatus` (`'ok'` or `'skipped'`), and `actor`. -- **CLI:** `archon workflow run "your message" --cwd --allow-env-keys` grants consent during this run's auto-registration. The grant is persisted (the codebase row is created with `allow_env_keys = true`) and logged as `env_leak_consent_granted` with `actor: 'user-cli'`. -- **Global bypass:** set `allow_target_repo_keys: true` in `~/.archon/config.yaml` to disable the gate for all codebases on this machine. `env_leak_gate_disabled` is logged at most once per process per source (global vs. repo) the first time `loadConfig` resolves the bypass as active. A repo-level `.archon/config.yaml` with `allow_target_repo_keys: false` re-enables the gate for that repo. - -**Startup scan:** When `allow_target_repo_keys` is not set, the server scans every registered codebase with `allow_env_keys = false` and emits one `startup_env_leak_gate_will_block` warning per codebase **that has findings** (i.e. would actually be blocked). This gives you a chance to grant consent before hitting a fatal error mid-workflow. The scan is skipped entirely when the global bypass is active. +**If you need env vars available during workflow execution**, use managed env injection: +- `.archon/config.yaml` `env:` section (per-repo, checked into version control) +- Web UI: Settings → Projects → Env Vars (per-codebase, stored in Archon DB) **CORS:** - API routes use `WEB_UI_ORIGIN` to restrict CORS. The default is `*` (allow all), which is appropriate for local single-developer use. Set a specific origin when exposing the server publicly. diff --git a/packages/paths/src/env-integration.test.ts b/packages/paths/src/env-integration.test.ts index 1607100c63..5bb2dd036b 100644 --- a/packages/paths/src/env-integration.test.ts +++ b/packages/paths/src/env-integration.test.ts @@ -20,6 +20,8 @@ const TEST_KEYS = [ 'ANTHROPIC_API_KEY', 'CLAUDE_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_USE_GLOBAL_AUTH', 'DATABASE_URL', 'LOG_LEVEL', @@ -31,6 +33,11 @@ const TEST_KEYS = [ 'CLAUDE_CODE_ENTRYPOINT', 'NODE_OPTIONS', 'REDIS_URL', + 'OPENAI_API_KEY', + 'ELEVENLABS_API_KEY', + 'SSH_AUTH_SOCK', + 'HTTP_PROXY', + 'MANAGED_SECRET', ]; describe('env isolation integration', () => { @@ -207,4 +214,117 @@ describe('env isolation integration', () => { expect(subprocessEnv.CLAUDE_CODE_ENTRYPOINT).toBeUndefined(); expect(subprocessEnv.CLAUDE_CODE_OAUTH_TOKEN).toBe('sk-ant-oat01-keep-this'); }); + + // ── Multiple .env file variants ──────────────────────────────────────── + + /** Simulate Bun auto-loading a specific .env file into process.env. */ + function simulateBunAutoLoad(filePath: string): void { + const parsed = config({ path: filePath, processEnv: {} }); + if (parsed.parsed) { + for (const [key, value] of Object.entries(parsed.parsed)) { + process.env[key] = value; + } + } + } + + it('strips keys from .env.local in addition to .env', () => { + // Bun auto-loads .env.local too — keys from there must also be stripped + writeFileSync(join(cwdDir, '.env.local'), 'OPENAI_API_KEY=sk-local-leaked\n'); + simulateBunAutoLoad(join(cwdDir, '.env.local')); + + const subprocessEnv = simulateEntryPointFlow( + 'ANTHROPIC_API_KEY=sk-main-leaked\n', + 'CLAUDE_USE_GLOBAL_AUTH=true\n' + ); + + expect(subprocessEnv.ANTHROPIC_API_KEY).toBeUndefined(); + expect(subprocessEnv.OPENAI_API_KEY).toBeUndefined(); + expect(subprocessEnv.CLAUDE_USE_GLOBAL_AUTH).toBe('true'); + }); + + it('strips keys from .env.development', () => { + writeFileSync(join(cwdDir, '.env.development'), 'ELEVENLABS_API_KEY=el-dev-leaked\n'); + simulateBunAutoLoad(join(cwdDir, '.env.development')); + + const subprocessEnv = simulateEntryPointFlow('', ''); + + expect(subprocessEnv.ELEVENLABS_API_KEY).toBeUndefined(); + }); + + // ── Shell-inherited env preservation ─────────────────────────────────── + + it('preserves shell-inherited env that is not in CWD .env', () => { + // User has SSH_AUTH_SOCK and HTTP_PROXY in their shell — these must survive + // because they are not from the target repo's .env + process.env.SSH_AUTH_SOCK = '/tmp/ssh-agent.sock'; + process.env.HTTP_PROXY = 'http://proxy.corp:8080'; + + const subprocessEnv = simulateEntryPointFlow('ANTHROPIC_API_KEY=sk-leaked\n', ''); + + // CWD key stripped + expect(subprocessEnv.ANTHROPIC_API_KEY).toBeUndefined(); + // Shell-inherited env preserved (not in any CWD .env file) + expect(subprocessEnv.SSH_AUTH_SOCK).toBe('/tmp/ssh-agent.sock'); + expect(subprocessEnv.HTTP_PROXY).toBe('http://proxy.corp:8080'); + }); + + it('strips shell-inherited env if same key also appears in CWD .env', () => { + // If SSH_AUTH_SOCK is in both shell AND CWD .env, the CWD value is what + // Bun auto-loaded — stripping removes it. This is correct behavior: + // the CWD .env overwrote the shell value during auto-load. + process.env.SSH_AUTH_SOCK = '/tmp/ssh-agent.sock'; + + const subprocessEnv = simulateEntryPointFlow('SSH_AUTH_SOCK=/tmp/repo-evil-agent.sock\n', ''); + + // Key was in CWD .env, so it gets stripped entirely + expect(subprocessEnv.SSH_AUTH_SOCK).toBeUndefined(); + }); + + // ── Bedrock/Vertex auth preservation ─────────────────────────────────── + + it('preserves CLAUDE_CODE_USE_BEDROCK and CLAUDE_CODE_USE_VERTEX', () => { + // These are CLAUDE_CODE_* vars but are auth-related — must survive marker strip + process.env.CLAUDECODE = '1'; + process.env.CLAUDE_CODE_ENTRYPOINT = 'cli'; + + const subprocessEnv = simulateEntryPointFlow( + '', + 'CLAUDE_CODE_USE_BEDROCK=1\nCLAUDE_CODE_USE_VERTEX=1\nCLAUDE_CODE_OAUTH_TOKEN=sk-token\n' + ); + + // Markers stripped + expect(subprocessEnv.CLAUDECODE).toBeUndefined(); + expect(subprocessEnv.CLAUDE_CODE_ENTRYPOINT).toBeUndefined(); + // Auth vars preserved + expect(subprocessEnv.CLAUDE_CODE_USE_BEDROCK).toBe('1'); + expect(subprocessEnv.CLAUDE_CODE_USE_VERTEX).toBe('1'); + expect(subprocessEnv.CLAUDE_CODE_OAUTH_TOKEN).toBe('sk-token'); + }); + + // ── Managed execution env (simulated) ────────────────────────────────── + + it('managed execution env merges on top of clean process.env', () => { + // After the entry point flow, the workflow executor merges managed env + // (from config.yaml env: + DB vars) on top of process.env. + // This simulates that final merge. + const subprocessEnv = simulateEntryPointFlow( + 'ANTHROPIC_API_KEY=sk-leaked\nDATABASE_URL=postgres://wrong\n', + 'CLAUDE_USE_GLOBAL_AUTH=true\n' + ); + + // Simulate managed env merge (what dag-executor does via requestOptions.env) + const managedEnv = { MANAGED_SECRET: 'from-db', ELEVENLABS_API_KEY: 'el-managed' }; + const finalEnv = { ...subprocessEnv, ...managedEnv }; + + // CWD keys still stripped + expect(finalEnv.ANTHROPIC_API_KEY).toBeUndefined(); + expect(finalEnv.DATABASE_URL).toBeUndefined(); + // Archon auth present + expect(finalEnv.CLAUDE_USE_GLOBAL_AUTH).toBe('true'); + // Managed env present + expect(finalEnv.MANAGED_SECRET).toBe('from-db'); + expect(finalEnv.ELEVENLABS_API_KEY).toBe('el-managed'); + // OS essentials present + expect(finalEnv.PATH ?? finalEnv.Path).toBeDefined(); + }); }); diff --git a/packages/providers/src/claude/provider.test.ts b/packages/providers/src/claude/provider.test.ts index e8e010a6e5..1b9ed947dd 100644 --- a/packages/providers/src/claude/provider.test.ts +++ b/packages/providers/src/claude/provider.test.ts @@ -471,7 +471,11 @@ describe('ClaudeProvider', () => { // consume } - const callArgs = mockQuery.mock.calls[0][0] as { options: { env: NodeJS.ProcessEnv } }; + const callArgs = mockQuery.mock.calls[0][0] as { + options: { env: NodeJS.ProcessEnv; executableArgs?: string[] }; + }; + // --no-env-file prevents Bun from auto-loading .env in subprocess CWD + expect(callArgs.options.executableArgs).toEqual(['--no-env-file']); expect(callArgs.options.env.CUSTOM_USER_KEY).toBe('user-trusted-value'); // Windows uses "Path" casing in spread objects and USERPROFILE instead of HOME const envPath = callArgs.options.env.PATH ?? callArgs.options.env.Path; diff --git a/packages/providers/src/claude/provider.ts b/packages/providers/src/claude/provider.ts index fade6db3df..57e430579b 100644 --- a/packages/providers/src/claude/provider.ts +++ b/packages/providers/src/claude/provider.ts @@ -514,6 +514,9 @@ function buildBaseClaudeOptions( return { cwd, pathToClaudeCodeExecutable: cliPath, + // Prevent Bun from auto-loading .env from the target repo cwd. + // Without this, the Claude Code subprocess inherits repo secrets. + executableArgs: ['--no-env-file'], env, model: requestOptions?.model ?? assistantDefaults.model, abortController: controller, diff --git a/packages/providers/src/codex/provider.ts b/packages/providers/src/codex/provider.ts index 046ae36c95..de9ffd13f4 100644 --- a/packages/providers/src/codex/provider.ts +++ b/packages/providers/src/codex/provider.ts @@ -482,8 +482,9 @@ export class CodexProvider implements IAgentProvider { }; } - // TODO(#1135): Pre-spawn env-leak gate was removed during provider extraction. - // Caller-side enforcement (orchestrator, dag-executor) is tracked in #1135. + // Env safety: Codex inherits cleaned parent env (stripCwdEnv at boot). + // Codex native binary does not auto-load .env from CWD (E2E verified). + // Managed env injection tracked in #1161. async *sendQuery( prompt: string, cwd: string, diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 0b502008d6..8099a8a9bd 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -73,9 +73,7 @@ import { logConfig, getPort, createWorkflowStore, - scanPathForSensitiveKeys, } from '@archon/core'; -import * as codebaseDb from '@archon/core/db/codebases'; import type { IPlatformAdapter } from '@archon/core'; import { createLogger, logArchonPaths, validateAppDefaultsPaths } from '@archon/paths'; @@ -199,58 +197,9 @@ export async function startServer(opts: ServerOptions = {}): Promise { process.exit(1); } - // Load configuration early so the startup env-leak scan can honor the - // global bypass. Without this, users who set `allow_target_repo_keys: true` - // would get a per-codebase warn spam on every boot even though the gate - // is intentionally disabled. const config = await loadConfig(); logConfig(config); - // Startup env-leak scan: warn for codebases that would be blocked at next - // spawn by the env-leak-gate. Skipped entirely when the global bypass is - // active. Best-effort — failures are surfaced but never block startup. - if (config.allowTargetRepoKeys) { - getLog().info('startup_env_leak_scan_skipped — allow_target_repo_keys is true'); - } else { - try { - const codebases = await codebaseDb.listCodebases(); - for (const cb of codebases) { - if (cb.allow_env_keys) continue; - try { - const report = scanPathForSensitiveKeys(cb.default_cwd); - if (report.findings.length > 0) { - const files = report.findings.map(f => f.file); - const keys = Array.from(new Set(report.findings.flatMap(f => f.keys))); - getLog().warn( - { - codebaseId: cb.id, - name: cb.name, - path: cb.default_cwd, - files, - keys, - }, - 'startup_env_leak_gate_will_block' - ); - } - } catch (scanErr) { - // Path may no longer exist (codebase moved/deleted on disk) — - // log at debug, do not abort the loop. This is the only quiet path. - getLog().debug( - { err: scanErr, codebaseId: cb.id, path: cb.default_cwd }, - 'startup_env_leak_scan_path_unavailable' - ); - } - } - } catch (error) { - // listCodebases() failed — the entire startup safety net is silently - // absent. Surface at error level so operators see it. - getLog().error( - { err: error }, - 'startup_env_leak_scan_failed — startup migration warnings suppressed' - ); - } - } - // Start cleanup scheduler startCleanupScheduler(); diff --git a/packages/server/src/routes/api.codebases.test.ts b/packages/server/src/routes/api.codebases.test.ts index 0265a359e1..d06615968b 100644 --- a/packages/server/src/routes/api.codebases.test.ts +++ b/packages/server/src/routes/api.codebases.test.ts @@ -48,15 +48,6 @@ mock.module('@archon/core', () => ({ this.name = 'ConversationNotFoundError'; } }, - scanPathForSensitiveKeys: mock((_p: string) => ({ path: _p, findings: [] })), - EnvLeakError: class EnvLeakError extends Error { - constructor(public report: { path: string; findings: { file: string; keys: string[] }[] }) { - super( - `Cannot add codebase — ${report.path} contains keys that will leak into AI subprocesses` - ); - this.name = 'EnvLeakError'; - } - }, getArchonWorkspacesPath: () => '/tmp/.archon/workspaces', generateAndSetTitle: mock(async () => {}), createLogger: () => ({ @@ -123,12 +114,10 @@ mock.module('@archon/core/db/conversations', () => ({ getConversationById: mock(async () => null), })); -const mockUpdateCodebaseAllowEnvKeys = mock(async (_id: string, _v: boolean) => {}); mock.module('@archon/core/db/codebases', () => ({ listCodebases: mockListCodebases, getCodebase: mockGetCodebase, deleteCodebase: mockDeleteCodebase, - updateCodebaseAllowEnvKeys: mockUpdateCodebaseAllowEnvKeys, })); mock.module('@archon/core/db/isolation-environments', () => ({ @@ -181,7 +170,6 @@ const MOCK_CODEBASE = { repository_url: 'https://github.com/user/repo', default_cwd: '/home/user/projects/my-project', ai_assistant_type: 'claude', - allow_env_keys: false, commands: {}, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), @@ -399,7 +387,7 @@ describe('POST /api/codebases', () => { const body = (await response.json()) as { id: string }; expect(body.id).toBe('codebase-uuid-1'); - expect(mockCloneRepository).toHaveBeenCalledWith('https://github.com/user/repo', undefined); + expect(mockCloneRepository).toHaveBeenCalledWith('https://github.com/user/repo'); }); test('registers existing URL codebase with 200', async () => { @@ -436,7 +424,7 @@ describe('POST /api/codebases', () => { body: JSON.stringify({ path: '/home/user/my-repo' }), }); expect(response.status).toBe(201); - expect(mockRegisterRepository).toHaveBeenCalledWith('/home/user/my-repo', undefined); + expect(mockRegisterRepository).toHaveBeenCalledWith('/home/user/my-repo'); }); test('returns 400 when both url and path are provided', async () => { @@ -508,101 +496,6 @@ describe('POST /api/codebases', () => { const body = (await response.json()) as { error: string }; expect(body.error).toContain('authentication required'); }); - - test('returns 422 when cloneRepository throws EnvLeakError', async () => { - const { EnvLeakError } = await import('@archon/core'); - mockCloneRepository.mockImplementationOnce(async () => { - throw new EnvLeakError({ - path: '/repo/path', - findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }], - }); - }); - - const app = makeApp(); - const response = await app.request('/api/codebases', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: 'https://github.com/user/repo' }), - }); - expect(response.status).toBe(422); - - const body = (await response.json()) as { error: string }; - expect(body.error).toContain('Cannot add codebase'); - }); - - test('passes allowEnvKeys=true to cloneRepository when body includes it', async () => { - mockCloneRepository.mockImplementationOnce(async () => ({ - codebaseId: 'clone-uuid-2', - alreadyExisted: false, - })); - mockGetCodebase.mockImplementationOnce(async () => MOCK_CODEBASE); - - const app = makeApp(); - const response = await app.request('/api/codebases', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: 'https://github.com/user/repo', allowEnvKeys: true }), - }); - expect(response.status).toBe(201); - expect(mockCloneRepository).toHaveBeenCalledWith('https://github.com/user/repo', true); - }); -}); - -// --------------------------------------------------------------------------- -// Tests: PATCH /api/codebases/:id -// --------------------------------------------------------------------------- - -describe('PATCH /api/codebases/:id', () => { - beforeEach(() => { - mockGetCodebase.mockReset(); - mockUpdateCodebaseAllowEnvKeys.mockReset(); - }); - - test('grants consent and returns updated codebase', async () => { - mockGetCodebase - .mockImplementationOnce(async () => MOCK_CODEBASE) - .mockImplementationOnce(async () => ({ ...MOCK_CODEBASE, allow_env_keys: true })); - mockUpdateCodebaseAllowEnvKeys.mockImplementationOnce(async () => {}); - - const app = makeApp(); - const response = await app.request('/api/codebases/codebase-uuid-1', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ allowEnvKeys: true }), - }); - expect(response.status).toBe(200); - const body = (await response.json()) as { allow_env_keys: boolean }; - expect(body.allow_env_keys).toBe(true); - expect(mockUpdateCodebaseAllowEnvKeys).toHaveBeenCalledWith('codebase-uuid-1', true); - }); - - test('revokes consent', async () => { - mockGetCodebase - .mockImplementationOnce(async () => ({ ...MOCK_CODEBASE, allow_env_keys: true })) - .mockImplementationOnce(async () => MOCK_CODEBASE); - mockUpdateCodebaseAllowEnvKeys.mockImplementationOnce(async () => {}); - - const app = makeApp(); - const response = await app.request('/api/codebases/codebase-uuid-1', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ allowEnvKeys: false }), - }); - expect(response.status).toBe(200); - expect(mockUpdateCodebaseAllowEnvKeys).toHaveBeenCalledWith('codebase-uuid-1', false); - }); - - test('returns 404 when codebase not found', async () => { - mockGetCodebase.mockImplementationOnce(async () => null); - - const app = makeApp(); - const response = await app.request('/api/codebases/missing', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ allowEnvKeys: true }), - }); - expect(response.status).toBe(404); - }); }); // --------------------------------------------------------------------------- diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index ed267c1d41..4bc814f685 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -27,8 +27,6 @@ import { registerRepository, ConversationNotFoundError, generateAndSetTitle, - EnvLeakError, - scanPathForSensitiveKeys, } from '@archon/core'; import { removeWorktree, toRepoPath, toWorktreePath } from '@archon/git'; import { @@ -109,7 +107,6 @@ import { codebaseSchema, codebaseIdParamsSchema, addCodebaseBodySchema, - updateCodebaseBodySchema, deleteCodebaseResponseSchema, codebaseEnvVarsResponseSchema, setEnvVarBodySchema, @@ -467,28 +464,6 @@ const addCodebaseRoute = createRoute({ }, }); -const updateCodebaseRoute = createRoute({ - method: 'patch', - path: '/api/codebases/{id}', - tags: ['Codebases'], - summary: 'Update codebase consent flags (e.g. allow_env_keys)', - request: { - params: codebaseIdParamsSchema, - body: { - content: { 'application/json': { schema: updateCodebaseBodySchema } }, - required: true, - }, - }, - responses: { - 200: { - content: { 'application/json': { schema: codebaseSchema } }, - description: 'Updated codebase', - }, - 404: jsonError('Not found'), - 500: jsonError('Server error'), - }, -}); - const deleteCodebaseRoute = createRoute({ method: 'delete', path: '/api/codebases/{id}', @@ -1531,8 +1506,8 @@ export function registerApiRoutes( try { // .refine() guarantees exactly one of url/path is present const result = body.url - ? await cloneRepository(body.url, body.allowEnvKeys) - : await registerRepository(body.path ?? '', body.allowEnvKeys); + ? await cloneRepository(body.url) + : await registerRepository(body.path ?? ''); // Fetch the full codebase record for a consistent response const codebase = await codebaseDb.getCodebase(result.codebaseId); @@ -1542,12 +1517,6 @@ export function registerApiRoutes( return c.json(codebase, result.alreadyExisted ? 200 : 201); } catch (error) { - if (error instanceof EnvLeakError) { - const path = body.url ?? body.path ?? ''; - const files = error.report.findings.map(f => f.file); - getLog().warn({ path, files }, 'add_codebase_env_leak_refused'); - return apiError(c, 422, error.message); - } getLog().error({ err: error }, 'add_codebase_failed'); return apiError( c, @@ -1557,71 +1526,6 @@ export function registerApiRoutes( } }); - // PATCH /api/codebases/:id - Update consent flags - registerOpenApiRoute(updateCodebaseRoute, async c => { - const id = c.req.param('id') ?? ''; - const body = getValidatedBody(c, updateCodebaseBodySchema); - try { - const codebase = await codebaseDb.getCodebase(id); - if (!codebase) { - return apiError(c, 404, 'Codebase not found'); - } - - // Capture scanner findings for the audit log (best-effort — path may be gone) - let files: string[] = []; - let keys: string[] = []; - let scanStatus: 'ok' | 'skipped' = 'ok'; - try { - const report = scanPathForSensitiveKeys(codebase.default_cwd); - files = report.findings.map(f => f.file); - keys = Array.from(new Set(report.findings.flatMap(f => f.keys))); - } catch (scanErr) { - scanStatus = 'skipped'; - getLog().warn( - { err: scanErr, codebaseId: id, path: codebase.default_cwd }, - 'env_leak_consent_scan_skipped' - ); - } - - await codebaseDb.updateCodebaseAllowEnvKeys(id, body.allowEnvKeys); - - // Audit log: emitted unconditionally on every grant/revoke. `scanStatus` - // distinguishes "scanned and these are the findings" from "could not - // scan, files/keys are empty for that reason" — important for later - // security review of the audit trail. - getLog().warn( - { - codebaseId: id, - name: codebase.name, - path: codebase.default_cwd, - files, - keys, - scanStatus, - actor: 'user-ui', - }, - body.allowEnvKeys ? 'env_leak_consent_granted' : 'env_leak_consent_revoked' - ); - - const updated = await codebaseDb.getCodebase(id); - if (!updated) { - return apiError(c, 500, 'Codebase updated but not found'); - } - let commands = updated.commands; - if (typeof commands === 'string') { - try { - commands = JSON.parse(commands); - } catch (parseErr) { - getLog().error({ err: parseErr, codebaseId: id }, 'corrupted_commands_json'); - commands = {}; - } - } - return c.json({ ...updated, commands }); - } catch (error) { - getLog().error({ err: error, codebaseId: id }, 'update_codebase_failed'); - return apiError(c, 500, 'Failed to update codebase'); - } - }); - // DELETE /api/codebases/:id - Delete a project and clean up registerOpenApiRoute(deleteCodebaseRoute, async c => { const id = c.req.param('id') ?? ''; diff --git a/packages/server/src/routes/schemas/codebase.schemas.ts b/packages/server/src/routes/schemas/codebase.schemas.ts index e8a6dea887..d2880a6be1 100644 --- a/packages/server/src/routes/schemas/codebase.schemas.ts +++ b/packages/server/src/routes/schemas/codebase.schemas.ts @@ -16,7 +16,6 @@ export const codebaseSchema = z repository_url: z.string().nullable(), default_cwd: z.string(), ai_assistant_type: z.string(), - allow_env_keys: z.boolean(), commands: z.record(codebaseCommandSchema), created_at: z.string(), updated_at: z.string(), @@ -34,20 +33,12 @@ export const addCodebaseBodySchema = z .object({ url: z.string().min(1).optional(), path: z.string().min(1).optional(), - allowEnvKeys: z.boolean().optional(), }) .refine(b => (b.url !== undefined) !== (b.path !== undefined), { message: 'Provide either "url" or "path", not both and not neither', }) .openapi('AddCodebaseBody'); -/** PATCH /api/codebases/:id request body. */ -export const updateCodebaseBodySchema = z - .object({ - allowEnvKeys: z.boolean(), - }) - .openapi('UpdateCodebaseBody'); - /** DELETE /api/codebases/:id response. */ export const deleteCodebaseResponseSchema = z .object({ success: z.boolean() }) diff --git a/packages/web/src/lib/api.generated.d.ts b/packages/web/src/lib/api.generated.d.ts index 193c619588..bb2ed58aef 100644 --- a/packages/web/src/lib/api.generated.d.ts +++ b/packages/web/src/lib/api.generated.d.ts @@ -549,51 +549,7 @@ export interface paths { }; options?: never; head?: never; - /** Update codebase consent flags (e.g. allow_env_keys) */ - patch: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['UpdateCodebaseBody']; - }; - }; - responses: { - /** @description Updated codebase */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Codebase']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Error']; - }; - }; - /** @description Server error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Error']; - }; - }; - }; - }; + patch?: never; trace?: never; }; '/api/codebases/{id}/env': { @@ -2010,7 +1966,6 @@ export interface components { repository_url: string | null; default_cwd: string; ai_assistant_type: string; - allow_env_keys: boolean; commands: { [key: string]: components['schemas']['CodebaseCommand']; }; @@ -2021,10 +1976,6 @@ export interface components { AddCodebaseBody: { url?: string; path?: string; - allowEnvKeys?: boolean; - }; - UpdateCodebaseBody: { - allowEnvKeys: boolean; }; DeleteCodebaseResponse: { success: boolean; diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index 6c81aa66b1..81a3529833 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -38,7 +38,6 @@ export interface CodebaseResponse { repository_url: string | null; default_cwd: string; ai_assistant_type: string; - allow_env_keys: boolean; commands: Record; created_at: string; updated_at: string; @@ -158,7 +157,7 @@ export async function getCodebase(id: string): Promise { } export async function addCodebase( - input: { url: string; allowEnvKeys?: boolean } | { path: string; allowEnvKeys?: boolean } + input: { url: string } | { path: string } ): Promise { return fetchJSON('/api/codebases', { method: 'POST', @@ -167,17 +166,6 @@ export async function addCodebase( }); } -export async function updateCodebase( - id: string, - input: { allowEnvKeys: boolean } -): Promise { - return fetchJSON(`/api/codebases/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); -} - export async function deleteCodebase(id: string): Promise<{ success: boolean }> { return fetchJSON<{ success: boolean }>(`/api/codebases/${id}`, { method: 'DELETE' }); } diff --git a/packages/web/src/routes/SettingsPage.tsx b/packages/web/src/routes/SettingsPage.tsx index 07a07690fc..0b9c7b6e60 100644 --- a/packages/web/src/routes/SettingsPage.tsx +++ b/packages/web/src/routes/SettingsPage.tsx @@ -10,7 +10,6 @@ import { getHealth, listCodebases, addCodebase, - updateCodebase, deleteCodebase, updateAssistantConfig, getCodebaseEnvVars, @@ -251,22 +250,11 @@ function EnvVarsPanel({ codebaseId }: { codebaseId: string }): React.ReactElemen ); } -function isEnvLeakError(error: unknown): boolean { - return ( - error instanceof Error && - 'status' in error && - (error as Error & { status: number }).status === 422 && - error.message.startsWith('Cannot add codebase') - ); -} - function ProjectsSection(): React.ReactElement { const queryClient = useQueryClient(); const [addPath, setAddPath] = useState(''); const [showAdd, setShowAdd] = useState(false); - const [allowEnvKeys, setAllowEnvKeys] = useState(false); const [expandedEnvVars, setExpandedEnvVars] = useState(null); - const [toggleError, setToggleError] = useState(null); const { data: codebases } = useQuery({ queryKey: ['codebases'], @@ -274,13 +262,11 @@ function ProjectsSection(): React.ReactElement { }); const addMutation = useMutation({ - mutationFn: ({ path, allowEnvKeys }: { path: string; allowEnvKeys?: boolean }) => - addCodebase({ path, allowEnvKeys }), + mutationFn: ({ path }: { path: string }) => addCodebase({ path }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['codebases'] }); setAddPath(''); setShowAdd(false); - setAllowEnvKeys(false); }, }); @@ -291,24 +277,10 @@ function ProjectsSection(): React.ReactElement { }, }); - const toggleEnvKeysMutation = useMutation({ - mutationFn: ({ id, allowEnvKeys }: { id: string; allowEnvKeys: boolean }) => - updateCodebase(id, { allowEnvKeys }), - onSuccess: () => { - setToggleError(null); - void queryClient.invalidateQueries({ queryKey: ['codebases'] }); - }, - onError: (err: Error) => { - // Without this the user clicks "Revoke env keys", confirms the - // destructive dialog, and gets no feedback if the PATCH fails. - setToggleError(err.message); - }, - }); - function handleAddSubmit(e: React.FormEvent): void { e.preventDefault(); if (addPath.trim()) { - addMutation.mutate({ path: addPath.trim(), allowEnvKeys: allowEnvKeys || undefined }); + addMutation.mutate({ path: addPath.trim() }); } } @@ -318,11 +290,6 @@ function ProjectsSection(): React.ReactElement { Projects - {toggleError && ( -
- Failed to update env-key consent: {toggleError} -
- )} {!codebases || codebases.length === 0 ? (
No projects registered.
) : ( @@ -331,40 +298,10 @@ function ProjectsSection(): React.ReactElement {
-
-
{cb.name}
- {cb.allow_env_keys && ( - - env keys allowed - - )} -
+
{cb.name}
{cb.default_cwd}
-
)} diff --git a/packages/workflows/src/dag-executor.test.ts b/packages/workflows/src/dag-executor.test.ts index 86d00f5e60..0df80c93df 100644 --- a/packages/workflows/src/dag-executor.test.ts +++ b/packages/workflows/src/dag-executor.test.ts @@ -5262,4 +5262,53 @@ describe('executeDagWorkflow -- script nodes', () => { const notFoundMsg = messages.find((m: string) => m.includes('not found in .archon/scripts/')); expect(notFoundMsg).toBeDefined(); }); + + it('bun script node does not leak repo .env from execution cwd (#1135)', async () => { + // Regression test: place a .env with a marker in the execution cwd. + // The bun script must NOT see it because --no-env-file is passed. + const mockDeps = createMockDeps(); + const platform = createMockPlatform(); + const workflowRun = makeWorkflowRun('env-leak-run-id', { + workflow_name: 'env-leak-test', + conversation_id: 'conv-env-leak', + user_message: 'env leak test', + }); + + // Write a .env with a marker in the script execution cwd + await writeFile(join(testDir, '.env'), 'LEAKED_REPO_SECRET=should_not_appear\n'); + + const scriptNode: ScriptNode = { + id: 'env-check', + script: 'console.log(process.env.LEAKED_REPO_SECRET ?? "CLEAN")', + runtime: 'bun', + }; + + await executeDagWorkflow( + mockDeps, + platform, + 'conv-env-leak', + testDir, + { name: 'env-leak-test', nodes: [scriptNode] }, + workflowRun, + 'claude', + undefined, + join(testDir, 'artifacts'), + join(testDir, 'logs'), + 'main', + 'docs/', + minimalConfig + ); + + // The node output should be "CLEAN" — the repo .env was not loaded + const eventCalls = (mockDeps.store.createWorkflowEvent as ReturnType).mock.calls; + const completedEvent = eventCalls.find( + (call: unknown[]) => + (call[0] as { event_type: string }).event_type === 'node_completed' && + (call[0] as { step_name: string }).step_name === 'env-check' + ); + expect(completedEvent).toBeDefined(); + expect((completedEvent![0] as { data: { node_output: string } }).data.node_output).toBe( + 'CLEAN' + ); + }); }); diff --git a/packages/workflows/src/dag-executor.ts b/packages/workflows/src/dag-executor.ts index 993f56162b..c0af88a140 100644 --- a/packages/workflows/src/dag-executor.ts +++ b/packages/workflows/src/dag-executor.ts @@ -1256,7 +1256,10 @@ async function executeScriptNode( // Inline code execution if (node.runtime === 'bun') { cmd = 'bun'; - args = ['-e', finalScript]; + // --no-env-file prevents Bun from auto-loading .env from the execution + // cwd (the target repo). Without this, repo .env leaks into the script + // subprocess despite Archon's parent process cleanup. + args = ['--no-env-file', '-e', finalScript]; } else { // uv run --with dep1 --with dep2 python -c cmd = 'uv'; @@ -1306,7 +1309,7 @@ async function executeScriptNode( args = ['run', ...withFlags, scriptDef.path]; } else { cmd = 'bun'; - args = ['run', scriptDef.path]; + args = ['--no-env-file', 'run', scriptDef.path]; } } diff --git a/packages/workflows/src/executor-shared.ts b/packages/workflows/src/executor-shared.ts index 0537609417..e1978ae106 100644 --- a/packages/workflows/src/executor-shared.ts +++ b/packages/workflows/src/executor-shared.ts @@ -67,13 +67,8 @@ export function matchesPattern(message: string, patterns: string[]): boolean { * Classify an error to determine if it's transient (can retry) or fatal (should fail). * FATAL patterns take priority over TRANSIENT patterns to prevent an error message * containing both (e.g. "unauthorized: process exited with code 1") from being retried. - * - * First-party named error types are checked by name (immune to message rewording). */ export function classifyError(error: Error): ErrorType { - // Named first-party errors checked by name — immune to message rewording - if (error.name === 'EnvLeakError') return 'FATAL'; - const message = error.message.toLowerCase(); if (matchesPattern(message, FATAL_PATTERNS)) { diff --git a/packages/workflows/src/script-node-deps.test.ts b/packages/workflows/src/script-node-deps.test.ts index ae4b6f9299..1c1fbf5a81 100644 --- a/packages/workflows/src/script-node-deps.test.ts +++ b/packages/workflows/src/script-node-deps.test.ts @@ -287,7 +287,7 @@ describe('script node deps field — command construction', () => { expect(args).toEqual(['run', 'python', '-c', 'print("no deps")']); }); - it('bun inline with deps uses bun -e (no extra flags — bun auto-installs)', async () => { + it('bun inline with deps uses bun --no-env-file -e (no extra dep flags — bun auto-installs)', async () => { const node: ScriptNode = { id: 'bun-with-deps', script: 'import { z } from "zod"; console.log(z.string().parse("hello"))', @@ -316,13 +316,13 @@ describe('script node deps field — command construction', () => { expect(scriptCall).toBeDefined(); const [cmd, args] = scriptCall as [string, string[]]; expect(cmd).toBe('bun'); - // No --packages or extra flags — bun auto-installs at runtime - expect(args).toEqual(['-e', node.script]); + // --no-env-file prevents repo .env auto-load; no dep flags — bun auto-installs + expect(args).toEqual(['--no-env-file', '-e', node.script]); expect(args).not.toContain('--packages'); expect(args).not.toContain('--with'); }); - it('bun inline without deps uses bun -e (no extra flags)', async () => { + it('bun inline without deps uses bun --no-env-file -e', async () => { const node: ScriptNode = { id: 'bun-no-deps', script: 'console.log("hello")', @@ -350,7 +350,7 @@ describe('script node deps field — command construction', () => { expect(scriptCall).toBeDefined(); const [cmd, args] = scriptCall as [string, string[]]; expect(cmd).toBe('bun'); - expect(args).toEqual(['-e', 'console.log("hello")']); + expect(args).toEqual(['--no-env-file', '-e', 'console.log("hello")']); }); it('uv named script with deps uses uv run --with flags', async () => {