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
7 changes: 3 additions & 4 deletions .claude/rules/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
27 changes: 7 additions & 20 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Comment on lines +15 to 24
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update the CLI env-isolation test to match the new contract.

packages/cli/src/cli.test.ts:215-250 still describes the removed DATABASE_URL deletion behavior and manually does the delete itself, so it can keep passing without verifying anything in cli.ts. This change should be paired with test coverage for the new behavior instead: global ~/.archon/.env override semantics and reliance on the subprocess allowlist/env-leak gate.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/cli.ts` around lines 15 - 24, The CLI env-isolation test
still deletes DATABASE_URL and asserts the old behavior; update the test to stop
removing DATABASE_URL and instead verify the new contract: that globalEnvPath is
loaded with config({ path: globalEnvPath, override: true }) (so ~/.archon/.env
overrides CWD), and that subprocess environment isolation relies on the
SUBPROCESS_ENV_ALLOWLIST and the env-leak gate scan before spawning (assert the
allowlist is consulted and the env-leak scanner is invoked or its result is
respected); keep references to the symbols globalEnvPath, config(... override:
true), SUBPROCESS_ENV_ALLOWLIST and the env-leak gate when adding assertions.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
└─────────────────────────────────┬───────────────────────────────┘
Expand Down
7 changes: 3 additions & 4 deletions packages/docs-web/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 5 additions & 7 deletions packages/docs-web/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** | `<archon-repo>/.env` | Platform tokens, database |
| **CLI** | `~/.archon/.env` | Global infrastructure config (only source, loaded with `override: true`) |
| **Server (dev)** | `<archon-repo>/.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
Expand Down
9 changes: 5 additions & 4 deletions packages/docs-web/src/content/docs/reference/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
71 changes: 35 additions & 36 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}
}
Comment on lines 34 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle ~/.archon/.env parse failures explicitly.

In bundled mode this file is now the only .env source, but this branch ignores dotenv.config() errors. A malformed global env file will fall through and later fail as no_ai_credentials or other config issues instead of surfacing the real startup problem. As per coding guidelines, "Prefer throwing early with a clear error for unsupported/unsafe states - never silently swallow errors or broaden permissions (Fail Fast + Explicit Errors)".

Suggested fix
 const globalEnvPath = resolve(process.env.HOME ?? '~', '.archon', '.env');
 if (existsSync(globalEnvPath)) {
-  config({ path: globalEnvPath, override: true });
+  const globalEnvResult = config({ path: globalEnvPath, override: true });
+  if (globalEnvResult.error) {
+    console.error(
+      `Failed to load .env from ${globalEnvPath}: ${globalEnvResult.error.message}`
+    );
+    process.exit(1);
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/server/src/index.ts` around lines 34 - 36, The current branch
calling existsSync(globalEnvPath) then config({ path: globalEnvPath, override:
true }) ignores any errors from dotenv.config; update the startup to check the
result of config(...) (or catch thrown errors) when globalEnvPath exists and, on
parse failure, log the specific error and abort startup (throw or
process.exit(1)) so malformed ~/.archon/.env is surfaced immediately; locate the
call to config in packages/server/src/index.ts and add explicit error handling
around the config(...) call referencing globalEnvPath, config, and existsSync.


// 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';
Expand Down Expand Up @@ -167,7 +166,7 @@ export async function startServer(opts: ServerOptions = {}): Promise<void> {
'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'
);
Expand Down
Loading