- commit
- 7dd8b89
- parent
- c0a641f
- author
- im_wower
- date
- 2026-03-22 22:32:04 +0800 CST
fix(host-ops): restrict exec environment
3 files changed,
+134,
-2
+1,
-0
1@@ -39,6 +39,7 @@
2 - `path`:可为绝对路径;相对路径会相对 `cwd` 解析。
3 - `timeoutMs`:仅 `exec` 使用。可选整数 `>= 0`,默认 `30000`;`0` 表示不启用超时。
4 - `maxBufferBytes`:仅 `exec` 使用。可选整数 `> 0`,默认 `10485760`。
5+- `exec` 子进程默认不会继承 `conductor-daemon` 的完整环境变量。当前在 macOS / Linux 只透传最小集合:`PATH`、`HOME`、`USER`、`LOGNAME`、`LANG`、`TERM`、`TMPDIR`;在 Windows 只透传启动 shell 和常见可执行文件解析所需的最小集合(如 `ComSpec`、`PATH`、`PATHEXT`、`SystemRoot`、`TEMP`、`TMP`、`USERNAME`、`USERPROFILE`、`WINDIR`)。因此 `BAA_SHARED_TOKEN`、`*_TOKEN`、`*_SECRET`、`*_KEY`、`*_PASSWORD` 等 daemon 自身环境变量不会默认暴露给 `exec`。
6 - 在 macOS 上,`exec` 会对 `cwd` 和 `command` 里明显命中的 TCC 受保护目录做前置检查,避免后台进程访问 `Desktop` / `Documents` / `Downloads` 时无提示挂住 30 秒。当前覆盖 `~/Desktop`、`~/Documents`、`~/Downloads` 以及 `/Users/<user>/Desktop|Documents|Downloads` 这类直接字面量命中。
7 - `createParents`:仅 `files/write` 使用。可选布尔值,默认 `true`;会递归创建缺失父目录。
8 - `overwrite`:仅 `files/write` 使用。可选布尔值,默认 `true`;为 `false` 且目标文件已存在时返回 `FILE_ALREADY_EXISTS`。
+89,
-1
1@@ -1,5 +1,5 @@
2 import assert from "node:assert/strict";
3-import { mkdtemp, readFile, rm } from "node:fs/promises";
4+import { mkdtemp, readFile, realpath, rm } from "node:fs/promises";
5 import { homedir, tmpdir } from "node:os";
6 import { join } from "node:path";
7 import test from "node:test";
8@@ -29,6 +29,34 @@ async function withMockedPlatform(platform, callback) {
9 }
10 }
11
12+async function withPatchedEnv(patch, callback) {
13+ const originalValues = new Map();
14+
15+ for (const [key, value] of Object.entries(patch)) {
16+ originalValues.set(key, process.env[key]);
17+
18+ if (value === undefined) {
19+ delete process.env[key];
20+ continue;
21+ }
22+
23+ process.env[key] = value;
24+ }
25+
26+ try {
27+ return await callback();
28+ } finally {
29+ for (const [key, value] of originalValues) {
30+ if (value === undefined) {
31+ delete process.env[key];
32+ continue;
33+ }
34+
35+ process.env[key] = value;
36+ }
37+ }
38+}
39+
40 test("executeCommand returns structured stdout for a successful command", { concurrency: false }, async () => {
41 const result = await executeCommand({
42 command: "printf 'host-ops-smoke'",
43@@ -56,6 +84,66 @@ test("executeCommand returns a structured failure for non-zero exit codes", { co
44 assert.equal(result.result?.stderr, "broken");
45 });
46
47+test(
48+ "executeCommand keeps enough environment for common shell commands",
49+ { concurrency: false },
50+ async () => {
51+ const directory = await mkdtemp(join(tmpdir(), "baa-conductor-host-ops-exec-env-"));
52+
53+ try {
54+ const resolvedDirectory = await realpath(directory);
55+ const result = await executeCommand({
56+ command: "pwd && ls . >/dev/null && node -v",
57+ cwd: directory,
58+ timeoutMs: 2_000
59+ });
60+
61+ assert.equal(result.ok, true);
62+
63+ const outputLines = result.result.stdout.trim().split("\n");
64+ assert.equal(outputLines[0], resolvedDirectory);
65+ assert.match(outputLines[1], /^v\d+\./);
66+ } finally {
67+ await rm(directory, { force: true, recursive: true });
68+ }
69+ }
70+);
71+
72+test(
73+ "executeCommand does not leak daemon secrets through child environment",
74+ { concurrency: false },
75+ async () => {
76+ const result = await withPatchedEnv(
77+ {
78+ BAA_SHARED_TOKEN: "shared-secret",
79+ GITHUB_TOKEN: "github-secret",
80+ OPENAI_API_KEY: "openai-secret"
81+ },
82+ () =>
83+ executeCommand({
84+ command: "node -e \"process.stdout.write(JSON.stringify(process.env))\"",
85+ timeoutMs: 2_000
86+ })
87+ );
88+
89+ assert.equal(result.ok, true);
90+
91+ const childEnv = JSON.parse(result.result.stdout);
92+
93+ assert.equal(childEnv.BAA_SHARED_TOKEN, undefined);
94+ assert.equal(childEnv.GITHUB_TOKEN, undefined);
95+ assert.equal(childEnv.OPENAI_API_KEY, undefined);
96+
97+ for (const key of ["PATH", "HOME", "USER", "LOGNAME", "LANG", "TERM", "TMPDIR"]) {
98+ const parentValue = process.env[key];
99+
100+ if (typeof parentValue === "string") {
101+ assert.equal(childEnv[key], parentValue);
102+ }
103+ }
104+ }
105+);
106+
107 test(
108 "executeCommand fails fast for macOS TCC-protected command paths",
109 { concurrency: false },
+44,
-1
1@@ -21,6 +21,20 @@ export const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
2 export const DEFAULT_EXEC_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
3 export const DEFAULT_TEXT_ENCODING = "utf8" as const;
4 const MACOS_TCC_PROTECTED_DIRECTORY_NAMES = ["Desktop", "Documents", "Downloads"] as const;
5+const SAFE_EXEC_ENV_KEYS = ["HOME", "LANG", "LOGNAME", "PATH", "TERM", "TMPDIR", "USER"] as const;
6+const WINDOWS_SAFE_EXEC_ENV_KEYS = [
7+ "ComSpec",
8+ "HOMEDRIVE",
9+ "HOMEPATH",
10+ "PATH",
11+ "PATHEXT",
12+ "SystemRoot",
13+ "TEMP",
14+ "TMP",
15+ "USERNAME",
16+ "USERPROFILE",
17+ "WINDIR"
18+] as const;
19 const MACOS_TCC_ABSOLUTE_PATH_PATTERN =
20 /(^|[\s"'`=:(|&;<>])(\/Users\/[^/\s"'`]+\/(?:Desktop|Documents|Downloads))(?=$|[\/\s"'`),:|&;<>])/u;
21 const MACOS_TCC_HOME_PATH_PATTERN =
22@@ -284,6 +298,14 @@ function getProcessPlatform(): string {
23 return "";
24 }
25
26+function getProcessEnv(): Record<string, string | undefined> {
27+ if (typeof process === "object" && process !== null && typeof process.env === "object" && process.env !== null) {
28+ return process.env;
29+ }
30+
31+ return {};
32+}
33+
34 function getProcessExecPath(): string {
35 if (typeof process === "object" && process !== null && isNonEmptyString(process.execPath)) {
36 return process.execPath;
37@@ -292,6 +314,27 @@ function getProcessExecPath(): string {
38 return "node";
39 }
40
41+function getSafeExecEnvKeys(platform = getProcessPlatform()): readonly string[] {
42+ return platform === "win32" ? WINDOWS_SAFE_EXEC_ENV_KEYS : SAFE_EXEC_ENV_KEYS;
43+}
44+
45+function buildSafeExecEnv(
46+ platform = getProcessPlatform(),
47+ sourceEnv: Record<string, string | undefined> = getProcessEnv()
48+): Record<string, string | undefined> {
49+ const safeEnv: Record<string, string | undefined> = {};
50+
51+ for (const key of getSafeExecEnvKeys(platform)) {
52+ const value = sourceEnv[key];
53+
54+ if (typeof value === "string") {
55+ safeEnv[key] = value;
56+ }
57+ }
58+
59+ return safeEnv;
60+}
61+
62 function isMacOs(): boolean {
63 return getProcessPlatform() === "darwin";
64 }
65@@ -762,7 +805,7 @@ export async function executeCommand(request: ExecOperationRequest): Promise<Exe
66 normalized.input.command,
67 {
68 cwd: normalized.input.cwd,
69- env: process?.env ?? {},
70+ env: buildSafeExecEnv(),
71 maxBuffer: normalized.input.maxBufferBytes,
72 timeout: normalized.input.timeoutMs
73 },