Skip to content

Commit 153ce85

Browse files
centdixclaude
andauthored
fix: keep launch project .env secrets out of tmux global env (#285)
* fix: keep launch project .env secrets out of tmux global env Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: extend .env strip to all tmux spawns and address review - extract leaked-key stripping into shared adapters/project-env - strip project keys in terminal.ts tmux/PTY spawns (close born-clean gap) - use Bun.env; also strip WEBMUX_PROJECT_ENV_KEYS marker itself - gate global-env scrub to once per backend process - add project-env unit tests and a scrub-in-isolation integration test Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 067f09a commit 153ce85

6 files changed

Lines changed: 260 additions & 6 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { afterEach, describe, expect, it } from "bun:test";
2+
import { leakedProjectEnvKeys, stripProjectEnv } from "../adapters/project-env";
3+
4+
const original = process.env.WEBMUX_PROJECT_ENV_KEYS;
5+
6+
afterEach(() => {
7+
if (original === undefined) delete process.env.WEBMUX_PROJECT_ENV_KEYS;
8+
else process.env.WEBMUX_PROJECT_ENV_KEYS = original;
9+
});
10+
11+
describe("leakedProjectEnvKeys", () => {
12+
it("returns an empty set when no project env keys were loaded", () => {
13+
delete process.env.WEBMUX_PROJECT_ENV_KEYS;
14+
expect(leakedProjectEnvKeys().size).toBe(0);
15+
});
16+
17+
it("includes the listed keys plus the marker var itself, trimming blanks", () => {
18+
process.env.WEBMUX_PROJECT_ENV_KEYS = "SUPABASE_URL, SUPABASE_ANON_KEY ,";
19+
expect(leakedProjectEnvKeys()).toEqual(
20+
new Set(["WEBMUX_PROJECT_ENV_KEYS", "SUPABASE_URL", "SUPABASE_ANON_KEY"]),
21+
);
22+
});
23+
});
24+
25+
describe("stripProjectEnv", () => {
26+
it("removes the launch project's keys and the marker, keeping unrelated vars", () => {
27+
process.env.WEBMUX_PROJECT_ENV_KEYS = "SUPABASE_URL";
28+
const result = stripProjectEnv({
29+
SUPABASE_URL: "secret",
30+
WEBMUX_PROJECT_ENV_KEYS: "SUPABASE_URL",
31+
PATH: "/usr/bin",
32+
HOME: "/root",
33+
UNSET: undefined,
34+
});
35+
expect(result).toEqual({ PATH: "/usr/bin", HOME: "/root" });
36+
});
37+
38+
it("returns a full copy of defined vars when there is nothing to strip", () => {
39+
delete process.env.WEBMUX_PROJECT_ENV_KEYS;
40+
expect(stripProjectEnv({ A: "1", B: "2", C: undefined })).toEqual({ A: "1", B: "2" });
41+
});
42+
});

backend/src/__tests__/tmux-adapter.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,18 @@ function parseManagedSessionResult(output: string): ManagedSessionResult {
115115
};
116116
}
117117

118+
function parseGlobalEnvResult(output: string): { hasLeaked: boolean; hasKept: boolean } {
119+
const value: unknown = JSON.parse(output);
120+
if (!value || typeof value !== "object") {
121+
throw new Error("global env result must be an object");
122+
}
123+
const { hasLeaked, hasKept } = value as { hasLeaked?: unknown; hasKept?: unknown };
124+
if (typeof hasLeaked !== "boolean" || typeof hasKept !== "boolean") {
125+
throw new Error("global env result must have boolean hasLeaked and hasKept");
126+
}
127+
return { hasLeaked, hasKept };
128+
}
129+
118130
describe("sanitizeTmuxNameSegment", () => {
119131
it("normalizes arbitrary path-like input", () => {
120132
expect(sanitizeTmuxNameSegment("Workmux Web/Desktop")).toBe("workmux-web-desktop");
@@ -298,6 +310,116 @@ describe("BunTmuxGateway", () => {
298310
}
299311
});
300312

313+
it("keeps launch-project .env keys out of the tmux global environment", async () => {
314+
const testRoot = await mkdtemp(join(tmpdir(), "webmux-tmux-env-leak-"));
315+
const projectRoot = join(testRoot, "repo");
316+
const runnerPath = join(testRoot, "ensure-session.ts");
317+
const tmuxModuleUrl = new URL("../adapters/tmux.ts", import.meta.url).href;
318+
await mkdir(projectRoot, { recursive: true });
319+
await Bun.write(
320+
runnerPath,
321+
[
322+
`import { BunTmuxGateway } from ${JSON.stringify(tmuxModuleUrl)};`,
323+
"",
324+
"function read(args: string[]): string {",
325+
' const result = Bun.spawnSync(args, { stdout: "pipe", stderr: "pipe" });',
326+
" if (result.exitCode !== 0) {",
327+
" const stderr = new TextDecoder().decode(result.stderr).trim();",
328+
' throw new Error(`${args.join(" ")} failed: ${stderr || `exit ${result.exitCode}`}`);',
329+
" }",
330+
' return new TextDecoder().decode(result.stdout).trim();',
331+
"}",
332+
"",
333+
"const projectRoot = process.argv[2];",
334+
'if (!projectRoot) throw new Error("expected projectRoot");',
335+
"const gateway = new BunTmuxGateway();",
336+
// ensureServer + ensureSession is the path that first creates a persistent
337+
// server, capturing this process's env into the tmux global environment.
338+
"gateway.ensureServer();",
339+
'gateway.ensureSession("wm-env-leak", projectRoot);',
340+
'const globalEnv = read(["tmux", "show-environment", "-g"]).split("\\n");',
341+
"console.log(JSON.stringify({",
342+
' hasLeaked: globalEnv.some((line) => line.startsWith("LEAKED_PROJECT_SECRET=")),',
343+
' hasKept: globalEnv.some((line) => line.startsWith("KEPT_SHELL_VAR=")),',
344+
"}));",
345+
].join("\n"),
346+
);
347+
348+
try {
349+
const result = parseGlobalEnvResult(readWithIsolatedTmux(
350+
["bun", runnerPath, projectRoot],
351+
buildEnv({
352+
WEBMUX_PROJECT_ENV_KEYS: "LEAKED_PROJECT_SECRET",
353+
LEAKED_PROJECT_SECRET: "service-role-key",
354+
KEPT_SHELL_VAR: "ok",
355+
}),
356+
));
357+
// The project .env key is stripped from the env used to spawn tmux, so the
358+
// server is born without it in the global environment...
359+
expect(result.hasLeaked).toBe(false);
360+
// ...while unrelated inherited vars are still passed through normally.
361+
expect(result.hasKept).toBe(true);
362+
} finally {
363+
await rm(testRoot, { recursive: true, force: true });
364+
}
365+
});
366+
367+
it("scrubs launch-project .env keys left in the global env by an already-running server", async () => {
368+
const testRoot = await mkdtemp(join(tmpdir(), "webmux-tmux-env-scrub-"));
369+
const projectRoot = join(testRoot, "repo");
370+
const runnerPath = join(testRoot, "scrub.ts");
371+
const tmuxModuleUrl = new URL("../adapters/tmux.ts", import.meta.url).href;
372+
await mkdir(projectRoot, { recursive: true });
373+
await Bun.write(
374+
runnerPath,
375+
[
376+
`import { BunTmuxGateway } from ${JSON.stringify(tmuxModuleUrl)};`,
377+
"",
378+
"function run(args: string[], env?: Record<string, string>): void {",
379+
' const result = Bun.spawnSync(args, { stdout: "pipe", stderr: "pipe", ...(env ? { env } : {}) });',
380+
" if (result.exitCode !== 0) {",
381+
" const stderr = new TextDecoder().decode(result.stderr).trim();",
382+
' throw new Error(`${args.join(" ")} failed: ${stderr || `exit ${result.exitCode}`}`);',
383+
" }",
384+
"}",
385+
"",
386+
"function globalHasLeaked(): boolean {",
387+
' const result = Bun.spawnSync(["tmux", "show-environment", "-g"], { stdout: "pipe", stderr: "pipe" });',
388+
' return new TextDecoder().decode(result.stdout).split("\\n").some((line) => line.startsWith("LEAKED_PROJECT_SECRET="));',
389+
"}",
390+
"",
391+
"const projectRoot = process.argv[2];",
392+
'if (!projectRoot) throw new Error("expected projectRoot");',
393+
// Simulate a server started before the stripped-env fix: its global env
394+
// captured the leaked key. gateway commands never spawn with it set, so
395+
// only the scrub can remove it. destroy-unattached off keeps this
396+
// detached session (and thus the server + its global env) alive even when
397+
// the tmux config enables destroy-unattached.
398+
'run(["tmux", "new-session", "-d", "-s", "preexisting", "-c", projectRoot, ";", "set-option", "-t", "preexisting", "destroy-unattached", "off"], { ...process.env, LEAKED_PROJECT_SECRET: "service-role-key" } as Record<string, string>);',
399+
"const before = globalHasLeaked();",
400+
"const gateway = new BunTmuxGateway();",
401+
"gateway.ensureServer();",
402+
'gateway.ensureSession("wm-scrub", projectRoot);',
403+
"console.log(JSON.stringify({ before, after: globalHasLeaked() }));",
404+
].join("\n"),
405+
);
406+
407+
try {
408+
const output = readWithIsolatedTmux(
409+
["bun", runnerPath, projectRoot],
410+
buildEnv({ WEBMUX_PROJECT_ENV_KEYS: "LEAKED_PROJECT_SECRET" }),
411+
);
412+
const value: unknown = JSON.parse(output);
413+
const { before, after } = value as { before?: unknown; after?: unknown };
414+
// The pre-existing server really did leak the key into the global env...
415+
expect(before).toBe(true);
416+
// ...and ensureSession's self-heal scrub removed it.
417+
expect(after).toBe(false);
418+
} finally {
419+
await rm(testRoot, { recursive: true, force: true });
420+
}
421+
});
422+
301423
it("treats missing windows, sessions, and servers as already closed", async () => {
302424
const testRoot = await mkdtemp(join(tmpdir(), "webmux-tmux-kill-window-"));
303425
const projectRoot = join(testRoot, "repo");
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/** The launch project's `.env`/`.env.local` keys that webmux's CLI loaded into
2+
* its own process env, passed down to the backend as a comma-separated list in
3+
* WEBMUX_PROJECT_ENV_KEYS. These are application secrets webmux does not need in
4+
* the tmux server: if the tmux *global* environment ever captures them (the
5+
* server inherits webmux's env from whatever process first starts it) they leak
6+
* into every session and pane of every project. WEBMUX_PROJECT_ENV_KEYS itself
7+
* is stripped too — it only holds key *names*, not values, but there is no
8+
* reason to let a webmux-internal marker reach the global env either. */
9+
export function leakedProjectEnvKeys(): Set<string> {
10+
const raw = Bun.env.WEBMUX_PROJECT_ENV_KEYS;
11+
if (!raw) return new Set();
12+
const keys = new Set<string>(["WEBMUX_PROJECT_ENV_KEYS"]);
13+
for (const key of raw.split(",").map((entry) => entry.trim()).filter(Boolean)) {
14+
keys.add(key);
15+
}
16+
return keys;
17+
}
18+
19+
/** Copy `base` (typically `Bun.env`) with the launch project's `.env` keys
20+
* removed, so a tmux server or client spawned by webmux never carries — nor
21+
* captures into its global environment — another project's secrets. Snapshots
22+
* `base` at call time; the leaked keys are fixed at launch, so a later mutation
23+
* to one of those vars is intentionally not reflected. */
24+
export function stripProjectEnv(base: Record<string, string | undefined>): Record<string, string> {
25+
const keys = leakedProjectEnvKeys();
26+
const env: Record<string, string> = {};
27+
for (const [key, val] of Object.entries(base)) {
28+
if (val !== undefined && !keys.has(key)) env[key] = val;
29+
}
30+
return env;
31+
}

backend/src/adapters/terminal.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { log } from "../lib/log";
2+
import { stripProjectEnv } from "./project-env";
23

34
interface PtyProcess {
45
pid: number;
@@ -77,11 +78,15 @@ const defaultSpawnTmuxProcess: SpawnTmuxProcess = (args, opts = {}) =>
7778
stdin: opts.stdin ?? "ignore",
7879
stdout: "ignore",
7980
stderr: "pipe",
81+
// Strip the launch project's `.env` keys: if one of these commands is ever
82+
// the first to reach a not-yet-running server it must not birth it with
83+
// another project's secrets in the global env (see project-env.ts).
84+
env: stripProjectEnv(Bun.env),
8085
});
8186
const defaultSleep: Sleep = (ms) => Bun.sleep(ms);
8287

8388
const defaultSpawnSyncCommand: SpawnSyncCommand = (args, opts = {}) => {
84-
const result = Bun.spawnSync(args, opts);
89+
const result = Bun.spawnSync(args, { ...opts, env: opts.env ?? stripProjectEnv(Bun.env) });
8590
return {
8691
exitCode: result.exitCode,
8792
stdout: result.stdout ?? new Uint8Array(),
@@ -242,7 +247,7 @@ export async function attach(
242247
initialPane,
243248
});
244249

245-
const proc = spawnPtyProcess(buildPtyArgs(cmd), { ...Bun.env, TERM: "xterm-256color" });
250+
const proc = spawnPtyProcess(buildPtyArgs(cmd), { ...stripProjectEnv(Bun.env), TERM: "xterm-256color" });
246251

247252
const session: TerminalSession = {
248253
proc,

backend/src/adapters/tmux.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createHash } from "node:crypto";
22
import { basename, resolve } from "node:path";
33
import type { PaneSplit } from "../domain/config";
4+
import { leakedProjectEnvKeys, stripProjectEnv } from "./project-env";
45

56
export interface TmuxWindowSummary {
67
sessionName: string;
@@ -41,10 +42,31 @@ export interface TmuxGateway {
4142
killPane(target: string): void;
4243
}
4344

45+
let cachedTmuxSpawnEnv: { value: Record<string, string> | undefined } | null = null;
46+
let globalEnvScrubbed = false;
47+
48+
/** Environment for spawning tmux control commands, stripped of the launch
49+
* project's `.env` keys (see {@link stripProjectEnv}). Whichever tmux command
50+
* first starts the server fixes the global environment for the server's
51+
* lifetime, so every tmux invocation must use the stripped env. Returns
52+
* `undefined` (inherit the process env unchanged) on the common path where no
53+
* project keys were loaded, so tmux spawns don't copy the whole env for
54+
* nothing. */
55+
function tmuxSpawnEnv(): Record<string, string> | undefined {
56+
if (cachedTmuxSpawnEnv) return cachedTmuxSpawnEnv.value;
57+
const value = Bun.env.WEBMUX_PROJECT_ENV_KEYS ? stripProjectEnv(Bun.env) : undefined;
58+
cachedTmuxSpawnEnv = { value };
59+
return value;
60+
}
61+
4462
function runTmux(args: string[]): { stdout: string; stderr: string; exitCode: number } {
63+
// Only pass `env` when we have a stripped copy: Bun.spawnSync treats an
64+
// explicit `env: undefined` as an *empty* environment, not "inherit".
65+
const spawnEnv = tmuxSpawnEnv();
4566
const result = Bun.spawnSync(["tmux", ...args], {
4667
stdout: "pipe",
4768
stderr: "pipe",
69+
...(spawnEnv ? { env: spawnEnv } : {}),
4870
});
4971

5072
return {
@@ -123,13 +145,34 @@ export class BunTmuxGateway implements TmuxGateway {
123145
["new-session", "-d", "-s", sessionName, "-c", cwd, ";", "set-option", "-t", sessionName, "destroy-unattached", "off"],
124146
`create tmux session ${sessionName}`,
125147
);
148+
this.scrubLeakedGlobalEnv();
126149
return;
127150
}
128151

129152
assertTmuxOk(
130153
["set-option", "-t", sessionName, "destroy-unattached", "off"],
131154
`set destroy-unattached off for ${sessionName}`,
132155
);
156+
this.scrubLeakedGlobalEnv();
157+
}
158+
159+
/** Self-heal a tmux server that was already running with the launch project's
160+
* `.env` keys in its global environment (e.g. a server started by an older
161+
* webmux that predates the stripped-env spawn). Removing them from the global
162+
* environment cleans every pane created afterwards, in existing and new
163+
* sessions alike. Runs once the server is known to be up (`exit-empty` means a
164+
* server only persists after a session exists). Tolerant: unsetting a key that
165+
* is absent is a no-op.
166+
*
167+
* Runs at most once per backend process: once the global env is scrubbed,
168+
* stripped-env spawns keep it clean, so re-scrubbing on every session-ensure /
169+
* reconciliation pass would be pure overhead (a tmux spawn per leaked key). */
170+
private scrubLeakedGlobalEnv(): void {
171+
if (globalEnvScrubbed) return;
172+
globalEnvScrubbed = true;
173+
for (const key of leakedProjectEnvKeys()) {
174+
runTmux(["set-environment", "-gu", key]);
175+
}
133176
}
134177

135178
hasWindow(sessionName: string, windowName: string): boolean {

bin/src/webmux.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,13 @@ function isWorktreeCommand(command: RootCommand): command is "add" | "list" | "o
183183

184184
// ── Load env files from CWD (.env.local overrides .env) ─────────────────────
185185

186-
async function loadEnvFile(path: string) {
187-
if (!existsSync(path)) return;
186+
/** Load a `.env` file from CWD into `process.env`, returning the keys it added.
187+
* Those keys are the launch project's env: webmux tracks them so it can keep
188+
* them out of the tmux server's *global* environment (see WEBMUX_PROJECT_ENV_KEYS),
189+
* where they would otherwise leak into every project's sessions and panes. */
190+
async function loadEnvFile(path: string): Promise<string[]> {
191+
if (!existsSync(path)) return [];
192+
const added: string[] = [];
188193
const lines = (await Bun.file(path).text()).split("\n");
189194
for (const line of lines) {
190195
const trimmed = line.trim();
@@ -195,8 +200,10 @@ async function loadEnvFile(path: string) {
195200
const val = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
196201
if (!(key in process.env)) {
197202
process.env[key] = val;
203+
added.push(key);
198204
}
199205
}
206+
return added;
200207
}
201208

202209
// ── Browser app mode ─────────────────────────────────────────────────────────
@@ -339,8 +346,9 @@ async function main(args: string[] = process.argv.slice(2)): Promise<void> {
339346
process.exit(code);
340347
}
341348

342-
await loadEnvFile(resolve(process.cwd(), ".env.local"));
343-
await loadEnvFile(resolve(process.cwd(), ".env"));
349+
const projectEnvKeys = new Set<string>();
350+
for (const key of await loadEnvFile(resolve(process.cwd(), ".env.local"))) projectEnvKeys.add(key);
351+
for (const key of await loadEnvFile(resolve(process.cwd(), ".env"))) projectEnvKeys.add(key);
344352

345353
// When the user didn't pin a port, point CLI commands at the live server for
346354
// this project rather than the 5111 default. `webmux serve` walks to a free
@@ -414,6 +422,9 @@ async function main(args: string[] = process.argv.slice(2)): Promise<void> {
414422
...process.env,
415423
PORT: String(parsed.port),
416424
WEBMUX_PROJECT_DIR: process.cwd(),
425+
// Tell the backend which keys came from the launch project's `.env` so it can
426+
// strip them from the tmux server's global environment (see tmux adapter).
427+
...(projectEnvKeys.size > 0 ? { WEBMUX_PROJECT_ENV_KEYS: [...projectEnvKeys].join(",") } : {}),
417428
...(parsed.debug ? { WEBMUX_DEBUG: "1" } : {}),
418429
};
419430

0 commit comments

Comments
 (0)