mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-28 15:41:57 +02:00
* fix(browse): prepare reliable cookie import wave for validation * ci: sequence quality and behavior for validation branch * fix(browse): isolate Windows qualification and preserve native diagnostics * test(browse): cover cookie workflow quality and isolate Windows user paths * test(browse): trace native member startup and initialize fresh folders * fix(browse): keep Windows member stdin alive through EOF * fix(browse): latch native timeouts and compare contained Edge startup * test(browse): verify native version metadata and actual Windows argv * test(browse): qualify Dia import on isolated macOS CI * fix(browse): require picker origin for session mutations * fix(browse): bound credential reads through stream completion * test(browse): inspect owned Windows process arguments natively * test(evals): preserve passing coverage during cookie repair reruns * test(browse): isolate Dia qualification in a fresh macOS account * test(browse): pass bounded integer timeouts to native Mac probes * test(browse): distinguish Windows profile initialization from containment * test(browse): await descendant pipe readiness before parent exit * test(browse): initialize and restore isolated macOS Keychain state * test(browse): initialize Windows fixture folders before qualification * test(ci): pin the same Node runtime across Windows checks * test(browse): distinguish native macOS browser preflight stages * test(browse): isolate Windows descendant console lifetime * test(browse): preserve native receipts and identify fixture lock holders * test(browse): prepare dependency resolution before native Mac worker startup * test(ci): include lock and close checks in native diagnostics * test(browse): preserve native owner probe stages and subprocess deadlines * fix(browse): classify Chromium profile-in-use exit precisely * test(browse): retain Mac qualification evidence through cleanup failures * test(browse): bound Mac fixture paths and retire its owned user domain * test(browse): accept vanished fixture entries without weakening cleanup * test(browse): identify probe-created macOS user domains safely * test(browse): observe Mac user domains without targeting them first * test(browse): use passive fresh-user ownership throughout Mac qualification * test(browse): distinguish profile and registered-home Keychain lookups * test(browse): qualify Dia under one registered account home * test(browse): identify Dia startup and owned process-group failures * test(browse): classify bounded Dia startup diagnostics without leaking output * fix(test): preserve native Mac sandboxing and reap owned browser children * fix(browse): preserve Chromium sandboxing for native profile imports * test(browse): inspect signed Mach-O architecture without launching Xcode tools * test(browse): sample pending Dia startup and reap on all cleanup paths * test(browse): compare protected Dia launches in fresh Bun and Node accounts * test(browse): inspect isolated Mac GUI readiness without browser access * v1.90.0.0 fix: bind cookie picker actions to their document * test: validate cookie guards and fit nested launch fixtures * ci: configure the bundled Chromium sandbox helper * fix(browse): classify Playwright authentication timeouts * test: retain bounded Windows lifecycle diagnostics * test(cso): reuse bounded NTFS precision candidates * test(review): handle explicit preservation choices safely * test(browse): remove owned fixture directories with explicit primitives * test(review): distinguish descriptive reuse from edit commitments * test: admit only the approved unscored cookie workflow refusal * test: keep the Office Hours judge mock export-complete * fix: keep dependency-free CI planners independent of the model SDK * test: observe the exact holder after a native fixture unlink failure * fix: start seeded PTY observations at owned readiness * test: acquire identity-bound Windows deletion admission before profile resets * test: preserve qualified Git index bits without authorizing mutations
151 lines
8.6 KiB
TypeScript
151 lines
8.6 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { existsSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { dlopen, FFIType, ptr, toArrayBuffer } from 'bun:ffi';
|
|
|
|
const hash = (text: string) => createHash('sha256').update(text).digest('hex');
|
|
|
|
export function decodeNativeCommandLine(buffer: Buffer, address: number | bigint): string | null {
|
|
if (buffer.length < 16) return null;
|
|
const length = buffer.readUInt16LE(0);
|
|
const offset = Number(buffer.readBigUInt64LE(8) - BigInt(address));
|
|
if (!Number.isSafeInteger(offset) || offset < 16 || offset + length > buffer.length || length % 2 !== 0) return null;
|
|
return buffer.subarray(offset, offset + length).toString('utf16le');
|
|
}
|
|
|
|
function knownFolders() {
|
|
const shell = dlopen('shell32.dll', {
|
|
SHGetFolderPathW: { args: [FFIType.u64, FFIType.i32, FFIType.u64, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
|
});
|
|
const normalized = (value: string) => path.win32.normalize(value).toLowerCase();
|
|
try {
|
|
return Object.fromEntries([['local', 0x1c, 'LOCALAPPDATA'], ['roaming', 0x1a, 'APPDATA']].map(([name, id, env]) => {
|
|
const calls = Object.fromEntries([['verified', 0], ['dontVerify', 0x4000]].map(([kind, flag]) => {
|
|
const output = Buffer.alloc(520);
|
|
const status = shell.symbols.SHGetFolderPathW(0, Number(id) | Number(flag), 0, 0, ptr(output));
|
|
let end = 0;
|
|
while (end + 2 <= output.length && output.readUInt16LE(end) !== 0) end += 2;
|
|
const folder = status >= 0 && end > 0 && end + 2 <= output.length ? output.subarray(0, end).toString('utf16le') : null;
|
|
return [kind, {
|
|
hresult: status,
|
|
pathHash: folder ? hash(normalized(folder)) : null,
|
|
exists: folder ? existsSync(folder) : null,
|
|
matchesEnvironment: folder ? normalized(folder) === normalized(process.env[String(env)] || '') : null,
|
|
underUserProfile: folder ? normalized(folder).startsWith(normalized(process.env.USERPROFILE || '') + '\\') : null,
|
|
}];
|
|
}));
|
|
return [name, calls];
|
|
}));
|
|
} finally {
|
|
shell.close();
|
|
}
|
|
}
|
|
|
|
function observe() {
|
|
if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) return { available: false, reason: 'not_windows' };
|
|
const input = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'));
|
|
if (input.mode === 'known-folders') return { available: true, knownFolders: knownFolders() };
|
|
if (!Number.isSafeInteger(input.pid) || input.pid <= 0 || input.pid > 0xffffffff || !Number.isSafeInteger(input.owner) || input.owner <= 0 || input.owner > 0xffffffff || typeof input.image !== 'string' || input.image.length > 32768) {
|
|
return { available: false, reason: 'invalid_input' };
|
|
}
|
|
const kernel = dlopen('kernel32.dll', {
|
|
OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 },
|
|
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
|
QueryFullProcessImageNameW: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
|
QueryInformationJobObject: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
|
IsProcessInJob: { args: [FFIType.u64, FFIType.u64, FFIType.ptr], returns: FFIType.i32 },
|
|
LocalFree: { args: [FFIType.ptr], returns: FFIType.ptr },
|
|
LocalSize: { args: [FFIType.ptr], returns: FFIType.u64 },
|
|
GetLastError: { args: [], returns: FFIType.u32 },
|
|
});
|
|
const nt = dlopen('ntdll.dll', {
|
|
NtQueryInformationProcess: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
|
});
|
|
const shell = dlopen('shell32.dll', {
|
|
CommandLineToArgvW: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.ptr },
|
|
});
|
|
let processHandle: number | bigint = 0;
|
|
let argumentMemory: ReturnType<typeof shell.symbols.CommandLineToArgvW> = null;
|
|
let stage = 'process_open';
|
|
try {
|
|
processHandle = kernel.symbols.OpenProcess(0x1000, 0, input.pid);
|
|
if (!processHandle) return { available: false, reason: stage, win32Error: kernel.symbols.GetLastError() };
|
|
stage = 'process_identity';
|
|
const basic = Buffer.alloc(48);
|
|
const returned = Buffer.alloc(4);
|
|
let status = nt.symbols.NtQueryInformationProcess(processHandle, 0, ptr(basic), basic.length, ptr(returned));
|
|
if (status !== 0) return { available: false, reason: stage, ntStatus: status };
|
|
const parentMatched = basic.readBigUInt64LE(40) === BigInt(input.owner);
|
|
const pidMatched = basic.readBigUInt64LE(32) === BigInt(input.pid);
|
|
const imageBuffer = Buffer.alloc(65536);
|
|
const imageLength = Buffer.alloc(4);
|
|
imageLength.writeUInt32LE(32768);
|
|
if (!kernel.symbols.QueryFullProcessImageNameW(processHandle, 0, ptr(imageBuffer), ptr(imageLength))) {
|
|
return { available: false, reason: stage, win32Error: kernel.symbols.GetLastError() };
|
|
}
|
|
const imageChars = imageLength.readUInt32LE(0);
|
|
const imageMatched = imageChars <= 32768 && imageBuffer.subarray(0, imageChars * 2).toString('utf16le').toLowerCase() === input.image.toLowerCase();
|
|
if (!parentMatched || !pidMatched || !imageMatched) return { available: false, reason: 'owned_process_unavailable', parentMatched, imageMatched };
|
|
stage = 'command_line';
|
|
status = nt.symbols.NtQueryInformationProcess(processHandle, 60, null, 0, ptr(returned));
|
|
const length = returned.readUInt32LE(0);
|
|
if (length < 16 || length > 131072) return { available: false, reason: 'command_line_length', ntStatus: status };
|
|
const commandBuffer = Buffer.alloc(length);
|
|
const commandAddress = ptr(commandBuffer);
|
|
status = nt.symbols.NtQueryInformationProcess(processHandle, 60, commandAddress, length, ptr(returned));
|
|
if (status !== 0) return { available: false, reason: stage, ntStatus: status };
|
|
const commandLine = decodeNativeCommandLine(commandBuffer, commandAddress);
|
|
if (commandLine === null) return { available: false, reason: 'command_line_bounds' };
|
|
stage = 'argument_parse';
|
|
const wideCommand = Buffer.from(commandLine + '\0', 'utf16le');
|
|
const count = Buffer.alloc(4);
|
|
argumentMemory = shell.symbols.CommandLineToArgvW(ptr(wideCommand), ptr(count));
|
|
const argumentCount = count.readInt32LE(0);
|
|
if (!argumentMemory || argumentCount < 1 || argumentCount > 4096) return { available: false, reason: stage };
|
|
const size = Number(kernel.symbols.LocalSize(argumentMemory));
|
|
if (!Number.isSafeInteger(size) || size < argumentCount * 8 || size > 1048576) return { available: false, reason: 'argument_bounds' };
|
|
const argumentsBuffer = Buffer.from(toArrayBuffer(argumentMemory, 0, size));
|
|
const args: string[] = [];
|
|
for (let index = 1; index < argumentCount; index++) {
|
|
const start = Number(argumentsBuffer.readBigUInt64LE(index * 8) - BigInt(argumentMemory));
|
|
if (!Number.isSafeInteger(start) || start < argumentCount * 8 || start % 2 !== 0 || start >= size) return { available: false, reason: 'argument_bounds' };
|
|
let end = start;
|
|
while (end + 2 <= size && argumentsBuffer.readUInt16LE(end) !== 0) end += 2;
|
|
if (end + 2 > size) return { available: false, reason: 'argument_bounds' };
|
|
args.push(argumentsBuffer.subarray(start, end).toString('utf16le'));
|
|
}
|
|
stage = 'job_query';
|
|
const limits = Buffer.alloc(144);
|
|
const jobKnown = kernel.symbols.QueryInformationJobObject(0, 9, ptr(limits), limits.length, ptr(returned));
|
|
const jobError = jobKnown ? undefined : kernel.symbols.GetLastError();
|
|
const inJob = Buffer.alloc(4);
|
|
const membershipKnown = kernel.symbols.IsProcessInJob(processHandle, 0, ptr(inJob));
|
|
const dataArgs = args.filter(arg => arg.startsWith('--user-data-dir='));
|
|
return {
|
|
available: true, parentMatched, imageMatched,
|
|
commandLineHash: hash(commandLine), argumentHashes: args.map(hash),
|
|
userDataDirCount: dataArgs.length,
|
|
userDataDirHash: dataArgs.length === 1 ? hash(dataArgs[0].slice(16)) : null,
|
|
pipePresent: args.includes('--remote-debugging-pipe'),
|
|
browserInJob: membershipKnown ? inJob.readInt32LE(0) !== 0 : null,
|
|
observerJobLimitFlags: jobKnown ? limits.readUInt32LE(16) : null,
|
|
observerJobQueryError: jobError,
|
|
knownFolders: knownFolders(),
|
|
};
|
|
} catch {
|
|
return { available: false, reason: stage };
|
|
} finally {
|
|
if (argumentMemory) kernel.symbols.LocalFree(argumentMemory);
|
|
if (processHandle) kernel.symbols.CloseHandle(processHandle);
|
|
shell.close(); nt.close(); kernel.close();
|
|
}
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
let result: object;
|
|
let exitCode = 0;
|
|
try { result = observe(); }
|
|
catch { result = { available: false, reason: 'observer_initialize' }; exitCode = 1; }
|
|
process.stdout.write(JSON.stringify(result) + '\n', () => process.exit(exitCode));
|
|
}
|