From 0c9a82fe015bf680079ff6d64b3dde610464a847 Mon Sep 17 00:00:00 2001 From: Hysann <1932819331hy@gmail.com> Date: Mon, 19 Oct 2026 16:21:27 +0900 Subject: [PATCH 1/3] fix(launcher): enforce private Windows credentials and backup files Create private Windows directories with protected security descriptors before any private payload exists. Verify exact SID grants, reject writable or replaceable immediate parent/provider directories, use trusted system PowerShell and literal path data, and fail closed on unverified permissions. Stage backup exports privately and preserve explicit file DACLs through same-volume rename. Keep POSIX 0700/0600 and replace Windows mode-only assertions with independent ACL checks and adversarial shared-directory controls. Make simulated Linux workspace permission hints use Linux metadata. Separate launcher fix based on actual upstream adb96ac; not part of dev-stack PR #181. Final focused Linux Node 24 validation: 19/19. Windows hosted validation pending. --- packages/run-spaceapp/README.md | 15 ++ packages/run-spaceapp/src/cli.mjs | 48 +++-- packages/run-spaceapp/src/index.mjs | 7 +- packages/run-spaceapp/src/private-files.mjs | 176 ++++++++++++++++++ .../run-spaceapp/tests/credentials.test.mjs | 11 +- .../tests/helpers/private-path.mjs | 80 ++++++++ .../run-spaceapp/tests/multi-user.test.mjs | 7 +- .../tests/pilot-findings.test.mjs | 22 ++- .../run-spaceapp/tests/private-files.test.mjs | 146 +++++++++++++++ 9 files changed, 475 insertions(+), 37 deletions(-) create mode 100644 packages/run-spaceapp/src/private-files.mjs create mode 100644 packages/run-spaceapp/tests/helpers/private-path.mjs create mode 100644 packages/run-spaceapp/tests/private-files.test.mjs diff --git a/packages/run-spaceapp/README.md b/packages/run-spaceapp/README.md index 0e8b1ab3b..b5f2ae613 100644 --- a/packages/run-spaceapp/README.md +++ b/packages/run-spaceapp/README.md @@ -123,6 +123,21 @@ restrictive files outside Git. OAuth and device-code credentials remain in isolated Docker provider state. Neither credential source is included in portable SpaceApp backups. +On Windows, provider-key directories and the CLI-settings/Space-Memory backup +archives use DACLs restricted to the current Windows account, SYSTEM, and local +Administrators. The launcher verifies the DACL before writing new private data; +if PowerShell or the filesystem cannot enforce it, that operation stops. Backup +exports are staged in a protected directory and retain a protected file DACL +when moved to their final name. POSIX hosts keep `0700` directories and `0600` +private files. + +Keep `SPACEAPP_HOME` and its ancestor paths under your account's or system +administrators' control. On Windows the launcher refuses a private directory +or its nearest existing parent if another account can write to or replace it; +public read-only access to the parent is allowed. New private directories carry +their protected DACL at creation. This does not protect against an account that +can replace a higher ancestor path or administer the machine. + Claude Code is not redistributed in SpaceApp images. Install the reviewed package as an explicit owner action with: diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index cd36c59f4..be0c66b8d 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -10,6 +10,7 @@ import { mkdtemp, readFile, readdir, + rename, rm, stat, writeFile @@ -48,6 +49,7 @@ import { writeSetupToken } from "./index.mjs"; import { inspectStoragePlan, storageChecks } from "./storage.mjs"; +import { createPrivateStagingFile, mkdirPrivate } from "./private-files.mjs"; import { ensureDockerAvailable, prepareDockerCliPath, @@ -273,6 +275,7 @@ export async function run(argv, { stdin = process.stdin, execute = executeCommand, inspectResources = inspectSystemResources, + workspaceMetadata = stat, ensureDocker = ensureDockerAvailable, prepareDockerPath = prepareDockerCliPath, request = globalThis.fetch, @@ -463,7 +466,7 @@ export async function run(argv, { return factoryResetCommand({ root, config, platform, stdin, stdout, stderr, execute: runtimeExecute, runtimeVersion, persistSetupToken }); } if (command === "workspace") { - return workspaceCommand(args, { root, config, platform, stdin, stdout, stderr, execute: runtimeExecute }); + return workspaceCommand(args, { root, config, platform, stdin, stdout, stderr, execute: runtimeExecute, workspaceMetadata }); } if (command === "credentials") { return credentialsCommand(args, { root, config, stdin, stdout, stderr, execute: runtimeExecute }); @@ -1746,7 +1749,7 @@ function assertRegisteredUser(config, userId) { } } -async function workspaceCommand(rawArgs, { root, config, platform, stdin, stdout, stderr, execute }) { +async function workspaceCommand(rawArgs, { root, config, platform, stdin, stdout, stderr, execute, workspaceMetadata }) { const { userId, args } = takeUserOption(rawArgs); if (userId) assertRegisteredUser(config, userId); const [action, identity, ...rest] = args; @@ -1767,7 +1770,7 @@ async function workspaceCommand(rawArgs, { root, config, platform, stdin, stdout await saveConfig(root, updated); await writeRuntimeFiles(root, updated); stdout.write(`Workspace registered: ${identity}\n`); - if (platform === "linux" && !readOnly) await writeAccessHint(identity, stdout); + if (platform === "linux" && !readOnly) await writeAccessHint(identity, stdout, workspaceMetadata); return applyWorkspaces(updated); } if (action === "remove") { @@ -1792,8 +1795,8 @@ async function workspaceCommand(rawArgs, { root, config, platform, stdin, stdout // The CLIs run as uid 10001 in Docker; on Linux a bind-mounted folder keeps its host owner, so they cannot write to it. const CLI_UID = 10001; -async function writeAccessHint(hostPath, stdout) { - const info = await stat(hostPath); +async function writeAccessHint(hostPath, stdout, workspaceMetadata) { + const info = await workspaceMetadata(hostPath); if (info.uid === CLI_UID || (info.mode & 0o002) !== 0) return; const owner = process.getuid?.() ?? info.uid; stdout.write( @@ -1803,18 +1806,29 @@ async function writeAccessHint(hostPath, stdout) { ); } -/** Streams one compose export into a 0600 archive; a failed export leaves no file. */ +/** Export inside a private directory, then retain the protected ACL on a same-volume rename. */ async function exportArchive(archive, action, { root, config, service, stdin, stderr, execute }) { - const out = createWriteStream(archive, { mode: 0o600 }); - await once(out, "open"); - const code = await execute( - composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), - { stdin, stdout: out, stderr } - ).catch(() => 1); - out.end(); - await once(out, "close"); - if (code !== 0) await rm(archive, { force: true }); - return code === 0; + const { path: temporary, directory } = await createPrivateStagingFile(archive); + let out; + try { + out = createWriteStream(temporary, { flags: "r+" }); + await once(out, "open"); + const code = await execute( + composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + { stdin, stdout: out, stderr } + ).catch(() => 1); + out.end(); + await once(out, "close"); + if (code !== 0) return false; + await rename(temporary, archive); + return true; + } finally { + if (out && !out.closed) { + out.destroy(); + await once(out, "close").catch(() => {}); + } + await rm(directory, { recursive: true, force: true }); + } } const userArchive = (directory, userId, kind) => join(directory, "users", `${userSlug(userId)}-${kind}.tar.gz`); @@ -1828,7 +1842,7 @@ async function addBackupExtras({ root, config, stdin, stdout, stderr, execute }) if (!saved) stderr.write("The backup has no CLI settings (MCP servers, base URLs): the CLI service did not answer or has none.\n"); // Each user's CLI settings and Space Memory, from their own container; their logins and keys never (MU-004). const users = config.users ?? []; - if (users.length > 0) await mkdir(join(directory, "users"), { recursive: true, mode: 0o700 }); + if (users.length > 0) await mkdirPrivate(join(directory, "users")); let complete = 0; for (const user of users) { const service = `spaceapp-cli-${userSlug(user.id)}`; diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index 3d71bffc2..e9913fb64 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -18,6 +18,7 @@ import { stripTrailingLineEndings } from "./string-utils.mjs"; import { UNIVERSAL_COMMAND } from "./package-info.mjs"; +import { mkdirPrivate } from "./private-files.mjs"; const CONFIG_SCHEMA_VERSION = 4; const CONTAINER_SECRET_MODE = 0o644; @@ -583,7 +584,7 @@ export async function writeCredential(root, provider, value, { userId } = {}) { throw new Error("Credential value cannot be empty or contain null bytes."); } const credentialsRoot = providerSecretsRoot(root, userId); - await mkdir(credentialsRoot, { recursive: true, mode: 0o700 }); + await mkdirPrivate(credentialsRoot); const target = join(credentialsRoot, `${provider}.key`); await atomicWrite(target, normalized); return target; @@ -918,7 +919,7 @@ export async function writeRuntimeFiles(root, config) { await atomicWrite(join(root, "compose.workspaces.yml"), renderWorkspaceCompose(config, { cliSandbox: await userNamespacesUsable() })); // The core reads it as uid 10001 through a read-only bind mount. await atomicWrite(join(root, "cli-hosts.json"), renderCliHosts(config), 0o644); - for (const user of config.users ?? []) await mkdir(providerSecretsRoot(root, user.id), { recursive: true, mode: 0o700 }); + for (const user of config.users ?? []) await mkdirPrivate(providerSecretsRoot(root, user.id)); await atomicWrite(join(root, "compose.host-access.yml"), renderHostAccessCompose(config)); } @@ -932,7 +933,7 @@ export async function prepareInstallation(root, { await mkdir(root, { recursive: true, mode: 0o700 }); await Promise.all([ mkdir(join(root, "backups"), { recursive: true, mode: 0o700 }), - mkdir(join(root, "secrets", "providers"), { recursive: true, mode: 0o700 }) + mkdirPrivate(join(root, "secrets", "providers")) ]); const resolvedProfile = profile === undefined ? undefined diff --git a/packages/run-spaceapp/src/private-files.mjs b/packages/run-spaceapp/src/private-files.mjs new file mode 100644 index 000000000..e7dd9a936 --- /dev/null +++ b/packages/run-spaceapp/src/private-files.mjs @@ -0,0 +1,176 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, win32 } from "node:path"; +import { promisify } from "node:util"; + +const executeFile = promisify(execFile); +const WINDOWS_ACL_VERIFIED = "SPACEAPP_PRIVATE_ACL_VERIFIED"; + +// Node's mode/chmod do not implement owner/group/other isolation on Windows. +// Keep Windows system administrators and SYSTEM, like root on POSIX, in the +// trust boundary. Use SIDs so the policy also works on localized Windows hosts. +// Paths are data in an environment variable, never interpolated into the script. +const WINDOWS_PRIVATE_ACL_SCRIPT = ` +$ErrorActionPreference = 'Stop' +$createdDirectory = $false +try { + $path = $env:SPACEAPP_PRIVATE_PATH + $directory = $env:SPACEAPP_PRIVATE_DIRECTORY -eq '1' + $createDirectory = $env:SPACEAPP_PRIVATE_CREATE_DIRECTORY -eq '1' + $exclusive = $env:SPACEAPP_PRIVATE_EXCLUSIVE -eq '1' + $owner = [Security.Principal.WindowsIdentity]::GetCurrent().User + $sids = @($owner.Value, 'S-1-5-18', 'S-1-5-32-544') | Select-Object -Unique + function Assert-SafeDirectory($candidate) { + $item = Get-Item -Force -LiteralPath $candidate + if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw 'Private storage requires a regular directory, not a reparse point.' + } + $security = Get-Acl -LiteralPath $candidate + if ($sids -notcontains $security.GetOwner([Security.Principal.SecurityIdentifier]).Value) { + throw 'Private storage requires a directory owned by this account or system administrators.' + } + $readOnly = [int][Security.AccessControl.FileSystemRights]::ReadAndExecute -bor [int][Security.AccessControl.FileSystemRights]::Synchronize + foreach ($entry in $security.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) { + if ($entry.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + ($entry.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly) -or + $sids -contains $entry.IdentityReference.Value) { continue } + if (([int]$entry.FileSystemRights -band (-bnot $readOnly)) -ne 0) { + throw 'Private storage refuses a directory writable or replaceable by another account.' + } + } + } + $parent = [IO.Path]::GetDirectoryName($path) + while (-not (Test-Path -LiteralPath $parent)) { + $parent = [IO.Path]::GetDirectoryName($parent) + if ([string]::IsNullOrEmpty($parent)) { throw 'No trusted parent directory exists.' } + } + Assert-SafeDirectory $parent + $exists = Test-Path -LiteralPath $path + if ($exclusive -and $exists) { throw 'Private staging path already exists.' } + if ($directory -and $exists) { Assert-SafeDirectory $path } + if ($directory) { + $acl = [Security.AccessControl.DirectorySecurity]::new() + $inheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit' + } else { + $acl = [Security.AccessControl.FileSecurity]::new() + $inheritance = [Security.AccessControl.InheritanceFlags]::None + } + $acl.SetOwner($owner) + $acl.SetAccessRuleProtection($true, $false) + foreach ($sid in $sids) { + $identity = [Security.Principal.SecurityIdentifier]::new($sid) + $rule = [Security.AccessControl.FileSystemAccessRule]::new($identity, 'FullControl', $inheritance, 'None', 'Allow') + $acl.AddAccessRule($rule) + } + if ($directory -and -not $exists -and $createDirectory) { + # This Windows PowerShell/.NET Framework overload applies the security + # descriptor during creation, without first exposing an inherited ACL. + [IO.Directory]::CreateDirectory($path, $acl) | Out-Null + $createdDirectory = $true + } else { + $item = Get-Item -Force -LiteralPath $path + if ($item.PSIsContainer -ne $directory -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw 'Expected a regular private file or directory, not a reparse point.' + } + Set-Acl -LiteralPath $path -AclObject $acl + } + $actual = Get-Acl -LiteralPath $path + if (-not $actual.AreAccessRulesProtected -or $actual.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $owner.Value) { + throw 'Private ACL ownership or inheritance could not be verified.' + } + $rules = @($actual.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) + if ($rules.Count -ne $sids.Count) { throw 'Unexpected private ACL entries.' } + foreach ($sid in $sids) { + $matching = @($rules | Where-Object { + $_.IdentityReference.Value -eq $sid -and + $_.AccessControlType -eq [Security.AccessControl.AccessControlType]::Allow -and + $_.FileSystemRights -eq [Security.AccessControl.FileSystemRights]::FullControl -and + $_.InheritanceFlags -eq $inheritance -and + $_.PropagationFlags -eq [Security.AccessControl.PropagationFlags]::None -and + -not $_.IsInherited + }) + if ($matching.Count -ne 1) { throw 'Private ACL rights could not be verified.' } + } + Write-Output '${WINDOWS_ACL_VERIFIED}' +} catch { + [Console]::Error.WriteLine($_.Exception.Message) + if ($createdDirectory) { + try { [IO.Directory]::Delete($path, $false) } catch { } + } + exit 1 +} +`; + +export async function restrictPrivatePath(path, { + directory = false, + createDirectory = false, + exclusive = false, + platform = process.platform, + execute = executeFile +} = {}) { + if (typeof path !== "string" || !isAbsolute(path) || path.includes("\0")) { + throw new Error("A private file path must be absolute and contain no null bytes."); + } + if (platform !== "win32") { + await chmod(path, directory ? 0o700 : 0o600); + return; + } + try { + const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows"; + if (!/^[A-Za-z]:[\\/]/.test(systemRoot)) throw new Error("SystemRoot must be an absolute Windows drive path."); + const executable = win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + const result = await execute(executable, [ + "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_PRIVATE_ACL_SCRIPT + ], { + env: { + ...process.env, + SPACEAPP_PRIVATE_PATH: path, + SPACEAPP_PRIVATE_DIRECTORY: directory ? "1" : "0", + SPACEAPP_PRIVATE_CREATE_DIRECTORY: createDirectory ? "1" : "0", + SPACEAPP_PRIVATE_EXCLUSIVE: exclusive ? "1" : "0" + }, + windowsHide: true, + timeout: 30_000, + maxBuffer: 64 * 1024, + encoding: "utf8" + }); + if (result.stdout.trim() !== WINDOWS_ACL_VERIFIED) { + throw new Error("PowerShell did not confirm the private ACL after reading it back."); + } + } catch (error) { + throw new Error(`Cannot enforce private Windows permissions for ${path}. Use storage controlled by your Windows account or system administrators. No new private data will be written.`, { cause: error }); + } +} + +export async function mkdirPrivate(path) { + if (process.platform === "win32") { + await restrictPrivatePath(path, { directory: true, createDirectory: true }); + return; + } + await mkdir(path, { recursive: true, mode: 0o700 }); + await restrictPrivatePath(path, { directory: true }); +} + +// Windows directories are private at creation: tightening an initially public +// directory would not revoke another account's existing WRITE_DAC handle. +// The file also gets an explicit protected ACL before leaving its private +// staging directory, so the final parent cannot supply a permissive file ACL. +export async function createPrivateStagingFile(destination, { protect = restrictPrivatePath } = {}) { + const windows = process.platform === "win32"; + const directory = windows + ? join(dirname(destination), `.spaceapp-private-${randomUUID()}`) + : await mkdtemp(join(dirname(destination), ".spaceapp-private-")); + let directoryReady = !windows; + try { + await protect(directory, { directory: true, createDirectory: windows, exclusive: windows }); + directoryReady = true; + const path = join(directory, "archive"); + await writeFile(path, "", { mode: 0o600, flag: "wx" }); + await protect(path); + return { path, directory }; + } catch (error) { + if (directoryReady) await rm(directory, { recursive: true, force: true }); + throw error; + } +} diff --git a/packages/run-spaceapp/tests/credentials.test.mjs b/packages/run-spaceapp/tests/credentials.test.mjs index 6245da584..32deef9fa 100644 --- a/packages/run-spaceapp/tests/credentials.test.mjs +++ b/packages/run-spaceapp/tests/credentials.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, stat } from "node:fs/promises"; +import { mkdtemp, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -8,6 +8,7 @@ import { removeCredential, writeCredential } from "../src/index.mjs"; +import { assertPrivatePath } from "./helpers/private-path.mjs"; test("credential providers distinguish bundled, owner-installed and experimental CLIs", () => { assert.deepEqual(credentialProviders(), { @@ -17,17 +18,13 @@ test("credential providers distinguish bundled, owner-installed and experimental }); }); -test("credentials are allowlisted, newline-trimmed, and use owner-only POSIX permissions", async () => { +test("credentials are allowlisted, newline-trimmed, and restricted to the owner and system administrators", async () => { const root = await mkdtemp(join(tmpdir(), "spaceapp-credential-")); const path = await writeCredential(root, "gemini", "example-value\n"); assert.equal(await readFile(path, "utf8"), "example-value"); - const metadata = await stat(path); - assert.equal(metadata.isFile(), true); - if (process.platform !== "win32") { - assert.equal(metadata.mode & 0o777, 0o600); - } + await assertPrivatePath(path); await assert.rejects( () => writeCredential(root, "../../escape", "value"), /provider/i diff --git a/packages/run-spaceapp/tests/helpers/private-path.mjs b/packages/run-spaceapp/tests/helpers/private-path.mjs new file mode 100644 index 000000000..aafd3d82e --- /dev/null +++ b/packages/run-spaceapp/tests/helpers/private-path.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { stat } from "node:fs/promises"; +import { win32 } from "node:path"; +import { promisify } from "node:util"; + +const executeFile = promisify(execFile); +const powershell = win32.join(process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + +// Independent readback: do not use the production permission setter or its +// success marker to decide whether the resulting file is private. +export async function windowsAcl(path) { + const { stdout } = await executeFile(powershell, [ + "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", ` +$ErrorActionPreference = 'Stop' +$acl = Get-Acl -LiteralPath $env:SPACEAPP_TEST_ACL_PATH +$rules = @($acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier]) | ForEach-Object { + @{ sid = $_.IdentityReference.Value; type = [int]$_.AccessControlType; rights = [int]$_.FileSystemRights; + inheritance = [int]$_.InheritanceFlags; propagation = [int]$_.PropagationFlags; inherited = $_.IsInherited } +}) +@{ owner = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value; + currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value; + protected = $acl.AreAccessRulesProtected; rules = $rules } | ConvertTo-Json -Depth 4 -Compress +` + ], { env: { ...process.env, SPACEAPP_TEST_ACL_PATH: path }, windowsHide: true, timeout: 30_000 }); + return JSON.parse(stdout.trim()); +} + +export async function assertPrivatePath(path, { directory = false, protectedDacl = directory } = {}) { + const metadata = await stat(path); + assert.equal(directory ? metadata.isDirectory() : metadata.isFile(), true); + if (process.platform !== "win32") { + assert.equal(metadata.mode & 0o777, directory ? 0o700 : 0o600); + return; + } + const acl = await windowsAcl(path); + const allowed = new Set([acl.currentUser, "S-1-5-18", "S-1-5-32-544"]); + assert.ok(allowed.has(acl.owner), `Unexpected private path owner: ${acl.owner}`); + if (protectedDacl) assert.equal(acl.protected, true); + assert.equal(acl.rules.length, allowed.size); + for (const rule of acl.rules) { + assert.ok(allowed.has(rule.sid), `Unexpected principal on private path: ${rule.sid}`); + assert.equal(rule.type, 0, "Only the intended Allow entries should remain"); + assert.equal(rule.rights, 0x1f01ff, "The trusted principal must retain FullControl"); + if (directory) { + assert.equal(rule.inheritance, 3, "New files and directories must inherit the private ACL"); + assert.equal(rule.propagation, 0); + } + } + assert.deepEqual(new Set(acl.rules.map((rule) => rule.sid)), allowed); +} + +// Only callers' disposable fixture paths are passed here. Seed a deliberately +// public Everyone grant so the Windows tests cannot pass on a private temp default. +export async function grantEveryoneInFixture(path, { directory = false, writable = false } = {}) { + await executeFile(powershell, [ + "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", ` +$ErrorActionPreference = 'Stop' +$path = $env:SPACEAPP_TEST_ACL_PATH +$acl = Get-Acl -LiteralPath $path +$sid = [Security.Principal.SecurityIdentifier]::new('S-1-1-0') +$inheritance = [Security.AccessControl.InheritanceFlags]::None +if ($env:SPACEAPP_TEST_ACL_DIRECTORY -eq '1') { $inheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit' } +$rights = [Security.AccessControl.FileSystemRights]::ReadAndExecute +if ($env:SPACEAPP_TEST_ACL_WRITABLE -eq '1') { $rights = [Security.AccessControl.FileSystemRights]::FullControl } +$rule = [Security.AccessControl.FileSystemAccessRule]::new($sid, $rights, $inheritance, 'None', 'Allow') +$acl.AddAccessRule($rule) +Set-Acl -LiteralPath $path -AclObject $acl +` + ], { + env: { + ...process.env, + SPACEAPP_TEST_ACL_PATH: path, + SPACEAPP_TEST_ACL_DIRECTORY: directory ? "1" : "0", + SPACEAPP_TEST_ACL_WRITABLE: writable ? "1" : "0" + }, + windowsHide: true, + timeout: 30_000 + }); +} diff --git a/packages/run-spaceapp/tests/multi-user.test.mjs b/packages/run-spaceapp/tests/multi-user.test.mjs index 00d120c40..69d07e302 100644 --- a/packages/run-spaceapp/tests/multi-user.test.mjs +++ b/packages/run-spaceapp/tests/multi-user.test.mjs @@ -1,11 +1,12 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, stat } from "node:fs/promises"; +import { mkdtemp, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Readable, Writable } from "node:stream"; import test from "node:test"; import { run } from "../src/cli.mjs"; import { addUser, createDefaultConfig, loadConfig, renderCliHosts, renderWorkspaceCompose, userSlug, validateConfig } from "../src/index.mjs"; +import { assertPrivatePath } from "./helpers/private-path.mjs"; // Multi-user (plan clipboard:evAaFYqrgDfd step 5): each user's CLIs run in their own container with their own keys, // logins, memory and workspaces; the core reads the user -> pane host socket map from SPACE_CLI_HOST_SOCKETS_FILE. @@ -66,7 +67,7 @@ test("user enable, add and remove give each user a CLI container and the core it assert.match(renderWorkspaceCompose(await loadConfig(root), { cliSandbox: true }), new RegExp(` spaceapp-cli-${slug}:[^]*?no-new-privileges:true\\n - seccomp=\\./seccomp-cli\\.json\\n - apparmor=unconfined`)); assert.match(compose, new RegExp(`\\nnetworks:\\n spaceapp-user-${slug}: \\{\\}`)); assert.deepEqual(JSON.parse(await readFile(join(root, "cli-hosts.json"), "utf8")), { "user:Bob12345": `/run/spaceapp-cli-users/${slug}/host.sock` }); - assert.equal((await stat(join(root, "secrets", "users", slug, "providers"))).mode & 0o777, 0o700); + await assertPrivatePath(join(root, "secrets", "users", slug, "providers"), { directory: true }); assert.match(stdout.value(), /credentials set --user user:Bob12345/); assert.equal(await run(["user", "remove", "user:Bob12345"], options), 0); @@ -164,7 +165,7 @@ test("a backup keeps each user's CLI settings and Space Memory, never their keys const directory = join(root, "backups", "spaceapp-backup-20261017T080000000Z"); assert.equal(await readFile(join(directory, "users", `${slug}-cli-config.tar.gz`), "utf8"), `spaceapp-cli-${slug}:settings`); assert.equal(await readFile(join(directory, "users", `${slug}-memory.tar.gz`), "utf8"), `spaceapp-cli-${slug}:memory`); - assert.equal((await stat(join(directory, "users", `${slug}-memory.tar.gz`))).mode & 0o777, 0o600); + await assertPrivatePath(join(directory, "users", `${slug}-memory.tar.gz`), { protectedDacl: true }); assert.match(fixture.stdout.value(), /CLI settings and Space Memory of 1 of 1 user\(s\)/); // The user is still there: a restore puts their state straight back into their own container. assert.equal(await run(["restore"], { ...fixture.options, stdin: Readable.from(["RESTORE\n"]) }), 0); diff --git a/packages/run-spaceapp/tests/pilot-findings.test.mjs b/packages/run-spaceapp/tests/pilot-findings.test.mjs index dcd730dd8..cccbda1b5 100644 --- a/packages/run-spaceapp/tests/pilot-findings.test.mjs +++ b/packages/run-spaceapp/tests/pilot-findings.test.mjs @@ -1,10 +1,11 @@ import assert from "node:assert/strict"; -import { chmod, mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Readable, Writable } from "node:stream"; import test from "node:test"; import { run } from "../src/cli.mjs"; +import { assertPrivatePath } from "./helpers/private-path.mjs"; // Findings of the 2026-10-16 pilot rehearsal: workspace changes reach the running services (F1), Linux shows how // the CLIs get write access (F3), backups carry the workspace list and CLI settings (F8), purge runs unattended (F9). @@ -38,17 +39,24 @@ test("workspace add and remove apply the mounts to the running core and CLI serv const calls = []; const { stdout, options } = await installation(async (spec) => { calls.push(spec.args); return 0; }); const folder = await mkdtemp(join(tmpdir(), "spaceapp-pilot-repo-")); - await chmod(folder, 0o755); - assert.equal(await run(["workspace", "add", folder], options), 0); + // A simulated Linux platform must use Linux metadata, including on Windows. + // The real host directory is still used by addWorkspace and Compose rendering. + const linuxOptions = { ...options, workspaceMetadata: async (path) => { + assert.equal(path, folder); + return { uid: 42, mode: 0o40755 }; + } }; + assert.equal(await run(["workspace", "add", folder], linuxOptions), 0); assert.deepEqual(calls.at(-1).slice(-4), ["up", "-d", "spaceapp-core", "spaceapp-cli"]); assert.match(stdout.value(), /uid 10001 and cannot write/); - assert.match(stdout.value(), new RegExp(`setfacl -R -m u:10001:rwX .*${folder}`)); + assert.match(stdout.value(), /setfacl -R -m u:10001:rwX/); + assert.ok(stdout.value().includes(JSON.stringify(folder))); assert.equal(await run(["workspace", "remove", folder], options), 0); assert.deepEqual(calls.at(-1).slice(-4), ["up", "-d", "spaceapp-core", "spaceapp-cli"]); const before = stdout.value().length; - await chmod(folder, 0o777); - assert.equal(await run(["workspace", "add", folder, "--read-only"], options), 0); + assert.equal(await run(["workspace", "add", folder, "--read-only"], { + ...options, workspaceMetadata: async () => { assert.fail("Read-only mounts need no write-access inspection"); } + }), 0); assert.doesNotMatch(stdout.value().slice(before), /setfacl/); }); @@ -78,7 +86,7 @@ test("a backup carries the workspace list and the CLI settings, and restore brin assert.equal(await run(["backup"], fixture.options), 0); const directory = join(root, "backups", "spaceapp-backup-20261016T070000000Z"); assert.equal(await readFile(join(directory, "cli-config.tar.gz"), "utf8"), "cli-settings-archive"); - assert.equal((await stat(join(directory, "cli-config.tar.gz"))).mode & 0o777, 0o600); + await assertPrivatePath(join(directory, "cli-config.tar.gz"), { protectedDacl: true }); assert.deepEqual(JSON.parse(await readFile(join(directory, "spaceapp-config.json"), "utf8")).workspaces.map((w) => w.hostPath), [folder]); await run(["workspace", "remove", folder], fixture.options); diff --git a/packages/run-spaceapp/tests/private-files.test.mjs b/packages/run-spaceapp/tests/private-files.test.mjs new file mode 100644 index 000000000..bc73dffa3 --- /dev/null +++ b/packages/run-spaceapp/tests/private-files.test.mjs @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, win32 } from "node:path"; +import test from "node:test"; +import { createPrivateStagingFile, mkdirPrivate, restrictPrivatePath } from "../src/private-files.mjs"; +import { writeCredential } from "../src/index.mjs"; +import { assertPrivatePath, grantEveryoneInFixture, windowsAcl } from "./helpers/private-path.mjs"; + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), "spaceapp-private-files-")); + t.after(() => rm(root, { recursive: true, force: true })); + return root; +} + +test("private directories replace permissive grants and new credentials inherit their protection", async (t) => { + const root = await fixture(t); + const providers = join(root, "secrets", "providers"); + await mkdir(providers, { recursive: true }); + if (process.platform === "win32") { + await grantEveryoneInFixture(root, { directory: true }); + await grantEveryoneInFixture(providers, { directory: true }); + assert.ok((await windowsAcl(providers)).rules.some((rule) => rule.sid === "S-1-1-0")); + } else { + await chmod(providers, 0o777); + } + const path = await writeCredential(root, "gemini", "first-synthetic-key\n"); + await assertPrivatePath(providers, { directory: true }); + await assertPrivatePath(path); + assert.equal(await readFile(path, "utf8"), "first-synthetic-key"); + // Replacing a legacy file with an explicit broad grant must not preserve it. + if (process.platform === "win32") await grantEveryoneInFixture(path); + else await chmod(path, 0o666); + await writeCredential(root, "gemini", "replacement-synthetic-key"); + await assertPrivatePath(path); + assert.equal(await readFile(path, "utf8"), "replacement-synthetic-key"); +}); + +test("private archive permissions survive writing and same-volume rename under a publicly readable parent", async (t) => { + const root = await fixture(t); + if (process.platform === "win32") await grantEveryoneInFixture(root, { directory: true }); + else await chmod(root, 0o777); + if (process.platform === "win32") { + // Negative control for the previous mode-only archive creation: a real + // Windows ACL still grants Everyone access after both mode and chmod 0600. + const legacy = join(root, "legacy-mode-only.tar.gz"); + await writeFile(legacy, "synthetic legacy archive", { mode: 0o600 }); + await chmod(legacy, 0o600); + assert.ok((await windowsAcl(legacy)).rules.some((rule) => rule.sid === "S-1-1-0" && rule.type === 0)); + await assert.rejects(assertPrivatePath(legacy), { name: "AssertionError" }); + await rm(legacy); + } + const archive = join(root, "archive [literal] '$();.tar.gz"); + const { path: temporary, directory } = await createPrivateStagingFile(archive, { protect: async (path, options) => { + if (options?.directory) { + if (process.platform === "win32") { + await assert.rejects(stat(path), { code: "ENOENT" }); + assert.equal(options.createDirectory, true); + assert.equal(options.exclusive, true); + } else { + assert.deepEqual(await readdir(path), []); + } + } else { + // This is the file's inherited ACL, before applying its explicit ACL. + await assertPrivatePath(path); + } + await restrictPrivatePath(path, options); + } }); + await assertPrivatePath(directory, { directory: true }); + await assertPrivatePath(temporary, { protectedDacl: true }); + await writeFile(temporary, "synthetic archive", { flag: "r+" }); + await rename(temporary, archive); + await assertPrivatePath(archive, { protectedDacl: true }); + assert.equal(await readFile(archive, "utf8"), "synthetic archive"); +}); + +test("a failed permission check removes staging before its producer can write data", async (t) => { + const root = await fixture(t); + const path = join(root, "failed-archive.tar.gz"); + for (const failDirectory of [true, false]) { + let produced = false; + await assert.rejects(async () => { + await createPrivateStagingFile(path, { protect: async (target, options) => { + if (options?.directory && process.platform === "win32") await assert.rejects(stat(target), { code: "ENOENT" }); + else if (options?.directory) assert.deepEqual(await readdir(target), []); + else assert.equal((await stat(target)).size, 0); + if (Boolean(options?.directory) === failDirectory) throw new Error("ACL readback denied"); + await restrictPrivatePath(target, options); + } }); + produced = true; + await writeFile(path, "must not be written"); + }, /ACL readback denied/); + assert.equal(produced, false); + assert.deepEqual(await readdir(root), []); + } +}); + +test("private staging does not overwrite an existing destination", async (t) => { + const path = join(await fixture(t), "existing"); + await writeFile(path, "keep existing data"); + const staged = await createPrivateStagingFile(path); + assert.equal((await stat(staged.path)).size, 0); + assert.equal(await readFile(path, "utf8"), "keep existing data"); +}); + +test("Windows permission enforcement fails closed on process failure or unverified success", async (t) => { + const path = join(await fixture(t), "unused [literal] '$();"); + for (const execute of [ + async () => { throw new Error("PowerShell unavailable"); }, + async () => ({ stdout: "" }), + async () => ({ stdout: "access denied" }) + ]) { + await assert.rejects(restrictPrivatePath(path, { platform: "win32", execute }), /Cannot enforce private Windows permissions/); + } + let invocation; + await assert.rejects(restrictPrivatePath(path, { platform: "win32", execute: async (...args) => { + invocation = args; + throw new Error("capture without executing"); + } }), /Cannot enforce/); + assert.ok(win32.isAbsolute(invocation[0]), "ACL enforcement must not search the working directory or PATH"); + assert.equal(win32.basename(invocation[0]), "powershell.exe"); + assert.equal(invocation[2].env.SPACEAPP_PRIVATE_PATH, path); + assert.ok(!invocation[1].at(-1).includes(path), "The path must never become PowerShell code"); +}); + +test("private directories reject writable Windows locations and retain private POSIX modes", async (t) => { + const root = await fixture(t); + const shared = join(root, "shared"); + await mkdir(shared); + if (process.platform === "win32") { + await grantEveryoneInFixture(shared, { directory: true, writable: true }); + await assert.rejects(createPrivateStagingFile(join(shared, "archive.tar.gz")), /Cannot enforce private Windows permissions/); + await assert.rejects(mkdirPrivate(join(shared, "private")), /Cannot enforce private Windows permissions/); + assert.deepEqual(await readdir(shared), [], "Unsafe parents must be rejected before creating storage"); + const providers = join(root, "secrets", "providers"); + await mkdir(providers, { recursive: true }); + await grantEveryoneInFixture(providers, { directory: true, writable: true }); + await assert.rejects(writeCredential(root, "gemini", "must not be written"), /Cannot enforce private Windows permissions/); + assert.deepEqual(await readdir(providers), [], "Unsafe existing provider directories must not receive new keys"); + await assert.rejects(restrictPrivatePath(shared, { directory: true, createDirectory: true, exclusive: true }), /Cannot enforce private Windows permissions/); + assert.equal((await stat(shared)).isDirectory(), true, "A pre-existing staging name must not be deleted"); + } else { + await mkdirPrivate(join(shared, "private")); + await assertPrivatePath(join(shared, "private"), { directory: true }); + } +}); From 607985bc909372ca0e6c4344b4be359df2577ec0 Mon Sep 17 00:00:00 2001 From: Hysann <1932819331hy@gmail.com> Date: Mon, 19 Oct 2026 16:30:26 +0900 Subject: [PATCH 2/3] fix(launcher): initialize Windows PowerShell system module path A launcher started through PowerShell 7 inherits module paths via Node that prevent Windows PowerShell 5.1 from loading Get-Acl. Remove inherited PSModulePath keys case-insensitively and initialize the ACL subprocess with its own system Modules path. Apply equivalent initialization to the independent ACL test reader. Retain the ACL script byte-for-byte. Add environment-key/path assertions: old implementation fails the new case, and all 19 focused tests pass with both conflicting path spellings injected. Real Windows validation must rerun. --- packages/run-spaceapp/src/private-files.mjs | 6 +++++- .../run-spaceapp/tests/helpers/private-path.mjs | 16 ++++++++++++---- .../run-spaceapp/tests/private-files.test.mjs | 2 ++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/run-spaceapp/src/private-files.mjs b/packages/run-spaceapp/src/private-files.mjs index e7dd9a936..a978eb3b2 100644 --- a/packages/run-spaceapp/src/private-files.mjs +++ b/packages/run-spaceapp/src/private-files.mjs @@ -120,11 +120,15 @@ export async function restrictPrivatePath(path, { const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows"; if (!/^[A-Za-z]:[\\/]/.test(systemRoot)) throw new Error("SystemRoot must be an absolute Windows drive path."); const executable = win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + // A Node process launched by PowerShell 7 inherits its incompatible module + // paths. Windows environment keys are case-insensitive; replace every form. + const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => name.toUpperCase() !== "PSMODULEPATH")); const result = await execute(executable, [ "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_PRIVATE_ACL_SCRIPT ], { env: { - ...process.env, + ...environment, + PSModulePath: win32.join(win32.dirname(executable), "Modules"), SPACEAPP_PRIVATE_PATH: path, SPACEAPP_PRIVATE_DIRECTORY: directory ? "1" : "0", SPACEAPP_PRIVATE_CREATE_DIRECTORY: createDirectory ? "1" : "0", diff --git a/packages/run-spaceapp/tests/helpers/private-path.mjs b/packages/run-spaceapp/tests/helpers/private-path.mjs index aafd3d82e..2b1f629ca 100644 --- a/packages/run-spaceapp/tests/helpers/private-path.mjs +++ b/packages/run-spaceapp/tests/helpers/private-path.mjs @@ -7,6 +7,15 @@ import { promisify } from "node:util"; const executeFile = promisify(execFile); const powershell = win32.join(process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); +function windowsPowerShellEnvironment(values) { + const environment = { ...process.env, ...values }; + for (const name of Object.keys(environment)) { + if (name.toUpperCase() === "PSMODULEPATH") delete environment[name]; + } + environment.PSModulePath = win32.join(win32.dirname(powershell), "Modules"); + return environment; +} + // Independent readback: do not use the production permission setter or its // success marker to decide whether the resulting file is private. export async function windowsAcl(path) { @@ -22,7 +31,7 @@ $rules = @($acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentif currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value; protected = $acl.AreAccessRulesProtected; rules = $rules } | ConvertTo-Json -Depth 4 -Compress ` - ], { env: { ...process.env, SPACEAPP_TEST_ACL_PATH: path }, windowsHide: true, timeout: 30_000 }); + ], { env: windowsPowerShellEnvironment({ SPACEAPP_TEST_ACL_PATH: path }), windowsHide: true, timeout: 30_000 }); return JSON.parse(stdout.trim()); } @@ -68,12 +77,11 @@ $acl.AddAccessRule($rule) Set-Acl -LiteralPath $path -AclObject $acl ` ], { - env: { - ...process.env, + env: windowsPowerShellEnvironment({ SPACEAPP_TEST_ACL_PATH: path, SPACEAPP_TEST_ACL_DIRECTORY: directory ? "1" : "0", SPACEAPP_TEST_ACL_WRITABLE: writable ? "1" : "0" - }, + }), windowsHide: true, timeout: 30_000 }); diff --git a/packages/run-spaceapp/tests/private-files.test.mjs b/packages/run-spaceapp/tests/private-files.test.mjs index bc73dffa3..1fddc781f 100644 --- a/packages/run-spaceapp/tests/private-files.test.mjs +++ b/packages/run-spaceapp/tests/private-files.test.mjs @@ -119,6 +119,8 @@ test("Windows permission enforcement fails closed on process failure or unverifi } }), /Cannot enforce/); assert.ok(win32.isAbsolute(invocation[0]), "ACL enforcement must not search the working directory or PATH"); assert.equal(win32.basename(invocation[0]), "powershell.exe"); + assert.deepEqual(Object.keys(invocation[2].env).filter((name) => name.toUpperCase() === "PSMODULEPATH"), ["PSModulePath"]); + assert.equal(invocation[2].env.PSModulePath, win32.join(win32.dirname(invocation[0]), "Modules"), "Windows PowerShell must not inherit PowerShell 7 module paths through Node"); assert.equal(invocation[2].env.SPACEAPP_PRIVATE_PATH, path); assert.ok(!invocation[1].at(-1).includes(path), "The path must never become PowerShell code"); }); From 47399bf2f3f1e405653394e8cdf3064b9b044201 Mon Sep 17 00:00:00 2001 From: Hysann <1932819331hy@gmail.com> Date: Mon, 19 Oct 2026 17:02:57 +0900 Subject: [PATCH 3/3] test(api): assert recorded watchdog event persistence --- apps/api/tests/room-watchdog.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/tests/room-watchdog.test.ts b/apps/api/tests/room-watchdog.test.ts index 0d6f02898..c564d4ee5 100644 --- a/apps/api/tests/room-watchdog.test.ts +++ b/apps/api/tests/room-watchdog.test.ts @@ -255,7 +255,7 @@ describe("Room Watchdog and Anti-Freeze Protection", () => { const recorded = await store.recordRoomEvent(watchdogEvent); expect(recorded.type).toBe("ROOM_WATCHDOG_ALERT"); - expect(store.getLatestEvent(room.id)?.type).toBe("ROOM_WATCHDOG_ALERT"); + expect(store.listEvents(room.id)).toContainEqual(recorded); }); it("gives queued operations their full execution window without queue-wait timer bleed", async () => {