diff --git a/docs/windows.md b/docs/windows.md new file mode 100644 index 000000000..5bdf3de93 --- /dev/null +++ b/docs/windows.md @@ -0,0 +1,27 @@ +# Native Windows compatibility + +Pullboard's private relay storage checks the actual NTFS owner and access-control +list on Windows. Synthetic Unix mode bits reported by Node are not used as NTFS +permissions. The current user must own the object and have read access; other +allowed principals are limited to SYSTEM and built-in Administrators. Reparse +points, null DACLs and failed permission queries are refused. New private files +also require a private parent directory before any bytes are written. + +Configured gates, formatters and untimed shell commands use the Bash bundled with +Git for Windows, because Pullboard emits POSIX shell syntax. Commands and child +Node programs still run natively on Windows; WSL is not required. Missing Git Bash +is an explicit error. + +The background view and other console children request hidden Windows consoles. +This prevents a new CMD/Git window for each view refresh while preserving output, +exit status, callbacks, environment, timeouts and existing child-process guards. + +Run the focused compatibility checks from a native Windows checkout: + +```powershell +node --test test/windows-storage.test.js test/windows-processes.test.js +``` + +This is a partial portability repair. Timed shell execution, POSIX process-group +cleanup and the complete test/landing workflow still need Windows-specific work. +These changes do not claim that the full gate passes on Windows. diff --git a/src/activity-inbox.js b/src/activity-inbox.js index c50bb17b8..9812a6dfb 100644 --- a/src/activity-inbox.js +++ b/src/activity-inbox.js @@ -1,6 +1,6 @@ /** Local delivery state and real Git observations for the existing inbox [R2,N26]. */ import { randomUUID } from 'node:crypto'; -import { execFileSync } from 'node:child_process'; +import { execFileSync } from './windows-child-process.js'; import { readFileSync, renameSync, diff --git a/src/affected-tests.js b/src/affected-tests.js index 51fbe2aa0..3e35b57e8 100644 --- a/src/affected-tests.js +++ b/src/affected-tests.js @@ -1,5 +1,5 @@ /** Select committed Node test files through import edges; uncertainty requires the full gate [V4,C7]. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { posix } from 'node:path'; import { fileURLToPath } from 'node:url'; import { pathToFileURL } from 'node:url'; diff --git a/src/agent-hook.js b/src/agent-hook.js index a9699cf0d..654022d02 100644 --- a/src/agent-hook.js +++ b/src/agent-hook.js @@ -1,5 +1,5 @@ /** Native hooks provide bounded context and warnings, never a blocking decision [I6]. */ -import { spawn } from 'node:child_process'; +import { spawn } from './windows-child-process.js'; import { fileURLToPath } from 'node:url'; import { AGENT_HOOK_EVENTS } from './agent-hooks.js'; diff --git a/src/approved-rows.js b/src/approved-rows.js index b98209b47..8f3a9cb03 100644 --- a/src/approved-rows.js +++ b/src/approved-rows.js @@ -8,7 +8,7 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { parseSpec, SIGNOFFS_FILE, diff --git a/src/background-view.js b/src/background-view.js index a73bceb6f..28828ad5e 100644 --- a/src/background-view.js +++ b/src/background-view.js @@ -7,7 +7,7 @@ * left alone, a busy port is one line and never a failure, and the machine setting * `view.background` (on unless set to false) turns it off. */ -import { spawn } from 'node:child_process'; +import { spawn } from './windows-child-process.js'; import { closeSync, mkdirSync, diff --git a/src/board-write-lock.js b/src/board-write-lock.js index d8b17a24b..582ed75e7 100644 --- a/src/board-write-lock.js +++ b/src/board-write-lock.js @@ -1,6 +1,6 @@ /** Advisory process-owned write records; never part of native board state or lock authority [C7,P4]. */ import { randomUUID } from 'node:crypto'; -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { mkdirSync } from 'node:fs'; import { writeFileSync } from 'node:fs'; import { unlinkSync } from 'node:fs'; diff --git a/src/browser-admission.js b/src/browser-admission.js index 961a0289a..befe72db7 100644 --- a/src/browser-admission.js +++ b/src/browser-admission.js @@ -63,6 +63,7 @@ const SAFE_CHILD_PROCESS_MODULES = new Map([ ['src/git.js', 'runs Git commands for repository inspection'], ['src/module-imports.js', 'runs the Node parser in an isolated child'], ['src/resources.js', 'reads process identity with /bin/ps'], + ['src/windows-child-process.js', 'delegates child calls with hidden Windows consoles; importing it starts nothing'], ]); /** Reviewed generic executors and fixed Git/Node calls retain prior import behavior; all other calls stay conservative. */ const REVIEWED_EXECUTORS = new Map([ @@ -592,6 +593,12 @@ async function testsNeedBrowserInTree(root, files, committed = null, onFallback, ? committedTarget(committed, paths[index], request) : await localTarget(root, paths[index], request); if (!target) return browserFallback(onFallback, paths[index], `import ${request} is missing or ambiguous`); + if (target === 'src/windows-child-process.js') { + if (byLaunch) starts[index] = true; + else if (!SAFE_CHILD_PROCESS_MODULES.has(paths[index])) { + return browserFallback(onFallback, paths[index], 'child_process use has no reviewed path-and-reason exception'); + } + } if (target === FIXTURE_CHILD && !safeFixtureChildImport(sources[index], request)) { if (byLaunch) starts[index] = true; else return browserFallback(onFallback, paths[index], 'fixture-child import exposes a generic caller-selected process target'); diff --git a/src/check-baseline-worker.js b/src/check-baseline-worker.js index 9c7fe1e50..690f0c538 100644 --- a/src/check-baseline-worker.js +++ b/src/check-baseline-worker.js @@ -1,5 +1,5 @@ /** Detached baseline measurements and durable completion receipts use the ordered board engine [V2,H16]. */ -import { spawn } from 'node:child_process'; +import { spawn } from './windows-child-process.js'; import { existsSync, mkdirSync, diff --git a/src/check-baseline.js b/src/check-baseline.js index d614ecf36..e75ab6799 100644 --- a/src/check-baseline.js +++ b/src/check-baseline.js @@ -4,7 +4,7 @@ import { } from './timing.js'; export { provingCheck, sayCheckBaseline } from './check-baseline-format.js'; /** Observe a proposed check on an isolated copy of main before recording its move [V2,H16]. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { randomUUID } from 'node:crypto'; import { mkdtempSync, diff --git a/src/child-journal-preload.js b/src/child-journal-preload.js index 861eb493a..0fc602e88 100644 --- a/src/child-journal-preload.js +++ b/src/child-journal-preload.js @@ -1,5 +1,5 @@ /** The SSH runner's spawn-journal preload, built from its directory alone so its content proves what it is [C7]. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -44,7 +44,7 @@ export function childJournalPreloadSource(directory) { const overflowPath = join(journalDirectory, 'overflow'); return ` import diagnostics from 'node:diagnostics_channel'; -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { closeSync, openSync, readFileSync, statSync, writeSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { createHash, randomUUID } from 'node:crypto'; diff --git a/src/cli.js b/src/cli.js index 991249ec8..ec2685576 100644 --- a/src/cli.js +++ b/src/cli.js @@ -34,7 +34,7 @@ import { import { spawn, spawnSync, -} from 'node:child_process'; +} from './windows-child-process.js'; import { existsSync, mkdirSync, diff --git a/src/code-map.js b/src/code-map.js index 18d67ee82..2507ac627 100644 --- a/src/code-map.js +++ b/src/code-map.js @@ -4,7 +4,7 @@ import { execFileSync, -} from 'node:child_process'; +} from './windows-child-process.js'; import { createHash } from 'node:crypto'; import { existsSync, @@ -227,7 +227,11 @@ export function scanSource(rel, src) { const importMap = new Map(); for (const im of imports) for (const nm of im.names) if (nm.local) importMap.set(nm.local, { from: im.from, imported: nm.imported }); const topNames = new Set(fns.map((f) => f.name)); const childNames = new Set(); const fsNames = new Set(); - for (const im of imports) { if (im.from === 'node:child_process' || im.from === 'child_process') im.names.forEach((x) => childNames.add(x.local)); if (/^(node:)?fs(\/promises)?$/.test(im.from)) im.names.forEach((x) => fsNames.add(x.local)); } + for (const im of imports) { + const windowsProcess = im.from.startsWith('.') && posix.normalize(posix.join(posix.dirname(rel), im.from)) === 'src/windows-child-process.js'; + if (im.from === 'node:child_process' || im.from === 'child_process' || windowsProcess) im.names.forEach((x) => childNames.add(x.local)); + if (/^(node:)?fs(\/promises)?$/.test(im.from)) im.names.forEach((x) => fsNames.add(x.local)); + } const netNames = new Set(); for (const im of imports) if (/^(node:)?(https?|http2|net|tls|dgram)$/.test(im.from)) im.names.forEach((x) => netNames.add(x.local)); const childExecImported = [...imports].some((im) => /child_process/.test(im.from) && im.names.some((x) => x.imported === 'exec')); diff --git a/src/commands/view.js b/src/commands/view.js index e9a76827b..0e7b6e351 100644 --- a/src/commands/view.js +++ b/src/commands/view.js @@ -36,7 +36,7 @@ import { } from '../view-session.js'; import { spawn, -} from 'node:child_process'; +} from '../windows-child-process.js'; import { existsSync, } from 'node:fs'; diff --git a/src/commit-signoff.js b/src/commit-signoff.js index d15cbe4a8..8b9e0e70f 100644 --- a/src/commit-signoff.js +++ b/src/commit-signoff.js @@ -1,5 +1,5 @@ /** Check outside contributors' author-matching DCO trailers against committed policy [C1,C7]. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { existsSync, readFileSync, diff --git a/src/committed-file-reader.js b/src/committed-file-reader.js index dc49b9794..66fba3cf7 100644 --- a/src/committed-file-reader.js +++ b/src/committed-file-reader.js @@ -1,5 +1,5 @@ /** Read pinned committed text files through one fresh Git batch, never a candidate's working bytes [N19,D1]. */ -import { spawn } from 'node:child_process'; +import { spawn } from './windows-child-process.js'; import { cleanGitEnvironment } from './git.js'; import { Refused } from './refused.js'; diff --git a/src/doctrine-checks.js b/src/doctrine-checks.js index 2e5edcd1d..651f63e8f 100644 --- a/src/doctrine-checks.js +++ b/src/doctrine-checks.js @@ -6,7 +6,7 @@ * doctrine: accepted main's at submit, HEAD's for `pullboard doctrine check`, never a working-tree * edit. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { mkdtempSync, rmSync, diff --git a/src/evidence.js b/src/evidence.js index 688f4ccbc..d79cca22d 100644 --- a/src/evidence.js +++ b/src/evidence.js @@ -1,5 +1,5 @@ /** Read-only links between spec rows, their tests, board items and sign-offs. */ -import { execFileSync } from 'node:child_process'; +import { execFileSync } from './windows-child-process.js'; import { resolve } from 'node:path'; /** diff --git a/src/gate.js b/src/gate.js index c4d71cf73..b6cb9a0cb 100644 --- a/src/gate.js +++ b/src/gate.js @@ -1,3 +1,4 @@ +import { commandShell } from './windows-shell.js'; /** * The gate (V4, C3, Q1, Q2, V18): the repo's check command and shared queue for gates and item checks. A green * run over a committed tree, with nothing untracked and nothing changed while it ran, leaves that @@ -12,7 +13,7 @@ import { import { spawn, spawnSync, -} from 'node:child_process'; +} from './windows-child-process.js'; import { createHash, randomUUID, @@ -362,7 +363,7 @@ export async function runProfiledShellAsync(root, command, { waitMs = 0, gateSlo const runtimes = captureTestRuntimes({ ...gitChildEnv(root), ...inherited }); const env = runtimes.env; const environment = executionEnvironment(); - const child = spawn(`(\n${command}\n) 2>&1`, { cwd: root, env, shell: true, stdio: ['ignore', 'pipe', 'pipe'] }); + const child = spawn(`(\n${command}\n) 2>&1`, { cwd: root, env, shell: commandShell(), stdio: ['ignore', 'pipe', 'pipe'] }); const chunks = []; let bytes = 0; let overflow = false; diff --git a/src/git.js b/src/git.js index da0af54f1..f4d45e58e 100644 --- a/src/git.js +++ b/src/git.js @@ -4,7 +4,7 @@ import { execFileSync, spawnSync, -} from 'node:child_process'; +} from './windows-child-process.js'; import { AsyncLocalStorage } from 'node:async_hooks'; import { createHash } from 'node:crypto'; import { diff --git a/src/guard-revert.js b/src/guard-revert.js index 527bffbd4..268e0cd4c 100644 --- a/src/guard-revert.js +++ b/src/guard-revert.js @@ -6,7 +6,7 @@ * Two forms: `guard: :-` (lines of the submitted file, removed in the scratch tree) and * `guard: patch ` (a patch file in the submitted commit, applied in reverse). */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { existsSync, readFileSync, diff --git a/src/gui-admission.js b/src/gui-admission.js index 5c1a44a27..62dbbd194 100644 --- a/src/gui-admission.js +++ b/src/gui-admission.js @@ -1,7 +1,7 @@ /** Explicit test-file desktop requirements, independent of Chrome command-line flags [C7,V4]. */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { explicitCheckFiles } from './browser-admission.js'; /** Only an explicit file annotation requests a GUI; --headless does not establish this capability. */ diff --git a/src/gui-session.js b/src/gui-session.js index b644afe61..5e9b91de6 100644 --- a/src/gui-session.js +++ b/src/gui-session.js @@ -1,7 +1,7 @@ /** Read this execution user's Darwin GUI-domain availability without environment guesses. */ import { spawnSync, -} from 'node:child_process'; +} from './windows-child-process.js'; /** * Return a fresh GUI-domain observation: true, known absent false, or unknown null. diff --git a/src/health.js b/src/health.js index 7f41ce65f..5bd4d2f26 100644 --- a/src/health.js +++ b/src/health.js @@ -8,7 +8,7 @@ import { localDependencyIds } from './item-dependencies.js'; * It reads the board through a read-only SQLite connection and asks git only questions that leave * every checkout alone: merge-tree writes a tree object and never an index or a working tree. */ -import { execFileSync } from 'node:child_process'; +import { execFileSync } from './windows-child-process.js'; import { existsSync } from 'node:fs'; import { missedJobFindings } from './recurring-jobs.js'; import { diff --git a/src/history.js b/src/history.js index b5801cd0e..3c02a4be5 100644 --- a/src/history.js +++ b/src/history.js @@ -3,7 +3,7 @@ * the spec, and every id a commit cites, must still be there, so a commit or an item that cites a * row keeps its meaning however the spec changes. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { parseCommitMessage } from './commit-message.js'; import { ID_RE, diff --git a/src/hooks.js b/src/hooks.js index 6c6a73be9..2ad865eca 100644 --- a/src/hooks.js +++ b/src/hooks.js @@ -1,3 +1,4 @@ +import { commandShell } from './windows-shell.js'; import { BOARD_PROOF_GUIDANCE } from './agent-proof-guidance.js'; /** * The checks git runs (C1–C3, L3, L4): the commit message cites the spec, a lane's worktree stays in @@ -7,7 +8,7 @@ import { BOARD_PROOF_GUIDANCE } from './agent-proof-guidance.js'; import { execFileSync, spawnSync, -} from 'node:child_process'; +} from './windows-child-process.js'; import { gitBoardDataPush } from './git-sync-format.js'; export { gitBoardDataPush }; import { hostname } from 'node:os'; @@ -706,7 +707,7 @@ export function applyFixers(root, fixers) { const result = spawnSync(`${fixer.run} ${files.map(shellQuote).join(' ')}`, { cwd: root, env: gitChildEnv(root), - shell: true, + shell: commandShell(), encoding: 'utf8', timeout: 120_000, }); diff --git a/src/init-finish.js b/src/init-finish.js index d15d83f4c..a95591108 100644 --- a/src/init-finish.js +++ b/src/init-finish.js @@ -3,7 +3,7 @@ import { createHash, randomUUID, } from 'node:crypto'; -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { lstatSync, mkdirSync, diff --git a/src/landing-candidates.js b/src/landing-candidates.js index 24445710d..ab487ce61 100644 --- a/src/landing-candidates.js +++ b/src/landing-candidates.js @@ -1,5 +1,5 @@ /** Isolate speculative landing gates while their one parent retains the board lease [B3,V4]. */ -import { spawn } from 'node:child_process'; +import { spawn } from './windows-child-process.js'; import { join, resolve, diff --git a/src/landing-shards.js b/src/landing-shards.js index a73dd9733..924bd1a1c 100644 --- a/src/landing-shards.js +++ b/src/landing-shards.js @@ -6,7 +6,7 @@ import { spawn, spawnSync, -} from 'node:child_process'; +} from './windows-child-process.js'; import { existsSync, lstatSync, diff --git a/src/landing-worker.js b/src/landing-worker.js index e1f0d73b0..6aff57340 100644 --- a/src/landing-worker.js +++ b/src/landing-worker.js @@ -2,7 +2,7 @@ import { spawn, spawnSync, -} from 'node:child_process'; +} from './windows-child-process.js'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import * as store from './board.js'; diff --git a/src/landing-worktree-prune.js b/src/landing-worktree-prune.js index bcc4189fc..a378682af 100644 --- a/src/landing-worktree-prune.js +++ b/src/landing-worktree-prune.js @@ -1,5 +1,5 @@ /** Best-effort local pruning follows an ordered retirement receipt, never changes the board [R3,C7]. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { randomUUID } from 'node:crypto'; import { existsSync, diff --git a/src/main-red.js b/src/main-red.js index b7f2d181b..61f49c791 100644 --- a/src/main-red.js +++ b/src/main-red.js @@ -1,5 +1,6 @@ +import { hasPrivatePermissions, writePrivateFileSync } from './windows-private-storage.js'; /** Fresh per-file main comparisons for inherited submit reds [V1]. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { randomUUID } from 'node:crypto'; import { lstatSync, @@ -99,7 +100,7 @@ function cacheFile(root) { function readCache(file) { try { const stat = lstatSync(file); - if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || stat.size > 4 * 1024 * 1024) return { version: 1, entries: {} }; + if (!stat.isFile() || stat.isSymbolicLink() || !hasPrivatePermissions(file, stat) || stat.size > 4 * 1024 * 1024) return { version: 1, entries: {} }; const value = JSON.parse(readFileSync(file, 'utf8')); if (value.version !== 1 || !value.entries || typeof value.entries !== 'object' || Array.isArray(value.entries)) return { version: 1, entries: {} }; const entries = {}; @@ -127,7 +128,7 @@ function writeCache(file, value) { mkdirSync(directory, { recursive: true, mode: 0o700 }); const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`; try { - writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); + writePrivateFileSync(Refused, temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); renameSync(temporary, file); } finally { rmSync(temporary, { force: true }); diff --git a/src/mechanical-merge.js b/src/mechanical-merge.js index 652e53379..2c0d24df2 100644 --- a/src/mechanical-merge.js +++ b/src/mechanical-merge.js @@ -6,7 +6,7 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { join } from 'node:path'; import { cleanGitEnvironment } from './git.js'; diff --git a/src/merge-only-source.js b/src/merge-only-source.js index 1f666349b..b4270e187 100644 --- a/src/merge-only-source.js +++ b/src/merge-only-source.js @@ -1,6 +1,6 @@ /** Bounded Git proof that a resubmission adds only trunk and actual conflict resolutions [V1,V4]. */ import { createHash } from 'node:crypto'; -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { cleanGitEnvironment, mainCheckout, diff --git a/src/module-imports.js b/src/module-imports.js index 23b658c89..c32b5ffd3 100644 --- a/src/module-imports.js +++ b/src/module-imports.js @@ -1,5 +1,5 @@ /** Parse import edges without evaluating repository code, using V8 rather than a JavaScript grammar guess [V4,C7]. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { mkdtempSync, rmSync, diff --git a/src/native-user-directory.js b/src/native-user-directory.js index b9a1b2daa..7fe3e0442 100644 --- a/src/native-user-directory.js +++ b/src/native-user-directory.js @@ -1,5 +1,5 @@ /** Read the current account's native macOS working directory without browser-specific policy [C7]. */ -import { execFileSync } from 'node:child_process'; +import { execFileSync } from './windows-child-process.js'; /** Keep the fixed getconf invocation separate from consumers' application-specific path names. */ export function nativeUserDirectory() { diff --git a/src/pr-github.js b/src/pr-github.js index 49a78b982..0f32b532e 100644 --- a/src/pr-github.js +++ b/src/pr-github.js @@ -1,5 +1,5 @@ /** Use GitHub CLI's credential store without copying credentials into config or receipts [B3,R3]. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { tryGit } from './git.js'; import { Refused } from './refused.js'; diff --git a/src/pr-proof-graph.js b/src/pr-proof-graph.js index 914ee5c14..db5079a53 100644 --- a/src/pr-proof-graph.js +++ b/src/pr-proof-graph.js @@ -1,5 +1,5 @@ /** One exact Git walk serves every ancestry and trailer question in a publication pass [B3,R3]. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { cleanGitEnvironment } from './git.js'; const COMMIT = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u; diff --git a/src/pr-publish.js b/src/pr-publish.js index f348dcfb7..b6e5966d5 100644 --- a/src/pr-publish.js +++ b/src/pr-publish.js @@ -12,7 +12,7 @@ import { writeFileSync, } from 'node:fs'; import { join } from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import * as store from './board.js'; import { git, diff --git a/src/private-check-worker.js b/src/private-check-worker.js index ff2f35360..c42e09415 100644 --- a/src/private-check-worker.js +++ b/src/private-check-worker.js @@ -1,5 +1,5 @@ /** Collect private command output through pipes; dependency files are never size-limited [V18,V2]. */ -import { spawn } from 'node:child_process'; +import { spawn } from './windows-child-process.js'; import { captureTestRuntimes, executionEnvironment, diff --git a/src/relay-board-config.js b/src/relay-board-config.js index 1d50e586f..9ba721f25 100644 --- a/src/relay-board-config.js +++ b/src/relay-board-config.js @@ -6,7 +6,7 @@ import { writeFileSync, } from 'node:fs'; import { join } from 'node:path'; -import { execFileSync } from 'node:child_process'; +import { execFileSync } from './windows-child-process.js'; import { CONFIG_FILE, loadConfig, diff --git a/src/relay-checkpoint.js b/src/relay-checkpoint.js index ab0ec143b..301b9379c 100644 --- a/src/relay-checkpoint.js +++ b/src/relay-checkpoint.js @@ -7,7 +7,7 @@ * so moves took minutes and failed RELAY_BUSY. A burst of moves now coalesces into one upload, and the relay lock * keeps one upload in flight; moves during an upload are covered by the next one. */ -import { spawn } from 'node:child_process'; +import { spawn } from './windows-child-process.js'; import { readFileSync, statSync, diff --git a/src/relay-key.js b/src/relay-key.js index 0500730ed..e4dc0ddee 100644 --- a/src/relay-key.js +++ b/src/relay-key.js @@ -1,5 +1,6 @@ +import { hasPrivatePermissions, writePrivateFileSync } from './windows-private-storage.js'; /** Device-only board keys, kept outside repositories and relay requests [H1,H15,H17]. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { randomUUID } from 'node:crypto'; import { existsSync, @@ -48,10 +49,10 @@ function writeKeyFile(id, encoded) { const directory = join(file, '..'); mkdirSync(directory, { recursive: true, mode: 0o700 }); const parent = lstatSync(directory); - if (!parent.isDirectory() || parent.isSymbolicLink() || (parent.mode & 0o077)) throw new Refused('RELAY_KEY_STORAGE', 'use an owner-only relay-keys directory with mode 700'); + if (!parent.isDirectory() || parent.isSymbolicLink() || !hasPrivatePermissions(directory, parent)) throw new Refused('RELAY_KEY_STORAGE', 'use an owner-only relay-keys directory with mode 700'); const temporary = file + '.' + randomUUID() + '.tmp'; try { - writeFileSync(temporary, encoded + '\n', { mode: 0o600, flag: 'wx' }); + writePrivateFileSync(Refused, temporary, encoded + '\n', { mode: 0o600, flag: 'wx' }); renameSync(temporary, file); } finally { rmSync(temporary, { force: true }); } return file; @@ -84,7 +85,7 @@ export function readBoardKey(boardId, { legacyKeychain = true } = {}) { const file = keyFile(id); if (existsSync(file)) { const stat = lstatSync(file); - if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077)) throw new Refused('RELAY_KEY_STORAGE', 'restore the board key as an owner-only regular file with mode 600'); + if (!stat.isFile() || stat.isSymbolicLink() || !hasPrivatePermissions(file, stat)) throw new Refused('RELAY_KEY_STORAGE', 'restore the board key as an owner-only regular file with mode 600'); let encoded; try { encoded = readFileSync(file, 'utf8').trim(); } catch (error) { @@ -143,7 +144,7 @@ export function scrubLegacyPersonSessions() { const directory = join(keyFile('0'.repeat(32)), '..'); if (!existsSync(directory)) return; const parent = lstatSync(directory); - if (!parent.isDirectory() || parent.isSymbolicLink() || (parent.mode & 0o077)) { + if (!parent.isDirectory() || parent.isSymbolicLink() || !hasPrivatePermissions(directory, parent)) { throw new Refused('RELAY_KEY_STORAGE', 'Restore the owner-only relay-keys directory before removing retired person sessions.'); } for (const name of readdirSync(directory)) { diff --git a/src/relay-link-read.js b/src/relay-link-read.js index 013282101..cf94a6344 100644 --- a/src/relay-link-read.js +++ b/src/relay-link-read.js @@ -1,3 +1,4 @@ +import { hasPrivatePermissions } from './windows-private-storage.js'; /** Read validated local link metadata without transport, token migration or a board write [C7,H5]. */ import { existsSync, @@ -11,7 +12,7 @@ import { relayOrigin } from './relay-origin.js'; export function readRelayLink(file) { if (!existsSync(file)) return null; const stat = lstatSync(file); - if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077)) throw new Refused('RELAY_STORAGE', 'restore relay.json as an owner-only regular file with mode 600'); + if (!stat.isFile() || stat.isSymbolicLink() || !hasPrivatePermissions(file, stat)) throw new Refused('RELAY_STORAGE', 'restore relay.json as an owner-only regular file with mode 600'); let state; try { state = JSON.parse(readFileSync(file, 'utf8')); } catch { throw new Refused('RELAY_STORAGE', 'restore valid relay link metadata before syncing this board'); } if (!state || typeof state !== 'object' || state.version !== 1 || !/^[0-9a-f]{32}$/.test(state.board) || !(state.token === undefined || state.personReauth && state.token === null || /^(ps_|pa_|pm_)[A-Za-z0-9_-]{43}$/.test(state.token)) || diff --git a/src/relay-local-first.js b/src/relay-local-first.js index 4ffc2104b..d5d5af425 100644 --- a/src/relay-local-first.js +++ b/src/relay-local-first.js @@ -3,7 +3,7 @@ * the relay is a mailbox. A move applies here and returns at once; a read never syncs or takes a lock. One * background sender per machine pushes the queued moves and pulls other machines' moves, for every board on it. */ -import { spawn } from 'node:child_process'; +import { spawn } from './windows-child-process.js'; import { createHash, randomUUID, diff --git a/src/relay-machine.js b/src/relay-machine.js index 1d8d52bc7..ac93b0459 100644 --- a/src/relay-machine.js +++ b/src/relay-machine.js @@ -1,3 +1,4 @@ +import { hasPrivatePermissions, writePrivateFileSync } from './windows-private-storage.js'; /** Owner-only machine metadata, authenticated device roster and one-use enrollment [H5,H15,H17]. */ import { randomUUID } from 'node:crypto'; import { @@ -36,7 +37,7 @@ function directory() { /** Refuse symlinks, non-owner files or broader permissions before reading machine secrets. */ function privateEntry(path, folder = false) { const stat = lstatSync(path); - if (stat.isSymbolicLink() || (folder ? !stat.isDirectory() : !stat.isFile()) || (stat.mode & 0o077) || + if (stat.isSymbolicLink() || (folder ? !stat.isDirectory() : !stat.isFile()) || !hasPrivatePermissions(path, stat) || (typeof process.getuid === 'function' && stat.uid !== process.getuid())) { throw new Refused('RELAY_MACHINE_STORAGE', 'Restore owner-only relay-machine storage: directory mode 700, files mode 600.'); } @@ -160,7 +161,7 @@ export function updateRelayMachine(mutate) { validate(state); temporary = join(folder, `state.${process.pid}.${randomUUID()}.tmp`); const fd = openSync(temporary, 'wx', 0o600); - try { writeFileSync(fd, JSON.stringify(state) + '\n'); fsyncSync(fd); } + try { writePrivateFileSync(Refused, fd, JSON.stringify(state) + '\n'); fsyncSync(fd); } finally { closeSync(fd); } renameSync(temporary, join(folder, 'state.json')); db.exec('COMMIT'); diff --git a/src/relay-page-cache.js b/src/relay-page-cache.js index 1d977ed42..d55f2868e 100644 --- a/src/relay-page-cache.js +++ b/src/relay-page-cache.js @@ -1,3 +1,4 @@ +import { hasPrivatePermissions } from './windows-private-storage.js'; /** Private device-only cipher cache and durable upload graph; no board key or decrypted record is stored [H15,H17]. */ import { createHash } from 'node:crypto'; import { @@ -29,12 +30,12 @@ export function createRelayPageCache(state) { const directory = join(process.env.PULLBOARD_HOME || join(homedir(), '.pullboard'), 'relay-pages'); mkdirSync(directory, { recursive: true, mode: 0o700 }); const parent = lstatSync(directory); - if (!parent.isDirectory() || parent.isSymbolicLink() || parent.mode & 0o077) throw invalid(); + if (!parent.isDirectory() || parent.isSymbolicLink() || !hasPrivatePermissions(directory, parent)) throw invalid(); const origin = snapshotSourceId(state.url); const file = join(directory, state.board + '-' + origin + '.sqlite'); try { closeSync(openSync(file, 'wx', 0o600)); } catch (error) { if (error.code !== 'EEXIST') throw error; } const stat = lstatSync(file); - if (!stat.isFile() || stat.isSymbolicLink() || stat.mode & 0o077) throw invalid(); + if (!stat.isFile() || stat.isSymbolicLink() || !hasPrivatePermissions(file, stat)) throw invalid(); const db = new DatabaseSync(file); try { db.exec(`PRAGMA busy_timeout=30000; PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; diff --git a/src/relay-pairing-client.js b/src/relay-pairing-client.js index db7832556..ec29eee77 100644 --- a/src/relay-pairing-client.js +++ b/src/relay-pairing-client.js @@ -1,3 +1,4 @@ +import { hasPrivatePermissions, writePrivateFileSync } from './windows-private-storage.js'; /** CLI pairing flow: the relay stores only a one-use client-encrypted package [H15,H17]. */ import { randomUUID } from 'node:crypto'; import { @@ -73,7 +74,7 @@ function linkFile(root) { function loadState(file) { if (!existsSync(file)) throw new Refused('RELAY_NOT_LINKED', 'link this board with pullboard relay on before pairing a device'); const stat = lstatSync(file); - if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077)) throw new Refused('RELAY_STORAGE', 'restore relay.json as an owner-only regular file with mode 600'); + if (!stat.isFile() || stat.isSymbolicLink() || !hasPrivatePermissions(file, stat)) throw new Refused('RELAY_STORAGE', 'restore relay.json as an owner-only regular file with mode 600'); let state; try { state = JSON.parse(readFileSync(file, 'utf8')); } catch { throw new Refused('RELAY_STORAGE', 'restore valid relay link metadata before pairing this board'); } if (!state || state.version !== 1 || !BOARD.test(state.board) || !TOKEN.test(state.token) || @@ -91,7 +92,7 @@ function saveState(file, state) { mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); const temporary = file + '.' + randomUUID() + '.tmp'; try { - writeFileSync(temporary, JSON.stringify(state) + '\n', { flag: 'wx', mode: 0o600 }); + writePrivateFileSync(Refused, temporary, JSON.stringify(state) + '\n', { flag: 'wx', mode: 0o600 }); renameSync(temporary, file); } finally { rmSync(temporary, { force: true }); } } diff --git a/src/relay-relink.js b/src/relay-relink.js index 91be681d9..9b1125e22 100644 --- a/src/relay-relink.js +++ b/src/relay-relink.js @@ -1,3 +1,4 @@ +import { hasPrivatePermissions, writePrivateFileSync } from './windows-private-storage.js'; /** Keep explicit local unlinking distinct from joining somebody else's existing remote board [H17]. */ import { randomBytes, @@ -25,7 +26,7 @@ export function relayRelink(root) { const file = markerFile(root); if (!existsSync(file)) return null; const stat = lstatSync(file); - if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) || stat.size > 1024) { + if (!stat.isFile() || stat.isSymbolicLink() || !hasPrivatePermissions(file, stat) || stat.size > 1024) { throw new Refused('RELAY_STORAGE', 'restore the private relink intent before linking this board'); } let value; @@ -56,7 +57,7 @@ export function rememberRelink(root, previous) { const file = markerFile(root); const temporary = file + '.' + randomUUID() + '.tmp'; try { - writeFileSync(temporary, JSON.stringify(value) + '\n', { flag: 'wx', mode: 0o600 }); + writePrivateFileSync(Refused, temporary, JSON.stringify(value) + '\n', { flag: 'wx', mode: 0o600 }); renameSync(temporary, file); } finally { rmSync(temporary, { force: true }); } return value; @@ -71,7 +72,7 @@ export function archiveRelinkOutbox(root, state) { .map(record => ({ sequence: record.sequence, sealed: record.sealed })); if (!queued && !records.length) return; const name = 'relay-outbox-' + state.board + '-' + randomUUID(); - writeFileSync(join(directory, name + '.json'), JSON.stringify({ version: 1, board: state.board, + writePrivateFileSync(Refused, join(directory, name + '.json'), JSON.stringify({ version: 1, board: state.board, url: state.url, sequence: state.sequence, machine: state.machine, records, ...(queued ? { encryptedFile: name + '.sealed' } : {}) }) + '\n', { flag: 'wx', mode: 0o600 }); if (queued) renameSync(file, join(directory, name + '.sealed')); diff --git a/src/relay-ssh-proof.js b/src/relay-ssh-proof.js index 03f4f1cc3..812a73cbd 100644 --- a/src/relay-ssh-proof.js +++ b/src/relay-ssh-proof.js @@ -1,5 +1,5 @@ /** Local SSH proofs checked against direct GitHub keys [E17]. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { existsSync, mkdtempSync, diff --git a/src/relay.js b/src/relay.js index db4b1603f..71ebaf19a 100644 --- a/src/relay.js +++ b/src/relay.js @@ -1,3 +1,4 @@ +import { hasPrivatePermissions, writePrivateFileSync } from './windows-private-storage.js'; import { orderedMoveCommand } from './relay-command-order.js'; import * as conflictRecords from './relay-conflict-record.js'; import { withRelayExclusion } from './relay-exclusion-state.js'; @@ -236,7 +237,7 @@ function deletedLink(root) { const file = deletedLinkFile(root); if (!existsSync(file)) return null; const stat = lstatSync(file); - if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) || stat.size > 4096) throw new Refused('RELAY_STORAGE', 'restore the private deleted-copy notice before reading relay status'); + if (!stat.isFile() || stat.isSymbolicLink() || !hasPrivatePermissions(file, stat) || stat.size > 4096) throw new Refused('RELAY_STORAGE', 'restore the private deleted-copy notice before reading relay status'); let value; try { value = JSON.parse(readFileSync(file, 'utf8')); } catch { throw new Refused('RELAY_STORAGE', 'restore the private deleted-copy notice before reading relay status'); } if (value?.version !== 1 || !/^[0-9a-f]{32}$/u.test(value.board ?? '') || typeof value.url !== 'string') throw new Refused('RELAY_STORAGE', 'restore the supported deleted-copy notice before reading relay status'); @@ -263,7 +264,7 @@ function saveDeletedLink(root, value) { const encoded = JSON.stringify(value) + '\n'; if (Buffer.byteLength(encoded) > 4096) throw new Refused('RELAY_STORAGE', 'restore a bounded deleted-copy notice before retrying'); try { - writeFileSync(temporary, encoded, { flag: 'wx', mode: 0o600 }); + writePrivateFileSync(Refused, temporary, encoded, { flag: 'wx', mode: 0o600 }); renameSync(temporary, marker); } finally { rmSync(temporary, { force: true }); } } @@ -366,7 +367,7 @@ export function saveLink(file, state) { const temporary = file + '.' + randomUUID() + '.tmp'; try { // The outbox summary is the outbox's own file; a link saved by the sender never overwrites a move's (#1013). - writeFileSync(temporary, JSON.stringify({ ...state, outbox: undefined }) + '\n', { flag: 'wx', mode: 0o600 }); + writePrivateFileSync(Refused, temporary, JSON.stringify({ ...state, outbox: undefined }) + '\n', { flag: 'wx', mode: 0o600 }); renameSync(temporary, file); } finally { rmSync(temporary, { force: true }); } } diff --git a/src/release-queue.js b/src/release-queue.js index 52545d9fc..bc611df9d 100644 --- a/src/release-queue.js +++ b/src/release-queue.js @@ -1,5 +1,5 @@ /** Capture one board's release priority before joining the machine resource transaction [C7,V4]. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { existsSync } from 'node:fs'; import { isAbsolute } from 'node:path'; import { join } from 'node:path'; diff --git a/src/resources.js b/src/resources.js index 88f021d64..6b23c727d 100644 --- a/src/resources.js +++ b/src/resources.js @@ -8,7 +8,7 @@ import { import { execFile, execFileSync, -} from 'node:child_process'; +} from './windows-child-process.js'; import { readFile, readlink, diff --git a/src/resume-sources.js b/src/resume-sources.js index c8f20b26d..6050099ce 100644 --- a/src/resume-sources.js +++ b/src/resume-sources.js @@ -1,6 +1,6 @@ /** Fresh, bounded Git facts for the ordinary resume card; detailed diagnostics stay explicit [N19,D1]. */ import { createHash } from 'node:crypto'; -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { existsSync } from 'node:fs'; import { readFileSync } from 'node:fs'; import { realpathSync } from 'node:fs'; diff --git a/src/rigor-proof.js b/src/rigor-proof.js index 2246738fb..6249084d5 100644 --- a/src/rigor-proof.js +++ b/src/rigor-proof.js @@ -2,7 +2,7 @@ import { spawn, spawnSync, -} from 'node:child_process'; +} from './windows-child-process.js'; import { mkdtempSync, realpathSync, diff --git a/src/rigor-quality.js b/src/rigor-quality.js index 448de7a93..c8d73ed5d 100644 --- a/src/rigor-quality.js +++ b/src/rigor-quality.js @@ -8,7 +8,7 @@ * * Nothing here drafts or approves a row; rigor-quality-draft.js drafts them for the person. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { mkdirSync, mkdtempSync, diff --git a/src/row-drafts.js b/src/row-drafts.js index 3ed53fc84..485b4848c 100644 --- a/src/row-drafts.js +++ b/src/row-drafts.js @@ -9,7 +9,7 @@ */ import { PERSON } from './board.js'; import { Refused } from './refused.js'; -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { loadDoctrine } from './doctrine.js'; import { loadSpec, diff --git a/src/run.js b/src/run.js index 0e90db120..d7eb8e9ce 100644 --- a/src/run.js +++ b/src/run.js @@ -14,7 +14,7 @@ import { itemNotesText } from './item-notes.js'; import { spawn, spawnSync, -} from 'node:child_process'; +} from './windows-child-process.js'; import { appendFileSync, existsSync, diff --git a/src/runner-custody.js b/src/runner-custody.js index 70739bc1d..8154b8db8 100644 --- a/src/runner-custody.js +++ b/src/runner-custody.js @@ -1,5 +1,5 @@ /** Custody of a runner job's processes: who the job started, proven from its own record, and stopping them [C7]. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { closeSync, existsSync, diff --git a/src/runner-worker.js b/src/runner-worker.js index 7b4d1ec26..c0a427b24 100644 --- a/src/runner-worker.js +++ b/src/runner-worker.js @@ -2,7 +2,7 @@ import { spawn, spawnSync, -} from 'node:child_process'; +} from './windows-child-process.js'; import { createHash } from 'node:crypto'; import { chmodSync, diff --git a/src/runners.js b/src/runners.js index f34bcc916..e0d2d4f3c 100644 --- a/src/runners.js +++ b/src/runners.js @@ -3,7 +3,7 @@ export { placeRunner, runnerStage } from './runner-stage.js'; import { spawn, spawnSync, -} from 'node:child_process'; +} from './windows-child-process.js'; import { createHash, randomUUID, diff --git a/src/sandbox-verify.js b/src/sandbox-verify.js index 046ec4ab2..2a962446b 100644 --- a/src/sandbox-verify.js +++ b/src/sandbox-verify.js @@ -1,5 +1,5 @@ /** Docker-compatible, fail-closed execution of outside programs. No host-process isolation fallback. */ -import { execFile } from 'node:child_process'; +import { execFile } from './windows-child-process.js'; import { createHash, randomUUID, diff --git a/src/sentinel-cpu.js b/src/sentinel-cpu.js index 1f0eaa430..ac327ae8b 100644 --- a/src/sentinel-cpu.js +++ b/src/sentinel-cpu.js @@ -1,5 +1,5 @@ /** Native CPU observations for registered agent worktrees; no process is signalled [A6,H16]. */ -import { execFileSync } from 'node:child_process'; +import { execFileSync } from './windows-child-process.js'; import { readlinkSync } from 'node:fs'; import { realpathSync } from 'node:fs'; import { basename } from 'node:path'; diff --git a/src/sentinel-runner.js b/src/sentinel-runner.js index 12572c8e7..523d78cc2 100644 --- a/src/sentinel-runner.js +++ b/src/sentinel-runner.js @@ -1,6 +1,6 @@ /** Run only an approved due board watch, outside any SQLite write transaction [A6]. */ -import { spawn } from 'node:child_process'; -import { spawnSync } from 'node:child_process'; +import { spawn } from './windows-child-process.js'; +import { spawnSync } from './windows-child-process.js'; import { randomUUID } from 'node:crypto'; import { realpathSync } from 'node:fs'; import { mkdtempSync } from 'node:fs'; diff --git a/src/serve-sentinel.js b/src/serve-sentinel.js index 13c392a72..6f71bd3f8 100644 --- a/src/serve-sentinel.js +++ b/src/serve-sentinel.js @@ -1,5 +1,5 @@ /** Keep each view-served board's health observer running without overlapping scans [A6]. */ -import { spawn } from 'node:child_process'; +import { spawn } from './windows-child-process.js'; import { fileURLToPath } from 'node:url'; import { listProjects } from './projects.js'; import { loadConfig } from './config.js'; diff --git a/src/serve.js b/src/serve.js index ccf592ed7..999e1b6f2 100644 --- a/src/serve.js +++ b/src/serve.js @@ -12,7 +12,7 @@ import { dependencyValues } from './item-dependencies.js'; * the hosted page at https://app.pullboard.dev, which gets the secret by a round trip that only ever returns there. * Idle reads open no outbound connection (P5); an explicit person sign-in reuses the existing relay linking flow. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { randomBytes, timingSafeEqual, diff --git a/src/shell-run.js b/src/shell-run.js index b61dd0feb..e95b8b52f 100644 --- a/src/shell-run.js +++ b/src/shell-run.js @@ -1,8 +1,9 @@ +import { commandShell } from './windows-shell.js'; /** * Run one shell command in a checkout and keep its output, apart from the gate's queue, runners and receipts. A * board read that replays a check baseline needs only this, so it never loads the gate [C7]. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { captureTestRuntimes, executionEnvironment, @@ -41,7 +42,7 @@ import { sandboxBlockedOperation } from './sandbox-denial.js'; */ export function runShell(root, command, { pipefail = false, env = {}, stages = false, timeoutMs, captureRuntimes = false } = {}) { const started = Date.now(); - const options = { cwd: root, env: { ...gitChildEnv(root), ...env }, shell: true, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', maxBuffer: 2 ** 30 }; + const options = { cwd: root, env: { ...gitChildEnv(root), ...env }, shell: commandShell(), stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', maxBuffer: 2 ** 30 }; let prefix = ''; if (pipefail) { // Probe the same shell as the command, with no load-sensitive deadline or alternate shell. diff --git a/src/signature.js b/src/signature.js index f29521c94..372c85233 100644 --- a/src/signature.js +++ b/src/signature.js @@ -20,7 +20,7 @@ import { join, resolve, } from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { gitConfig } from './git.js'; import { Refused } from './refused.js'; diff --git a/src/spec-gate-renames.js b/src/spec-gate-renames.js index acd90efd1..4f6e35df2 100644 --- a/src/spec-gate-renames.js +++ b/src/spec-gate-renames.js @@ -1,7 +1,7 @@ /** Real Git rename hints for approved requirements whose gate disappeared [B23,N39]. */ import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { cleanGitEnvironment } from './git.js'; /** Follow recorded renames in chronological order; never guess a replacement from similar names. */ diff --git a/src/spec.js b/src/spec.js index 14c4fee1e..a743e369e 100644 --- a/src/spec.js +++ b/src/spec.js @@ -9,7 +9,7 @@ */ import { posix } from 'node:path'; import { createHash } from 'node:crypto'; -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { appendFileSync, existsSync, diff --git a/src/sync.js b/src/sync.js index 163d29d23..d19517100 100644 --- a/src/sync.js +++ b/src/sync.js @@ -1,7 +1,7 @@ import { requirePreparedComputedConflict } from './computed-conflict-receipt.js'; import { withRecordedConflictContext } from './recorded-conflict-context.js'; /** Account-free ordered moves on the repository's own Git remote [H11,H10]. */ -import { spawn } from 'node:child_process'; +import { spawn } from './windows-child-process.js'; import { existsSync, readFileSync, diff --git a/src/test-action-cache.js b/src/test-action-cache.js index 0d1a2711c..d61a74cfb 100644 --- a/src/test-action-cache.js +++ b/src/test-action-cache.js @@ -32,7 +32,7 @@ import { resolve, sep, } from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; export const TEST_ACTIONS_ENV = 'PULLBOARD_TEST_ACTIONS'; export const TEST_STAGE_ENV = 'PULLBOARD_TEST_STAGE'; diff --git a/src/test-file-dispatch.js b/src/test-file-dispatch.js index 16c5b13fa..55b42b4a7 100644 --- a/src/test-file-dispatch.js +++ b/src/test-file-dispatch.js @@ -1,10 +1,10 @@ /** Parent-controlled file admission and one structured result stream [C7]. */ /** @pullboard-module-loads ** */ // Native test files are caller-selected, just as in bin/run-tests.js. -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { devNull } from 'node:os'; import { fileURLToPath } from 'node:url'; -import { execFileSync } from 'node:child_process'; +import { execFileSync } from './windows-child-process.js'; import { channel } from 'node:diagnostics_channel'; import { performance } from 'node:perf_hooks'; import { createWriteStream } from 'node:fs'; diff --git a/src/test-run.js b/src/test-run.js index f4f9d083c..a86aee973 100644 --- a/src/test-run.js +++ b/src/test-run.js @@ -1,5 +1,5 @@ /** pullboard test: named test files on the runner pool, with the focused runner's per-file timeout [C7]. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { existsSync, mkdtempSync, diff --git a/src/timed-shell-worker.js b/src/timed-shell-worker.js index ac1aed3d8..4937ada26 100644 --- a/src/timed-shell-worker.js +++ b/src/timed-shell-worker.js @@ -1,5 +1,5 @@ /** Run a bounded shell under identity-checked process custody before returning its result [E27]. */ -import { spawn } from 'node:child_process'; +import { spawn } from './windows-child-process.js'; import { appendFileSync, mkdtempSync, diff --git a/src/tour.js b/src/tour.js index a4d3a9546..a3f0d029e 100644 --- a/src/tour.js +++ b/src/tour.js @@ -4,7 +4,7 @@ * a second agent finds it and rejects, and the rework is accepted. No model runs and nothing leaves * the machine; the repo stays behind to look around in. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { chmodSync, mkdirSync, diff --git a/src/trunk-merge.js b/src/trunk-merge.js index 47aec8283..1efa8451a 100644 --- a/src/trunk-merge.js +++ b/src/trunk-merge.js @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; /** In-memory Git conflict computation without loading check execution or resource admission [V4,C7]. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { cleanGitEnvironment } from './git.js'; import { mainCheckout } from './git.js'; import { trunkRef } from './trusted-policy-read.js'; diff --git a/src/trusted-policy-read.js b/src/trusted-policy-read.js index 8acd5f1c6..23b107c81 100644 --- a/src/trusted-policy-read.js +++ b/src/trusted-policy-read.js @@ -1,5 +1,5 @@ /** Read committed coordinator policy without loading check execution or resource admission [C7,V4]. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { configFromSource } from './config.js'; import { cleanGitEnvironment, diff --git a/src/trusted-policy.js b/src/trusted-policy.js index e992e4814..d1c1e5ae1 100644 --- a/src/trusted-policy.js +++ b/src/trusted-policy.js @@ -13,7 +13,7 @@ export { mainPolicy, policyAt, trunkRef } from './trusted-policy-read.js'; /** Coordinator policy and immutable submission checks cannot come from a builder's candidate [V4,V16,L3,M3]. */ import { randomUUID } from 'node:crypto'; import { environmentProblem } from './execution-environment.js'; -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { digestOf, writeTimingProfile, diff --git a/src/verdict-freshness.js b/src/verdict-freshness.js index 8ee39d3bc..12c58bd1c 100644 --- a/src/verdict-freshness.js +++ b/src/verdict-freshness.js @@ -1,6 +1,6 @@ /** Acceptance stays append-only; freshness is derived from the known Git trunk [E8,V8]. */ import { mergeOnlyLandingProblem } from './merge-only-verdict.js'; -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import * as store from './board-query.js'; import { cleanGitEnvironment, diff --git a/src/verdict-trunk.js b/src/verdict-trunk.js index ae1a6c994..7cc89051d 100644 --- a/src/verdict-trunk.js +++ b/src/verdict-trunk.js @@ -4,7 +4,7 @@ * `verify.unrelatedTrunkMoves` on, a move is kept when its commits change none of the item's files and none of the * files its check and affected tests read; anything else, or anything the selection cannot see, refuses as before. */ -import { execFileSync } from 'node:child_process'; +import { execFileSync } from './windows-child-process.js'; import { selectAffectedTests } from './affected-tests.js'; import { explicitCheckFiles } from './browser-admission.js'; diff --git a/src/view-session.js b/src/view-session.js index b5c343980..19774a73d 100644 --- a/src/view-session.js +++ b/src/view-session.js @@ -1,5 +1,5 @@ /** Private runtime records that let an unattended CLI hand off its live view link. */ -import { spawnSync } from 'node:child_process'; +import { spawnSync } from './windows-child-process.js'; import { Refused } from './refused.js'; import { chmodSync, diff --git a/src/windows-child-process.js b/src/windows-child-process.js new file mode 100644 index 000000000..124e130c8 --- /dev/null +++ b/src/windows-child-process.js @@ -0,0 +1,51 @@ +import * as native from 'node:child_process'; +import { promisify } from 'node:util'; + +const wrappedProcesses = new WeakMap(); + +/** Detached views have no console to inherit, so even Git reads can create visible windows. */ +export function hideConsoleWindow(run, platform = process.platform) { + if (platform !== 'win32') return run; + if (wrappedProcesses.has(run)) return wrappedProcesses.get(run); + const wrapped = function (...args) { + const index = + Array.isArray(args[1]) || + (args[1] == null && args.length >= 3 && typeof args[2] !== 'function') + ? 2 + : 1; + const options = args[index]; + if (typeof options === 'function') args.splice(index, 0, { windowsHide: true }); + else if (options == null || (typeof options === 'object' && !Array.isArray(options))) { + args[index] = { ...options, windowsHide: true }; + } + return Reflect.apply(run, this, args); + }; + wrappedProcesses.set(run, wrapped); + if (typeof run[promisify.custom] === 'function') { + Object.defineProperty(wrapped, promisify.custom, { + configurable: true, + value: hideConsoleWindow(run[promisify.custom], platform), + }); + } + return wrapped; +} + +/** Native guards replace these methods after imports, so dispatch must observe their live bindings. */ +function liveProcess(method) { + const run = function (...args) { + return Reflect.apply(hideConsoleWindow(native[method]), this, args); + }; + Object.defineProperty(run, promisify.custom, { + configurable: true, + get() { + const custom = native[method][promisify.custom]; + return typeof custom === 'function' ? hideConsoleWindow(custom) : undefined; + }, + }); + return run; +} + +export const spawn = liveProcess('spawn'); +export const spawnSync = liveProcess('spawnSync'); +export const execFile = liveProcess('execFile'); +export const execFileSync = liveProcess('execFileSync'); diff --git a/src/windows-private-storage.js b/src/windows-private-storage.js new file mode 100644 index 000000000..3f278f87b --- /dev/null +++ b/src/windows-private-storage.js @@ -0,0 +1,67 @@ +import { spawnSync } from 'node:child_process'; +import { lstatSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +/** Reject inherited public access before any sensitive bytes reach disk. */ +export function writePrivateFileSync(Refusal, path, ...args) { + if (process.platform === 'win32' && typeof path !== 'number') { + const parent = dirname(path); + const stat = lstatSync(parent); + if (!stat.isDirectory() || stat.isSymbolicLink() || !hasPrivatePermissions(parent, stat)) { + throw new Refusal( + 'WINDOWS_PRIVATE_STORAGE', + 'Restore current-user-only NTFS permissions on the storage directory before writing private Pullboard data.', + ); + } + } + return writeFileSync(path, ...args); +} + +/** Check NTFS rather than Node's synthetic Unix mode bits on Windows. */ +export function hasPrivatePermissions(path, stat) { + if (process.platform !== 'win32') return (stat.mode & 0o077) === 0; + const literal = path.replaceAll("'", "''"); + const script = ` +$ErrorActionPreference = 'Stop' +try { + $path = '${literal}' + $item = Get-Item -LiteralPath $path -Force + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { exit 1 } + $acl = Get-Acl -LiteralPath $path + $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + if ($acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $sid) { exit 1 } + $raw = [Security.AccessControl.RawSecurityDescriptor]::new($acl.Sddl) + if ($null -eq $raw.DiscretionaryAcl) { exit 1 } + $ownerAccess = $false + foreach ($rule in $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) { + if ($rule.AccessControlType -eq [Security.AccessControl.AccessControlType]::Allow) { + $principal = $rule.IdentityReference.Value + if ($principal -notin @($sid, 'S-1-5-18', 'S-1-5-32-544')) { exit 1 } + if ($principal -eq $sid -and ($rule.FileSystemRights -band [Security.AccessControl.FileSystemRights]::ReadData)) { + $ownerAccess = $true + } + } + } + if (-not $ownerAccess) { exit 1 } + exit 0 +} catch { exit 1 } +`; + const result = spawnSync( + join( + process.env.SystemRoot || 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ), + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-EncodedCommand', + Buffer.from(script, 'utf16le').toString('base64'), + ], + { windowsHide: true, stdio: 'ignore', timeout: 10000 }, + ); + return !result.error && result.status === 0; +} diff --git a/src/windows-shell.js b/src/windows-shell.js new file mode 100644 index 000000000..7aefce075 --- /dev/null +++ b/src/windows-shell.js @@ -0,0 +1,28 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +/** Pullboard emits POSIX syntax; Git for Windows supplies a native Windows shell. */ +export function commandShell() { + if (process.platform !== 'win32') return true; + const executables = execFileSync('where.exe', ['git.exe'], { + encoding: 'utf8', + windowsHide: true, + }) + .trim() + .split(/\r?\n/); + for (const executable of executables) { + const directory = dirname(executable); + const shell = [ + join(directory, '..', 'bin', 'bash.exe'), + join(directory, 'bash.exe'), + join(directory, '..', '..', 'usr', 'bin', 'bash.exe'), + ] + .map((path) => resolve(path)) + .find((path) => existsSync(path)); + if (shell) return shell; + } + throw new Error( + 'Pullboard needs Git for Windows with its bundled bash.exe to execute POSIX gate and formatter commands.', + ); +} diff --git a/test/windows-processes.test.js b/test/windows-processes.test.js new file mode 100644 index 000000000..bfa76361a --- /dev/null +++ b/test/windows-processes.test.js @@ -0,0 +1,173 @@ +/** @pullboard-test-input source-tree */ +/** @pullboard-test-loads src/windows-child-process.js src/git.js src/browser-slot-guard.js src/test-file-pool.js src/code-map.js src/browser-admission.js */ +import assert from 'node:assert/strict'; +import childProcess from 'node:child_process'; +import { syncBuiltinESMExports } from 'node:module'; +import { join } from 'node:path'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import test from 'node:test'; +import { hideConsoleWindow, spawnSync, execFile, execFileSync } from '../src/windows-child-process.js'; +import { scanSource } from '../src/code-map.js'; +import { testsNeedBrowser } from '../src/browser-admission.js'; + +const windows = process.platform === 'win32'; +const installed = process.env.PULLBOARD_PACKAGE_ROOT || fileURLToPath(new URL('..', import.meta.url)); + +test('source map retains aliased process effects through the Windows adapter [A8,C7]', () => { + for (const [path, adapter] of [['src/example.js', './windows-child-process.js'], ['src/commands/example.js', '../windows-child-process.js']]) { + const source = `import { spawn as launch } from '${adapter}';\nexport function execute(command) { return launch(command); }\n`; + const scan = scanSource(path, source); + assert.ok(scan.functions.find(fn => fn.name === 'execute').effects.includes('spawn')); + } +}); + +test('browser admission retains native process provenance through the Windows adapter [A8,C7]', async () => { + const root = mkdtempSync(join(tmpdir(), 'pullboard-windows-admission-')); + try { + mkdirSync(join(root, 'src')); + mkdirSync(join(root, 'test')); + writeFileSync(join(root, 'src', 'windows-child-process.js'), readFileSync(new URL('../src/windows-child-process.js', import.meta.url))); + writeFileSync(join(root, 'src', 'git.js'), "import { spawn } from './windows-child-process.js'; export function run() { return spawn('git'); }\n"); + writeFileSync(join(root, 'test', 'plain.test.js'), "import '../src/git.js';\n"); + assert.equal(await testsNeedBrowser(root, ['test/plain.test.js'], { byLaunch: false }), false); + writeFileSync(join(root, 'src', 'generic.js'), "import { spawn } from './windows-child-process.js'; export function run(command) { return spawn(command); }\n"); + writeFileSync(join(root, 'test', 'generic.test.js'), "import '../src/generic.js';\n"); + assert.equal(await testsNeedBrowser(root, ['test/generic.test.js'], { byLaunch: false }), true); + writeFileSync(join(root, 'src', 'generic.js'), "import { spawn } from './windows-child-process.js'; export function run() { return spawn('google-chrome'); }\n"); + assert.equal(await testsNeedBrowser(root, ['test/generic.test.js'], { byLaunch: true }), true); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('adapter preserves child-process overloads, options and callbacks [A8]', () => { + const received = []; + const receiver = {}; + const native = function (...args) { + received.push({ args, receiver: this }); + return 7; + }; + const wrapped = hideConsoleWindow(native, 'win32'); + const callback = () => {}; + const environment = { SYNTHETIC: 'value' }; + const options = { cwd: 'a b', env: environment, timeout: 123, stdio: 'pipe', windowsHide: false }; + for (const args of [ + ['file'], + ['file', ['a b']], + ['file', options], + ['file', ['a b'], options], + ['file', callback], + ['file', ['a b'], callback], + ['file', undefined, options], + ['file', null, options, callback], + ['file', ['a b'], undefined, callback], + ]) + assert.equal(wrapped.apply(receiver, args), 7); + assert.ok(received.every((call) => call.receiver === receiver)); + assert.ok(received.every((call) => call.args.some((value) => value?.windowsHide === true))); + assert.equal(received[2].args[1].env, environment); + assert.equal(received[2].args[1].timeout, 123); + assert.equal(received[2].args[1].cwd, 'a b'); + assert.equal(received[2].args[1].stdio, 'pipe'); + assert.equal(options.windowsHide, false, 'caller options must remain immutable'); + assert.equal(received[4].args[2], callback); + assert.equal(received[5].args[3], callback); + assert.equal(received[6].args[2].env, environment); + assert.equal(received[7].args[3], callback); + assert.equal(received[8].args[3], callback); + assert.equal(hideConsoleWindow(native, 'linux'), native); + wrapped('file', 'invalid-options'); + assert.equal(received.at(-1).args[1], 'invalid-options', 'preserve native validation'); + const invalidOptions = []; + wrapped('file', [], invalidOptions); + assert.equal(received.at(-1).args[2], invalidOptions, 'preserve invalid options arrays'); + const selfCustom = () => Promise.resolve(42); + selfCustom[promisify.custom] = selfCustom; + const hiddenCustom = hideConsoleWindow(selfCustom, 'win32'); + assert.equal( + promisify(hiddenCustom), + hiddenCustom, + 'self-referential custom promisifiers must not recurse', + ); +}); + +test('hidden native children preserve output, failures and async results [A8]', async () => { + const result = spawnSync( + process.execPath, + ['-e', 'console.log("synthetic"); process.exitCode=7'], + { encoding: 'utf8' }, + ); + assert.equal(result.status, 7); + assert.equal(result.stdout.trim(), 'synthetic'); + assert.equal(execFileSync(process.execPath, ['-p', '"a b"'], { encoding: 'utf8' }).trim(), 'a b'); + await new Promise((resolve, reject) => { + execFile(process.execPath, ['-p', '42'], { encoding: 'utf8' }, (error, stdout) => { + if (error) return reject(error); + assert.equal(stdout.trim(), '42'); + resolve(); + }); + }); + const asyncResult = await promisify(execFile)(process.execPath, ['-p', '43']); + assert.equal(asyncResult.stdout.trim(), '43'); + await assert.rejects(promisify(execFile)(process.execPath, ['-e', 'process.exitCode=7']), { + code: 7, + }); + const timeout = spawnSync(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { timeout: 100 }); + assert.equal(timeout.error?.code, 'ETIMEDOUT'); +}); + +test( + 'installed view Git reads hide console windows and preserve their result [A8]', + { skip: !windows }, + async () => { + const calls = []; + const original = childProcess.execFileSync; + childProcess.execFileSync = (file, args, options) => { + calls.push({ file, options }); + return original(file, args, { ...options, windowsHide: true }); + }; + syncBuiltinESMExports(); + try { + const { git } = await import(pathToFileURL(join(installed, 'src', 'git.js')).href); + const result = git(process.cwd(), ['--version']); + assert.match(result, /^git version /); + assert.ok(calls.length > 0); + assert.ok( + calls.every(({ options }) => options?.windowsHide === true), + 'every installed Git child must request windowsHide before reaching Node', + ); + } finally { + childProcess.execFileSync = original; + syncBuiltinESMExports(); + } + }, +); + +test( + 'installed adapters retain browser guards and default-import linkage [A8]', + () => { + const adapter = pathToFileURL(join(installed, 'src', 'windows-child-process.js')).href; + const guard = pathToFileURL(join(installed, 'src', 'browser-slot-guard.js')).href; + const pool = pathToFileURL(join(installed, 'src', 'test-file-pool.js')).href; + const script = ` + import assert from 'node:assert/strict'; + import { promisify } from 'node:util'; + const adapter = await import(${JSON.stringify(adapter)}); + await import(${JSON.stringify(pool)}); + process.env.PULLBOARD_TEST_BROWSER_SLOT = 'none'; + await import(${JSON.stringify(guard)}); + const absentBrowser = 'C:/__pullboard_synthetic_absent__/google-chrome'; + assert.throws(() => adapter.spawn(absentBrowser), { code: 'BROWSER_SLOT_REQUIRED' }); + assert.throws(() => adapter.spawnSync(absentBrowser), { code: 'BROWSER_SLOT_REQUIRED' }); + await assert.rejects(promisify(adapter.execFile)(absentBrowser), { code: 'BROWSER_SLOT_REQUIRED' }); + console.log('guard preserved'); + `; + const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { + encoding: 'utf8', + env: { ...process.env, PULLBOARD_TEST_BROWSER_SLOT: 'held' }, + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), 'guard preserved'); + }, +); diff --git a/test/windows-storage.test.js b/test/windows-storage.test.js new file mode 100644 index 000000000..7ec5c8607 --- /dev/null +++ b/test/windows-storage.test.js @@ -0,0 +1,254 @@ +/** @pullboard-test-input source-tree */ +/** @pullboard-test-loads src/windows-private-storage.js src/windows-shell.js src/windows-child-process.js src/relay-machine.js src/relay.js src/shell-run.js */ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from '../src/windows-child-process.js'; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import test from 'node:test'; +import { hasPrivatePermissions } from '../src/windows-private-storage.js'; +import { commandShell } from '../src/windows-shell.js'; + +const windows = process.platform === 'win32'; +const powershell = join( + process.env.SystemRoot || 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', +); +const installed = process.env.PULLBOARD_PACKAGE_ROOT || fileURLToPath(new URL('..', import.meta.url)); + +test( + 'native Git shell preserves quoting, Windows Node and failure status [A8]', + { skip: !windows }, + () => { + const result = spawnSync("node -p 'process.platform' && printf '%s' 'a b'", { + shell: commandShell(), + encoding: 'utf8', + windowsHide: true, + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.replaceAll('\r', '').trim(), 'win32\na b'); + const failure = spawnSync('exit 7', { shell: commandShell(), windowsHide: true }); + assert.equal(failure.status, 7); + }, +); + +test('installed Pullboard shell runner executes POSIX syntax natively [A8]', { skip: !windows }, () => { + const module = pathToFileURL(join(installed, 'src', 'shell-run.js')).href; + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + `import { runShell } from ${JSON.stringify(module)}; const result = runShell(process.cwd(), "printf '%s' 'a b'", { pipefail: true }); console.log(result.output); process.exitCode = result.isGreen ? 0 : 1;`, + ], + { encoding: 'utf8', windowsHide: true }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), 'a b'); +}); + +/** Isolate synthetic ACL fixtures and remove only the directory this test created. */ +function temporary(work) { + const root = mkdtempSync(join(tmpdir(), 'pullboard-windows-test-')); + try { + return work(root); + } finally { + assert.ok( + resolve(root).startsWith(resolve(tmpdir()) + sep) && + basename(root).startsWith('pullboard-windows-test-'), + ); + rmSync(root, { recursive: true, force: true }); + } +} + +/** Set explicit synthetic NTFS grants without reading real credentials. */ +function acl(path, extra = '') { + const literal = path.replaceAll("'", "''"); + const script = ` +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$path = '${literal}' +$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User +$directory = (Get-Item -LiteralPath $path).PSIsContainer +$acl = if ($directory) { [Security.AccessControl.DirectorySecurity]::new() } else { [Security.AccessControl.FileSecurity]::new() } +$acl.SetAccessRuleProtection($true, $false) +$inherit = if ($directory) { 'ContainerInherit, ObjectInherit' } else { 'None' } +$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($sid, 'FullControl', $inherit, 'None', 'Allow')) +${extra} +if ($directory) { [IO.Directory]::SetAccessControl($path, $acl) } else { [IO.File]::SetAccessControl($path, $acl) } +`; + execFileSync( + powershell, + [ + '-NoProfile', + '-NonInteractive', + '-EncodedCommand', + Buffer.from(script, 'utf16le').toString('base64'), + ], + { windowsHide: true }, + ); +} + +/** Build a private machine record, optionally granting public read to prove refusal. */ +function machine(root, insecure = false) { + acl(root); + const directory = join(root, 'relay-machine'); + mkdirSync(directory); + writeFileSync( + join(directory, 'state.json'), + JSON.stringify({ + v: 2, + autoLink: false, + owner: null, + machine: 'test-machine', + devices: [], + excluded: [], + pending: null, + revocations: [], + approvals: [], + }), + ); + if (insecure) + acl( + directory, + "$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new('S-1-1-0'), 'Read', 'ContainerInherit, ObjectInherit', 'None', 'Allow'))", + ); + return directory; +} + +/** Exercise the actual machine reader against a synthetic private home. */ +function readMachine(root) { + const module = pathToFileURL(join(installed, 'src', 'relay-machine.js')).href; + return spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + `import { readRelayMachine } from ${JSON.stringify(module)}; try { const state = readRelayMachine(); console.log(state.machine); } catch (error) { console.error(error.code); process.exitCode = 1; }`, + ], + { + env: { ...process.env, PULLBOARD_HOME: root }, + encoding: 'utf8', + windowsHide: true, + }, + ); +} + +/** Exercise the actual credential writer with a synthetic noncredential value. */ +function saveLink(root) { + const module = pathToFileURL(join(installed, 'src', 'relay.js')).href; + return spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + `import { saveLink } from ${JSON.stringify(module)}; import { join } from 'node:path'; try { saveLink(join(process.env.PULLBOARD_HOME, 'relay.json'), { token: 'synthetic-test-data' }); console.log('written'); } catch (error) { console.error(error.code); process.exitCode = 1; }`, + ], + { + env: { ...process.env, PULLBOARD_HOME: root }, + encoding: 'utf8', + windowsHide: true, + }, + ); +} + +test( + 'installed Pullboard refuses credential creation under a public parent [A8]', + { skip: !windows }, + () => + temporary((root) => { + acl( + root, + "$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new('S-1-1-0'), 'Read', 'ContainerInherit, ObjectInherit', 'None', 'Allow'))", + ); + const result = saveLink(root); + assert.equal(result.status, 1, 'public-parent write must fail before creating a file'); + assert.match(result.stderr, /WINDOWS_PRIVATE_STORAGE/); + assert.equal(existsSync(join(root, 'relay.json')), false); + }), +); + +test('installed Pullboard writes link metadata under a private parent [A8]', { skip: !windows }, () => + temporary((root) => { + acl(root); + const result = saveLink(root); + assert.equal(result.status, 0, result.stderr); + const file = join(root, 'relay.json'); + assert.equal(JSON.parse(readFileSync(file, 'utf8')).token, 'synthetic-test-data'); + assert.equal(hasPrivatePermissions(file, lstatSync(file)), true); + }), +); + +test('installed Pullboard reads secure native Windows machine storage [A8]', { skip: !windows }, () => + temporary((root) => { + machine(root); + const result = readMachine(root); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), 'test-machine'); + }), +); + +test('installed Pullboard rejects public access to machine storage [A8]', { skip: !windows }, () => + temporary((root) => { + machine(root, true); + const result = readMachine(root); + assert.equal(result.status, 1); + assert.match(result.stderr, /RELAY_MACHINE_STORAGE/); + }), +); + +test( + 'ACL validation accepts private files and rejects broader and inherited access [A8]', + { skip: !windows }, + () => + temporary((root) => { + acl(root); + const file = join(root, "quoted ' path.txt"); + writeFileSync(file, 'synthetic'); + assert.equal(hasPrivatePermissions(file, lstatSync(file)), true); + acl( + root, + "$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new('S-1-1-0'), 'Read', 'ContainerInherit, ObjectInherit', 'None', 'Allow'))", + ); + assert.equal(hasPrivatePermissions(root, lstatSync(root)), false); + assert.equal(hasPrivatePermissions(file, lstatSync(file)), false); + }), +); + +test( + 'ACL validation accepts SYSTEM and Administrators and fails closed on missing paths [A8]', + { skip: !windows }, + () => + temporary((root) => { + acl( + root, + "foreach ($trusted in @('S-1-5-18', 'S-1-5-32-544')) { $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($trusted), 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow')) }", + ); + assert.equal(hasPrivatePermissions(root, lstatSync(root)), true); + assert.equal(hasPrivatePermissions(join(root, 'missing'), lstatSync(root)), false); + }), +); + +test('ACL validation rejects directory junctions [A8]', { skip: !windows }, () => + temporary((root) => { + acl(root); + const target = join(root, 'target'); + const link = join(root, 'link'); + mkdirSync(target); + symlinkSync(target, link, 'junction'); + assert.equal(hasPrivatePermissions(link, lstatSync(link)), false); + }), +);