diff --git a/.claude/rules/cli.md b/.claude/rules/cli.md index e0db51c154..11a1d68d81 100644 --- a/.claude/rules/cli.md +++ b/.claude/rules/cli.md @@ -29,10 +29,9 @@ bun run cli version ## Startup Behavior -1. Deletes `process.env.DATABASE_URL` (prevent target repo's DB from leaking in) -2. Loads `~/.archon/.env` with `override: true` -3. Smart Claude auth default: if no `CLAUDE_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`, sets `CLAUDE_USE_GLOBAL_AUTH=true` -4. Imports all commands AFTER dotenv setup +1. Loads `~/.archon/.env` with `override: true` (Archon's config wins over any Bun-auto-loaded CWD vars) +2. Smart Claude auth default: if no `CLAUDE_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`, sets `CLAUDE_USE_GLOBAL_AUTH=true` +3. Imports all commands AFTER dotenv setup ## WorkflowRunOptions Interface diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index c32863d271..96c0209666 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -12,26 +12,13 @@ import { config } from 'dotenv'; import { resolve } from 'path'; import { existsSync } from 'fs'; -// Strip all vars that Bun may have auto-loaded from CWD's .env. -// Bun auto-loads .env relative to CWD before any user code runs. The CLI -// runs from target repos whose .env contains keys for that app (ANTHROPIC_API_KEY, -// DATABASE_URL, OPENAI_API_KEY, etc.) — none of which should affect Archon. -// Strategy: parse the CWD .env without applying it, then delete those keys. -const cwdEnvPath = resolve(process.cwd(), '.env'); -if (existsSync(cwdEnvPath)) { - const cwdEnvResult = config({ path: cwdEnvPath, processEnv: {} }); - // If parse fails, cwdEnvResult.parsed is undefined — safe to skip: - // Bun uses the same RFC-style parser, so a file dotenv cannot parse - // was also unparseable by Bun and contributed no keys to process.env. - if (cwdEnvResult.parsed) { - for (const key of Object.keys(cwdEnvResult.parsed)) { - Reflect.deleteProperty(process.env, key); - } - } -} - -// Load .env from global Archon config only (override: true so ~/.archon/.env -// always wins over any remaining Bun-auto-loaded vars) +// Load .env from global Archon config (override: true so ~/.archon/.env +// always wins over any Bun-auto-loaded CWD vars). +// +// Credential safety: target repo .env keys that Bun auto-loads from CWD +// cannot leak into AI subprocesses — SUBPROCESS_ENV_ALLOWLIST blocks them. +// The env-leak gate provides a second layer by scanning target repos before +// spawning. No CWD stripping needed. const globalEnvPath = resolve(process.env.HOME ?? '~', '.archon', '.env'); if (existsSync(globalEnvPath)) { const result = config({ path: globalEnvPath, override: true }); diff --git a/packages/docs-web/src/content/docs/contributing/cli-internals.md b/packages/docs-web/src/content/docs/contributing/cli-internals.md index b644eb2246..2adaa99fa2 100644 --- a/packages/docs-web/src/content/docs/contributing/cli-internals.md +++ b/packages/docs-web/src/content/docs/contributing/cli-internals.md @@ -38,8 +38,8 @@ packages/cli/ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ -│ cli.ts:15-31 Load environment │ -│ Suppresses cwd .env → loads ~/.archon/.env only │ +│ cli.ts Load environment │ +│ Loads ~/.archon/.env with override: true │ └─────────────────────────────────┬───────────────────────────────┘ │ ▼ diff --git a/packages/docs-web/src/content/docs/reference/cli.md b/packages/docs-web/src/content/docs/reference/cli.md index d51244380a..f2821a1b8b 100644 --- a/packages/docs-web/src/content/docs/reference/cli.md +++ b/packages/docs-web/src/content/docs/reference/cli.md @@ -362,12 +362,11 @@ When using `--branch`, workflows run inside the worktree directory. ## Environment -The CLI loads environment variables exclusively from `~/.archon/.env`. It does **not** load `.env` from the current working directory. This prevents conflicts when running Archon from target projects that have their own database configurations. +The CLI loads `~/.archon/.env` with `override: true`, so Archon's own config always wins over any env vars Bun auto-loads from the current working directory. Target repo env vars remain in `process.env` but cannot reach AI subprocesses — `SUBPROCESS_ENV_ALLOWLIST` blocks all non-whitelisted keys. On startup, the CLI: -1. Deletes any `DATABASE_URL` that Bun may have auto-loaded from the target repo's `.env` -2. Loads `~/.archon/.env` with `override: true` -3. Auto-enables global Claude auth if no explicit tokens are set +1. Loads `~/.archon/.env` with `override: true` (Archon's config wins over CWD vars) +2. Auto-enables global Claude auth if no explicit tokens are set ## Database diff --git a/packages/docs-web/src/content/docs/reference/configuration.md b/packages/docs-web/src/content/docs/reference/configuration.md index e636957b23..a1024c530c 100644 --- a/packages/docs-web/src/content/docs/reference/configuration.md +++ b/packages/docs-web/src/content/docs/reference/configuration.md @@ -296,21 +296,19 @@ Infrastructure configuration (database URL, platform tokens) is stored in `.env` | Component | Location | Purpose | |-----------|----------|---------| -| **CLI** | `~/.archon/.env` | Global infrastructure config (only source loaded) | -| **Server** | `/.env` | Platform tokens, database | +| **CLI** | `~/.archon/.env` | Global infrastructure config (only source, loaded with `override: true`) | +| **Server (dev)** | `/.env` + `~/.archon/.env` | Repo `.env` for platform tokens; `~/.archon/.env` overrides with `override: true` | +| **Server (binary)** | `~/.archon/.env` | Single source of truth (repo `.env` path is not available in compiled binaries) | -**Important**: The CLI loads `.env` **only** from `~/.archon/.env`. On startup, it explicitly deletes any `DATABASE_URL` that Bun may have auto-loaded from the current working directory's `.env`, then loads `~/.archon/.env` with `override: true`. This prevents conflicts when running Archon from target projects that have their own database configurations. +**How it works**: Both the CLI and server load `~/.archon/.env` with `override: true`, so Archon's own config always wins over any env vars Bun auto-loads from the current working directory. Target repo env vars remain in `process.env` but cannot reach AI subprocesses — `SUBPROCESS_ENV_ALLOWLIST` blocks all non-whitelisted keys. -**Best practice**: Use `~/.archon/.env` as the single source of truth. If running the server, symlink or copy to the archon repo: +**Best practice**: Use `~/.archon/.env` as the single source of truth: ```bash # Create global config mkdir -p ~/.archon cp .env.example ~/.archon/.env # Edit with your values - -# For server, symlink to repo -ln -s ~/.archon/.env .env ``` ## Docker Configuration diff --git a/packages/docs-web/src/content/docs/reference/security.md b/packages/docs-web/src/content/docs/reference/security.md index 14195a7374..26e26d169a 100644 --- a/packages/docs-web/src/content/docs/reference/security.md +++ b/packages/docs-web/src/content/docs/reference/security.md @@ -118,13 +118,14 @@ The GitHub and Gitea adapters verify webhook signatures to ensure payloads origi - The `.env.example` file in the repository contains placeholder values -- copy it and fill in real values. - Never commit `.env` files to git. The repository's `.gitignore` excludes them. -**CWD `.env` isolation:** -- 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. +**Subprocess env isolation:** +- Bun auto-loads `.env` from CWD before any Archon code runs. These vars remain in the server/CLI's `process.env` but **cannot reach AI subprocesses** — Claude Code subprocesses receive only an explicit allowlist of env vars (`SUBPROCESS_ENV_ALLOWLIST`: system essentials, Claude auth, Archon runtime config, git identity, GitHub tokens). Keys like `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `DATABASE_URL` are not on the allowlist and are blocked. +- `~/.archon/.env` is loaded with `override: true`, so Archon's own config always wins over any Bun-auto-loaded CWD vars for overlapping keys. +- Per-codebase env vars configured via `codebase_env_vars` or `.archon/config.yaml` `env:` are merged on top of this filtered base at workflow execution time. ### 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. +Beyond the subprocess allowlist, Archon also scans target repos for sensitive keys **before spawning**. A Claude or Codex subprocess started with `cwd=/path/to/target/repo` inherits its own Bun auto-loaded `.env` — the env-leak gate catches this by scanning the target repo's `.env` files at registration and pre-spawn time. **What Archon scans:** auto-loaded filenames `.env`, `.env.local`, `.env.development`, `.env.production`, `.env.development.local`, `.env.production.local`. diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 04633bc8ad..e2551e1049 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -3,52 +3,51 @@ * Multi-platform AI coding assistant (Telegram, Discord, Slack, GitHub, Gitea) */ -// Load environment variables FIRST — resolve to monorepo root .env -// Uses dotenv with explicit path so it works from any CWD (worktrees, packages/server/, etc.) +// Load environment variables FIRST — before any application imports. +// +// Credential safety: target repo `.env` keys (like CLAUDE_API_KEY) that Bun +// auto-loads from CWD cannot leak into AI subprocesses because +// SUBPROCESS_ENV_ALLOWLIST blocks them. The env-leak gate provides a second +// layer by scanning target repos before spawning. No CWD stripping needed. import { config } from 'dotenv'; import { resolve } from 'path'; import { existsSync } from 'fs'; - -// Strip all vars that Bun may have auto-loaded from CWD's .env. -// When the server is started from inside a target repo, Bun auto-loads that -// repo's .env (containing e.g. ANTHROPIC_API_KEY for the target app) before -// any user code runs. Strip those vars now so they don't bleed into server env -// or subprocess spawns. -const cwdEnvPath = resolve(process.cwd(), '.env'); -if (existsSync(cwdEnvPath)) { - const cwdEnvResult = config({ path: cwdEnvPath, processEnv: {} }); - // If parse fails, cwdEnvResult.parsed is undefined — safe to skip: - // Bun uses the same RFC-style parser, so a file dotenv cannot parse - // was also unparseable by Bun and contributed no keys to process.env. - if (cwdEnvResult.parsed) { - for (const key of Object.keys(cwdEnvResult.parsed)) { - Reflect.deleteProperty(process.env, key); - } +import { BUNDLED_IS_BINARY } from '@archon/paths'; + +// In dev/source mode, load the repo root .env (platform tokens, API keys, etc.) +// import.meta.dir is frozen at build time, so skip in compiled binaries. +const envPath = BUNDLED_IS_BINARY ? undefined : resolve(import.meta.dir, '..', '..', '..', '.env'); + +if (envPath) { + const dotenvResult = config({ path: envPath }); + if (dotenvResult.error) { + // Use console.error since logger depends on env vars (LOG_LEVEL) + console.error(`Failed to load .env from ${envPath}: ${dotenvResult.error.message}`); + console.error('Hint: Copy .env.example to .env and configure your credentials.'); } } -// Resolve from this file's location: packages/server/src/ → ../../.. → repo root -const envPath = resolve(import.meta.dir, '..', '..', '..', '.env'); -const dotenvResult = config({ path: envPath }); - -if (dotenvResult.error) { - // Use console.error since logger depends on env vars (LOG_LEVEL) - console.error(`Failed to load .env from ${envPath}: ${dotenvResult.error.message}`); - console.error('Hint: Copy .env.example to .env and configure your credentials.'); -} - -// Load ~/.archon/.env for infrastructure config (DATABASE_URL). -// The CLI loads this file with override: true, so both CLI and server -// resolve DATABASE_URL from the same source. We only override DATABASE_URL -// (not PORT, LOG_LEVEL, etc.) to avoid stomping on server-specific config. +// Load ~/.archon/.env with override — Archon's config always wins over any +// Bun-auto-loaded CWD vars. In binary mode this is the single source of truth. +// In dev mode it overrides CWD vars for keys like DATABASE_URL. const globalEnvPath = resolve(process.env.HOME ?? '~', '.archon', '.env'); if (existsSync(globalEnvPath)) { - const globalResult = config({ path: globalEnvPath, processEnv: {} }); - if (globalResult.parsed?.DATABASE_URL) { - process.env.DATABASE_URL = globalResult.parsed.DATABASE_URL; + const globalResult = config({ path: globalEnvPath, override: true }); + if (globalResult.error) { + console.error(`Failed to load .env from ${globalEnvPath}: ${globalResult.error.message}`); + console.error('Hint: Check for syntax errors in your ~/.archon/.env file.'); } } +// Smart default: use Claude Code's built-in OAuth if no explicit credentials +if ( + !process.env.CLAUDE_API_KEY && + !process.env.CLAUDE_CODE_OAUTH_TOKEN && + process.env.CLAUDE_USE_GLOBAL_AUTH === undefined +) { + process.env.CLAUDE_USE_GLOBAL_AUTH = 'true'; +} + import { OpenAPIHono } from '@hono/zod-openapi'; import { validationErrorHook } from './routes/openapi-defaults'; import { TelegramAdapter, GitHubAdapter, DiscordAdapter, SlackAdapter } from '@archon/adapters'; @@ -167,7 +166,7 @@ export async function startServer(opts: ServerOptions = {}): Promise { 'Or set CODEX_ID_TOKEN + CODEX_ACCESS_TOKEN in .env', 'See .env.example for all options', ], - envFile: envPath, + envFile: BUNDLED_IS_BINARY ? globalEnvPath : envPath, }, 'no_ai_credentials' );