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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ Options:
--json Output machine-readable JSON (for workflow list)
--workflow <name> 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?"
Expand Down Expand Up @@ -190,6 +193,7 @@ async function main(): Promise<number> {
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
Expand All @@ -211,6 +215,7 @@ async function main(): Promise<number> {
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) {
Expand Down Expand Up @@ -323,6 +328,7 @@ async function main(): Promise<number> {
fromBranch,
noWorktree,
resume: resumeFlag,
allowEnvKeys: allowEnvKeysFlag,
quiet: values.quiet as boolean | undefined,
verbose: values.verbose as boolean | undefined,
};
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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');
Expand Down
41 changes: 40 additions & 1 deletion packages/core/src/clients/claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<ReturnType<typeof configLoader.loadConfig>>);
// 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',
Expand Down
19 changes: 16 additions & 3 deletions packages/core/src/clients/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createLogger> | undefined;
Expand Down Expand Up @@ -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');
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/clients/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
17 changes: 14 additions & 3 deletions packages/core/src/clients/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createLogger> | undefined;
Expand Down Expand Up @@ -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');
}
}
}

Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/config/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,24 @@ function getLog(): ReturnType<typeof createLogger> {
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
*/
Expand Down Expand Up @@ -198,6 +216,7 @@ function getDefaults(): MergedConfig {
loadDefaultCommands: true,
loadDefaultWorkflows: true,
},
allowTargetRepoKeys: false,
};
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/config/config-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -158,6 +172,12 @@ export interface RepoConfig {
*/
env?: Record<string, string>;

/**
* 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
*/
Expand Down Expand Up @@ -240,6 +260,14 @@ export interface MergedConfig {
* Undefined when no env vars are configured.
*/
envVars?: Record<string, string>;

/**
* 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;
}

/**
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/db/codebases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
findCodebaseByDefaultCwd,
findCodebaseByName,
updateCodebase,
updateCodebaseAllowEnvKeys,
deleteCodebase,
} from './codebases';

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading