baa-conductor

git clone 

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
M docs/api/local-host-ops.md
+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`。
M packages/host-ops/src/index.test.js
+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 },
M packages/host-ops/src/index.ts
+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       },