Skip to content
Closed
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
21 changes: 11 additions & 10 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 10 additions & 8 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,23 @@
* archon workflow run <name> [msg] Run a workflow
* archon version Show version info
*/
// Design rule: the CLI must never load target repo env.
//
// Bun's runtime auto-loads CWD `.env` files before any user code runs. When
// `archon` is invoked from inside a target repo, that repo's `.env` leaks into
// `process.env`. Side-effect import strips those keys during module load —
// MUST be the first import so that downstream modules reading env at init
// time (e.g. the Pino logger's LOG_LEVEL) see a clean environment.
import '@archon/paths/strip-cwd-env-boot';

import { parseArgs } from 'util';
import { config } from 'dotenv';
import { resolve } from 'path';
import { existsSync } from 'fs';

// 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 });
const result = config({ path: globalEnvPath });
if (result.error) {
// Logger may not be available yet (early startup), so use console for user-facing error
console.error(`Error loading .env from ${globalEnvPath}: ${result.error.message}`);
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,14 @@ export async function serveCommand(opts: ServeOptions): Promise<number> {
}

// Import server and start (dynamic import keeps CLI startup fast for other commands)
// Platform adapters (Telegram, Discord, Slack, GitHub, Gitea, GitLab) auto-start
// when their respective tokens are present in ~/.archon/.env. Users who only
// want the web UI simply leave those tokens unset.
try {
const { startServer } = await import('@archon/server');
await startServer({
webDistPath: webDistDir,
port: opts.port,
skipPlatformAdapters: true,
});
} catch (err) {
const error = toError(err);
Expand Down
5 changes: 4 additions & 1 deletion packages/paths/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./strip-cwd-env": "./src/strip-cwd-env.ts",
"./strip-cwd-env-boot": "./src/strip-cwd-env-boot.ts"
},
"scripts": {
"test": "bun test src/",
"type-check": "bun x tsc --noEmit"
},
"dependencies": {
"dotenv": "^17.2.3",
"pino": "^9",
"pino-pretty": "^13"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/paths/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ export {
parseLatestRelease,
} from './update-check';
export type { UpdateCheckResult } from './update-check';

// CWD env isolation
export { stripCwdEnv } from './strip-cwd-env';
18 changes: 18 additions & 0 deletions packages/paths/src/strip-cwd-env-boot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Side-effect boot module: strip Bun-auto-loaded CWD `.env` keys immediately
* on import. Import this as the FIRST import in CLI/server entry points to
* guarantee the strip runs before any module that reads `process.env` at
* load time (e.g. the Pino logger in `@archon/paths/logger`).
*
* Usage:
* import '@archon/paths/strip-cwd-env-boot'; // must be the first import
* // ...other imports...
*
* The separation between `strip-cwd-env.ts` (pure function, testable) and
* this boot file (side-effect wrapper) keeps the stripping logic unit-testable
* while still providing the "runs before everything else" guarantee that
* entry points need.
*/
import { stripCwdEnv } from './strip-cwd-env';

stripCwdEnv();
107 changes: 107 additions & 0 deletions packages/paths/src/strip-cwd-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { stripCwdEnv } from './strip-cwd-env';

describe('stripCwdEnv', () => {
let tmpDir: string;
let originalCwd: string;
const testKeys = [
'STRIP_TEST_MARKER_A',
'STRIP_TEST_MARKER_B',
'STRIP_TEST_LOCAL_MARKER',
'STRIP_TEST_DEV_MARKER',
'STRIP_TEST_PROD_MARKER',
'STRIP_TEST_OVERLAP_KEY',
'STRIP_TEST_PRESERVED_KEY',
'STRIP_TEST_MALFORMED_KEY',
];

beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'strip-cwd-env-'));
originalCwd = process.cwd();
process.chdir(tmpDir);
// Clean any leaked keys from earlier runs
for (const key of testKeys) {
delete process.env[key];
}
});

afterEach(() => {
process.chdir(originalCwd);
rmSync(tmpDir, { recursive: true, force: true });
for (const key of testKeys) {
delete process.env[key];
}
});

test('strips keys present in CWD .env', () => {
writeFileSync(join(tmpDir, '.env'), 'STRIP_TEST_MARKER_A=from_target_repo\n');
// Simulate Bun's auto-load
process.env.STRIP_TEST_MARKER_A = 'from_target_repo';

const stripped = stripCwdEnv();

expect(process.env.STRIP_TEST_MARKER_A).toBeUndefined();
expect(stripped).toContain('STRIP_TEST_MARKER_A');
});

test('strips keys from .env.local, .env.development, .env.production', () => {
writeFileSync(join(tmpDir, '.env.local'), 'STRIP_TEST_LOCAL_MARKER=local\n');
writeFileSync(join(tmpDir, '.env.development'), 'STRIP_TEST_DEV_MARKER=dev\n');
writeFileSync(join(tmpDir, '.env.production'), 'STRIP_TEST_PROD_MARKER=prod\n');
process.env.STRIP_TEST_LOCAL_MARKER = 'local';
process.env.STRIP_TEST_DEV_MARKER = 'dev';
process.env.STRIP_TEST_PROD_MARKER = 'prod';

const stripped = stripCwdEnv();

expect(process.env.STRIP_TEST_LOCAL_MARKER).toBeUndefined();
expect(process.env.STRIP_TEST_DEV_MARKER).toBeUndefined();
expect(process.env.STRIP_TEST_PROD_MARKER).toBeUndefined();
expect(stripped).toContain('STRIP_TEST_LOCAL_MARKER');
expect(stripped).toContain('STRIP_TEST_DEV_MARKER');
expect(stripped).toContain('STRIP_TEST_PROD_MARKER');
});

test('does nothing when no CWD .env files exist', () => {
process.env.STRIP_TEST_PRESERVED_KEY = 'should_remain';

const stripped = stripCwdEnv();

expect(process.env.STRIP_TEST_PRESERVED_KEY).toBe('should_remain');
expect(stripped).toEqual([]);
});

test('preserves keys not present in any CWD .env', () => {
writeFileSync(join(tmpDir, '.env'), 'STRIP_TEST_MARKER_A=from_target\n');
process.env.STRIP_TEST_MARKER_A = 'from_target';
process.env.STRIP_TEST_PRESERVED_KEY = 'should_remain';

stripCwdEnv();

expect(process.env.STRIP_TEST_MARKER_A).toBeUndefined();
expect(process.env.STRIP_TEST_PRESERVED_KEY).toBe('should_remain');
});

test('ignores parse errors in target repo .env', () => {
// Write a .env with syntactically dubious content; dotenv's parser is
// lenient but we still want to verify nothing throws.
writeFileSync(join(tmpDir, '.env'), 'STRIP_TEST_MALFORMED_KEY="unterminated\n=noKey\n \n');
process.env.STRIP_TEST_MALFORMED_KEY = 'set_before_strip';

// Should not throw
expect(() => stripCwdEnv()).not.toThrow();
});

test('does not strip keys that dotenv parses but are absent from process.env', () => {
writeFileSync(join(tmpDir, '.env'), 'STRIP_TEST_MARKER_A=only_in_file\n');
// Intentionally do NOT set process.env.STRIP_TEST_MARKER_A
// (simulates a .env file that Bun didn't auto-load — e.g. wrong CWD)

const stripped = stripCwdEnv();

expect(stripped).not.toContain('STRIP_TEST_MARKER_A');
});
});
49 changes: 49 additions & 0 deletions packages/paths/src/strip-cwd-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { parse } from 'dotenv';
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';

/**
* Strip Bun-auto-loaded CWD `.env` keys from `process.env`.
*
* Bun's runtime (and compiled binaries) auto-load `.env` files from the current
* working directory before any user code runs. When `archon` is invoked from
* inside a target repo, that repo's `.env` leaks into the Archon process env,
* contaminating logging, config, and any `process.env.X` reads.
*
* The design rule is: **the CLI must never load target repo env**. Call this
* function at the very top of the CLI/server entry point — before loading
* `~/.archon/.env` — to undo Bun's auto-load.
*
* Files checked (matches Bun's auto-load set): `.env.local`, `.env.development`,
* `.env.production`, `.env`. For each existing file, parsed keys are deleted
* from `process.env`. Parse errors are ignored — a broken target repo `.env`
* is not our concern; we only need to strip keys, not validate them.
*
* Returns the list of keys that were stripped (useful for tests and debug logs).
*/
export function stripCwdEnv(): string[] {
const cwdEnvFiles = ['.env.local', '.env.development', '.env.production', '.env'];
const stripped: string[] = [];

for (const filename of cwdEnvFiles) {
const path = resolve(process.cwd(), filename);
if (!existsSync(path)) continue;
try {
const parsed = parse(readFileSync(path));
for (const key of Object.keys(parsed)) {
if (key in process.env) {
// Dynamic delete is required: keys come from the target repo's .env
// at runtime, so they cannot be known statically.
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete process.env[key];
stripped.push(key);
Comment on lines +34 to +39
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

Avoid deleting shell-provided env vars while stripping CWD leakage

At Line 34, the current key in process.env check deletes any existing env var if that key is present in CWD .env*. This can wipe legitimate operator/shell values (including critical vars) that were never leaked by Bun.

💡 Proposed fix
-      for (const key of Object.keys(parsed)) {
-        if (key in process.env) {
+      for (const key of Object.keys(parsed)) {
+        const currentValue = process.env[key];
+        // Strip only when process.env currently matches the CWD .env value.
+        // This avoids deleting externally provided env vars with different values.
+        if (currentValue !== undefined && currentValue === parsed[key]) {
           // Dynamic delete is required: keys come from the target repo's .env
           // at runtime, so they cannot be known statically.
           // eslint-disable-next-line `@typescript-eslint/no-dynamic-delete`
           delete process.env[key];
           stripped.push(key);
         }
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (key in process.env) {
// Dynamic delete is required: keys come from the target repo's .env
// at runtime, so they cannot be known statically.
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete process.env[key];
stripped.push(key);
for (const key of Object.keys(parsed)) {
const currentValue = process.env[key];
// Strip only when process.env currently matches the CWD .env value.
// This avoids deleting externally provided env vars with different values.
if (currentValue !== undefined && currentValue === parsed[key]) {
// Dynamic delete is required: keys come from the target repo's .env
// at runtime, so they cannot be known statically.
// eslint-disable-next-line `@typescript-eslint/no-dynamic-delete`
delete process.env[key];
stripped.push(key);
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/paths/src/strip-cwd-env.ts` around lines 34 - 39, The current
deletion unconditionally removes any env var whose key appears in the target
repo's .env, which can wipe legitimate shell-provided values; update the
stripCwdEnv logic to only delete when the existing process.env[key] appears to
contain a CWD-leak (e.g., its value includes or startsWith the detected cwd or
the leaked path pattern you computed) or matches the exact value read from the
repo .env, otherwise leave process.env[key] intact and skip pushing key into
stripped; locate this logic in the stripCwdEnv function where the variable key
is checked and deleted and adjust the conditional to verify the value contains
the cwd/leaked path before performing delete and pushing to stripped.

}
}
} catch {
// Ignore parse errors — we're only trying to undo Bun's auto-load,
// not validate the target repo's .env file.
}
}

return stripped;
}
23 changes: 13 additions & 10 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
* Multi-platform AI coding assistant (Telegram, Discord, Slack, GitHub, Gitea)
*/

// 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.
// Design rule: the server must never load target repo env. Bun's runtime
// auto-loads CWD `.env` files before any user code runs — the side-effect
// import below strips those keys during module load, BEFORE any module that
// reads env at init time (e.g. the Pino logger's LOG_LEVEL). Must be the
// first import.
import '@archon/paths/strip-cwd-env-boot';

// Load environment variables — before any application imports that depend
// on env vars being set.
import { config } from 'dotenv';
import { resolve } from 'path';
import { existsSync } from 'fs';
Expand All @@ -18,7 +21,7 @@ import { BUNDLED_IS_BINARY } from '@archon/paths';
// 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) {
if (envPath && existsSync(envPath)) {
const dotenvResult = config({ path: envPath });
if (dotenvResult.error) {
// Use console.error since logger depends on env vars (LOG_LEVEL)
Expand All @@ -27,9 +30,9 @@ if (envPath) {
}
}

// 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.
// Load ~/.archon/.env — Archon's config is the single source of truth.
// In binary mode this is the only .env loaded. In dev mode it overrides the
// repo root .env (with override:true) for keys like DATABASE_URL.
const globalEnvPath = resolve(process.env.HOME ?? '~', '.archon', '.env');
if (existsSync(globalEnvPath)) {
const globalResult = config({ path: globalEnvPath, override: true });
Expand Down
Loading