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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/tests/room-watchdog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
15 changes: 15 additions & 0 deletions packages/run-spaceapp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
48 changes: 31 additions & 17 deletions packages/run-spaceapp/src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
mkdtemp,
readFile,
readdir,
rename,
rm,
stat,
writeFile
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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;
Expand All @@ -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") {
Expand All @@ -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(
Expand All @@ -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`);
Expand All @@ -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)}`;
Expand Down
7 changes: 4 additions & 3 deletions packages/run-spaceapp/src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}

Expand All @@ -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
Expand Down
180 changes: 180 additions & 0 deletions packages/run-spaceapp/src/private-files.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
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");
// 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: {
...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",
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;
}
}
11 changes: 4 additions & 7 deletions packages/run-spaceapp/tests/credentials.test.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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(), {
Expand All @@ -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
Expand Down
Loading
Loading