diff --git a/CHANGELOG.md b/CHANGELOG.md index 02776a0ca4..c5f0a9027a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **CLI `--allow-env-keys` flag** for `archon workflow run` — grant env-leak-gate consent during auto-registration without needing the Web UI. The grant is audit-logged as `env_leak_consent_granted` with `actor: 'user-cli'` (#973). +- **Global `allow_target_repo_keys` flag** in `~/.archon/config.yaml` — bypass the env-leak gate for all codebases on this machine. Per-repo `.archon/config.yaml` `allow_target_repo_keys: false` re-enables the gate for that repo. The server emits `env_leak_gate_disabled` once per process per source the first time `loadConfig` resolves the bypass as active (#973). +- **`PATCH /api/codebases/:id`** endpoint to flip `allow_env_keys` on existing codebases without delete/re-add. Audit-logged at `warn` level on every grant and revoke, including a `scanStatus` field that distinguishes "scanned" from "scan failed" so audit reviewers can tell empty key lists apart (#973). +- **Settings → Projects per-row toggle** to grant or revoke env-key consent retroactively, with an "env keys allowed" badge and inline error feedback if the PATCH fails (#973). +- **Startup env-leak scan**: when `allow_target_repo_keys` is not set, the server emits one `startup_env_leak_gate_will_block` warn per registered codebase whose `.env` would block the next spawn. Skipped entirely when the global bypass is active (#973). + +### Changed + +- **Env-leak gate error messages** are now context-aware: separate remediation copy for Web Add-Project, CLI auto-register, and pre-spawn-of-existing-codebase paths. Previously every error pointed at the Web UI checkbox even from the CLI (#973). + +### Security + +- The default `allow_env_keys` per codebase remains `false` (fail-closed). Existing codebases with sensitive keys in their auto-loaded `.env` files (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.) will continue to be blocked at the next workflow run. **Remediation paths** (any one): (1) remove the key from `.env`, (2) rename to `.env.secrets`, (3) toggle "Allow env keys" in Settings → Projects, (4) `archon workflow run --allow-env-keys ...`, (5) set `allow_target_repo_keys: true` in `~/.archon/config.yaml`. See `docs/reference/security.md` for full details. + + ## [0.2.12] - 2026-03-20 Chat-first navigation redesign, DAG graph viewer, per-node MCP and skills, and extensive bug fixes across the web UI and workflow engine. diff --git a/CLAUDE.md b/CLAUDE.md index b7b6428469..fa00b0fb04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,6 +198,10 @@ 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 @@ -743,6 +747,12 @@ Pattern: Use `classifyIsolationError()` (from `@archon/isolation`) to map git er - `POST /api/workflows/runs/{runId}/abandon` - Abandon a non-terminal run (marks as cancelled) - `DELETE /api/workflows/runs/{runId}` - Delete a terminal workflow run and its events +**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` +- `DELETE /api/codebases/:id` - Delete a codebase and clean up resources + **Artifact Files:** - `GET /api/artifacts/:runId/*` - Serve a workflow artifact file by run ID and relative path; returns `text/markdown` for `.md` files, `text/plain` otherwise; 400 on path traversal (`..`), 404 if run or file not found diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7330047e92..8852dbc657 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -127,6 +127,9 @@ 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) Examples: archon chat "What does the orchestrator do?" @@ -190,6 +193,7 @@ async function main(): Promise { reason: { type: 'string' }, workflow: { type: 'string' }, 'no-context': { type: 'boolean' }, + 'allow-env-keys': { type: 'boolean' }, }, allowPositionals: true, strict: false, // Allow unknown flags to pass through @@ -211,6 +215,7 @@ 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) { @@ -323,6 +328,7 @@ 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 61987521d9..99edcb0bfe 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -62,6 +62,8 @@ 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; } @@ -321,7 +323,7 @@ export async function workflowRunCommand( const repoRoot = await git.findRepoRoot(cwd); if (repoRoot) { try { - const result = await registerRepository(repoRoot); + const result = await registerRepository(repoRoot, options.allowEnvKeys, 'register-cli'); codebase = await codebaseDb.getCodebase(result.codebaseId); if (!result.alreadyExisted) { getLog().info({ name: result.name }, 'cli.codebase_auto_registered'); diff --git a/packages/core/src/clients/claude.test.ts b/packages/core/src/clients/claude.test.ts index e031a0d72a..fd99746e03 100644 --- a/packages/core/src/clients/claude.test.ts +++ b/packages/core/src/clients/claude.test.ts @@ -20,6 +20,7 @@ import { ClaudeClient } from './claude'; import * as claudeModule from './claude'; import * as codebaseDb from '../db/codebases'; import * as envLeakScanner from '../utils/env-leak-scanner'; +import * as configLoader from '../config/config-loader'; describe('ClaudeClient', () => { let client: ClaudeClient; @@ -987,7 +988,7 @@ describe('ClaudeClient', () => { for await (const _ of client.sendQuery('test', '/workspace')) { // consume } - }).toThrow('Cannot add codebase'); + }).toThrow('Cannot run workflow'); }); test('skips scan when codebase has allow_env_keys: true', async () => { @@ -1016,6 +1017,44 @@ describe('ClaudeClient', () => { expect(chunks).toHaveLength(1); }); + test('skips scan when allowTargetRepoKeys is true in merged config', async () => { + const spyLoadConfig = spyOn(configLoader, 'loadConfig').mockResolvedValueOnce({ + allowTargetRepoKeys: true, + } as Awaited>); + // Even though scanner would return a finding, the config bypass must short-circuit + spyScan.mockReturnValueOnce({ + path: '/workspace', + findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }], + }); + + const chunks = []; + for await (const chunk of client.sendQuery('test', '/workspace')) { + chunks.push(chunk); + } + + expect(spyScan).not.toHaveBeenCalled(); + expect(chunks).toHaveLength(1); + spyLoadConfig.mockRestore(); + }); + + test('falls back to scanner when loadConfig throws (fail-closed)', async () => { + const spyLoadConfig = spyOn(configLoader, 'loadConfig').mockRejectedValueOnce( + new Error('YAML parse error') + ); + spyScan.mockReturnValueOnce({ + path: '/workspace', + findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }], + }); + + await expect(async () => { + for await (const _ of client.sendQuery('test', '/workspace')) { + // consume + } + }).toThrow('Cannot run workflow'); + expect(spyScan).toHaveBeenCalled(); + spyLoadConfig.mockRestore(); + }); + test('uses prefix lookup for worktree paths when exact match returns null', async () => { spyFindByPathPrefix.mockResolvedValueOnce({ id: 'codebase-1', diff --git a/packages/core/src/clients/claude.ts b/packages/core/src/clients/claude.ts index 6e0d996f32..0a0624c69c 100644 --- a/packages/core/src/clients/claude.ts +++ b/packages/core/src/clients/claude.ts @@ -29,6 +29,7 @@ import { createLogger } from '@archon/paths'; import { buildCleanSubprocessEnv } from '../utils/env-allowlist'; import { scanPathForSensitiveKeys, EnvLeakError } from '../utils/env-leak-scanner'; import * as codebaseDb from '../db/codebases'; +import { loadConfig } from '../config/config-loader'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ let cachedLog: ReturnType | undefined; @@ -267,9 +268,21 @@ export class ClaudeClient implements IAssistantClient { (await codebaseDb.findCodebaseByDefaultCwd(cwd)) ?? (await codebaseDb.findCodebaseByPathPrefix(cwd)); if (!codebase?.allow_env_keys) { - const report = scanPathForSensitiveKeys(cwd); - if (report.findings.length > 0) { - throw new EnvLeakError(report); + // Fail-closed: a config load failure (corrupt YAML, permission denied) + // must NOT silently bypass the gate. Catch, log, and treat as + // `allowTargetRepoKeys = false` so the scanner still runs. + let allowTargetRepoKeys = false; + try { + const merged = await loadConfig(cwd); + allowTargetRepoKeys = merged.allowTargetRepoKeys; + } catch (configErr) { + getLog().warn({ err: configErr, cwd }, 'env_leak_gate.config_load_failed_gate_enforced'); + } + if (!allowTargetRepoKeys) { + const report = scanPathForSensitiveKeys(cwd); + if (report.findings.length > 0) { + throw new EnvLeakError(report, 'spawn-existing'); + } } } diff --git a/packages/core/src/clients/codex.test.ts b/packages/core/src/clients/codex.test.ts index 3dbc9350a6..e29002cd0a 100644 --- a/packages/core/src/clients/codex.test.ts +++ b/packages/core/src/clients/codex.test.ts @@ -1043,7 +1043,7 @@ describe('CodexClient', () => { } }; - await expect(consumeGenerator()).rejects.toThrow('Cannot add codebase'); + await expect(consumeGenerator()).rejects.toThrow('Cannot run workflow'); }); test('skips scan when codebase has allow_env_keys: true', async () => { diff --git a/packages/core/src/clients/codex.ts b/packages/core/src/clients/codex.ts index 4e60942a47..a7c52731e1 100644 --- a/packages/core/src/clients/codex.ts +++ b/packages/core/src/clients/codex.ts @@ -20,6 +20,7 @@ import { import { createLogger } from '@archon/paths'; import { scanPathForSensitiveKeys, EnvLeakError } from '../utils/env-leak-scanner'; import * as codebaseDb from '../db/codebases'; +import { loadConfig } from '../config/config-loader'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ let cachedLog: ReturnType | undefined; @@ -163,9 +164,19 @@ export class CodexClient implements IAssistantClient { (await codebaseDb.findCodebaseByDefaultCwd(cwd)) ?? (await codebaseDb.findCodebaseByPathPrefix(cwd)); if (!codebase?.allow_env_keys) { - const report = scanPathForSensitiveKeys(cwd); - if (report.findings.length > 0) { - throw new EnvLeakError(report); + // Fail-closed: a config load failure must NOT silently bypass the gate. + let allowTargetRepoKeys = false; + try { + const merged = await loadConfig(cwd); + allowTargetRepoKeys = merged.allowTargetRepoKeys; + } catch (configErr) { + getLog().warn({ err: configErr, cwd }, 'env_leak_gate.config_load_failed_gate_enforced'); + } + if (!allowTargetRepoKeys) { + const report = scanPathForSensitiveKeys(cwd); + if (report.findings.length > 0) { + throw new EnvLeakError(report, 'spawn-existing'); + } } } diff --git a/packages/core/src/config/config-loader.ts b/packages/core/src/config/config-loader.ts index f0f51ba0a4..8ee702c613 100644 --- a/packages/core/src/config/config-loader.ts +++ b/packages/core/src/config/config-loader.ts @@ -38,6 +38,24 @@ 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 */ @@ -198,6 +216,7 @@ function getDefaults(): MergedConfig { loadDefaultCommands: true, loadDefaultWorkflows: true, }, + allowTargetRepoKeys: false, }; } @@ -302,6 +321,12 @@ 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; } @@ -375,6 +400,14 @@ 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 6b49bfbcff..f3bbdf41cf 100644 --- a/packages/core/src/config/config-types.ts +++ b/packages/core/src/config/config-types.ts @@ -84,6 +84,20 @@ 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; } /** @@ -158,6 +172,12 @@ 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 */ @@ -240,6 +260,14 @@ 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/codebases.test.ts b/packages/core/src/db/codebases.test.ts index 8fef07439e..ec3c249d14 100644 --- a/packages/core/src/db/codebases.test.ts +++ b/packages/core/src/db/codebases.test.ts @@ -22,6 +22,7 @@ import { findCodebaseByDefaultCwd, findCodebaseByName, updateCodebase, + updateCodebaseAllowEnvKeys, deleteCodebase, } from './codebases'; @@ -398,6 +399,26 @@ 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 7b7f491719..b9f45578b6 100644 --- a/packages/core/src/db/codebases.ts +++ b/packages/core/src/db/codebases.ts @@ -158,6 +158,21 @@ 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 d58f5f037d..7f948cfb33 100644 --- a/packages/core/src/handlers/clone.test.ts +++ b/packages/core/src/handlers/clone.test.ts @@ -961,14 +961,20 @@ describe('RegisterResult shape', () => { ); }); - test('does not scan when allowEnvKeys is true, even with scanner findings available', async () => { + 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'] }], + }); - // allowEnvKeys=true should bypass the gate; scanner not called even with findings queued const result = await cloneRepository('https://github.com/owner/repo', true); - expect(mockScanPathForSensitiveKeys).not.toHaveBeenCalled(); 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 4042a3f127..3dc96f499c 100644 --- a/packages/core/src/handlers/clone.ts +++ b/packages/core/src/handlers/clone.ts @@ -16,7 +16,12 @@ import { parseOwnerRepo, } from '@archon/paths'; import { findMarkdownFilesRecursive } from '../utils/commands'; -import { scanPathForSensitiveKeys, EnvLeakError } from '../utils/env-leak-scanner'; +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) */ @@ -42,14 +47,50 @@ async function registerRepoAtPath( targetPath: string, name: string, repositoryUrl: string | null, - allowEnvKeys = false + allowEnvKeys = false, + context: LeakErrorContext = 'register-ui' ): Promise { - // Scan for sensitive keys in auto-loaded .env files before registering + // 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 report = scanPathForSensitiveKeys(targetPath); - if (report.findings.length > 0) { - throw new EnvLeakError(report); + 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 @@ -203,12 +244,13 @@ function normalizeRepoUrl(rawUrl: string): { */ export async function cloneRepository( repoUrl: string, - allowEnvKeys?: boolean + allowEnvKeys?: boolean, + context: LeakErrorContext = 'register-ui' ): 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); + return registerRepository(resolvedPath, allowEnvKeys, context); } const { workingUrl, ownerName, repoName, targetPath } = normalizeRepoUrl(repoUrl); @@ -293,7 +335,8 @@ export async function cloneRepository( targetPath, `${ownerName}/${repoName}`, workingUrl, - allowEnvKeys + allowEnvKeys, + context ); getLog().info({ url: workingUrl, targetPath }, 'clone_completed'); return result; @@ -304,7 +347,8 @@ export async function cloneRepository( */ export async function registerRepository( localPath: string, - allowEnvKeys?: boolean + allowEnvKeys?: boolean, + context: LeakErrorContext = 'register-ui' ): Promise { // Validate path exists and is a git repo try { @@ -371,5 +415,5 @@ export async function registerRepository( ); // default_cwd is the real local path (not the symlink) - return registerRepoAtPath(localPath, name, remoteUrl, allowEnvKeys); + return registerRepoAtPath(localPath, name, remoteUrl, allowEnvKeys, context); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e8caf0ec6e..e212eb10c9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -151,6 +151,7 @@ export { scanPathForSensitiveKeys, formatLeakError, type LeakReport, + type LeakErrorContext, } from './utils/env-leak-scanner'; // GitHub GraphQL diff --git a/packages/core/src/utils/env-leak-scanner.test.ts b/packages/core/src/utils/env-leak-scanner.test.ts index fe528a1575..4d436bbc24 100644 --- a/packages/core/src/utils/env-leak-scanner.test.ts +++ b/packages/core/src/utils/env-leak-scanner.test.ts @@ -4,6 +4,7 @@ import { join } from 'path'; import { scanPathForSensitiveKeys, EnvLeakError, + formatLeakError, SENSITIVE_KEYS, AUTOLOADED_FILES, } from './env-leak-scanner'; @@ -93,6 +94,28 @@ describe('EnvLeakError', () => { 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', diff --git a/packages/core/src/utils/env-leak-scanner.ts b/packages/core/src/utils/env-leak-scanner.ts index 46e0a84685..48edc2c6b7 100644 --- a/packages/core/src/utils/env-leak-scanner.ts +++ b/packages/core/src/utils/env-leak-scanner.ts @@ -30,10 +30,25 @@ export interface LeakReport { 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 { - constructor(public readonly report: LeakReport) { - super(formatLeakError(report)); + public readonly context: LeakErrorContext; + constructor( + public readonly report: LeakReport, + context: LeakErrorContext = 'register-ui' + ) { + super(formatLeakError(report, context)); this.name = 'EnvLeakError'; + this.context = context; } } @@ -76,10 +91,48 @@ export function scanPathForSensitiveKeys(dirPath: string): LeakReport { return { path: dirPath, findings }; } -export function formatLeakError(report: LeakReport): string { +/** + * 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'); - return `Cannot add codebase — ${report.path} contains keys that will leak into AI subprocesses + 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} @@ -98,7 +151,5 @@ ${fileList} mv .env .env.secrets # update your app to load it explicitly - 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.`; +${consent}`; } diff --git a/packages/docs-web/src/content/docs/reference/api.md b/packages/docs-web/src/content/docs/reference/api.md index cdde8df067..0df9cbbfd0 100644 --- a/packages/docs-web/src/content/docs/reference/api.md +++ b/packages/docs-web/src/content/docs/reference/api.md @@ -138,6 +138,7 @@ 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 | @@ -165,6 +166,16 @@ 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 5963408c17..6148fb8bc6 100644 --- a/packages/docs-web/src/content/docs/reference/cli.md +++ b/packages/docs-web/src/content/docs/reference/cli.md @@ -122,6 +122,7 @@ 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 78a9fbce77..e636957b23 100644 --- a/packages/docs-web/src/content/docs/reference/configuration.md +++ b/packages/docs-web/src/content/docs/reference/configuration.md @@ -82,6 +82,12 @@ paths: # Concurrency limits 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 @@ -128,6 +134,12 @@ defaults: # env: # 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 c834418e8e..14195a7374 100644 --- a/packages/docs-web/src/content/docs/reference/security.md +++ b/packages/docs-web/src/content/docs/reference/security.md @@ -122,6 +122,37 @@ The GitHub and Gitea adapters verify webhook signatures to ensure payloads origi - When running inside a target repository, Bun auto-loads that repo's `.env` before any Archon code runs. Both the CLI and server strip every key parsed from the CWD `.env` at startup, then load only `~/.archon/.env` (which always wins via `override: true`). This prevents target-repo secrets (e.g. `ANTHROPIC_API_KEY`, `DATABASE_URL`, `OPENAI_API_KEY`) from bleeding into Archon or its subprocesses. - Claude Code subprocesses receive only an explicit allowlist of env vars (system essentials, Claude auth, Archon runtime config, git identity, GitHub tokens). Per-codebase env vars configured via `codebase_env_vars` or `.archon/config.yaml` `env:` are merged on top of this filtered base. +### Env-leak gate (target repo `.env` keys) + +Archon scrubs its own environment, but **Bun auto-loads `.env` from the subprocess working directory** before any user code runs. That means a Claude or Codex subprocess started with `cwd=/path/to/target/repo` will re-inject any sensitive keys present in that repo's auto-loaded `.env` files — bypassing the allowlist above and silently billing the wrong API account. + +**What Archon scans:** auto-loaded filenames `.env`, `.env.local`, `.env.development`, `.env.production`, `.env.development.local`, `.env.production.local`. + +**Scanned keys:** `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, `OPENAI_API_KEY`, `CODEX_API_KEY`, `GEMINI_API_KEY`. + +:::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. + **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/server/src/index.ts b/packages/server/src/index.ts index d8463f573c..4d405b63ba 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -70,7 +70,9 @@ 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'; @@ -182,6 +184,58 @@ async function main(): 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(); @@ -198,10 +252,6 @@ async function main(): Promise { // Validate app defaults paths (non-blocking, just logs warnings) await validateAppDefaultsPaths(); - // Load and log configuration - const config = await loadConfig(); - logConfig(config); - // Initialize conversation lock manager const maxConcurrent = parseInt(process.env.MAX_CONCURRENT_CONVERSATIONS ?? '10'); const lockManager = new ConversationLockManager(maxConcurrent); diff --git a/packages/server/src/routes/api.codebases.test.ts b/packages/server/src/routes/api.codebases.test.ts index e7105ddd42..0265a359e1 100644 --- a/packages/server/src/routes/api.codebases.test.ts +++ b/packages/server/src/routes/api.codebases.test.ts @@ -48,6 +48,7 @@ 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( @@ -122,10 +123,12 @@ 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', () => ({ @@ -545,6 +548,63 @@ describe('POST /api/codebases', () => { }); }); +// --------------------------------------------------------------------------- +// 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); + }); +}); + // --------------------------------------------------------------------------- // Tests: DELETE /api/codebases/:id // --------------------------------------------------------------------------- diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index 20d26b308d..81afc6db3d 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -28,6 +28,7 @@ import { ConversationNotFoundError, generateAndSetTitle, EnvLeakError, + scanPathForSensitiveKeys, } from '@archon/core'; import { removeWorktree, toRepoPath, toWorktreePath } from '@archon/git'; import { @@ -104,6 +105,7 @@ import { codebaseSchema, codebaseIdParamsSchema, addCodebaseBodySchema, + updateCodebaseBodySchema, deleteCodebaseResponseSchema, codebaseEnvVarsResponseSchema, setEnvVarBodySchema, @@ -458,6 +460,28 @@ 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}', @@ -1509,6 +1533,71 @@ 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 7114864d3d..e8a6dea887 100644 --- a/packages/server/src/routes/schemas/codebase.schemas.ts +++ b/packages/server/src/routes/schemas/codebase.schemas.ts @@ -41,6 +41,13 @@ export const addCodebaseBodySchema = z }) .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.ts b/packages/web/src/lib/api.ts index 1478e91257..f13034f274 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -167,6 +167,17 @@ 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 a945ccb2c5..07a07690fc 100644 --- a/packages/web/src/routes/SettingsPage.tsx +++ b/packages/web/src/routes/SettingsPage.tsx @@ -10,6 +10,7 @@ import { getHealth, listCodebases, addCodebase, + updateCodebase, deleteCodebase, updateAssistantConfig, getCodebaseEnvVars, @@ -265,6 +266,7 @@ function ProjectsSection(): React.ReactElement { 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'], @@ -289,6 +291,20 @@ 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()) { @@ -302,6 +318,11 @@ function ProjectsSection(): React.ReactElement { Projects + {toggleError && ( +
+ Failed to update env-key consent: {toggleError} +
+ )} {!codebases || codebases.length === 0 ? (
No projects registered.
) : ( @@ -310,10 +331,40 @@ function ProjectsSection(): React.ReactElement {
-
{cb.name}
+
+
{cb.name}
+ {cb.allow_env_keys && ( + + env keys allowed + + )} +
{cb.default_cwd}
+