diff --git a/packages/host-kit/src/internal/host-process.ts b/packages/host-kit/src/internal/host-process.ts index dba0c2dee7..bedd365d5c 100644 --- a/packages/host-kit/src/internal/host-process.ts +++ b/packages/host-kit/src/internal/host-process.ts @@ -1,6 +1,7 @@ import type { ExecOptions, ExecResult } from './exec.ts'; import { runCmd, runCmdSync } from './exec.ts'; import { sleep } from './timeouts.ts'; +import { readMacosProcesses } from './macos-process.ts'; const PS_TIMEOUT_MS = 1_000; const HOST_PS_COMMAND = process.platform === 'win32' ? 'ps' : '/bin/ps'; @@ -111,17 +112,21 @@ export function readHostProcessIdentityObservations( allowFailure: true, timeoutMs: PS_TIMEOUT_MS, }); - if (result.exitCode !== 0) return observations; - for (const line of result.stdout.split('\n')) { - const match = /^\s*(\d+)\s+(\S+)\s+(.+?)\s*$/.exec(line); - if (!match) continue; - const pid = Number.parseInt(match[1]!, 10); - if (!Number.isInteger(pid) || pid <= 0) continue; - observations.set(pid, { state: match[2]!, startTime: match[3]! }); + if (result.exitCode === 0) { + for (const line of result.stdout.split('\n')) { + const match = /^\s*(\d+)\s+(\S+)\s+(.+?)\s*$/.exec(line); + if (!match) continue; + const pid = Number.parseInt(match[1]!, 10); + if (!Number.isInteger(pid) || pid <= 0) continue; + observations.set(pid, { state: match[2]!, startTime: match[3]! }); + } + return observations; } } catch { - // A failed ps snapshot is unknown evidence; callers remain fail-closed. + // macOS sandboxes can refuse execution of the setuid ps binary. } + for (const entry of readMacosProcesses(selected)) + observations.set(entry.pid, { state: entry.state, startTime: entry.startTime }); return observations; } @@ -132,12 +137,21 @@ function readProcessField(pid: number, field: 'lstart=' | 'command=' | 'state=') allowFailure: true, timeoutMs: PS_TIMEOUT_MS, }); - if (result.exitCode !== 0) return null; - const value = result.stdout.trim(); - return value.length > 0 ? value : null; + if (result.exitCode === 0) { + const value = result.stdout.trim(); + return value.length > 0 ? value : null; + } } catch { - return null; + // macOS sandboxes can refuse execution of the setuid ps binary. } + const observation = readMacosProcesses([pid])[0]; + if (!observation) return null; + const fields = { + 'lstart=': observation.startTime, + 'state=': observation.state, + 'command=': observation.command, + }; + return fields[field] || null; } export function parseHostProcessList(stdout: string): HostProcessInfo[] { @@ -157,16 +171,24 @@ export function parseHostProcessList(stdout: string): HostProcessInfo[] { export async function listHostProcesses( options: ListHostProcessesOptions, ): Promise { - const result = await (options.runCommand ?? runCmd)( - options.runCommand ? 'ps' : HOST_PS_COMMAND, - ['-ax', '-o', 'pid=,ppid=,command='], - { - allowFailure: true, - timeoutMs: options.timeoutMs, - }, - ); - if (result.exitCode !== 0) return []; - return parseHostProcessList(result.stdout); + try { + const result = await (options.runCommand ?? runCmd)( + options.runCommand ? 'ps' : HOST_PS_COMMAND, + ['-ax', '-o', 'pid=,ppid=,command='], + { + allowFailure: true, + timeoutMs: options.timeoutMs, + }, + ); + if (result.exitCode === 0) return parseHostProcessList(result.stdout); + } catch { + // macOS sandboxes can refuse execution of the setuid ps binary. + } + return options.runCommand + ? [] + : readMacosProcesses('all', options.timeoutMs) + .filter((entry) => entry.command) + .map(({ pid, ppid, command }) => ({ pid, ppid: ppid || undefined, command })); } export function expandProcessTree( diff --git a/packages/host-kit/src/internal/macos-process.c b/packages/host-kit/src/internal/macos-process.c new file mode 100644 index 0000000000..ec17093473 --- /dev/null +++ b/packages/host-kit/src/internal/macos-process.c @@ -0,0 +1,108 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MAX_ARGS_BYTES (32 * 1024) +#define MAX_KERNEL_BYTES (1024 * 1024) + +static bool identity(int pid, struct proc_bsdinfo *info) { + /* XNU proc_pidinfo requires arg=1 for PROC_PIDTBSDINFO to include zombies. */ + return proc_pidinfo(pid, PROC_PIDTBSDINFO, 1, info, sizeof(*info)) == sizeof(*info) + && info->pbi_pid == (unsigned int)pid && info->pbi_uid == getuid() + && info->pbi_ruid == getuid() && info->pbi_start_tvsec > 0 + && info->pbi_start_tvusec < 1000000; +} + +static bool same_identity(const struct proc_bsdinfo *a, const struct proc_bsdinfo *b) { + return a->pbi_pid == b->pbi_pid && a->pbi_uid == b->pbi_uid && a->pbi_ruid == b->pbi_ruid + && a->pbi_start_tvsec == b->pbi_start_tvsec && a->pbi_start_tvusec == b->pbi_start_tvusec + && (a->pbi_status == SZOMB) == (b->pbi_status == SZOMB); +} + +static bool args_bounds(char *buffer, size_t length, size_t pointer_size, char **start, char **finish, int *argc) { + if (length <= sizeof(int)) return false; + memcpy(argc, buffer, sizeof(int)); + if (*argc <= 0 || *argc > MAX_ARGS_BYTES) return false; + char *cursor = buffer + sizeof(int), *end = buffer + length; + size_t size = strnlen(cursor, (size_t)(end - cursor)); + if (size == 0 || size == (size_t)(end - cursor)) return false; + /* XNU exec_extract_strings aligns executable_path= plus the path to the target pointer size. */ + const size_t prefix = sizeof("executable_path=") - 1; + size_t offset = ((prefix + size + 1 + pointer_size - 1) / pointer_size) * pointer_size - prefix; + if (offset >= (size_t)(end - cursor)) return false; + for (size_t i = size; i < offset; i++) if (cursor[i] != 0) return false; + cursor += offset; + if (*cursor == 0) return false; + *start = cursor; + for (int i = 0; i < *argc; i++) { + if (cursor >= end) return false; + size = strnlen(cursor, (size_t)(end - cursor)); + if (size == (size_t)(end - cursor)) return false; + cursor += size + 1; + if (cursor - *start > MAX_ARGS_BYTES) return false; + } + *finish = cursor; + return true; +} + +static int observe(int pid) { + struct proc_bsdinfo before = {0}, after = {0}; + if (!identity(pid, &before)) return 1; + char *buffer = NULL, *start = NULL, *finish = NULL; + int argc = 0; + if (before.pbi_status != SZOMB) { + buffer = malloc(MAX_KERNEL_BYTES); + size_t length = MAX_KERNEL_BYTES; + int mib[] = {CTL_KERN, KERN_PROCARGS2, pid}; + if (!buffer || sysctl(mib, 3, buffer, &length, NULL, 0) != 0 + || !args_bounds(buffer, length, (before.pbi_flags & PROC_FLAG_LP64) ? 8 : 4, &start, &finish, &argc)) { + free(buffer); + return 1; + } + } + if (!identity(pid, &after) || !same_identity(&before, &after)) { + free(buffer); + return 1; + } + printf("{\"pid\":%d,\"ppid\":%u,\"startSeconds\":\"%llu\",\"startMicros\":%llu,\"zombie\":%s,\"argc\":%d,\"argvHex\":\"", + pid, after.pbi_ppid, after.pbi_start_tvsec, after.pbi_start_tvusec, + after.pbi_status == SZOMB ? "true" : "false", argc); + for (char *cursor = start; cursor && cursor < finish; cursor++) printf("%02x", (unsigned char)*cursor); + puts("\"}"); + free(buffer); + return 0; +} + +int main(int argc, char **argv) { + if (argc == 2 && strcmp(argv[1], "--all") == 0) { + int count = proc_listallpids(NULL, 0); + if (count <= 0 || count > 16384) return 1; + size_t bytes = (size_t)(count + 128) * sizeof(int); + int *pids = calloc(1, bytes); + if (!pids) return 1; + count = proc_listallpids(pids, (int)bytes); + if (count < 0 || count > (int)(bytes / sizeof(int))) { free(pids); return 1; } + for (int i = 0; i < count; i++) if (pids[i] > 0) observe(pids[i]); + free(pids); + return 0; + } + if (argc < 2 || argc > 1025) return 2; + int successes = 0; + for (int i = 1; i < argc; i++) { + if (argv[i][0] < '1' || argv[i][0] > '9') return 2; + char *end = NULL; + errno = 0; + long pid = strtol(argv[i], &end, 10); + if (errno || *end || pid <= 0 || pid > INT_MAX) return 2; + if (observe((int)pid) == 0) successes++; + } + return successes ? 0 : 1; +} diff --git a/packages/host-kit/src/internal/macos-process.test.ts b/packages/host-kit/src/internal/macos-process.test.ts new file mode 100644 index 0000000000..eb24cd723a --- /dev/null +++ b/packages/host-kit/src/internal/macos-process.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { afterEach, test, vi } from 'vitest'; +import * as exec from './exec.ts'; +import { readMacosProcesses } from './macos-process.ts'; + +afterEach(() => vi.restoreAllMocks()); + +test.skipIf(process.platform !== 'darwin')( + 'native query validates requested PID and refuses malformed evidence', + () => { + assert.equal(readMacosProcesses([process.pid])[0]?.pid, process.pid); + const base = { + pid: process.pid, + ppid: process.ppid, + startSeconds: '1700000000', + startMicros: 123456, + zombie: false, + argc: 1, + argvHex: Buffer.from('node\0').toString('hex'), + }; + const spy = vi.spyOn(exec, 'runCmdSync'); + for (const changes of [ + { pid: process.pid + 1 }, + { startSeconds: 'NaN' }, + { startMicros: 1_000_000 }, + { argc: 2 }, + { argvHex: 'ff00' }, + { argvHex: 'node' }, + { argvHex: '61'.repeat(32769) }, + { zombie: true }, + { argvHex: '6e6f6465' }, + ]) { + spy.mockReturnValue({ + stdout: JSON.stringify({ ...base, ...changes }), + stderr: '', + exitCode: 0, + }); + assert.deepEqual(readMacosProcesses([process.pid]), []); + } + spy.mockReturnValue({ stdout: '{', stderr: '', exitCode: 0 }); + assert.deepEqual(readMacosProcesses([process.pid]), []); + spy.mockImplementation(() => { + throw new Error('access denied'); + }); + assert.deepEqual(readMacosProcesses([process.pid]), []); + }, +); + +test('invalid process selectors do not execute native tooling', () => { + const spy = vi.spyOn(exec, 'runCmdSync'); + for (const pids of [ + [], + [0], + [-1], + [1.1], + [Number.NaN], + [Infinity], + [2_147_483_648], + Array(1025).fill(1), + ]) { + assert.deepEqual(readMacosProcesses(pids), []); + } + assert.equal(spy.mock.calls.length, 0); +}); + +test.skipIf(process.platform !== 'darwin')( + 'cached helper avoids recompilation on repeated ownership checks', + () => { + const realRun = exec.runCmdSync; + const spy = vi.spyOn(exec, 'runCmdSync').mockImplementation(realRun); + assert.equal(readMacosProcesses([process.pid])[0]?.pid, process.pid); + spy.mockClear(); + assert.equal(readMacosProcesses([process.pid])[0]?.pid, process.pid); + assert.equal(spy.mock.calls.length, 1); + assert.notEqual(spy.mock.calls[0]?.[0], '/usr/bin/clang'); + }, +); diff --git a/packages/host-kit/src/internal/macos-process.ts b/packages/host-kit/src/internal/macos-process.ts new file mode 100644 index 0000000000..533250019c --- /dev/null +++ b/packages/host-kit/src/internal/macos-process.ts @@ -0,0 +1,158 @@ +import { createHash } from 'node:crypto'; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runCmdSync } from './exec.ts'; + +type MacosProcess = { + pid: number; + ppid: number; + command: string; + state: string; + startTime: string; +}; + +function requirePrivatePath(path: string, kind: 'directory' | 'file'): void { + const stat = lstatSync(path); + const correctKind = kind === 'directory' ? stat.isDirectory() : stat.isFile(); + if (!correctKind || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0) + throw new Error('macOS process helper path is not private and owned'); +} + +function compileHelper(source: string, directory: string, binary: string): void { + const temporary = mkdtempSync(join(directory, 'compile-')); + try { + const output = join(temporary, 'process'); + const result = runCmdSync( + '/usr/bin/clang', + ['-std=c11', '-O2', '-Wall', '-Wextra', '-Werror', source, '-o', output], + { timeoutMs: 5_000, allowFailure: true }, + ); + if (result.exitCode !== 0) throw new Error('macOS process helper compilation failed'); + chmodSync(output, 0o700); + renameSync(output, binary); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } +} + +function helperPath(): string { + const source = fileURLToPath(new URL('./macos-process.c', import.meta.url)); + const hash = createHash('sha256').update(readFileSync(source)).update(process.arch).digest('hex'); + const directory = join(tmpdir(), `agent-device-process-${process.getuid?.()}-${hash}`); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + requirePrivatePath(directory, 'directory'); + const binary = join(directory, 'process'); + if (!existsSync(binary)) compileHelper(source, directory, binary); + requirePrivatePath(binary, 'file'); + return binary; +} + +function psStartTime(seconds: number): string { + const date = new Date(seconds * 1_000); + const day = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][date.getDay()]; + const month = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ][date.getMonth()]; + const time = [date.getHours(), date.getMinutes(), date.getSeconds()] + .map((value) => String(value).padStart(2, '0')) + .join(':'); + return `${day} ${month} ${String(date.getDate()).padStart(2, ' ')} ${time} ${date.getFullYear()}`; +} + +function boundedInteger(value: unknown, minimum: number, maximum: number): value is number { + return ( + typeof value === 'number' && Number.isInteger(value) && value >= minimum && value <= maximum + ); +} + +function argumentBytes(value: Record): string { + if ( + typeof value.argvHex !== 'string' || + value.argvHex.length > 65_536 || + !/^(?:[a-f0-9]{2})*$/.test(value.argvHex) + ) + throw new Error('invalid argument bytes'); + const bytes = Buffer.from(value.argvHex, 'hex'); + const decoded = bytes.toString('utf8'); + if (!Buffer.from(decoded).equals(bytes)) throw new Error('invalid argument encoding'); + return decoded; +} + +function processArguments(value: Record): string[] { + if (!boundedInteger(value.argc, 0, 32_768)) throw new Error('invalid argument count'); + const args = argumentBytes(value).split('\0'); + if (args.pop() !== '' || args.length !== value.argc) + throw new Error('invalid argument boundaries'); + if (value.zombie ? args.length !== 0 : !args[0]) throw new Error('invalid process arguments'); + return args; +} + +function processObservation(line: string): MacosProcess { + if (line.length > 66_000) throw new Error('process observation is oversized'); + const value = JSON.parse(line); + if (!boundedInteger(value.pid, 1, 2_147_483_647) || !boundedInteger(value.ppid, 0, 2_147_483_647)) + throw new Error('invalid process identity'); + if (typeof value.zombie !== 'boolean') throw new Error('invalid process state'); + if ( + typeof value.startSeconds !== 'string' || + !/^[1-9]\d{0,10}$/.test(value.startSeconds) || + !boundedInteger(value.startMicros, 0, 999_999) + ) + throw new Error('invalid process start time'); + return { + pid: value.pid, + ppid: value.ppid, + command: processArguments(value).join(' ').trim(), + state: value.zombie ? 'Z' : 'S', + startTime: psStartTime(Number(value.startSeconds)), + }; +} + +export function readMacosProcesses( + pids: readonly number[] | 'all', + timeoutMs = 1_000, +): MacosProcess[] { + if (process.platform !== 'darwin') return []; + if ( + pids !== 'all' && + (pids.length === 0 || + pids.length > 1024 || + pids.some((pid) => !boundedInteger(pid, 1, 2_147_483_647))) + ) + return []; + try { + const result = runCmdSync(helperPath(), pids === 'all' ? ['--all'] : pids.map(String), { + timeoutMs, + allowFailure: true, + maxBuffer: 8 * 1024 * 1024, + }); + if (result.exitCode !== 0) return []; + const observations = result.stdout.trim().split('\n').map(processObservation); + if (pids !== 'all' && observations.some((value) => !pids.includes(value.pid))) return []; + return observations; + } catch { + return []; + } +} diff --git a/packages/platform-web/src/agent-browser-lifecycle.test.ts b/packages/platform-web/src/agent-browser-lifecycle.test.ts index bc3ef2888a..b37c1d78d8 100644 --- a/packages/platform-web/src/agent-browser-lifecycle.test.ts +++ b/packages/platform-web/src/agent-browser-lifecycle.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { runCmdSync } from '@agent-device/host-kit/command'; import { installFakeManagedAgentBrowser, mkdtempForTestSync } from './__tests__/test-utils.ts'; const { runCmdMock } = vi.hoisted(() => ({ @@ -205,6 +206,9 @@ test('cleanup does not treat the shared socket directory mtime as browser activi }); test('cleanup reads recorded browser identities without reconstructing a process tree', async () => { + const exitedPid = Number( + runCmdSync(process.execPath, ['-p', 'process.pid'], { timeoutMs: 1000 }).stdout.trim(), + ); const stateDir = mkdtempForTestSync('agent-device-web-life-'); const originalIdleTimeout = process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS; process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS = '1'; @@ -216,7 +220,7 @@ test('cleanup reads recorded browser identities without reconstructing a process status: 'decoded' as const, records: [ { - pid: 101, + pid: exitedPid, startTime: 'start-101', command: 'command-101', purpose: 'managed-web-browser', @@ -235,7 +239,7 @@ test('cleanup reads recorded browser identities without reconstructing a process ownedProcessRecords, }); - assert.deepEqual(result.pids, [101]); + assert.deepEqual(result.pids, [exitedPid]); assert.deepEqual(result.signalPids, []); assert.equal(mockRunCmd.mock.calls.length, 0); expect(ownedProcessRecords.clear).toHaveBeenCalledOnce(); diff --git a/test/integration/fixtures/macos-process-check.c b/test/integration/fixtures/macos-process-check.c new file mode 100644 index 0000000000..d5052e6557 --- /dev/null +++ b/test/integration/fixtures/macos-process-check.c @@ -0,0 +1,68 @@ +#define main process_helper_main +#include "../../../packages/host-kit/src/internal/macos-process.c" +#undef main +#include +#include + +int main(int argc, char **argv) { + if (argc == 2 && strcmp(argv[1], "zombie") == 0) { + pid_t child = fork(); + if (child == 0) _exit(0); + assert(child > 0); + printf("%d\n", child); + fflush(stdout); + getchar(); + waitpid(child, NULL, 0); + return 0; + } + struct proc_bsdinfo a = {0}, b = {0}; + a.pbi_pid = b.pbi_pid = 123; + a.pbi_start_tvsec = b.pbi_start_tvsec = 1234; + assert(same_identity(&a, &b)); + b.pbi_start_tvusec++; + assert(!same_identity(&a, &b)); + b = a; b.pbi_start_tvsec++; + assert(!same_identity(&a, &b)); + b = a; b.pbi_uid++; + assert(!same_identity(&a, &b)); + b = a; b.pbi_ruid++; + assert(!same_identity(&a, &b)); + b = a; b.pbi_pid++; + assert(!same_identity(&a, &b)); + b = a; b.pbi_status = SZOMB; + assert(!same_identity(&a, &b)); + + char buffer[256] = {0}, *start = NULL, *finish = NULL; + int count = 3, parsed = 0; + memcpy(buffer, &count, sizeof(count)); + const char sample[] = "/bin/node\0\0\0\0\0\0\0node\0\0arg\0SECRET=not-an-argument\0"; + memcpy(buffer + sizeof(count), sample, sizeof(sample)); + size_t length = sizeof(count) + sizeof(sample); + assert(args_bounds(buffer, length, 8, &start, &finish, &parsed)); + assert(parsed == 3 && finish - start == 10); + assert(memcmp(start, "node\0\0arg\0", 10) == 0); + assert(!args_bounds(buffer, sizeof(count), 8, &start, &finish, &parsed)); + assert(!args_bounds(buffer, (size_t)(finish - buffer) - 1, 8, &start, &finish, &parsed)); + count = INT_MAX; + memcpy(buffer, &count, sizeof(count)); + assert(!args_bounds(buffer, length, 8, &start, &finish, &parsed)); + for (size_t pointer = 4; pointer <= 8; pointer += 4) { + for (size_t path_length = 1; path_length <= 24; path_length++) { + memset(buffer, 0, sizeof(buffer)); + count = 2; + memcpy(buffer, &count, sizeof(count)); + memset(buffer + sizeof(count), 'x', path_length); + size_t offset = ((16 + path_length + 1 + pointer - 1) / pointer) * pointer - 16; + char *arguments = buffer + sizeof(count) + offset; + const char ordinary[] = "sleep\00030\000SECRET=not-an-argument\0"; + memcpy(arguments, ordinary, sizeof(ordinary)); + assert(args_bounds(buffer, sizeof(buffer), pointer, &start, &finish, &parsed)); + assert(finish - start == 9 && parsed == 2); + const char empty_first[] = "\00030\000SECRET=not-an-argument\0"; + memcpy(arguments, empty_first, sizeof(empty_first)); + assert(!args_bounds(buffer, sizeof(buffer), pointer, &start, &finish, &parsed)); + } + } + puts("identity and argument bounds passed"); + return 0; +} diff --git a/test/integration/fixtures/macos-process-smoke.mjs b/test/integration/fixtures/macos-process-smoke.mjs new file mode 100644 index 0000000000..ea62a9208d --- /dev/null +++ b/test/integration/fixtures/macos-process-smoke.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { execFileSync, spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { createInterface } from 'node:readline'; +import { readMacosProcesses } from '../../../packages/host-kit/src/internal/macos-process.ts'; +import { + readProcessCommand, + readProcessStartTime, + isProcessZombie, + readHostProcessIdentityObservations, + listHostProcesses, +} from '../../../packages/host-kit/src/internal/host-process.ts'; +import { classifyOwnerLiveness } from '../../../packages/host-kit/src/internal/owner-identity.ts'; + +const [mode, sentinel, checker, expectedStart] = process.argv.slice(2); +if (mode === 'denied') { + assert.deepEqual(readMacosProcesses([process.pid]), []); +} else { + assert.throws(() => readFileSync(sentinel)); + assert.throws(() => execFileSync('/bin/ps', ['-p', String(process.pid), '-o', 'command='])); + assert.equal(readProcessStartTime(process.ppid), expectedStart); + const child = spawn( + process.execPath, + ['-e', 'console.log("ready"); setInterval(() => {}, 1000)', '--', 'space and "quotes"', ''], + { stdio: ['ignore', 'pipe', 'ignore'] }, + ); + const exited = once(child, 'exit'); + await once(child.stdout, 'data'); + try { + const marker = { pid: child.pid, startTime: readProcessStartTime(child.pid) }; + assert(marker.startTime); + assert.match(readProcessCommand(child.pid), /space and "quotes"/); + assert.equal(isProcessZombie(child.pid), false); + assert.equal(classifyOwnerLiveness({ owner: marker }), 'live'); + assert.equal( + classifyOwnerLiveness({ owner: { ...marker, startTime: 'different' } }), + 'owner-process-reused', + ); + const observations = readHostProcessIdentityObservations([child.pid]); + assert.equal(observations.get(child.pid)?.startTime, marker.startTime); + assert((await listHostProcesses({ timeoutMs: 5000 })).some((entry) => entry.pid === child.pid)); + child.kill(); + await exited; + assert.equal(classifyOwnerLiveness({ owner: marker }), 'owner-process-dead'); + } finally { + if (child.exitCode === null && child.signalCode === null) { + child.kill(); + await exited; + } + } + const emptyArgv = spawn('/bin/sleep', ['30'], { + argv0: '', + env: { TEST_PROCESS_SENTINEL: 'synthetic-not-an-argument' }, + stdio: 'ignore', + }); + const emptyExit = once(emptyArgv, 'exit'); + await once(emptyArgv, 'spawn'); + try { + assert.deepEqual(readMacosProcesses([emptyArgv.pid]), []); + assert.equal(readProcessCommand(emptyArgv.pid), null); + assert( + !(await listHostProcesses({ timeoutMs: 5000 })).some((entry) => entry.pid === emptyArgv.pid), + ); + } finally { + emptyArgv.kill(); + await emptyExit; + } + const holder = spawn(checker, ['zombie'], { stdio: ['pipe', 'pipe', 'inherit'] }); + const holderExit = once(holder, 'exit'); + const lines = createInterface({ input: holder.stdout }); + try { + const [line] = await once(lines, 'line'); + const pid = Number(line); + for (let attempt = 0; attempt < 100 && !isProcessZombie(pid); attempt++) + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(isProcessZombie(pid), true); + assert.equal( + classifyOwnerLiveness({ owner: { pid, startTime: readProcessStartTime(pid) } }), + 'owner-process-dead', + ); + } finally { + holder.stdin.end('\n'); + await holderExit; + lines.close(); + } +} +console.log(`sandbox process ${mode} passed`); diff --git a/test/integration/smoke-host-process.test.ts b/test/integration/smoke-host-process.test.ts new file mode 100644 index 0000000000..d62fc990e7 --- /dev/null +++ b/test/integration/smoke-host-process.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { realpathSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import { mkdtempForTestSync } from '../../src/__tests__/test-utils/tmp-dir.ts'; + +test( + 'macOS sandbox process evidence preserves ownership, zombies, and protected-file denial', + { skip: process.platform !== 'darwin' }, + () => { + const temporary = realpathSync(mkdtempForTestSync('agent-device-process-sandbox-')); + const checker = join(temporary, 'check'); + execFileSync( + '/usr/bin/clang', + [ + '-std=c11', + '-Wall', + '-Wextra', + '-Werror', + fileURLToPath(new URL('./fixtures/macos-process-check.c', import.meta.url)), + '-o', + checker, + ], + { timeout: 10_000 }, + ); + assert.match( + execFileSync(checker, { encoding: 'utf8' }), + /identity and argument bounds passed/, + ); + const sentinel = join(temporary, 'secret'); + writeFileSync(sentinel, 'must stay unreadable'); + const expectedStart = execFileSync('/bin/ps', ['-p', String(process.pid), '-o', 'lstart='], { + encoding: 'utf8', + }).trim(); + for (const mode of ['allowed', 'denied']) { + const policy = `(version 1)(allow default)(deny file-read-data (literal ${JSON.stringify(sentinel)}))${mode === 'denied' ? '(deny process-info*)' : ''}`; + const output = execFileSync( + '/usr/bin/sandbox-exec', + [ + '-p', + policy, + process.execPath, + fileURLToPath(new URL('./fixtures/macos-process-smoke.mjs', import.meta.url)), + mode, + sentinel, + checker, + expectedStart, + ], + { timeout: 30_000, encoding: 'utf8' }, + ); + assert.match(output, new RegExp(`sandbox process ${mode} passed`)); + } + }, +); diff --git a/tsdown.config.ts b/tsdown.config.ts index 9e368d46c3..9ab5bdde32 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -54,6 +54,7 @@ const publicSdkChunkGroups = [ ] as const; export default defineConfig({ + copy: [{ from: 'packages/host-kit/src/internal/macos-process.c', to: 'dist/src' }], entry: { index: 'src/sdk/index.ts', io: 'src/sdk/io.ts', diff --git a/website/docs/docs/installation.md b/website/docs/docs/installation.md index 1553277d8c..7de70fee7c 100644 --- a/website/docs/docs/installation.md +++ b/website/docs/docs/installation.md @@ -90,6 +90,8 @@ vega device list ## macOS desktop notes +On macOS, a sandbox may prevent the system `ps` executable from running. Process ownership checks then use a read-only native helper, compiled on first use with Apple command-line tools and cached in a private temporary directory by source hash and architecture. The fallback observes only same-user processes and does not weaken the sandbox. If compilation or native inspection is denied too, recording and cleanup retain their existing fail-closed ownership behavior. + - The macOS desktop path uses a local `agent-device-macos-helper` for permission checks (`settings permission ...`), alert handling, and helper-backed desktop snapshot surfaces (`frontmost-app`, `desktop`, `menubar`). - Source checkouts build the helper lazily on first use and cache it under `~/.agent-device/macos-helper/current/`. - Release distribution should ship a stable signed/notarized helper build so macOS trust/TCC state is tied to a durable code signature instead of an ad-hoc local binary.