Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 44 additions & 22 deletions packages/host-kit/src/internal/host-process.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}

Expand All @@ -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[] {
Expand All @@ -157,16 +171,24 @@ export function parseHostProcessList(stdout: string): HostProcessInfo[] {
export async function listHostProcesses(
options: ListHostProcessesOptions,
): Promise<HostProcessInfo[]> {
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(
Expand Down
108 changes: 108 additions & 0 deletions packages/host-kit/src/internal/macos-process.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#include <errno.h>
#include <libproc.h>
#include <limits.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/proc.h>
#include <sys/proc_info.h>
#include <sys/sysctl.h>
#include <unistd.h>

#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;
}
77 changes: 77 additions & 0 deletions packages/host-kit/src/internal/macos-process.test.ts
Original file line number Diff line number Diff line change
@@ -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');
},
);
Loading