- 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
+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 变量、命令替换或更复杂的间接路径展开。
+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+);
+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
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;