Skip to content
Closed
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
91 changes: 91 additions & 0 deletions packages/stim-cli/shim/macos-process.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#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 || argv[1][0] < '1' || argv[1][0] > '9') return 2;
char *end = NULL;
errno = 0;
long pid = strtol(argv[1], &end, 10);
if (errno || *end || pid <= 0 || pid > INT_MAX) return 2;
return observe((int)pid);
}
98 changes: 98 additions & 0 deletions packages/stim-cli/src/__tests__/macos-process.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { once } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { getExecutor, resetExecutor, setExecutor } from '../exec.ts';
import { readMacosProcess } from '../macos-process.ts';
import { readProcessArgs, readProcessStartTime } from '../process-args.ts';

describe.skipIf(process.platform !== 'darwin')('native macOS process inspection', () => {
let home: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'stim-native-process-'));
process.env.STIM_HOME = home;
});
afterEach(() => {
resetExecutor();
delete process.env.STIM_HOME;
rmSync(home, { recursive: true, force: true });
});

test('keeps exact argv boundaries and start identity when ps execution is denied', async () => {
const executor = getExecutor();
const args = ['-e', 'setInterval(() => {}, 1000)', '--', 'space and "quotes"', '', 'back\\slash', '\u2603', '', ''];
const child = executor.spawn(process.execPath, args, { stdio: 'ignore' });
const exited = once(child, 'exit');
await once(child, 'spawn');
try {
const observation = readMacosProcess(child.pid!);
expect(observation?.args).toEqual([process.execPath, ...args]);
expect(observation?.zombie).toBe(false);
expect(observation?.startTime.getTime()).toBeLessThanOrEqual(Date.now());
expect(observation?.startTime.getTime()).toBeGreaterThan(Date.now() - 10_000);
setExecutor({
...executor,
runFile(file, argv, options) {
if (file === 'ps') throw Object.assign(new Error('denied'), { code: 'EPERM' });
return executor.runFile(file, argv, options);
},
});
expect(readProcessArgs(child.pid!)).toEqual(observation?.args);
expect(readProcessStartTime(child.pid!)).toEqual(observation?.startTime);
child.kill();
await exited;
expect(readMacosProcess(child.pid!)).toBeNull();
expect(readProcessArgs(child.pid!)).toBeNull();
} finally {
if (child.exitCode === null && child.signalCode === null) {
child.kill();
await exited;
}
}
});

test('refuses invalid PIDs without running a compiler or process query', () => {
setExecutor({
runFile() {
throw new Error('must not execute');
},
});
for (const pid of [0, -1, 1.1, NaN, Infinity, 2_147_483_648]) expect(readMacosProcess(pid)).toBeNull();
});

test('does not accept truncated, foreign-PID, oversized, or malformed native evidence', () => {
expect(readMacosProcess(process.pid)).not.toBeNull();
const base = {
pid: process.pid,
startSeconds: '1700000000',
startMicros: 123456,
zombie: false,
argc: 1,
argvHex: Buffer.from('node\0').toString('hex'),
};
for (const change 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' },
]) {
setExecutor({
runFile() {
return JSON.stringify({ ...base, ...change });
},
});
expect(readMacosProcess(process.pid)).toBeNull();
}
setExecutor({
runFile() {
return '{';
},
});
expect(readMacosProcess(process.pid)).toBeNull();
});
});
5 changes: 4 additions & 1 deletion packages/stim-cli/src/collector/ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ function identifiesCollector(args: readonly string[], platform: string): boolean
}

function tokenize(args: readonly string[]): string[] {
const only = args.length === 1 ? args[0] : null;
const only =
args.length === 1 || (args[0]?.startsWith(TITLE_PREFIX) && args.slice(1).every((arg) => arg === ''))
? args[0]
: null;
return only && /\s/.test(only) ? only.trim().split(/\s+/) : [...args];
}

Expand Down
9 changes: 8 additions & 1 deletion packages/stim-cli/src/guide/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,14 @@ THE ONE CASE GC WILL NOT REAP
genuinely stale and gets dropped, as before. A pid that started at or
before that claim may still be the collector Stim registered, so the
record is kept and reported for a retry, the same way a device teardown
that could not be confirmed keeps its record.`,
that could not be confirmed keeps its record.

On macOS, if the sandbox prevents ps from executing, Stim reads same-user
process identity through native macOS APIs. The first fallback compiles a
small read-only helper using the installed Apple command-line tools and
caches it under $STIM_HOME/tools by source hash and architecture. A missing
compiler or denied native inspection still leaves ownership unverified;
it never authorizes signalling an unknown process.`,
},
disk: {
summary: 'disk usage, AVD and build-log sizes, the data partition, trimming the shared caches',
Expand Down
83 changes: 83 additions & 0 deletions packages/stim-cli/src/macos-process.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { createHash } from 'node:crypto';
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getConfigDir } from './config.ts';
import { getExecutor } from './exec.ts';

type MacosProcess = { args: string[]; startTime: Date; zombie: boolean };

function helperPath(): string {
const source = ['../shim/macos-process.c', '../../shim/macos-process.c']
.map((path) => fileURLToPath(new URL(path, import.meta.url)))
.find(existsSync);
if (!source) throw new Error('macOS process helper source is missing');
const hash = createHash('sha256').update(readFileSync(source)).update(process.arch).digest('hex');
const directory = join(getConfigDir(), 'tools', `macos-process-${hash}`);
mkdirSync(directory, { recursive: true, mode: 0o700 });
const directoryStat = lstatSync(directory);
if (!directoryStat.isDirectory() || directoryStat.uid !== process.getuid?.() || (directoryStat.mode & 0o077) !== 0) {
throw new Error('macOS process helper directory is not private');
}
const binary = join(directory, 'process');
if (!existsSync(binary)) {
const temporary = mkdtempSync(join(directory, 'compile-'));
try {
const output = join(temporary, 'process');
getExecutor().runFile(
'/usr/bin/clang',
['-std=c11', '-O2', '-Wall', '-Wextra', '-Werror', source, '-o', output],
{
timeoutMs: 5_000,
},
);
chmodSync(output, 0o700);
renameSync(output, binary);
} finally {
rmSync(temporary, { recursive: true, force: true });
}
}
const stat = lstatSync(binary);
if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0) {
throw new Error('macOS process helper is not a private owned executable');
}
return binary;
}

export function readMacosProcess(pid: number): MacosProcess | null {
if (process.platform !== 'darwin' || !Number.isInteger(pid) || pid <= 0 || pid > 2_147_483_647) return null;
try {
const output = getExecutor().runFile(helperPath(), [String(pid)], { timeoutMs: 1_000 });
if (output.length > 66_000) return null;
const value = JSON.parse(output);
if (
value.pid !== pid ||
typeof value.zombie !== 'boolean' ||
typeof value.startSeconds !== 'string' ||
!/^[1-9]\d{0,10}$/.test(value.startSeconds) ||
!Number.isInteger(value.startMicros) ||
value.startMicros < 0 ||
value.startMicros >= 1_000_000 ||
!Number.isInteger(value.argc) ||
value.argc < 0 ||
value.argc > 32_768 ||
typeof value.argvHex !== 'string' ||
value.argvHex.length > 65_536 ||
!/^(?:[a-f0-9]{2})*$/.test(value.argvHex)
)
return null;
const bytes = Buffer.from(value.argvHex, 'hex');
const decoded = bytes.toString('utf8');
if (!Buffer.from(decoded).equals(bytes)) return null;
const args = decoded.split('\0');
if (args.pop() !== '' || args.length !== value.argc) return null;
if (value.zombie ? args.length !== 0 : !args[0]) return null;
return {
args,
startTime: new Date(Number(value.startSeconds) * 1_000 + Math.floor(value.startMicros / 1_000)),
zombie: value.zombie,
};
} catch {
return null;
}
}
9 changes: 9 additions & 0 deletions packages/stim-cli/src/process-args.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { closeSync, openSync, readFileSync, readSync } from 'node:fs';
import { getExecutor } from './exec.ts';
import { readMacosProcess } from './macos-process.ts';

export const PROCESS_COMMAND_TIMEOUT_MS: number = 1_000;
export const PROCESS_COMMAND_MAX_BYTES: number = 32 * 1024;
Expand Down Expand Up @@ -110,6 +111,10 @@ export function readProcessArgs(
if (platform === 'win32') return null;
return parsePsCommand(runPsCommand(pid, PROCESS_COMMAND_TIMEOUT_MS));
} catch {
if (platform === 'darwin' && runPsCommand === defaultRunPsCommand) {
const observation = readMacosProcess(pid);
return observation && !observation.zombie ? observation.args : null;
}
return null;
}
}
Expand Down Expand Up @@ -183,6 +188,10 @@ export function readProcessStartTime(
const parsed = new Date(raw);
return Number.isNaN(parsed.getTime()) ? null : parsed;
} catch {
if (platform === 'darwin' && runPsCommand === runPsStartCommand) {
const observation = readMacosProcess(pid);
return observation && !observation.zombie ? observation.startTime : null;
}
return null;
}
}
Loading
Loading