baa-conductor

git clone 

commit
2778b51
parent
4e53054
author
im_wower
date
2026-03-22 21:25:14 +0800 CST
fix(host-ops): fail fast on macOS TCC protected exec paths
4 files changed,  +284, -7
M docs/api/local-host-ops.md
+32, -1
 1@@ -18,7 +18,7 @@
 2 
 3 | operation | request | success.result | failure.error |
 4 | --- | --- | --- | --- |
 5-| `exec` | `command`, `cwd?`, `timeoutMs?`, `maxBufferBytes?` | `stdout`, `stderr`, `exitCode`, `signal`, `durationMs`, `startedAt`, `finishedAt`, `timedOut` | `INVALID_INPUT`, `EXEC_TIMEOUT`, `EXEC_EXIT_NON_ZERO`, `EXEC_OUTPUT_LIMIT`, `EXEC_FAILED` |
 6+| `exec` | `command`, `cwd?`, `timeoutMs?`, `maxBufferBytes?` | `stdout`, `stderr`, `exitCode`, `signal`, `durationMs`, `startedAt`, `finishedAt`, `timedOut` | `INVALID_INPUT`, `EXEC_TIMEOUT`, `EXEC_EXIT_NON_ZERO`, `EXEC_OUTPUT_LIMIT`, `EXEC_FAILED`, `TCC_PERMISSION_DENIED` |
 7 | `files/read` | `path`, `cwd?`, `encoding?` | `absolutePath`, `content`, `sizeBytes`, `modifiedAt`, `encoding` | `INVALID_INPUT`, `FILE_NOT_FOUND`, `NOT_A_FILE`, `FILE_READ_FAILED` |
 8 | `files/write` | `path`, `content`, `cwd?`, `encoding?`, `createParents?`, `overwrite?` | `absolutePath`, `bytesWritten`, `created`, `modifiedAt`, `encoding` | `INVALID_INPUT`, `FILE_ALREADY_EXISTS`, `NOT_A_FILE`, `FILE_WRITE_FAILED` |
 9 
10@@ -30,6 +30,7 @@
11 - `path`:可为绝对路径;相对路径会相对 `cwd` 解析。
12 - `timeoutMs`:仅 `exec` 使用。可选整数 `>= 0`,默认 `30000`;`0` 表示不启用超时。
13 - `maxBufferBytes`:仅 `exec` 使用。可选整数 `> 0`,默认 `10485760`。
14+- 在 macOS 上,`exec` 会对 `cwd` 和 `command` 里明显命中的 TCC 受保护目录做前置检查,避免后台进程访问 `Desktop` / `Documents` / `Downloads` 时无提示挂住 30 秒。当前覆盖 `~/Desktop`、`~/Documents`、`~/Downloads` 以及 `/Users/<user>/Desktop|Documents|Downloads` 这类直接字面量命中。
15 - `createParents`:仅 `files/write` 使用。可选布尔值,默认 `true`;会递归创建缺失父目录。
16 - `overwrite`:仅 `files/write` 使用。可选布尔值,默认 `true`;为 `false` 且目标文件已存在时返回 `FILE_ALREADY_EXISTS`。
17 
18@@ -156,3 +157,33 @@ curl -X POST "${LOCAL_API_BASE}/v1/files/write" \
19 - `runHostOperation(request)`
20 
21 它们全部返回结构化 result union,不依赖 HTTP 层;HTTP 层只是把这份 union 包进 `data`。
22+
23+## macOS TCC Fast-Fail
24+
25+如果 `exec` 在 macOS 上检测到命令或 `cwd` 直接命中受 TCC 保护的用户目录,会在启动子进程前直接返回:
26+
27+```json
28+{
29+  "ok": false,
30+  "operation": "exec",
31+  "input": {
32+    "command": "ls ~/Desktop",
33+    "cwd": "/tmp",
34+    "timeoutMs": 2000,
35+    "maxBufferBytes": 10485760
36+  },
37+  "error": {
38+    "code": "TCC_PERMISSION_DENIED",
39+    "message": "Command references macOS TCC-protected path /Users/george/Desktop. Grant Full Disk Access to the Node.js binary running conductor-daemon (/opt/homebrew/bin/node) and retry.",
40+    "retryable": false,
41+    "details": {
42+      "accessPoint": "command",
43+      "protectedPath": "/Users/george/Desktop",
44+      "nodeBinary": "/opt/homebrew/bin/node",
45+      "requiresFullDiskAccess": true
46+    }
47+  }
48+}
49+```
50+
51+当前这是基于命令字面量的快速预检,不会解析 shell 变量、命令替换或更复杂的间接路径展开。
M packages/host-ops/src/index.test.js
+109, -6
  1@@ -1,6 +1,6 @@
  2 import assert from "node:assert/strict";
  3 import { mkdtemp, readFile, rm } from "node:fs/promises";
  4-import { tmpdir } from "node:os";
  5+import { homedir, tmpdir } from "node:os";
  6 import { join } from "node:path";
  7 import test from "node:test";
  8 
  9@@ -11,7 +11,25 @@ import {
 10   writeTextFile
 11 } from "./index.ts";
 12 
 13-test("executeCommand returns structured stdout for a successful command", async () => {
 14+async function withMockedPlatform(platform, callback) {
 15+  const descriptor = Object.getOwnPropertyDescriptor(process, "platform");
 16+
 17+  assert.ok(descriptor);
 18+
 19+  Object.defineProperty(process, "platform", {
 20+    configurable: true,
 21+    enumerable: descriptor.enumerable ?? true,
 22+    value: platform
 23+  });
 24+
 25+  try {
 26+    return await callback();
 27+  } finally {
 28+    Object.defineProperty(process, "platform", descriptor);
 29+  }
 30+}
 31+
 32+test("executeCommand returns structured stdout for a successful command", { concurrency: false }, async () => {
 33   const result = await executeCommand({
 34     command: "printf 'host-ops-smoke'",
 35     timeoutMs: 2_000
 36@@ -25,7 +43,7 @@ test("executeCommand returns structured stdout for a successful command", async
 37   assert.equal(result.result.timedOut, false);
 38 });
 39 
 40-test("executeCommand returns a structured failure for non-zero exit codes", async () => {
 41+test("executeCommand returns a structured failure for non-zero exit codes", { concurrency: false }, async () => {
 42   const result = await executeCommand({
 43     command: "sh -c \"printf broken >&2; exit 7\"",
 44     timeoutMs: 2_000
 45@@ -38,7 +56,88 @@ test("executeCommand returns a structured failure for non-zero exit codes", asyn
 46   assert.equal(result.result?.stderr, "broken");
 47 });
 48 
 49-test("writeTextFile and readTextFile roundtrip content with metadata", async () => {
 50+test(
 51+  "executeCommand fails fast for macOS TCC-protected command paths",
 52+  { concurrency: false },
 53+  async () => {
 54+    const result = await withMockedPlatform("darwin", () =>
 55+      executeCommand({
 56+        command: "ls /Users/example/Desktop/project",
 57+        cwd: "/tmp",
 58+        timeoutMs: 2_000
 59+      })
 60+    );
 61+
 62+    assert.equal(result.ok, false);
 63+    assert.equal(result.operation, "exec");
 64+    assert.equal(result.error.code, "TCC_PERMISSION_DENIED");
 65+    assert.equal(result.error.retryable, false);
 66+    assert.equal(result.error.details?.accessPoint, "command");
 67+    assert.equal(result.error.details?.protectedPath, "/Users/example/Desktop");
 68+    assert.equal(result.error.details?.requiresFullDiskAccess, true);
 69+    assert.match(result.error.message, /Full Disk Access/);
 70+    assert.equal(result.result, undefined);
 71+  }
 72+);
 73+
 74+test(
 75+  "executeCommand fails fast when cwd is inside a macOS TCC-protected directory",
 76+  { concurrency: false },
 77+  async () => {
 78+    const result = await withMockedPlatform("darwin", () =>
 79+      executeCommand({
 80+        command: "pwd",
 81+        cwd: join(homedir(), "Downloads", "nested"),
 82+        timeoutMs: 2_000
 83+      })
 84+    );
 85+
 86+    assert.equal(result.ok, false);
 87+    assert.equal(result.operation, "exec");
 88+    assert.equal(result.error.code, "TCC_PERMISSION_DENIED");
 89+    assert.equal(result.error.details?.accessPoint, "cwd");
 90+    assert.equal(result.error.details?.protectedPath, join(homedir(), "Downloads"));
 91+  }
 92+);
 93+
 94+test(
 95+  "executeCommand detects ~/Documents style macOS TCC-protected paths",
 96+  { concurrency: false },
 97+  async () => {
 98+    const result = await withMockedPlatform("darwin", () =>
 99+      executeCommand({
100+        command: "ls ~/Documents/project",
101+        cwd: "/tmp",
102+        timeoutMs: 2_000
103+      })
104+    );
105+
106+    assert.equal(result.ok, false);
107+    assert.equal(result.operation, "exec");
108+    assert.equal(result.error.code, "TCC_PERMISSION_DENIED");
109+    assert.equal(result.error.details?.accessPoint, "command");
110+    assert.equal(result.error.details?.protectedPath, join(homedir(), "Documents"));
111+  }
112+);
113+
114+test(
115+  "executeCommand only applies macOS TCC preflight on darwin",
116+  { concurrency: false },
117+  async () => {
118+    const result = await withMockedPlatform("linux", () =>
119+      executeCommand({
120+        command: "printf '/Users/example/Desktop'",
121+        cwd: "/tmp",
122+        timeoutMs: 2_000
123+      })
124+    );
125+
126+    assert.equal(result.ok, true);
127+    assert.equal(result.result.stdout, "/Users/example/Desktop");
128+  }
129+);
130+
131+test("writeTextFile and readTextFile roundtrip content with metadata", { concurrency: false }, async () => {
132   const directory = await mkdtemp(join(tmpdir(), "baa-conductor-host-ops-"));
133   const targetFile = join(directory, "nested", "hello.txt");
134 
135@@ -79,7 +178,10 @@ test("writeTextFile and readTextFile roundtrip content with metadata", async ()
136   }
137 });
138 
139-test("writeTextFile can reject overwriting an existing file with a structured failure", async () => {
140+test(
141+  "writeTextFile can reject overwriting an existing file with a structured failure",
142+  { concurrency: false },
143+  async () => {
144   const directory = await mkdtemp(join(tmpdir(), "baa-conductor-host-ops-overwrite-"));
145   const targetFile = join(directory, "hello.txt");
146 
147@@ -107,4 +209,5 @@ test("writeTextFile can reject overwriting an existing file with a structured fa
148   } finally {
149     await rm(directory, { force: true, recursive: true });
150   }
151-});
152+  }
153+);
M packages/host-ops/src/index.ts
+137, -0
  1@@ -1,5 +1,6 @@
  2 import { exec } from "node:child_process";
  3 import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
  4+import { homedir } from "node:os";
  5 import { dirname, isAbsolute, resolve } from "node:path";
  6 
  7 export const HOST_OPERATION_NAMES = ["exec", "files/read", "files/write"] as const;
  8@@ -9,6 +10,7 @@ export const HOST_OP_ERROR_CODES = [
  9   "EXEC_EXIT_NON_ZERO",
 10   "EXEC_OUTPUT_LIMIT",
 11   "EXEC_FAILED",
 12+  "TCC_PERMISSION_DENIED",
 13   "FILE_ALREADY_EXISTS",
 14   "FILE_NOT_FOUND",
 15   "NOT_A_FILE",
 16@@ -18,6 +20,11 @@ export const HOST_OP_ERROR_CODES = [
 17 export const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
 18 export const DEFAULT_EXEC_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
 19 export const DEFAULT_TEXT_ENCODING = "utf8" as const;
 20+const MACOS_TCC_PROTECTED_DIRECTORY_NAMES = ["Desktop", "Documents", "Downloads"] as const;
 21+const MACOS_TCC_ABSOLUTE_PATH_PATTERN =
 22+  /(^|[\s"'`=:(|&;<>])(\/Users\/[^/\s"'`]+\/(?:Desktop|Documents|Downloads))(?=$|[\/\s"'`),:|&;<>])/u;
 23+const MACOS_TCC_HOME_PATH_PATTERN =
 24+  /(^|[\s=:(|&;<>])(~\/(?:Desktop|Documents|Downloads))(?=$|[\/\s"'`),:|&;<>])/u;
 25 
 26 export type HostOperationName = (typeof HOST_OPERATION_NAMES)[number];
 27 export type HostOpErrorCode = (typeof HOST_OP_ERROR_CODES)[number];
 28@@ -181,6 +188,11 @@ interface RuntimeErrorLike {
 29   signal?: string | null;
 30 }
 31 
 32+interface MacOsTccBlockMatch {
 33+  protectedPath: string;
 34+  source: "command" | "cwd";
 35+}
 36+
 37 interface NormalizedInputSuccess<TInput> {
 38   ok: true;
 39   input: TInput;
 40@@ -264,6 +276,125 @@ function resolveOperationPath(pathValue: string, cwd: string): string {
 41   return isAbsolute(pathValue) ? pathValue : resolve(cwd, pathValue);
 42 }
 43 
 44+function getProcessPlatform(): string {
 45+  if (typeof process === "object" && process !== null && typeof process.platform === "string") {
 46+    return process.platform;
 47+  }
 48+
 49+  return "";
 50+}
 51+
 52+function getProcessExecPath(): string {
 53+  if (typeof process === "object" && process !== null && isNonEmptyString(process.execPath)) {
 54+    return process.execPath;
 55+  }
 56+
 57+  return "node";
 58+}
 59+
 60+function isMacOs(): boolean {
 61+  return getProcessPlatform() === "darwin";
 62+}
 63+
 64+function isPathWithinDirectory(pathValue: string, directory: string): boolean {
 65+  const normalizedPath = resolve(pathValue);
 66+  const normalizedDirectory = resolve(directory);
 67+
 68+  return normalizedPath === normalizedDirectory || normalizedPath.startsWith(`${normalizedDirectory}/`);
 69+}
 70+
 71+function getMacOsTccProtectedDirectories(userHomeDirectory = homedir()): readonly string[] {
 72+  return MACOS_TCC_PROTECTED_DIRECTORY_NAMES.map((directoryName) =>
 73+    resolve(userHomeDirectory, directoryName)
 74+  );
 75+}
 76+
 77+function findMacOsTccProtectedCwd(cwd: string, userHomeDirectory = homedir()): string | null {
 78+  const protectedDirectories = getMacOsTccProtectedDirectories(userHomeDirectory);
 79+
 80+  for (const protectedDirectory of protectedDirectories) {
 81+    if (isPathWithinDirectory(cwd, protectedDirectory)) {
 82+      return protectedDirectory;
 83+    }
 84+  }
 85+
 86+  return null;
 87+}
 88+
 89+function findMacOsTccProtectedCommandPath(
 90+  command: string,
 91+  userHomeDirectory = homedir()
 92+): string | null {
 93+  const absolutePathMatch = MACOS_TCC_ABSOLUTE_PATH_PATTERN.exec(command);
 94+  const homePathMatch = MACOS_TCC_HOME_PATH_PATTERN.exec(command);
 95+  const absolutePath = typeof absolutePathMatch?.[2] === "string" ? absolutePathMatch[2] : null;
 96+  const homePath = typeof homePathMatch?.[2] === "string" ? homePathMatch[2] : null;
 97+
 98+  if (absolutePath === null && homePath === null) {
 99+    return null;
100+  }
101+
102+  if (absolutePath !== null && (homePathMatch === null || absolutePathMatch!.index <= homePathMatch.index)) {
103+    return resolve(absolutePath);
104+  }
105+
106+  return resolve(userHomeDirectory, homePath!.slice(2));
107+}
108+
109+function findMacOsTccBlockMatch(input: ExecOperationInput): MacOsTccBlockMatch | null {
110+  if (!isMacOs()) {
111+    return null;
112+  }
113+
114+  const protectedCwd = findMacOsTccProtectedCwd(input.cwd);
115+
116+  if (protectedCwd !== null) {
117+    return {
118+      protectedPath: protectedCwd,
119+      source: "cwd"
120+    };
121+  }
122+
123+  const protectedCommandPath = findMacOsTccProtectedCommandPath(input.command);
124+
125+  if (protectedCommandPath === null) {
126+    return null;
127+  }
128+
129+  return {
130+    protectedPath: protectedCommandPath,
131+    source: "command"
132+  };
133+}
134+
135+function createMacOsTccPermissionDeniedResponse(
136+  input: ExecOperationInput,
137+  blockMatch: MacOsTccBlockMatch
138+): ExecOperationFailure {
139+  const nodeBinary = getProcessExecPath();
140+  const blockedVia =
141+    blockMatch.source === "cwd"
142+      ? `Command cwd resolves inside macOS TCC-protected path ${blockMatch.protectedPath}.`
143+      : `Command references macOS TCC-protected path ${blockMatch.protectedPath}.`;
144+
145+  return {
146+    ok: false,
147+    operation: "exec",
148+    input,
149+    error: createHostOpError(
150+      "TCC_PERMISSION_DENIED",
151+      `${blockedVia} Grant Full Disk Access to the Node.js binary running conductor-daemon (${nodeBinary}) and retry.`,
152+      false,
153+      {
154+        accessPoint: blockMatch.source,
155+        nodeBinary,
156+        protectedPath: blockMatch.protectedPath,
157+        requiresFullDiskAccess: true
158+      }
159+    )
160+  };
161+}
162+
163 function getErrorLike(error: unknown): RuntimeErrorLike {
164   if (typeof error === "object" && error !== null) {
165     return error as RuntimeErrorLike;
166@@ -617,6 +748,12 @@ export async function executeCommand(request: ExecOperationRequest): Promise<Exe
167     return normalized.response;
168   }
169 
170+  const macOsTccBlockMatch = findMacOsTccBlockMatch(normalized.input);
171+
172+  if (macOsTccBlockMatch !== null) {
173+    return createMacOsTccPermissionDeniedResponse(normalized.input, macOsTccBlockMatch);
174+  }
175+
176   const startedAt = new Date().toISOString();
177   const startedMs = Date.now();
178 
M packages/host-ops/src/node-shims.d.ts
+6, -0
 1@@ -59,9 +59,15 @@ declare module "node:path" {
 2   export function resolve(...paths: string[]): string;
 3 }
 4 
 5+declare module "node:os" {
 6+  export function homedir(): string;
 7+}
 8+
 9 declare const process:
10   | {
11       cwd(): string;
12       env: Record<string, string | undefined>;
13+      execPath: string;
14+      platform: string;
15     }
16   | undefined;