From 08f48432b514e2af54053d855d6b6a849ca0248c Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Tue, 22 Sep 2026 12:03:26 +0500 Subject: [PATCH 01/23] feat(sandbox): add opt-in Cageforge command session boundary Co-authored-by: codex --- ci/cageforge-command-suite/run.sh | 13 ++ docs/cageforge-command-sessions.md | 68 +++++++++ gradle/libs.versions.toml | 2 + modules/boss-command-sandbox/build.gradle.kts | 51 +++++++ .../boss/sandbox/CageforgeSessionLauncher.kt | 76 ++++++++++ .../ai/rever/boss/sandbox/SandboxCommand.kt | 11 ++ .../boss/sandbox/SandboxPolicySnapshot.kt | 101 ++++++++++++++ .../ai/rever/boss/sandbox/SandboxSession.kt | 20 +++ .../rever/boss/sandbox/SandboxSessionPlan.kt | 23 +++ .../boss/sandbox/CommandSecurityProbe.java | 73 ++++++++++ .../boss/sandbox/CommandNativeSecurityTest.kt | 132 ++++++++++++++++++ .../rever/boss/sandbox/CommandWindowsSetup.kt | 35 +++++ .../boss/sandbox/SandboxPolicySnapshotTest.kt | 101 ++++++++++++++ settings.gradle.kts | 3 + 14 files changed, 709 insertions(+) create mode 100644 ci/cageforge-command-suite/run.sh create mode 100644 docs/cageforge-command-sessions.md create mode 100644 modules/boss-command-sandbox/build.gradle.kts create mode 100644 modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/CageforgeSessionLauncher.kt create mode 100644 modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxCommand.kt create mode 100644 modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshot.kt create mode 100644 modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSession.kt create mode 100644 modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionPlan.kt create mode 100644 modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java create mode 100644 modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt create mode 100644 modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandWindowsSetup.kt create mode 100644 modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshotTest.kt diff --git a/ci/cageforge-command-suite/run.sh b/ci/cageforge-command-suite/run.sh new file mode 100644 index 0000000000..8470425f7d --- /dev/null +++ b/ci/cageforge-command-suite/run.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +bundle_root=${CAGEFORGE_NATIVE_TEST_BUNDLE_ROOT:?native test bundle root is required} +classpath_entries=("$bundle_root/classes") +while IFS= read -r -d '' jar; do + classpath_entries+=("$jar") +done < <(find "$bundle_root/lib" -maxdepth 1 -type f -name '*.jar' -print0 | sort -z) +classpath=$(IFS=:; printf '%s' "${classpath_entries[*]}") +test_home=$(mktemp -d /tmp/boss-command-test-home.XXXXXX) +trap 'rmdir "$test_home" 2>/dev/null || true' EXIT +java -Duser.home="$test_home" -cp "$classpath" org.junit.runner.JUnitCore \ + ai.rever.boss.sandbox.CommandNativeSecurityTest diff --git a/docs/cageforge-command-sessions.md b/docs/cageforge-command-sessions.md new file mode 100644 index 0000000000..4f781ebec5 --- /dev/null +++ b/docs/cageforge-command-sessions.md @@ -0,0 +1,68 @@ +# Sandboxed command sessions + +BOSS command sandboxing is an explicit opt-in launch of a root executable. Cageforge +owns that process and all its descendants. Starting `codex`, for example, places the +shells, Git commands and compilers it creates inside the same native boundary. +An MCP call is a transport operation, not a new sandbox boundary. Ordinary terminal +and plugin execution are outside this feature and retain their existing behavior. + +## Policy and approval contract + +A session selects a project directory, a TOML file, a named CLI profile, and an +executable with separate arguments. BOSS reads the policy once, adds a final child +profile containing that exact command and working directory, then asks Cageforge +Java 0.6.1 for its permission request. Approval identifies this immutable snapshot. +Edits to the TOML file affect the next preparation, never a running session. A plan +can launch once. A failed launch has no unsandboxed retry or fallback. + +The final child profile `boss-command-session` is reserved. It enforces preflight +approval and captured stdin/stdout/stderr. It inherits the selected policy; native +Cageforge resolves all filesystem, environment, network and OS-specific rules. +The project directory is the resolution context even when the TOML file is elsewhere. +There is no automatic discovery or execution of repository-provided commands. + +TOML is trusted configuration. The permissions shown for review may grant resources +outside the project. A configuration file is not itself a security ceiling. BOSS +must show the resolved permission request before issuing a grant. No MCP credentials +are added to ordinary command sessions. Agent connection configuration belongs only +to explicitly requested agent sessions. + +## TOML inheritance + +Use one project policy with shared profiles and named CLI profiles. Cageforge's +`inherits` performs the merge; BOSS does not concatenate independent policy files +or implement a second TOML merger. Parents are applied in inheritance order, shared +ancestors once, and children last. A platform overlay participates at each profile. + +- Matching filesystem rules are replaced by canonical target identity, not appended + indiscriminately. Different targets remain present. +- `workspace_roots` maps paths to booleans; `false` disables an inherited root. +- Command arguments replace the inherited argument list, including an empty list. +- Environment set/remove entries override the same case-insensitive variable name. +- Changing a policy mode can clear inherited mode-specific rules. An empty list is + not a general instruction to remove inherited permissions. +- Cycles, unknown fields, duplicate canonical rules and unknown profiles fail. + +Custom runtimes can require explicit readable paths. On macOS, custom executable +roots additionally need `runtime.executable_roots`; read access alone does not grant +executable mapping. Windows setup is explicit and may require elevation. Launch only +verifies existing setup; it never invokes UAC implicitly. + +The session uses pipes. This does not promise a PTY, terminal emulation, resize +support, or compatibility with CLIs that require a controlling terminal. + +## Verification work + +The command session module separates immutable preparation and native launch from +Compose and MCP integration. Ordinary tests cover snapshot identity, argument +handling, bounds and approval replay. Native policy and security tests must exercise +the published binding and real backend on Linux, macOS and Windows. Linux enforcement +runs in a prepared QEMU guest with the consumer compiled on the host. GUI tests must +exercise opt-in, review, denial, failure, output and termination through Compose. + +The existing `feat/cageforge-secure-plugin` branch supplies useful native provisioning +and QEMU patterns, but its protected plugin lifecycle is not this feature's launch +model. In particular, replacing a plugin worker cannot isolate commands launched by +an unrelated terminal plugin. No claim that the old branch works or fails on all +platforms follows from its presence in Git; its CI and native evidence need separate +inspection. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0d3ef93386..2858a2e5e4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ detekt = "1.23.8" essenty = "2.6.0" junit-jupiter = "6.1.3" clikt = "5.1.0" +cageforge = "0.6.1" kotlin = "2.4.10" kotlinx-coroutines = "1.11.0" ktlint-gradle = "14.2.0" @@ -177,6 +178,7 @@ intellij-platform = "243.21565.199" boss-plugin-api = "1.0.93" [libraries] +cageforge-java = { module = "io.github.m62624:cageforge-java", version.ref = "cageforge" } bouncycastle-pkix = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bouncycastle" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } diff --git a/modules/boss-command-sandbox/build.gradle.kts b/modules/boss-command-sandbox/build.gradle.kts new file mode 100644 index 0000000000..a2fc9b4284 --- /dev/null +++ b/modules/boss-command-sandbox/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + alias(libs.plugins.kotlinJvm) +} + +java { + toolchain.languageVersion.set(JavaLanguageVersion.of(17)) +} + +dependencies { + implementation(libs.cageforge.java) + testImplementation(libs.kotlin.test.junit) +} + +// Never attached to check/build: native enforcement requires a prepared OS. +val nativeSecurity = sourceSets.create("nativeSecurityTest") +nativeSecurity.compileClasspath += sourceSets.main.get().output +nativeSecurity.runtimeClasspath += sourceSets.main.get().output +configurations[nativeSecurity.implementationConfigurationName].extendsFrom(configurations.testImplementation.get()) +configurations[nativeSecurity.runtimeOnlyConfigurationName].extendsFrom(configurations.testRuntimeOnly.get()) + +tasks.register("nativeSecurityTest") { + group = "verification" + description = "Tests root and descendant isolation on a prepared native backend" + testClassesDirs = nativeSecurity.output.classesDirs + classpath = nativeSecurity.runtimeClasspath + doFirst { + systemProperty("boss.sandbox.probe.classpath", classpath.asPath) + } +} + +tasks.register("nativeSecurityTestBundle") { + group = "verification" + description = "Compiles and packages the native consumer before QEMU starts" + archiveFileName.set("boss-command-sandbox-tests.tar.gz") + destinationDirectory.set(layout.buildDirectory.dir("nativeSecurityTest")) + compression = Compression.GZIP + dependsOn(nativeSecurity.classesTaskName) + from(sourceSets.main.get().output) { into("classes") } + from(nativeSecurity.output) { into("classes") } + from(configurations[nativeSecurity.runtimeClasspathConfigurationName]) { into("lib") } + from(rootProject.file("ci/cageforge-command-suite/run.sh")) { into("ci/cageforge-command-suite") } +} + +tasks.withType().configureEach { + val testHome = layout.buildDirectory.dir("test-home/$name") + systemProperty("user.home", testHome.get().asFile.absolutePath) + doFirst { + testHome.get().asFile.deleteRecursively() + testHome.get().asFile.mkdirs() + } +} diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/CageforgeSessionLauncher.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/CageforgeSessionLauncher.kt new file mode 100644 index 0000000000..6ee8fdbe24 --- /dev/null +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/CageforgeSessionLauncher.kt @@ -0,0 +1,76 @@ +package ai.rever.boss.sandbox + +import ai.cageforge.Cageforge +import ai.cageforge.PermissionApprover +import ai.cageforge.PermissionRequest +import ai.cageforge.RuntimeContext +import ai.cageforge.WindowsSetup +import ai.cageforge.WindowsSetupState + +/** The only native launch boundary for opt-in command sessions. No unsandboxed fallback exists. */ +class CageforgeSessionLauncher { + fun prepare(command: SandboxCommand): SandboxSessionPlan { + val snapshot = SandboxPolicySnapshot.read(command) + // In 0.6.1 checkToml also initializes JNI. permissionRequest expects it loaded. + Cageforge.checkToml( + snapshot.toml, + SandboxPolicySnapshot.LAUNCH_PROFILE, + RuntimeContext(snapshot.projectDirectory), + ) + return permissionRequest(snapshot).use { request -> + SandboxSessionPlan(snapshot, request.digest, request.json) + } + } + + /** Call only after the operator approves this exact plan, through the GUI or explicit CLI action. */ + fun launch( + plan: SandboxSessionPlan, + approvedDigest: String, + ): SandboxSession { + plan.claim(approvedDigest) + ensurePlatformReady() + val snapshot = plan.snapshot + return permissionRequest(snapshot).use { request -> + check(request.digest == plan.permissionDigest) { "Permissions changed since review; prepare a new session" } + PermissionApprover().approve(request).use { grant -> + val runtime = + Cageforge.fromToml( + snapshot.toml, + SandboxPolicySnapshot.LAUNCH_PROFILE, + RuntimeContext(snapshot.projectDirectory), + grant, + request, + ) + launchOwned(runtime) + } + } + } + + private fun launchOwned(runtime: Cageforge): SandboxSession { + var transferred = false + // use preserves a launch failure even if cleanup also fails. After a successful + // launch, the session owns the runtime and closes it after the process boundary. + val pendingRuntime = AutoCloseable { if (!transferred) runtime.close() } + return pendingRuntime.use { + SandboxSession(runtime.launchProcess(), runtime).also { transferred = true } + } + } + + private fun permissionRequest(snapshot: SandboxPolicySnapshot): PermissionRequest = + Cageforge.permissionRequest( + snapshot.toml, + SandboxPolicySnapshot.LAUNCH_PROFILE, + RuntimeContext(snapshot.projectDirectory), + toolId = "boss-command-session", + configDigest = snapshot.digest, + ) + + private fun ensurePlatformReady() { + if (WindowsSetup.isSupported()) { + check(WindowsSetup.status() == WindowsSetupState.READY) { + "Cageforge Windows setup is not ready. Run explicit setup before starting a sandboxed session." + } + WindowsSetup.verify() + } + } +} diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxCommand.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxCommand.kt new file mode 100644 index 0000000000..f815e19644 --- /dev/null +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxCommand.kt @@ -0,0 +1,11 @@ +package ai.rever.boss.sandbox + +import java.nio.file.Path + +/** One root executable and its arguments, never an implicitly interpreted shell command. */ +data class SandboxCommand( + val projectDirectory: Path, + val policyFile: Path, + val profile: String, + val argv: List, +) diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshot.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshot.kt new file mode 100644 index 0000000000..6c7152ac5b --- /dev/null +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshot.kt @@ -0,0 +1,101 @@ +package ai.rever.boss.sandbox + +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.security.MessageDigest +import java.util.Collections + +/** Captures policy bytes and command together before the permission request is shown. */ +internal class SandboxPolicySnapshot private constructor( + val projectDirectory: Path, + val policyFile: Path, + val profile: String, + val argv: List, + val toml: String, +) { + val digest: String = + MessageDigest + .getInstance("SHA-256") + .digest("$projectDirectory\u0000$policyFile\u0000$toml".toByteArray(StandardCharsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + + companion object { + const val LAUNCH_PROFILE = "boss-command-session" + internal const val MAX_POLICY_BYTES = 1024 * 1024 + private const val MAX_ARGUMENT_BYTES = 128 * 1024 + + fun read(command: SandboxCommand): SandboxPolicySnapshot { + require(command.projectDirectory.isAbsolute) { "Project directory must be absolute" } + require(command.policyFile.isAbsolute) { "Policy file must be absolute" } + val project = command.projectDirectory.toRealPath() + require(Files.isDirectory(project)) { "Project directory does not exist" } + val policy = command.policyFile.toRealPath() + require(Files.isRegularFile(policy)) { "Policy must be a regular file" } + require(command.profile.matches(Regex("[A-Za-z0-9][A-Za-z0-9_-]*"))) { "Invalid profile name" } + require(command.profile != LAUNCH_PROFILE) { "$LAUNCH_PROFILE is reserved for the host" } + val argv = command.argv.toList() + require(argv.isNotEmpty() && argv.first().isNotBlank()) { "An executable is required" } + require(argv.none { '\u0000' in it }) { "Command arguments must not contain NUL" } + require(argv.sumOf { it.toByteArray(StandardCharsets.UTF_8).size.toLong() + 1 } <= MAX_ARGUMENT_BYTES) { + "Command arguments exceed 128 KiB" + } + val bytes = Files.newInputStream(policy).use { it.readNBytes(MAX_POLICY_BYTES + 1) } + require(bytes.size <= MAX_POLICY_BYTES) { "Policy exceeds 1 MiB" } + val source = + StandardCharsets.UTF_8 + .newDecoder() + .decode(ByteBuffer.wrap(bytes)) + .toString() + require(source.isNotBlank()) { "Policy must not be empty" } + return SandboxPolicySnapshot( + project, + policy, + command.profile, + Collections.unmodifiableList(argv), + compose(source, command.profile, argv, project), + ) + } + + // Cageforge owns inheritance, canonical rule replacement and OS overlays. The final + // child only binds this session's command and pipes; it never reimplements policy merge. + private fun compose( + source: String, + profile: String, + argv: List, + project: Path, + ): String = + buildString { + appendLine(source) + appendLine() + appendLine("[profiles.$LAUNCH_PROFILE]") + appendLine("inherits = [${tomlString(profile)}]") + appendLine("[profiles.$LAUNCH_PROFILE.approval]") + appendLine("mode = \"preflight\"") + appendLine("persistence = \"session\"") + appendLine("[profiles.$LAUNCH_PROFILE.command]") + appendLine("program = ${tomlString(argv.first())}") + appendLine("args = [${argv.drop(1).joinToString(", ", transform = ::tomlString)}]") + appendLine("working_directory = ${tomlString(project.toString())}") + appendLine("[profiles.$LAUNCH_PROFILE.command.stdio]") + appendLine("stdin = \"pipe\"") + appendLine("stdout = \"pipe\"") + appendLine("stderr = \"pipe\"") + } + } +} + +internal fun tomlString(value: String): String = + buildString { + append('"') + for (character in value) { + when (character) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + in '\u0000'..'\u001f', '\u007f' -> append("\\u%04x".format(character.code)) + else -> append(character) + } + } + append('"') + } diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSession.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSession.kt new file mode 100644 index 0000000000..de0c4d4e0f --- /dev/null +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSession.kt @@ -0,0 +1,20 @@ +package ai.rever.boss.sandbox + +import ai.cageforge.CageforgeProcess +import java.util.concurrent.atomic.AtomicBoolean + +/** Owns a root process, its native descendant boundary, and the runtime that created them. */ +class SandboxSession internal constructor( + private val child: CageforgeProcess, + private val runtime: AutoCloseable, +) : AutoCloseable { + val process: Process get() = child + private val closed = AtomicBoolean() + + /** Terminates the complete native boundary, including descendants, before releasing the runtime. */ + override fun close() { + if (closed.compareAndSet(false, true)) { + runtime.use { child.close() } + } + } +} diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionPlan.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionPlan.kt new file mode 100644 index 0000000000..50ffad5f9a --- /dev/null +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionPlan.kt @@ -0,0 +1,23 @@ +package ai.rever.boss.sandbox + +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicBoolean + +/** A reviewable immutable command/policy snapshot. Preparing this value does not launch anything. */ +class SandboxSessionPlan internal constructor( + internal val snapshot: SandboxPolicySnapshot, + internal val permissionDigest: String, + val permissionsJson: String, +) { + val projectDirectory: Path get() = snapshot.projectDirectory + val policyFile: Path get() = snapshot.policyFile + val profile: String get() = snapshot.profile + val argv: List get() = snapshot.argv + val approvalDigest: String get() = snapshot.digest + private val consumed = AtomicBoolean() + + internal fun claim(approvedDigest: String) { + require(approvedDigest == approvalDigest) { "Approval does not match the command and policy shown" } + check(consumed.compareAndSet(false, true)) { "This session plan has already been used; prepare a new session" } + } +} diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java b/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java new file mode 100644 index 0000000000..839e19115e --- /dev/null +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java @@ -0,0 +1,73 @@ +package ai.rever.boss.sandbox; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Arrays; + +/** Runs as the selected root and again as its ordinary, unwrapped descendant. */ +public final class CommandSecurityProbe { + public static void main(String[] args) throws Exception { + String mode = args[0]; + Path project = Path.of(args[1]); + if (mode.equals("heartbeat")) { + while (true) { + Files.writeString(project.resolve("heartbeat"), ".", StandardOpenOption.CREATE, StandardOpenOption.APPEND); + Thread.sleep(50); + } + } + if (mode.equals("tree")) { + childCommand(args, "heartbeat").start(); + System.out.println("TREE_READY"); + System.out.flush(); + Thread.sleep(60000); + throw new AssertionError("The host did not terminate the session"); + } + Path outside = Path.of(args[2]); + int port = Integer.parseInt(args[3]); + if (!"child".equals(System.getenv("BOSS_SANDBOX_VALUE"))) { + throw new AssertionError("Inherited TOML environment was not overridden"); + } + if (!Path.of("").toRealPath().equals(project.toRealPath())) { + throw new AssertionError("Wrong session working directory"); + } + Files.writeString(project.resolve(mode + "-allowed"), "allowed"); + try { + Files.readString(outside.resolve("secret")); + throw new AssertionError("Read escaped the project policy: " + mode); + } catch (IOException expected) { + // Baseline readability is asserted by the host before launching. + } + try { + Files.writeString(outside.resolve(mode + "-escape"), "escaped"); + throw new AssertionError("Write escaped the project policy: " + mode); + } catch (IOException expected) { + // A real writable directory exists here outside the policy. + } + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress("127.0.0.1", port), 1000); + throw new AssertionError("Direct loopback escaped the network policy: " + mode); + } catch (IOException expected) { + // The host establishes a successful connection to this same listener first. + } + if (mode.equals("root")) { + Process child = childCommand(args, "descendant").inheritIO().start(); + if (child.waitFor() != 0) throw new AssertionError("Descendant security probe failed"); + } + System.out.println("SECURITY_OK:" + mode); + } + + private static ProcessBuilder childCommand(String[] args, String mode) { + String executable = Path.of(System.getProperty("java.home"), "bin", + System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java").toString(); + ArrayList command = new ArrayList<>(Arrays.asList(executable, "-cp", + System.getProperty("java.class.path"), CommandSecurityProbe.class.getName())); + command.add(mode); + command.addAll(Arrays.asList(args).subList(1, args.length)); + return new ProcessBuilder(command); + } +} diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt new file mode 100644 index 0000000000..1dfbfd27ab --- /dev/null +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt @@ -0,0 +1,132 @@ +package ai.rever.boss.sandbox + +import ai.cageforge.CageforgeConfigurationException +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CommandNativeSecurityTest { + @get:Rule + val temporary = TemporaryFolder() + + private val launcher = CageforgeSessionLauncher() + private val javaHome = Path.of(System.getProperty("java.home")).toRealPath() + private val executable = javaHome.resolve("bin/" + if (File.separatorChar == '\\') "java.exe" else "java") + private val probeClasspath = + System.getProperty("boss.sandbox.probe.classpath", System.getProperty("java.class.path")) + + @Test(timeout = 90000) + fun rootAndDescendantsEnforceFilesystemAndNetworkPolicy() { + val project = temporary.newFolder("project").toPath() + Files.createDirectory(project.resolve(".git")) + val outside = temporary.newFolder("private").toPath() + Files.writeString(outside.resolve("secret"), "host-secret") + assertEquals("host-secret", Files.readString(outside.resolve("secret"))) + ServerSocket(0, 4, InetAddress.getByName("127.0.0.1")).use { server -> + Socket("127.0.0.1", server.localPort).use { server.accept().close() } + val arguments = listOf("root", project.toString(), outside.toString(), server.localPort.toString()) + val command = command(project, arguments) + val plan = launcher.prepare(command) + // A disk edit cannot replace the already reviewed command/policy snapshot. + Files.writeString(command.policyFile, "malformed replacement") + launcher.launch(plan, plan.approvalDigest).use { session -> + session.process.outputStream.close() + assertTrue(session.process.waitFor(30, TimeUnit.SECONDS), "Native probe timed out") + val output = + session.process.inputStream + .bufferedReader() + .readText() + val errors = + session.process.errorStream + .bufferedReader() + .readText() + assertEquals(0, session.process.exitValue(), errors) + assertTrue(output.contains("SECURITY_OK:root"), output) + assertTrue(output.contains("SECURITY_OK:descendant"), output) + } + } + assertTrue(Files.exists(project.resolve("root-allowed"))) + assertTrue(Files.exists(project.resolve("descendant-allowed"))) + assertFalse(Files.exists(outside.resolve("root-escape"))) + assertFalse(Files.exists(outside.resolve("descendant-escape"))) + } + + @Test(timeout = 90000) + fun closingSessionTerminatesRunningDescendant() { + val project = temporary.newFolder("tree").toPath() + Files.createDirectory(project.resolve(".git")) + val plan = launcher.prepare(command(project, listOf("tree", project.toString()))) + val heartbeat = project.resolve("heartbeat") + launcher.launch(plan, plan.approvalDigest).use { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20) + while ((!Files.exists(heartbeat) || Files.size(heartbeat) < 2) && System.nanoTime() < deadline) { + Thread.sleep(25) + } + assertTrue(Files.exists(heartbeat) && Files.size(heartbeat) >= 2, "Descendant did not start") + } + val stoppedSize = Files.size(heartbeat) + Thread.sleep(300) + assertEquals(stoppedSize, Files.size(heartbeat), "Descendant survived boundary termination") + } + + @Test + fun invalidInheritanceFailsBeforeLaunch() { + val project = temporary.newFolder("invalid").toPath() + val command = command(project, listOf("root")) + Files.writeString(command.policyFile, "[profiles.cli]\ninherits = [\"missing\"]\n") + assertFailsWith { launcher.prepare(command) } + assertFalse(Files.exists(project.resolve("root-allowed"))) + } + + private fun command( + project: Path, + arguments: List, + ): SandboxCommand { + val roots = (probeClasspath.split(File.pathSeparator).map { Path.of(it).toRealPath() } + javaHome).distinct() + val rules = roots.joinToString(",\n") { "{ target = \"absolute\", path = ${quote(it)}, access = \"read\" }" } + val policy = project.resolve("cageforge.toml") + Files.writeString( + policy, + """ + [profiles.base] + workspace_roots = { "." = true } + [profiles.base.filesystem] + mode = "restricted" + rules = [ + { target = "minimal", access = "read" }, + { target = "workspace-root", access = "write" }, + $rules + ] + [profiles.base.network] + mode = "disabled" + [profiles.base.command] + program = ${quote(executable)} + [profiles.base.command.environment] + inherit = "core" + set = { BOSS_SANDBOX_VALUE = "parent" } + [profiles.base.platforms.macos.runtime] + executable_roots = [${quote(javaHome)}] + [profiles.cli] + inherits = ["base"] + [profiles.cli.command.environment] + set = { BOSS_SANDBOX_VALUE = "child" } + """.trimIndent(), + ) + val prefix = listOf(executable.toString(), "-cp", probeClasspath, CommandSecurityProbe::class.java.name) + val argv = prefix + arguments + return SandboxCommand(project, policy, "cli", argv) + } + + private fun quote(path: Path): String = "\"${path.toString().replace("\\", "\\\\").replace("\"", "\\\"")}\"" +} diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandWindowsSetup.kt b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandWindowsSetup.kt new file mode 100644 index 0000000000..5dc207cdea --- /dev/null +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandWindowsSetup.kt @@ -0,0 +1,35 @@ +package ai.rever.boss.sandbox + +import ai.cageforge.WindowsSetup +import ai.cageforge.WindowsSetupState +import java.nio.file.Files +import java.nio.file.Path + +/** Explicit provisioning for disposable CI runners, never called by a command launch. */ +object CommandWindowsSetup { + @JvmStatic + fun main(args: Array) { + check(WindowsSetup.isSupported()) { "This provisioning entry point is Windows-only" } + val marker = Path.of(checkNotNull(System.getenv("BOSS_CAGEFORGE_SETUP_MARKER"))) + when (args.single()) { + "install" -> { + check(WindowsSetup.status() == WindowsSetupState.MISSING) { "Refusing to replace an existing setup" } + // Retain ownership on an interrupted/partial installation so teardown can reconcile it. + Files.createFile(marker) + WindowsSetup.install() + WindowsSetup.verify() + } + + "uninstall" -> { + if (Files.exists(marker)) { + WindowsSetup.uninstall() + Files.delete(marker) + } + } + + else -> { + error("Expected install or uninstall") + } + } + } +} diff --git a/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshotTest.kt b/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshotTest.kt new file mode 100644 index 0000000000..35255aacd3 --- /dev/null +++ b/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshotTest.kt @@ -0,0 +1,101 @@ +package ai.rever.boss.sandbox + +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.nio.charset.CharacterCodingException +import java.nio.file.Files +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class SandboxPolicySnapshotTest { + @get:Rule + val temporary = TemporaryFolder() + + private fun command(argv: List = listOf("node", "script.js")): SandboxCommand { + val project = temporary.newFolder().toPath() + val policy = project.resolve("cageforge.toml") + Files.writeString(policy, "[profiles.node]\nworkspace_roots = { \".\" = true }\n") + return SandboxCommand(project, policy, "node", argv) + } + + @Test + fun `preparation freezes both the file and caller owned arguments`() { + val arguments = mutableListOf("node", "original.js") + val command = command(arguments) + val snapshot = SandboxPolicySnapshot.read(command) + val original = snapshot.toml + arguments[1] = "replacement.js" + Files.writeString(command.policyFile, "[profiles.node]\nnetwork.mode = \"enabled\"\n") + assertEquals(listOf("node", "original.js"), snapshot.argv) + assertEquals(original, snapshot.toml) + assertFailsWith { (snapshot.argv as MutableList)[0] = "other" } + assertNotEquals(snapshot.digest, SandboxPolicySnapshot.read(command).digest) + } + + @Test + fun `approval identifies arguments and project as well as policy`() { + val command = command() + val original = SandboxPolicySnapshot.read(command) + val otherArguments = SandboxPolicySnapshot.read(command.copy(argv = listOf("node", "other.js"))) + val otherProject = SandboxPolicySnapshot.read(command.copy(projectDirectory = temporary.newFolder().toPath())) + assertNotEquals(original.digest, otherArguments.digest) + assertNotEquals(original.digest, otherProject.digest) + assertEquals(original.digest, SandboxPolicySnapshot.read(command).digest) + } + + @Test + fun `explicit empty arguments are retained and control characters cannot inject tables`() { + val arguments = listOf("node", "", "\n[profiles.evil]\n", "C:\\tools\\cli", "\"") + val snapshot = SandboxPolicySnapshot.read(command(arguments)) + val encoded = "args = [\"\", \"\\u000a[profiles.evil]\\u000a\", \"C:\\\\tools\\\\cli\", \"\\\"\"]" + assertTrue(snapshot.toml.contains(encoded)) + assertEquals("\"\\u0009\\u007f\"", tomlString("\t\u007f")) + } + + @Test + fun `a missing policy and invalid commands fail before any native work`() { + val command = command() + assertFailsWith { SandboxPolicySnapshot.read(command.copy(argv = emptyList())) } + assertFailsWith { SandboxPolicySnapshot.read(command.copy(argv = listOf(" "))) } + assertFailsWith { + SandboxPolicySnapshot.read(command.copy(argv = listOf("node", "a\u0000b"))) + } + assertFailsWith { SandboxPolicySnapshot.read(command.copy(profile = "x\n")) } + assertFailsWith { + SandboxPolicySnapshot.read( + command.copy(profile = SandboxPolicySnapshot.LAUNCH_PROFILE), + ) + } + Files.delete(command.policyFile) + assertFailsWith { SandboxPolicySnapshot.read(command) } + } + + @Test + fun `policy reads and command size are bounded`() { + val command = command() + Files.write(command.policyFile, ByteArray(SandboxPolicySnapshot.MAX_POLICY_BYTES + 1) { 32 }) + assertFailsWith { SandboxPolicySnapshot.read(command) } + assertFailsWith { + SandboxPolicySnapshot.read(command.copy(argv = listOf("node", "x".repeat(128 * 1024)))) + } + } + + @Test + fun `invalid UTF-8 policy is rejected instead of silently rewritten`() { + val command = command() + Files.write(command.policyFile, byteArrayOf(0xc3.toByte(), 0x28)) + assertFailsWith { SandboxPolicySnapshot.read(command) } + } + + @Test + fun `wrong approval cannot launch or consume a plan and successful claim cannot be replayed`() { + val snapshot = SandboxPolicySnapshot.read(command()) + val plan = SandboxSessionPlan(snapshot, "native-permissions", "{}") + assertFailsWith { CageforgeSessionLauncher().launch(plan, "wrong") } + plan.claim(plan.approvalDigest) + assertFailsWith { plan.claim(plan.approvalDigest) } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index cc74e07465..4aac1fc3fe 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,6 +36,9 @@ dependencyResolutionManagement { include(":composeApp") include(":server") +// Command sessions do not depend on the microkernel or protoc. +include(":boss-command-sandbox") +project(":boss-command-sandbox").projectDir = file("modules/boss-command-sandbox") // Microkernel architecture modules // protoc and protoc-gen-grpc-java do not publish Windows ARM64 binaries, // so all microkernel modules (which depend on boss-ipc proto generation) From 41d35152847d60e1d542c5901bf1c2da7883a369 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Tue, 22 Sep 2026 12:03:48 +0500 Subject: [PATCH 02/23] ci(sandbox): isolate command enforcement checks by native platform Co-authored-by: codex --- .../workflows/cageforge-command-sessions.yml | 139 +++++++++ ci/run-cageforge-command-linux-vm.sh | 274 ++++++++++++++++++ 2 files changed, 413 insertions(+) create mode 100644 .github/workflows/cageforge-command-sessions.yml create mode 100644 ci/run-cageforge-command-linux-vm.sh diff --git a/.github/workflows/cageforge-command-sessions.yml b/.github/workflows/cageforge-command-sessions.yml new file mode 100644 index 0000000000..fc12ded142 --- /dev/null +++ b/.github/workflows/cageforge-command-sessions.yml @@ -0,0 +1,139 @@ +name: Cageforge command sessions + +on: + push: + branches: [dev] + pull_request: + branches: [dev, main] + paths: + - 'modules/boss-command-sandbox/**' + - 'composeApp/**' + - 'ci/cageforge-command-suite/**' + - 'ci/run-cageforge-command-linux-vm.sh' + - '.github/workflows/cageforge-command-sessions.yml' + - 'gradle/libs.versions.toml' + - 'settings.gradle.kts' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: cageforge-command-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + native-desktop: + name: Command sessions on ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-15, windows-2025] + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + defaults: + run: + shell: bash + env: + BOSS_CAGEFORGE_SETUP_MARKER: ${{ runner.temp }}/boss-command-setup-owned + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-java@v6.0.0 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/setup-gradle@v6 + - name: Build and check session module + run: ./gradlew :boss-command-sandbox:check :boss-command-sandbox:nativeSecurityTestBundle --no-daemon --max-workers=2 --console=plain + - name: Stage self-contained consumer + run: | + set -euo pipefail + stage=$(mktemp -d "$RUNNER_TEMP/boss-command-runtime.XXXXXX") + tar -xzf modules/boss-command-sandbox/build/nativeSecurityTest/boss-command-sandbox-tests.tar.gz -C "$stage" + separator=: + if [[ "$RUNNER_OS" == Windows ]]; then + stage=$(cygpath -m "$stage") + separator=';' + fi + classpath="$stage/classes" + while IFS= read -r -d '' jar; do + classpath="$classpath$separator$jar" + done < <(find "$stage/lib" -maxdepth 1 -type f -name '*.jar' -print0 | sort -z) + echo "BOSS_NATIVE_CP=$classpath" >> "$GITHUB_ENV" + - name: Explicit Windows setup + if: runner.os == 'Windows' + run: java -cp "$BOSS_NATIVE_CP" ai.rever.boss.sandbox.CommandWindowsSetup install + - name: Native root and descendant enforcement + timeout-minutes: 6 + run: | + set -euo pipefail + java -cp "$BOSS_NATIVE_CP" org.junit.runner.JUnitCore ai.rever.boss.sandbox.CommandNativeSecurityTest \ + 2>&1 | tee "$RUNNER_TEMP/boss-command-native.log" + - name: Remove owned Windows setup + if: always() && runner.os == 'Windows' + run: | + if [[ -f "$BOSS_CAGEFORGE_SETUP_MARKER" ]]; then + java -cp "$BOSS_NATIVE_CP" ai.rever.boss.sandbox.CommandWindowsSetup uninstall + fi + - uses: actions/upload-artifact@v7 + if: always() + with: + name: command-session-results-${{ matrix.os }} + path: | + modules/boss-command-sandbox/build/test-results/ + ${{ runner.temp }}/boss-command-native.log + + linux-qemu: + name: Command descendants in prepared Linux QEMU + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + CAGEFORGE_VM_IMAGE_SHA256: 6e40c07ae715f744f84af0bec76415cc1987dd115b4b8de437818561f01a3733 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-java@v6.0.0 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/setup-gradle@v6 + - name: Compile libraries and consumer on the host + run: ./gradlew :boss-command-sandbox:check :boss-command-sandbox:nativeSecurityTestBundle --no-daemon --max-workers=2 --console=plain + - name: Install VM tools + run: | + sudo apt-get update + sudo apt-get install --yes genisoimage qemu-system-x86 qemu-utils + test -c /dev/kvm + sudo chmod 0666 /dev/kvm + - uses: actions/cache@v4 + id: image + with: + path: ${{ runner.temp }}/noble-server-cloudimg-amd64.img + key: boss-cageforge-noble-${{ env.CAGEFORGE_VM_IMAGE_SHA256 }} + - name: Download pinned guest + if: steps.image.outputs.cache-hit != 'true' + run: | + curl --fail --silent --show-error --location --retry 3 --max-filesize 1073741824 --max-time 900 \ + https://cloud-images.ubuntu.com/noble/20260814/noble-server-cloudimg-amd64.img \ + --output "$RUNNER_TEMP/noble-server-cloudimg-amd64.img" + - name: Verify guest image and execute prebuilt consumer + env: + CAGEFORGE_VM_ARTIFACTS: ${{ runner.temp }}/boss-command-vm-logs + run: | + set -euo pipefail + image="$RUNNER_TEMP/noble-server-cloudimg-amd64.img" + printf '%s %s\n' "$CAGEFORGE_VM_IMAGE_SHA256" "$image" | sha256sum --check --status + bash ci/run-cageforge-command-linux-vm.sh --image "$image" \ + --test-bundle "$GITHUB_WORKSPACE/modules/boss-command-sandbox/build/nativeSecurityTest/boss-command-sandbox-tests.tar.gz" \ + 2>&1 | tee "$RUNNER_TEMP/boss-command-native.log" + - uses: actions/upload-artifact@v7 + if: always() + with: + name: command-session-results-linux + path: | + modules/boss-command-sandbox/build/test-results/ + ${{ runner.temp }}/boss-command-native.log + ${{ runner.temp }}/boss-command-vm-logs/ diff --git a/ci/run-cageforge-command-linux-vm.sh b/ci/run-cageforge-command-linux-vm.sh new file mode 100644 index 0000000000..7ce01cf5a0 --- /dev/null +++ b/ci/run-cageforge-command-linux-vm.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +usage() { + echo "Usage: run-cageforge-command-linux-vm.sh --image IMAGE --test-bundle BUNDLE" >&2 + exit 64 +} + +image= +test_bundle= +while (($# > 0)); do + case "$1" in + --image) (($# >= 2)) || usage; image=$2; shift 2 ;; + --test-bundle) (($# >= 2)) || usage; test_bundle=$2; shift 2 ;; + *) usage ;; + esac +done + +[[ -f "$image" ]] || { echo "image is missing: $image" >&2; exit 66; } +[[ -f "$test_bundle" ]] || { echo "native test bundle is missing: $test_bundle" >&2; exit 66; } +for command in genisoimage qemu-img qemu-system-x86_64 ssh ssh-keygen; do + command -v "$command" >/dev/null || { echo "required command is missing: $command" >&2; exit 69; } +done +[[ -c /dev/kvm ]] || { echo "CAGEFORGE_KVM_UNAVAILABLE: /dev/kvm is not available" >&2; exit 86; } + +runner_temp=${RUNNER_TEMP:-/tmp} +work_dir=$(mktemp -d "$runner_temp/boss-cageforge-vm.XXXXXX") +artifacts_dir=${CAGEFORGE_VM_ARTIFACTS:-} +qemu_pid= +ssh_port=$((22000 + RANDOM % 1000)) +ssh_key="$work_dir/guest_ed25519" +overlay="$work_dir/guest-overlay.qcow2" +seed_iso="$work_dir/seed.iso" +test_bundle_iso="$work_dir/test-bundle.iso" +serial_log="$work_dir/qemu-serial.log" +stderr_log="$work_dir/qemu.stderr.log" + +cleanup() { + if [[ -n "$qemu_pid" ]] && kill -0 "$qemu_pid" 2>/dev/null; then + kill "$qemu_pid" 2>/dev/null || true + wait "$qemu_pid" 2>/dev/null || true + fi + if [[ -n "$artifacts_dir" ]]; then + mkdir -p "$artifacts_dir" + # Keep diagnostics, but never export the temporary SSH key, seed ISO, test bundle, + # or guest disk image. The artifact directory is uploaded by CI and must not contain + # credentials or a copy of the guest filesystem. + cp "$serial_log" "$artifacts_dir/qemu-serial.log" 2>/dev/null || true + cp "$stderr_log" "$artifacts_dir/qemu.stderr.log" 2>/dev/null || true + fi + rm -rf "$work_dir" +} +trap cleanup EXIT + +ssh-keygen -q -t ed25519 -N '' -f "$ssh_key" +ssh_public_key_value=$(<"$ssh_key.pub") +cat >"$work_dir/meta-data" <"$work_dir/user-data" <"\$bootstrap_log" + exec >>"\$bootstrap_log" 2>&1 + export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + echo '[boss] configuring user namespaces' + printf '%s\n' 'kernel.unprivileged_userns_clone=1' >"\$sysctl_config" + sysctl -w kernel.unprivileged_userns_clone=1 + for apparmor_sysctl in \ + kernel.apparmor_restrict_unprivileged_userns \ + kernel.apparmor_restrict_unprivileged_unconfined; do + apparmor_path=/proc/sys/\${apparmor_sysctl//./\/} + if [[ -w "\$apparmor_path" ]]; then + sysctl -w "\$apparmor_sysctl=0" + printf '%s=0\n' "\$apparmor_sysctl" >>"\$sysctl_config" + fi + done + sysctl --system + echo '[boss] probing Bubblewrap user, PID, IPC, and network namespaces' + echo "[boss] userns=\$(sysctl -n kernel.unprivileged_userns_clone)" + if [[ -r /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]]; then + echo "[boss] apparmor_userns=\$(sysctl -n kernel.apparmor_restrict_unprivileged_userns)" + else + echo '[boss] apparmor_userns=sysctl-unavailable' + fi + probe_bubblewrap_namespace() { + local namespace=\$1 + local flag=\$2 + local guidance=\$3 + shift 3 + echo "[boss] probing \$namespace namespace (\$flag)" + if ! timeout --kill-after=5s 15s runuser -u ubuntu -- bwrap \ + --die-with-parent --unshare-user "\$@" --ro-bind / / /bin/true; then + echo "[boss] \$namespace namespace probe failed (\$flag): \$guidance" >&2 + return 1 + fi + } + probe_bubblewrap_namespace user --unshare-user \ + 'enable unprivileged user namespaces and permit them in the guest security policy' + probe_bubblewrap_namespace PID --unshare-pid \ + 'the guest kernel must permit CLONE_NEWPID' --unshare-pid --as-pid-1 + probe_bubblewrap_namespace IPC --unshare-ipc \ + 'the guest kernel must permit CLONE_NEWIPC' --unshare-ipc + probe_bubblewrap_namespace network --unshare-net \ + 'the guest kernel must permit CLONE_NEWNET' --unshare-net + echo '[boss] all Bubblewrap namespace probes passed' + echo '[boss] probing nested user namespace isolation (--disable-userns)' + if ! timeout --kill-after=5s 15s runuser -u ubuntu -- bwrap \ + --die-with-parent --unshare-user --disable-userns --ro-bind / / /bin/true; then + echo '[boss] nested user namespace isolation failed: the guest must permit namespaced user.max_user_namespaces lockdown' >&2 + exit 1 + fi + echo '[boss] nested user namespace isolation passed' + echo '[boss] probing root capability removal (--cap-drop ALL)' + if ! timeout --kill-after=5s 15s bwrap \ + --die-with-parent --unshare-user --unshare-pid --as-pid-1 --cap-drop ALL \ + --ro-bind / / --proc /proc /bin/sh -c \ + 'awk '\''/^Cap(Inh|Prm|Eff|Bnd|Amb):/ { found++; if (\$2 != "0000000000000000") bad=1 } END { exit (found == 5 && bad == 0 ? 0 : 1) }'\'' /proc/self/status'; then + echo '[boss] root capability removal failed: the guest must permit capability reduction inside user namespaces' >&2 + exit 1 + fi + echo '[boss] root capability removal passed' + touch /var/lib/boss-cageforge-bootstrap-complete +runcmd: + - [cloud-init-per, once, boss-cageforge-bootstrap, bash, /etc/boss-cageforge-bootstrap.sh] +EOF + +qemu-img create -q -f qcow2 -F qcow2 -o size=16G -b "$image" "$overlay" +genisoimage -quiet -output "$seed_iso" -volid CIDATA -joliet -rock "$work_dir/user-data" "$work_dir/meta-data" +genisoimage -quiet -output "$test_bundle_iso" -volid BOSS_TEST_BUNDLE -joliet -rock \ + -graft-points "native-test-bundle.tar.gz=$test_bundle" + +ssh_guest() { + ssh -q -i "$ssh_key" -p "$ssh_port" -o BatchMode=yes -o ConnectTimeout=2 \ + -o ServerAliveInterval=5 -o ServerAliveCountMax=6 \ + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ubuntu@127.0.0.1 "$@" +} + +start_guest() { + local network_mode=$1 + local network_spec="user,id=net0,hostfwd=tcp:127.0.0.1:$ssh_port-:22" + [[ "$network_mode" == restricted ]] && network_spec="user,id=net0,restrict=on,hostfwd=tcp:127.0.0.1:$ssh_port-:22" + : >"$serial_log" + : >"$stderr_log" + qemu-system-x86_64 \ + -machine q35,accel=kvm -cpu host -no-reboot -smp 2 -m 4096 \ + -drive "if=virtio,format=qcow2,file=$overlay" \ + -drive "if=ide,media=cdrom,readonly=on,format=raw,file=$seed_iso" \ + -drive "if=ide,media=cdrom,readonly=on,format=raw,file=$test_bundle_iso" \ + -netdev "$network_spec" -device virtio-net-pci,netdev=net0 \ + -display none -serial "file:$serial_log" >/dev/null 2>"$stderr_log" & + qemu_pid=$! +} + +stop_guest() { + # Flush the bootstrap marker and package state before asking systemd to power off. + # The next restricted boot reuses this overlay; killing QEMU with dirty state can + # lose the marker and make cloud-init retry package setup without network access. + ssh_guest 'sudo sync; sudo systemctl poweroff --no-block' >/dev/null 2>&1 || true + # Give systemd a bounded grace period to flush the guest before force-killing QEMU. + for _ in {1..30}; do + if ! kill -0 "$qemu_pid" 2>/dev/null; then + wait "$qemu_pid" 2>/dev/null || true + qemu_pid= + return + fi + sleep 1 + done + kill "$qemu_pid" 2>/dev/null || true + wait "$qemu_pid" 2>/dev/null || true + qemu_pid= +} + +wait_for_ssh() { + for _ in {1..120}; do + ssh_guest true >/dev/null 2>&1 && return + kill -0 "$qemu_pid" 2>/dev/null || { tail -n 100 "$serial_log" >&2 || true; cat "$stderr_log" >&2 || true; exit 70; } + sleep 2 + done + tail -n 100 "$serial_log" >&2 || true + cat "$stderr_log" >&2 || true + exit 70 +} + +wait_for_bootstrap() { + local wait_for_cloud_init=$1 + for _ in {1..180}; do + if ssh_guest 'sudo test -f /var/lib/boss-cageforge-bootstrap-complete' >/dev/null 2>&1; then + # The marker is written by the final cloud-init command. Wait until that + # command has returned before powering off; otherwise the next restricted + # boot may resume package setup and leave the guest unresponsive. + if [[ "$wait_for_cloud_init" != true ]] || ssh_guest 'cloud-init status --wait >/dev/null 2>&1'; then + return + fi + fi + if ssh_guest 'systemctl is-failed --quiet cloud-final.service' >/dev/null 2>&1; then + print_guest_bootstrap_diagnostics >&2 || true + exit 70 + fi + sleep 2 + done + print_guest_bootstrap_diagnostics >&2 || true + exit 70 +} + +print_guest_bootstrap_diagnostics() { + echo '--- guest Cageforge bootstrap log ---' + ssh_guest 'sudo tail -n 160 /var/log/boss-cageforge-bootstrap.log || true' || true + echo '--- guest cloud-init diagnostics ---' + ssh_guest 'sudo cloud-init status --long || true +sudo systemctl show cloud-final.service --property=ActiveState,SubState,ExecMainStatus --no-pager || true +sudo journalctl -u cloud-final.service -n 80 --no-pager || true +sudo grep -Ei "error|fail|unexpected|exit code|traceback" /var/log/cloud-init-output.log /var/log/cloud-init.log | tail -n 80 || true +sudo dpkg --audit || true' || true +} + +echo '[boss] bootstrapping native guest in unrestricted mode' +start_guest unrestricted +wait_for_ssh +wait_for_bootstrap true +stop_guest + +echo '[boss] running native security smoke in restricted guest' +start_guest restricted +wait_for_ssh +wait_for_bootstrap false +set +e +ssh_guest timeout --kill-after=10s 180s bash -s <<'EOF' +set -euo pipefail +bundle_root=$(mktemp -d /home/ubuntu/boss-command-bundle.XXXXXX) +sudo mkdir -p /mnt/boss-test-bundle +sudo mount -L BOSS_TEST_BUNDLE -o ro /mnt/boss-test-bundle +tar --extract \ + --file=/mnt/boss-test-bundle/native-test-bundle.tar.gz \ + --directory="$bundle_root" \ + --no-same-owner +export CAGEFORGE_NATIVE_TEST_BUNDLE_ROOT="$bundle_root" +bash "$bundle_root/ci/cageforge-command-suite/run.sh" +sudo umount /mnt/boss-test-bundle +EOF +result=$? +set -e +if [[ "$result" -ne 0 ]]; then + print_guest_bootstrap_diagnostics >&2 || true +fi +stop_guest +if [[ "$result" -ne 0 ]]; then + tail -n 160 "$serial_log" >&2 || true + cat "$stderr_log" >&2 || true +fi +exit "$result" From f846dcc32a6a5ce57e492465f76d25b2e7556d87 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Tue, 22 Sep 2026 12:09:43 +0500 Subject: [PATCH 03/23] fix(test): preserve absolute runtime roots in native probes Co-authored-by: codex --- .github/workflows/cageforge-command-sessions.yml | 6 ++++-- .../ai/rever/boss/sandbox/CommandNativeSecurityTest.kt | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cageforge-command-sessions.yml b/.github/workflows/cageforge-command-sessions.yml index fc12ded142..54e587a451 100644 --- a/.github/workflows/cageforge-command-sessions.yml +++ b/.github/workflows/cageforge-command-sessions.yml @@ -34,8 +34,6 @@ jobs: defaults: run: shell: bash - env: - BOSS_CAGEFORGE_SETUP_MARKER: ${{ runner.temp }}/boss-command-setup-owned steps: - uses: actions/checkout@v7 with: @@ -64,6 +62,8 @@ jobs: echo "BOSS_NATIVE_CP=$classpath" >> "$GITHUB_ENV" - name: Explicit Windows setup if: runner.os == 'Windows' + env: + BOSS_CAGEFORGE_SETUP_MARKER: ${{ runner.temp }}/boss-command-setup-owned run: java -cp "$BOSS_NATIVE_CP" ai.rever.boss.sandbox.CommandWindowsSetup install - name: Native root and descendant enforcement timeout-minutes: 6 @@ -73,6 +73,8 @@ jobs: 2>&1 | tee "$RUNNER_TEMP/boss-command-native.log" - name: Remove owned Windows setup if: always() && runner.os == 'Windows' + env: + BOSS_CAGEFORGE_SETUP_MARKER: ${{ runner.temp }}/boss-command-setup-owned run: | if [[ -f "$BOSS_CAGEFORGE_SETUP_MARKER" ]]; then java -cp "$BOSS_NATIVE_CP" ai.rever.boss.sandbox.CommandWindowsSetup uninstall diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt index 1dfbfd27ab..14560e61d2 100644 --- a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt @@ -93,7 +93,8 @@ class CommandNativeSecurityTest { project: Path, arguments: List, ): SandboxCommand { - val roots = (probeClasspath.split(File.pathSeparator).map { Path.of(it).toRealPath() } + javaHome).distinct() + val classpathRoots = probeClasspath.split(File.pathSeparator).map { Path.of(it).toRealPath() } + val roots = (classpathRoots + listOf(javaHome)).distinct() val rules = roots.joinToString(",\n") { "{ target = \"absolute\", path = ${quote(it)}, access = \"read\" }" } val policy = project.resolve("cageforge.toml") Files.writeString( From eec02420ddda5de305ce74e807c22700bea1582f Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Tue, 22 Sep 2026 12:15:01 +0500 Subject: [PATCH 04/23] fix(ci): normalize Windows archive paths before extraction Co-authored-by: codex --- .github/workflows/cageforge-command-sessions.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cageforge-command-sessions.yml b/.github/workflows/cageforge-command-sessions.yml index 54e587a451..1247f3dfb2 100644 --- a/.github/workflows/cageforge-command-sessions.yml +++ b/.github/workflows/cageforge-command-sessions.yml @@ -48,15 +48,23 @@ jobs: - name: Stage self-contained consumer run: | set -euo pipefail - stage=$(mktemp -d "$RUNNER_TEMP/boss-command-runtime.XXXXXX") + runner_temp="$RUNNER_TEMP" + if [[ "$RUNNER_OS" == Windows ]]; then + runner_temp=$(cygpath -u "$runner_temp") + fi + stage=$(mktemp -d "$runner_temp/boss-command-runtime.XXXXXX") tar -xzf modules/boss-command-sandbox/build/nativeSecurityTest/boss-command-sandbox-tests.tar.gz -C "$stage" separator=: + classes="$stage/classes" if [[ "$RUNNER_OS" == Windows ]]; then - stage=$(cygpath -m "$stage") + classes=$(cygpath -m "$classes") separator=';' fi - classpath="$stage/classes" + classpath="$classes" while IFS= read -r -d '' jar; do + if [[ "$RUNNER_OS" == Windows ]]; then + jar=$(cygpath -m "$jar") + fi classpath="$classpath$separator$jar" done < <(find "$stage/lib" -maxdepth 1 -type f -name '*.jar' -print0 | sort -z) echo "BOSS_NATIVE_CP=$classpath" >> "$GITHUB_ENV" From 5bd86c19e152a0a9e0a6c6782a63356850120780 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Tue, 22 Sep 2026 12:22:13 +0500 Subject: [PATCH 05/23] fix(test): restrict macOS runtime roots to macOS fixtures Co-authored-by: codex --- .../ai/rever/boss/sandbox/CommandNativeSecurityTest.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt index 14560e61d2..fde5b42ec8 100644 --- a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt @@ -96,6 +96,13 @@ class CommandNativeSecurityTest { val classpathRoots = probeClasspath.split(File.pathSeparator).map { Path.of(it).toRealPath() } val roots = (classpathRoots + listOf(javaHome)).distinct() val rules = roots.joinToString(",\n") { "{ target = \"absolute\", path = ${quote(it)}, access = \"read\" }" } + // Platform overlays are validated using their target's path syntax, even on another OS. + val macosRuntime = + if (System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) { + "[profiles.base.platforms.macos.runtime]\nexecutable_roots = [${quote(javaHome)}]" + } else { + "" + } val policy = project.resolve("cageforge.toml") Files.writeString( policy, @@ -116,8 +123,7 @@ class CommandNativeSecurityTest { [profiles.base.command.environment] inherit = "core" set = { BOSS_SANDBOX_VALUE = "parent" } - [profiles.base.platforms.macos.runtime] - executable_roots = [${quote(javaHome)}] + $macosRuntime [profiles.cli] inherits = ["base"] [profiles.cli.command.environment] From 3aba4925bdd128a92f7a74a5c3e8db2b414ddb40 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Tue, 22 Sep 2026 12:24:49 +0500 Subject: [PATCH 06/23] build(sandbox): upgrade Cageforge Java to 0.7.0 Co-authored-by: codex --- docs/cageforge-command-sessions.md | 2 +- gradle/libs.versions.toml | 2 +- .../kotlin/ai/rever/boss/sandbox/CageforgeSessionLauncher.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/cageforge-command-sessions.md b/docs/cageforge-command-sessions.md index 4f781ebec5..023c78728b 100644 --- a/docs/cageforge-command-sessions.md +++ b/docs/cageforge-command-sessions.md @@ -11,7 +11,7 @@ and plugin execution are outside this feature and retain their existing behavior A session selects a project directory, a TOML file, a named CLI profile, and an executable with separate arguments. BOSS reads the policy once, adds a final child profile containing that exact command and working directory, then asks Cageforge -Java 0.6.1 for its permission request. Approval identifies this immutable snapshot. +Java 0.7.0 for its permission request. Approval identifies this immutable snapshot. Edits to the TOML file affect the next preparation, never a running session. A plan can launch once. A failed launch has no unsandboxed retry or fallback. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2858a2e5e4..fff5c3aafe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ detekt = "1.23.8" essenty = "2.6.0" junit-jupiter = "6.1.3" clikt = "5.1.0" -cageforge = "0.6.1" +cageforge = "0.7.0" kotlin = "2.4.10" kotlinx-coroutines = "1.11.0" ktlint-gradle = "14.2.0" diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/CageforgeSessionLauncher.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/CageforgeSessionLauncher.kt index 6ee8fdbe24..162e834166 100644 --- a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/CageforgeSessionLauncher.kt +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/CageforgeSessionLauncher.kt @@ -11,7 +11,7 @@ import ai.cageforge.WindowsSetupState class CageforgeSessionLauncher { fun prepare(command: SandboxCommand): SandboxSessionPlan { val snapshot = SandboxPolicySnapshot.read(command) - // In 0.6.1 checkToml also initializes JNI. permissionRequest expects it loaded. + // checkToml also initializes JNI; permissionRequest expects it loaded. Cageforge.checkToml( snapshot.toml, SandboxPolicySnapshot.LAUNCH_PROFILE, From e3cf796d8ff1f83c01c6c61a55c6d0c4e6a87d54 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Tue, 22 Sep 2026 12:36:45 +0500 Subject: [PATCH 07/23] feat(sandbox): manage bounded session pipes and termination Co-authored-by: codex --- modules/boss-command-sandbox/build.gradle.kts | 2 + .../boss/sandbox/ManagedSandboxSession.kt | 136 ++++++++++++++++++ .../ai/rever/boss/sandbox/SandboxSession.kt | 7 + .../boss/sandbox/SandboxSessionOutput.kt | 25 ++++ .../boss/sandbox/CommandNativeSecurityTest.kt | 27 ++-- .../ai/rever/boss/sandbox/SessionIoProbe.java | 22 +++ .../boss/sandbox/ManagedSandboxSessionTest.kt | 82 +++++++++++ 7 files changed, 287 insertions(+), 14 deletions(-) create mode 100644 modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/ManagedSandboxSession.kt create mode 100644 modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionOutput.kt create mode 100644 modules/boss-command-sandbox/src/test/java/ai/rever/boss/sandbox/SessionIoProbe.java create mode 100644 modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/ManagedSandboxSessionTest.kt diff --git a/modules/boss-command-sandbox/build.gradle.kts b/modules/boss-command-sandbox/build.gradle.kts index a2fc9b4284..3af88aeec2 100644 --- a/modules/boss-command-sandbox/build.gradle.kts +++ b/modules/boss-command-sandbox/build.gradle.kts @@ -8,6 +8,7 @@ java { dependencies { implementation(libs.cageforge.java) + api(libs.kotlinx.coroutines.core) testImplementation(libs.kotlin.test.junit) } @@ -45,6 +46,7 @@ tasks.withType().configureEach { val testHome = layout.buildDirectory.dir("test-home/$name") systemProperty("user.home", testHome.get().asFile.absolutePath) doFirst { + systemProperty("boss.sandbox.test.classpath", classpath.asPath) testHome.get().asFile.deleteRecursively() testHome.get().asFile.mkdirs() } diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/ManagedSandboxSession.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/ManagedSandboxSession.kt new file mode 100644 index 0000000000..d578aa9738 --- /dev/null +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/ManagedSandboxSession.kt @@ -0,0 +1,136 @@ +package ai.rever.boss.sandbox + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withContext +import java.io.InputStream + +/** Concurrently drains both pipes; stopping always targets the native process boundary. */ +class ManagedSandboxSession internal constructor( + private val process: Process, + private val boundary: AutoCloseable, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val inputMutex = Mutex() + private val boundaryLock = Any() + private var boundaryClosed = false + private val completed = CompletableDeferred() + private val mutableOutput = MutableStateFlow(SandboxSessionOutput()) + val output: StateFlow = mutableOutput.asStateFlow() + + init { + scope.launch { + val result = runCatching { runProcess() } + mutableOutput.update { + it.copy(running = false, exitCode = result.getOrNull(), failure = result.exceptionOrNull()) + } + completed.complete(Unit) + scope.cancel() + } + } + + suspend fun awaitCompletion(): SandboxSessionOutput { + completed.await() + return output.value + } + + suspend fun sendInput(text: String) { + val bytes = text.toByteArray(Charsets.UTF_8) + require(bytes.size <= MAX_INPUT_BYTES) { "Input exceeds 16 KiB" } + withInputLock { + process.outputStream.write(bytes) + process.outputStream.flush() + } + } + + suspend fun closeInput() = withInputLock { process.outputStream.close() } + + /** Waits for confirmed cleanup, rather than reporting success after merely requesting a stop. */ + suspend fun stop() { + withContext(NonCancellable + Dispatchers.IO) { + terminate() + completed.await() + } + } + + private suspend fun withInputLock(action: () -> Unit) { + check(inputMutex.tryLock()) { "Session input is busy" } + try { + check(output.value.running) { "Session has finished" } + withContext(Dispatchers.IO) { action() } + } finally { + inputMutex.unlock() + } + } + + private suspend fun runProcess(): Int = + coroutineScope { + // Close inside the scope body: native close releases blocking readers before + // coroutineScope waits for its children, including on wait/read failure. + AutoCloseable(::closeBoundary).use { + val stdout = async { pump(process.inputStream, false) } + val stderr = async { pump(process.errorStream, true) } + val code = process.waitFor() + // End detached descendants too; they can otherwise keep output pipes open. + terminate() + stdout.await() + stderr.await() + code + } + } + + private fun pump( + stream: InputStream, + stderr: Boolean, + ) { + runCatching { + stream.reader(Charsets.UTF_8).use { reader -> + val buffer = CharArray(4096) + var count = reader.read(buffer) + while (count >= 0) { + val text = String(buffer, 0, count) + mutableOutput.update { + if (stderr) { + it.copy(stderr = it.stderr.append(text)) + } else { + it.copy(stdout = it.stdout.append(text)) + } + } + count = reader.read(buffer) + } + } + }.onFailure { terminate() }.getOrThrow() + } + + private fun terminate() { + synchronized(boundaryLock) { + if (!boundaryClosed) process.destroyForcibly() + } + } + + private fun closeBoundary() { + synchronized(boundaryLock) { + try { + boundary.close() + } finally { + boundaryClosed = true + } + } + } + + companion object { + const val MAX_INPUT_BYTES = 16 * 1024 + } +} diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSession.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSession.kt index de0c4d4e0f..9dbb667f98 100644 --- a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSession.kt +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSession.kt @@ -10,6 +10,13 @@ class SandboxSession internal constructor( ) : AutoCloseable { val process: Process get() = child private val closed = AtomicBoolean() + private val managed = AtomicBoolean() + + /** Transfers pipe consumption to a bounded session worker. Call once, before reading any pipe. */ + fun manage(): ManagedSandboxSession { + check(!closed.get() && managed.compareAndSet(false, true)) { "Session is closed or already managed" } + return ManagedSandboxSession(process, this) + } /** Terminates the complete native boundary, including descendants, before releasing the runtime. */ override fun close() { diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionOutput.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionOutput.kt new file mode 100644 index 0000000000..0b1a67d31d --- /dev/null +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionOutput.kt @@ -0,0 +1,25 @@ +package ai.rever.boss.sandbox + +/** A bounded tail, with an explicit count of characters no longer retained. */ +data class SandboxOutputTail( + val text: String = "", + val discardedCharacters: Long = 0, +) { + internal fun append(chunk: String): SandboxOutputTail { + val combined = text + chunk + val discarded = (combined.length - MAX_CHARACTERS).coerceAtLeast(0) + return SandboxOutputTail(combined.drop(discarded), discardedCharacters + discarded) + } + + companion object { + const val MAX_CHARACTERS = 65_536 + } +} + +data class SandboxSessionOutput( + val stdout: SandboxOutputTail = SandboxOutputTail(), + val stderr: SandboxOutputTail = SandboxOutputTail(), + val running: Boolean = true, + val exitCode: Int? = null, + val failure: Throwable? = null, +) diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt index fde5b42ec8..574f196058 100644 --- a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt @@ -1,6 +1,7 @@ package ai.rever.boss.sandbox import ai.cageforge.CageforgeConfigurationException +import kotlinx.coroutines.runBlocking import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder @@ -40,20 +41,18 @@ class CommandNativeSecurityTest { val plan = launcher.prepare(command) // A disk edit cannot replace the already reviewed command/policy snapshot. Files.writeString(command.policyFile, "malformed replacement") - launcher.launch(plan, plan.approvalDigest).use { session -> - session.process.outputStream.close() - assertTrue(session.process.waitFor(30, TimeUnit.SECONDS), "Native probe timed out") - val output = - session.process.inputStream - .bufferedReader() - .readText() - val errors = - session.process.errorStream - .bufferedReader() - .readText() - assertEquals(0, session.process.exitValue(), errors) - assertTrue(output.contains("SECURITY_OK:root"), output) - assertTrue(output.contains("SECURITY_OK:descendant"), output) + runBlocking { + val session = launcher.launch(plan, plan.approvalDigest).manage() + try { + session.closeInput() + val output = session.awaitCompletion() + assertEquals(null, output.failure, output.failure?.stackTraceToString()) + assertEquals(0, output.exitCode, output.stderr.text) + assertTrue(output.stdout.text.contains("SECURITY_OK:root"), output.stdout.text) + assertTrue(output.stdout.text.contains("SECURITY_OK:descendant"), output.stdout.text) + } finally { + session.stop() + } } } assertTrue(Files.exists(project.resolve("root-allowed"))) diff --git a/modules/boss-command-sandbox/src/test/java/ai/rever/boss/sandbox/SessionIoProbe.java b/modules/boss-command-sandbox/src/test/java/ai/rever/boss/sandbox/SessionIoProbe.java new file mode 100644 index 0000000000..d08dc829c2 --- /dev/null +++ b/modules/boss-command-sandbox/src/test/java/ai/rever/boss/sandbox/SessionIoProbe.java @@ -0,0 +1,22 @@ +package ai.rever.boss.sandbox; + +import java.nio.charset.StandardCharsets; + +public final class SessionIoProbe { + public static void main(String[] args) throws Exception { + if (args[0].equals("flood")) { + for (int i = 0; i < 256; i++) { + System.out.print("o".repeat(1024)); + System.err.print("e".repeat(1024)); + } + System.out.print("STDOUT_END"); + System.err.print("STDERR_END"); + } else if (args[0].equals("echo")) { + System.out.write(System.in.readAllBytes()); + } else { + System.out.write("READY".getBytes(StandardCharsets.UTF_8)); + System.out.flush(); + Thread.sleep(60000); + } + } +} diff --git a/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/ManagedSandboxSessionTest.kt b/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/ManagedSandboxSessionTest.kt new file mode 100644 index 0000000000..baa33bc214 --- /dev/null +++ b/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/ManagedSandboxSessionTest.kt @@ -0,0 +1,82 @@ +package ai.rever.boss.sandbox + +import kotlinx.coroutines.runBlocking +import org.junit.Test +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ManagedSandboxSessionTest { + @Test(timeout = 20000) + fun `both pipes drain beyond retention capacity without blocking the child`() = + runBlocking { + val (session, closed) = start("flood") + try { + val output = session.awaitCompletion() + assertNull(output.failure) + assertEquals(0, output.exitCode) + assertTrue(closed.get()) + assertEquals(SandboxOutputTail.MAX_CHARACTERS, output.stdout.text.length) + assertEquals(SandboxOutputTail.MAX_CHARACTERS, output.stderr.text.length) + assertTrue(output.stdout.text.endsWith("STDOUT_END")) + assertTrue(output.stderr.text.endsWith("STDERR_END")) + assertTrue(output.stdout.discardedCharacters > 0) + assertTrue(output.stderr.discardedCharacters > 0) + } finally { + session.stop() + } + } + + @Test(timeout = 20000) + fun `input preserves Unicode and EOF and oversized input is rejected`() = + runBlocking { + val (session, _) = start("echo") + try { + assertFailsWith { + session.sendInput("x".repeat(ManagedSandboxSession.MAX_INPUT_BYTES + 1)) + } + session.sendInput("Привет\nλ\n") + session.closeInput() + val output = session.awaitCompletion() + assertNull(output.failure) + assertEquals("Привет\nλ\n", output.stdout.text) + } finally { + session.stop() + } + } + + @Test(timeout = 20000) + fun `stop waits for boundary cleanup and can be repeated`() = + runBlocking { + val (session, closed) = start("wait") + session.stop() + session.stop() + assertTrue(closed.get()) + assertFalse(session.output.value.running) + } + + private fun start(mode: String): Pair { + val javaName = if (System.getProperty("os.name").startsWith("Windows")) "java.exe" else "java" + val java = Path.of(System.getProperty("java.home"), "bin", javaName).toString() + val process = + ProcessBuilder( + java, + "-cp", + System.getProperty("boss.sandbox.test.classpath"), + SessionIoProbe::class.java.name, + mode, + ).start() + val closed = AtomicBoolean() + val owner = + AutoCloseable { + process.destroyForcibly() + process.waitFor() + closed.set(true) + } + return ManagedSandboxSession(process, owner) to closed + } +} From d23c2a3ad6ec5931d206c918a256079cc53f3813 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Tue, 22 Sep 2026 12:36:45 +0500 Subject: [PATCH 08/23] feat(sandbox): add single-use and app-lifetime consent Co-authored-by: codex --- docs/cageforge-command-sessions.md | 28 ++++ .../rever/boss/sandbox/SandboxConsentQueue.kt | 122 +++++++++++++++++ .../boss/sandbox/SandboxPermissionReview.kt | 38 ++++++ .../boss/sandbox/SandboxConsentQueueTest.kt | 127 ++++++++++++++++++ 4 files changed, 315 insertions(+) create mode 100644 modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxConsentQueue.kt create mode 100644 modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxPermissionReview.kt create mode 100644 modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxConsentQueueTest.kt diff --git a/docs/cageforge-command-sessions.md b/docs/cageforge-command-sessions.md index 023c78728b..284197582f 100644 --- a/docs/cageforge-command-sessions.md +++ b/docs/cageforge-command-sessions.md @@ -51,6 +51,34 @@ verifies existing setup; it never invokes UAC implicitly. The session uses pipes. This does not promise a PTY, terminal emulation, resize support, or compatibility with CLIs that require a controlling terminal. +## Additional permission requests + +Cageforge 0.7.0 supports explicit permission escalation. The integration must use +its `requestEscalation`, `approveEscalation` and `launchEscalated` APIs, not rewrite +the running process's policy. The native contract requires a new immutable sandbox; +relaunching a session must stop its previous process boundary first. It is not an +in-place permission change or a promise to preserve an agent's in-memory state. + +The MCP request must identify the command, project/session, additional filesystem +or network capabilities, and a human-readable reason. The agent requests access; +it never supplies the approval decision. BOSS must show the exact command and +resolved native permissions in the GUI before launching anything with more access. +Ordinary MCP tool trust is not authorization for arbitrary sandbox capabilities. + +The host consent queue implements these two scopes: + +- **Allow once**: one execution of the reviewed command and policy. Replaying the + authorization is rejected, including after a failed launch. +- **Allow until BOSS closes**: remember only the exact reviewed command, policy + and capabilities in this application process. Changed requests still prompt. + Nothing is persisted; restarting BOSS asks again. Revocation invalidates pending + responses and unused authorizations as well as remembered decisions. + +Denial, timeout, cancellation, queue overflow and application shutdown never grant +permission. A stale dialog cannot approve the next request. This consent mechanism +and its unit tests are implemented in the session module; native escalation and +GUI/MCP wiring remain integration work, not verified end-to-end functionality. + ## Verification work The command session module separates immutable preparation and native launch from diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxConsentQueue.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxConsentQueue.kt new file mode 100644 index 0000000000..0f6d5e0c75 --- /dev/null +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxConsentQueue.kt @@ -0,0 +1,122 @@ +package ai.rever.boss.sandbox + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Native capability consent, independent of tool-wide MCP routing permission. + * Only the host GUI calls decide/revoke. No approval or trust is written to disk. + */ +class SandboxConsentQueue( + private val timeoutMs: Long = 120_000, + private val capacity: Int = 8, +) : AutoCloseable { + private class Pending( + val request: SandboxConsentRequest, + val answer: CompletableDeferred = CompletableDeferred(), + ) + + private val lock = Any() + private val pending = linkedMapOf() + private val trusted = mutableSetOf() + private val mutableRequests = MutableStateFlow>(emptyList()) + val requests: StateFlow> = mutableRequests.asStateFlow() + private var revision = 0L + private var closed = false + + init { + require(timeoutMs > 0 && capacity > 0) + } + + suspend fun request(review: SandboxPermissionReview): SandboxConsentPermit? { + currentCoroutineContext().ensureActive() + val item: Pending + val requestedRevision: Long + synchronized(lock) { + check(!closed) { "Sandbox consent is closed" } + if (review.approvalDigest in trusted) return SandboxConsentPermit(lock, review.approvalDigest, revision) + check(pending.size < capacity) { "Sandbox approval queue is full" } + requestedRevision = revision + item = Pending(SandboxConsentRequest(review)) + pending[item.request.id] = item + publish() + } + return try { + val choice = withTimeoutOrNull(timeoutMs) { item.answer.await() } + val approved = choice == SandboxConsentChoice.ONCE || choice == SandboxConsentChoice.UNTIL_APP_CLOSES + currentCoroutineContext().ensureActive() + synchronized(lock) { + if (closed || revision != requestedRevision || !approved) { + null + } else { + if (choice == SandboxConsentChoice.UNTIL_APP_CLOSES) { + check(trusted.size < MAX_TRUSTED_REVIEWS) { "Revoke session approvals before adding more" } + trusted.add(review.approvalDigest) + } + SandboxConsentPermit(lock, review.approvalDigest, revision) + } + } + } finally { + synchronized(lock) { + pending.remove(item.request.id) + publish() + } + } + } + + /** A stale dialog or a double click cannot decide a different request. */ + fun decide( + requestId: String, + choice: SandboxConsentChoice, + ): Boolean = + synchronized(lock) { + val accepted = pending[requestId]?.answer?.complete(choice) ?: false + publish() + accepted + } + + fun consume( + permit: SandboxConsentPermit, + review: SandboxPermissionReview, + ) { + synchronized(lock) { + check(!closed && permit.issuer === lock && permit.revision == revision) { + "Sandbox approval has been revoked" + } + require(permit.digest == review.approvalDigest) { "Sandbox approval does not match this request" } + check(permit.consumed.compareAndSet(false, true)) { "Sandbox approval has already been used" } + } + } + + fun revoke() { + synchronized(lock) { invalidate() } + } + + override fun close() { + synchronized(lock) { + closed = true + invalidate() + } + } + + private fun invalidate() { + revision++ + trusted.clear() + pending.values.forEach { it.answer.complete(SandboxConsentChoice.DENY) } + pending.clear() + publish() + } + + private fun publish() { + mutableRequests.value = pending.values.filterNot { it.answer.isCompleted }.map { it.request } + } + + companion object { + private const val MAX_TRUSTED_REVIEWS = 256 + } +} diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxPermissionReview.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxPermissionReview.kt new file mode 100644 index 0000000000..409ecc6df2 --- /dev/null +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxPermissionReview.kt @@ -0,0 +1,38 @@ +package ai.rever.boss.sandbox + +import java.util.Collections +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean + +/** Host-created review data. Agents may request capabilities, but cannot create an approval. */ +class SandboxPermissionReview internal constructor( + val approvalDigest: String, + val projectDirectory: String, + argv: List, + val permissionsJson: String, + val reason: String, + val requiresRestart: Boolean, +) { + val argv: List = Collections.unmodifiableList(argv.toList()) +} + +class SandboxConsentRequest internal constructor( + val review: SandboxPermissionReview, +) { + val id: String = UUID.randomUUID().toString() +} + +enum class SandboxConsentChoice { + ONCE, + UNTIL_APP_CLOSES, + DENY, +} + +/** A single-use authorization, rechecked immediately before native launch. */ +class SandboxConsentPermit internal constructor( + internal val issuer: Any, + internal val digest: String, + internal val revision: Long, +) { + internal val consumed = AtomicBoolean() +} diff --git a/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxConsentQueueTest.kt b/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxConsentQueueTest.kt new file mode 100644 index 0000000000..c13dbe2fda --- /dev/null +++ b/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxConsentQueueTest.kt @@ -0,0 +1,127 @@ +package ai.rever.boss.sandbox + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.runBlocking +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SandboxConsentQueueTest { + @Test(timeout = 5000) + fun `once approval is single use and repeats prompt`() = + runBlocking { + SandboxConsentQueue().use { queue -> + val review = review("first") + val answer = async(start = CoroutineStart.UNDISPATCHED) { queue.request(review) } + val id = + queue.requests.value + .single() + .id + assertTrue(queue.decide(id, SandboxConsentChoice.ONCE)) + assertFalse(queue.decide(id, SandboxConsentChoice.ONCE)) + val permit = assertNotNull(answer.await()) + assertFailsWith { queue.consume(permit, review("changed")) } + queue.consume(permit, review) + assertFailsWith { queue.consume(permit, review) } + val again = async(start = CoroutineStart.UNDISPATCHED) { queue.request(review) } + assertEquals(1, queue.requests.value.size) + assertFalse(queue.decide(id, SandboxConsentChoice.ONCE)) + again.cancelAndJoin() + assertTrue(queue.requests.value.isEmpty()) + } + } + + @Test(timeout = 5000) + fun `app lifetime trusts only exact review and never another queue`() = + runBlocking { + SandboxConsentQueue().use { queue -> + val review = review("first") + val answer = async(start = CoroutineStart.UNDISPATCHED) { queue.request(review) } + queue.decide( + queue.requests.value + .single() + .id, + SandboxConsentChoice.UNTIL_APP_CLOSES, + ) + queue.consume(assertNotNull(answer.await()), review) + val reused = assertNotNull(queue.request(review)) + assertTrue(queue.requests.value.isEmpty()) + val changed = async(start = CoroutineStart.UNDISPATCHED) { queue.request(review("other")) } + assertEquals(1, queue.requests.value.size) + changed.cancelAndJoin() + SandboxConsentQueue().use { nextApp -> + assertFailsWith { nextApp.consume(reused, review) } + val afterRestart = async(start = CoroutineStart.UNDISPATCHED) { nextApp.request(review) } + assertEquals(1, nextApp.requests.value.size) + afterRestart.cancelAndJoin() + } + queue.revoke() + assertFailsWith { queue.consume(reused, review) } + } + } + + @Test(timeout = 5000) + fun `revocation beats already answered prompt before authorization returns`() = + runBlocking { + SandboxConsentQueue().use { queue -> + val answer = async(start = CoroutineStart.UNDISPATCHED) { queue.request(review("first")) } + queue.decide( + queue.requests.value + .single() + .id, + SandboxConsentChoice.UNTIL_APP_CLOSES, + ) + queue.revoke() + assertNull(answer.await()) + assertTrue(queue.requests.value.isEmpty()) + } + } + + @Test(timeout = 5000) + fun `timeout deny overflow cancellation and shutdown withhold authorization`() = + runBlocking { + SandboxConsentQueue(timeoutMs = 20, capacity = 1).use { queue -> + val answer = async(start = CoroutineStart.UNDISPATCHED) { queue.request(review("first")) } + assertFailsWith { queue.request(review("overflow")) } + assertNull(answer.await()) + assertTrue(queue.requests.value.isEmpty()) + val denied = async(start = CoroutineStart.UNDISPATCHED) { queue.request(review("denied")) } + queue.decide( + queue.requests.value + .single() + .id, + SandboxConsentChoice.DENY, + ) + assertNull(denied.await()) + val cancelled = async(start = CoroutineStart.UNDISPATCHED) { queue.request(review("cancelled")) } + queue.decide( + queue.requests.value + .single() + .id, + SandboxConsentChoice.UNTIL_APP_CLOSES, + ) + cancelled.cancelAndJoin() + val repeated = async(start = CoroutineStart.UNDISPATCHED) { queue.request(review("cancelled")) } + assertEquals(1, queue.requests.value.size) + queue.close() + assertNull(repeated.await()) + assertFailsWith { queue.request(review("closed")) } + } + } + + private fun review(digest: String) = + SandboxPermissionReview( + approvalDigest = digest, + projectDirectory = "/project", + argv = listOf("tool", "argument"), + permissionsJson = "{}", + reason = "Read input", + requiresRestart = true, + ) +} From aba1179dbc71f19c9fb0a89adbc139e091957c01 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Wed, 23 Sep 2026 14:13:23 +0500 Subject: [PATCH 09/23] test(sandbox): stream native progress and isolate probe classpath Co-authored-by: codex --- .../workflows/cageforge-command-sessions.yml | 9 ++- ci/cageforge-command-suite/run.sh | 3 +- ci/run-cageforge-command-linux-vm.sh | 10 ++- modules/boss-command-sandbox/build.gradle.kts | 3 - .../boss/sandbox/CommandNativeTestRunner.java | 68 +++++++++++++++++++ .../boss/sandbox/CommandNativeSecurityTest.kt | 17 ++++- 6 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandNativeTestRunner.java diff --git a/.github/workflows/cageforge-command-sessions.yml b/.github/workflows/cageforge-command-sessions.yml index 1247f3dfb2..6072471864 100644 --- a/.github/workflows/cageforge-command-sessions.yml +++ b/.github/workflows/cageforge-command-sessions.yml @@ -30,7 +30,7 @@ jobs: matrix: os: [macos-15, windows-2025] runs-on: ${{ matrix.os }} - timeout-minutes: 35 + timeout-minutes: 20 defaults: run: shell: bash @@ -44,6 +44,7 @@ jobs: java-version: '17' - uses: gradle/actions/setup-gradle@v6 - name: Build and check session module + timeout-minutes: 10 run: ./gradlew :boss-command-sandbox:check :boss-command-sandbox:nativeSecurityTestBundle --no-daemon --max-workers=2 --console=plain - name: Stage self-contained consumer run: | @@ -69,17 +70,19 @@ jobs: done < <(find "$stage/lib" -maxdepth 1 -type f -name '*.jar' -print0 | sort -z) echo "BOSS_NATIVE_CP=$classpath" >> "$GITHUB_ENV" - name: Explicit Windows setup + timeout-minutes: 2 if: runner.os == 'Windows' env: BOSS_CAGEFORGE_SETUP_MARKER: ${{ runner.temp }}/boss-command-setup-owned run: java -cp "$BOSS_NATIVE_CP" ai.rever.boss.sandbox.CommandWindowsSetup install - name: Native root and descendant enforcement - timeout-minutes: 6 + timeout-minutes: 3 run: | set -euo pipefail - java -cp "$BOSS_NATIVE_CP" org.junit.runner.JUnitCore ai.rever.boss.sandbox.CommandNativeSecurityTest \ + java -cp "$BOSS_NATIVE_CP" ai.rever.boss.sandbox.CommandNativeTestRunner \ 2>&1 | tee "$RUNNER_TEMP/boss-command-native.log" - name: Remove owned Windows setup + timeout-minutes: 2 if: always() && runner.os == 'Windows' env: BOSS_CAGEFORGE_SETUP_MARKER: ${{ runner.temp }}/boss-command-setup-owned diff --git a/ci/cageforge-command-suite/run.sh b/ci/cageforge-command-suite/run.sh index 8470425f7d..d2f8396bc9 100644 --- a/ci/cageforge-command-suite/run.sh +++ b/ci/cageforge-command-suite/run.sh @@ -9,5 +9,4 @@ done < <(find "$bundle_root/lib" -maxdepth 1 -type f -name '*.jar' -print0 | sor classpath=$(IFS=:; printf '%s' "${classpath_entries[*]}") test_home=$(mktemp -d /tmp/boss-command-test-home.XXXXXX) trap 'rmdir "$test_home" 2>/dev/null || true' EXIT -java -Duser.home="$test_home" -cp "$classpath" org.junit.runner.JUnitCore \ - ai.rever.boss.sandbox.CommandNativeSecurityTest +java -Duser.home="$test_home" -cp "$classpath" ai.rever.boss.sandbox.CommandNativeTestRunner diff --git a/ci/run-cageforge-command-linux-vm.sh b/ci/run-cageforge-command-linux-vm.sh index 7ce01cf5a0..6b992952d4 100644 --- a/ci/run-cageforge-command-linux-vm.sh +++ b/ci/run-cageforge-command-linux-vm.sh @@ -195,8 +195,11 @@ stop_guest() { } wait_for_ssh() { - for _ in {1..120}; do + for attempt in {1..120}; do ssh_guest true >/dev/null 2>&1 && return + if (( attempt % 10 == 0 )); then + echo "[boss] waiting for guest SSH (attempt $attempt/120)" + fi kill -0 "$qemu_pid" 2>/dev/null || { tail -n 100 "$serial_log" >&2 || true; cat "$stderr_log" >&2 || true; exit 70; } sleep 2 done @@ -207,7 +210,10 @@ wait_for_ssh() { wait_for_bootstrap() { local wait_for_cloud_init=$1 - for _ in {1..180}; do + for attempt in {1..180}; do + if (( attempt % 10 == 0 )); then + echo "[boss] waiting for guest bootstrap (attempt $attempt/180)" + fi if ssh_guest 'sudo test -f /var/lib/boss-cageforge-bootstrap-complete' >/dev/null 2>&1; then # The marker is written by the final cloud-init command. Wait until that # command has returned before powering off; otherwise the next restricted diff --git a/modules/boss-command-sandbox/build.gradle.kts b/modules/boss-command-sandbox/build.gradle.kts index 3af88aeec2..f26d2116fc 100644 --- a/modules/boss-command-sandbox/build.gradle.kts +++ b/modules/boss-command-sandbox/build.gradle.kts @@ -24,9 +24,6 @@ tasks.register("nativeSecurityTest") { description = "Tests root and descendant isolation on a prepared native backend" testClassesDirs = nativeSecurity.output.classesDirs classpath = nativeSecurity.runtimeClasspath - doFirst { - systemProperty("boss.sandbox.probe.classpath", classpath.asPath) - } } tasks.register("nativeSecurityTestBundle") { diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandNativeTestRunner.java b/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandNativeTestRunner.java new file mode 100644 index 0000000000..01dd2aadb1 --- /dev/null +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandNativeTestRunner.java @@ -0,0 +1,68 @@ +package ai.rever.boss.sandbox; + +import java.time.Instant; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.runner.Description; +import org.junit.runner.JUnitCore; +import org.junit.runner.Result; +import org.junit.runner.notification.Failure; +import org.junit.runner.notification.RunListener; + +/** Test-only progress reporting; native calls can block without producing JUnit dots. */ +public final class CommandNativeTestRunner { + private static final AtomicReference STAGE = new AtomicReference<>("starting suite"); + private static final long STARTED = System.nanoTime(); + + private CommandNativeTestRunner() {} + + public static void stage(String value) { + STAGE.set(value); + log(value); + } + + private static void log(String message) { + long seconds = TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - STARTED); + System.out.println(Instant.now() + " [native +" + seconds + "s] " + message); + System.out.flush(); + } + + public static void main(String[] args) { + ScheduledExecutorService progress = Executors.newSingleThreadScheduledExecutor(task -> { + Thread thread = new Thread(task, "native-test-progress"); + thread.setDaemon(true); + return thread; + }); + progress.scheduleAtFixedRate(() -> log("still waiting: " + STAGE.get()), 15, 15, TimeUnit.SECONDS); + JUnitCore junit = new JUnitCore(); + junit.addListener(new RunListener() { + @Override + public void testStarted(Description test) { + stage("START " + test.getMethodName()); + } + + @Override + public void testFailure(Failure failure) { + log("FAIL " + failure.getDescription().getMethodName()); + System.out.print(failure.getTrace()); + System.out.flush(); + } + + @Override + public void testFinished(Description test) { + stage("END " + test.getMethodName()); + } + }); + Result result; + try { + result = junit.run(CommandNativeSecurityTest.class); + log("RESULT tests=" + result.getRunCount() + " failures=" + result.getFailureCount() + + " elapsed_ms=" + result.getRunTime()); + } finally { + progress.shutdownNow(); + } + System.exit(result.wasSuccessful() ? 0 : 1); + } +} diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt index 574f196058..45a1a9cb62 100644 --- a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt @@ -24,8 +24,15 @@ class CommandNativeSecurityTest { private val launcher = CageforgeSessionLauncher() private val javaHome = Path.of(System.getProperty("java.home")).toRealPath() private val executable = javaHome.resolve("bin/" + if (File.separatorChar == '\\') "java.exe" else "java") + + // The pure-Java child needs only its classes, not the host's JUnit/Kotlin/JNI jars. private val probeClasspath = - System.getProperty("boss.sandbox.probe.classpath", System.getProperty("java.class.path")) + Path + .of( + CommandSecurityProbe::class.java.protectionDomain.codeSource.location + .toURI(), + ).toRealPath() + .toString() @Test(timeout = 90000) fun rootAndDescendantsEnforceFilesystemAndNetworkPolicy() { @@ -38,12 +45,15 @@ class CommandNativeSecurityTest { Socket("127.0.0.1", server.localPort).use { server.accept().close() } val arguments = listOf("root", project.toString(), outside.toString(), server.localPort.toString()) val command = command(project, arguments) + CommandNativeTestRunner.stage("preparing root policy") val plan = launcher.prepare(command) // A disk edit cannot replace the already reviewed command/policy snapshot. Files.writeString(command.policyFile, "malformed replacement") runBlocking { + CommandNativeTestRunner.stage("launching root boundary") val session = launcher.launch(plan, plan.approvalDigest).manage() try { + CommandNativeTestRunner.stage("waiting for root and descendant enforcement probes") session.closeInput() val output = session.awaitCompletion() assertEquals(null, output.failure, output.failure?.stackTraceToString()) @@ -51,6 +61,7 @@ class CommandNativeSecurityTest { assertTrue(output.stdout.text.contains("SECURITY_OK:root"), output.stdout.text) assertTrue(output.stdout.text.contains("SECURITY_OK:descendant"), output.stdout.text) } finally { + CommandNativeTestRunner.stage("closing root boundary") session.stop() } } @@ -65,14 +76,18 @@ class CommandNativeSecurityTest { fun closingSessionTerminatesRunningDescendant() { val project = temporary.newFolder("tree").toPath() Files.createDirectory(project.resolve(".git")) + CommandNativeTestRunner.stage("preparing descendant termination policy") val plan = launcher.prepare(command(project, listOf("tree", project.toString()))) val heartbeat = project.resolve("heartbeat") + CommandNativeTestRunner.stage("launching heartbeat boundary") launcher.launch(plan, plan.approvalDigest).use { + CommandNativeTestRunner.stage("waiting for descendant heartbeat") val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20) while ((!Files.exists(heartbeat) || Files.size(heartbeat) < 2) && System.nanoTime() < deadline) { Thread.sleep(25) } assertTrue(Files.exists(heartbeat) && Files.size(heartbeat) >= 2, "Descendant did not start") + CommandNativeTestRunner.stage("closing heartbeat boundary") } val stoppedSize = Files.size(heartbeat) Thread.sleep(300) From 69f5847b9e36ad1a9603f3607e6d523242ac9b6f Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Wed, 23 Sep 2026 14:14:31 +0500 Subject: [PATCH 10/23] build(sandbox): upgrade Cageforge Java to 0.7.1 Co-authored-by: codex --- docs/cageforge-command-sessions.md | 4 ++-- gradle/libs.versions.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/cageforge-command-sessions.md b/docs/cageforge-command-sessions.md index 284197582f..b09ad90e4d 100644 --- a/docs/cageforge-command-sessions.md +++ b/docs/cageforge-command-sessions.md @@ -11,7 +11,7 @@ and plugin execution are outside this feature and retain their existing behavior A session selects a project directory, a TOML file, a named CLI profile, and an executable with separate arguments. BOSS reads the policy once, adds a final child profile containing that exact command and working directory, then asks Cageforge -Java 0.7.0 for its permission request. Approval identifies this immutable snapshot. +Java 0.7.1 for its permission request. Approval identifies this immutable snapshot. Edits to the TOML file affect the next preparation, never a running session. A plan can launch once. A failed launch has no unsandboxed retry or fallback. @@ -53,7 +53,7 @@ support, or compatibility with CLIs that require a controlling terminal. ## Additional permission requests -Cageforge 0.7.0 supports explicit permission escalation. The integration must use +Since 0.7.0, Cageforge supports explicit permission escalation. The integration must use its `requestEscalation`, `approveEscalation` and `launchEscalated` APIs, not rewrite the running process's policy. The native contract requires a new immutable sandbox; relaunching a session must stop its previous process boundary first. It is not an diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fff5c3aafe..0683b58c3c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ detekt = "1.23.8" essenty = "2.6.0" junit-jupiter = "6.1.3" clikt = "5.1.0" -cageforge = "0.7.0" +cageforge = "0.7.1" kotlin = "2.4.10" kotlinx-coroutines = "1.11.0" ktlint-gradle = "14.2.0" From 4700b2f44a2b3e160e13b68b615cdb6ff964aa76 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Wed, 23 Sep 2026 14:14:31 +0500 Subject: [PATCH 11/23] test(sandbox): verify explicit file grants for root and descendants Co-authored-by: codex --- .../rever/boss/sandbox/CommandSecurityProbe.java | 10 ++++++++++ .../boss/sandbox/CommandNativeSecurityTest.kt | 16 +++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java b/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java index 839e19115e..bd9244a025 100644 --- a/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java @@ -36,6 +36,16 @@ public static void main(String[] args) throws Exception { throw new AssertionError("Wrong session working directory"); } Files.writeString(project.resolve(mode + "-allowed"), "allowed"); + Path approvedFile = Path.of(args[4]); + if (!"approved-input".equals(Files.readString(approvedFile))) { + throw new AssertionError("Explicit file read grant did not work: " + mode); + } + try { + Files.writeString(approvedFile, "overwritten"); + throw new AssertionError("Explicit read-only file grant allowed a write: " + mode); + } catch (IOException expected) { + // The explicit file grant must not grant write access or access to its siblings. + } try { Files.readString(outside.resolve("secret")); throw new AssertionError("Read escaped the project policy: " + mode); diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt index 45a1a9cb62..b6d3147ddc 100644 --- a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt @@ -40,11 +40,19 @@ class CommandNativeSecurityTest { Files.createDirectory(project.resolve(".git")) val outside = temporary.newFolder("private").toPath() Files.writeString(outside.resolve("secret"), "host-secret") + val approvedFile = Files.writeString(outside.resolve("approved"), "approved-input") assertEquals("host-secret", Files.readString(outside.resolve("secret"))) ServerSocket(0, 4, InetAddress.getByName("127.0.0.1")).use { server -> Socket("127.0.0.1", server.localPort).use { server.accept().close() } - val arguments = listOf("root", project.toString(), outside.toString(), server.localPort.toString()) - val command = command(project, arguments) + val arguments = + listOf( + "root", + project.toString(), + outside.toString(), + server.localPort.toString(), + approvedFile.toString(), + ) + val command = command(project, arguments, listOf(approvedFile)) CommandNativeTestRunner.stage("preparing root policy") val plan = launcher.prepare(command) // A disk edit cannot replace the already reviewed command/policy snapshot. @@ -70,6 +78,7 @@ class CommandNativeSecurityTest { assertTrue(Files.exists(project.resolve("descendant-allowed"))) assertFalse(Files.exists(outside.resolve("root-escape"))) assertFalse(Files.exists(outside.resolve("descendant-escape"))) + assertEquals("approved-input", Files.readString(approvedFile)) } @Test(timeout = 90000) @@ -106,9 +115,10 @@ class CommandNativeSecurityTest { private fun command( project: Path, arguments: List, + additionalReadPaths: List = emptyList(), ): SandboxCommand { val classpathRoots = probeClasspath.split(File.pathSeparator).map { Path.of(it).toRealPath() } - val roots = (classpathRoots + listOf(javaHome)).distinct() + val roots = (classpathRoots + listOf(javaHome) + additionalReadPaths).distinct() val rules = roots.joinToString(",\n") { "{ target = \"absolute\", path = ${quote(it)}, access = \"read\" }" } // Platform overlays are validated using their target's path syntax, even on another OS. val macosRuntime = From ae550ee359a80357c850b6c5e7cc0a4c0cfc5568 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Wed, 23 Sep 2026 14:32:10 +0500 Subject: [PATCH 12/23] fix(test): respect Windows sandbox traversal and ACL cleanup Co-authored-by: codex --- .../workflows/cageforge-command-sessions.yml | 5 +++++ docs/cageforge-command-sessions.md | 8 +++++++ .../boss/sandbox/CommandSecurityProbe.java | 8 +++---- .../boss/sandbox/CommandNativeSecurityTest.kt | 22 ++++++++++++++++--- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cageforge-command-sessions.yml b/.github/workflows/cageforge-command-sessions.yml index 6072471864..ec3337257c 100644 --- a/.github/workflows/cageforge-command-sessions.yml +++ b/.github/workflows/cageforge-command-sessions.yml @@ -60,6 +60,11 @@ jobs: if [[ "$RUNNER_OS" == Windows ]]; then classes=$(cygpath -m "$classes") separator=';' + # ACL restoration happens during uninstall, after JUnit has finished. + # Keep fixtures in the disposable stage until runner cleanup, not in + # TemporaryFolder's per-test deletion lifecycle. + mkdir "$stage/native-fixtures" + echo "BOSS_NATIVE_FIXTURE_ROOT=$(cygpath -m "$stage/native-fixtures")" >> "$GITHUB_ENV" fi classpath="$classes" while IFS= read -r -d '' jar; do diff --git a/docs/cageforge-command-sessions.md b/docs/cageforge-command-sessions.md index b09ad90e4d..c866de0358 100644 --- a/docs/cageforge-command-sessions.md +++ b/docs/cageforge-command-sessions.md @@ -88,6 +88,14 @@ the published binding and real backend on Linux, macOS and Windows. Linux enforc runs in a prepared QEMU guest with the consumer compiled on the host. GUI tests must exercise opt-in, review, denial, failure, output and termination through Compose. +On Windows, native tests require `BOSS_NATIVE_FIXTURE_ROOT` to name an existing +absolute directory in disposable test storage. Fixtures stay there after JUnit +finishes: Cageforge restores journaled ACLs during explicit `WindowsSetup.uninstall`, +not when a child exits. Do not delete those files before uninstall succeeds. CI +retains them in its staging directory until the runner is disposed. The cwd probe +writes relative paths and the host verifies their contents in the selected project; +it does not use `toRealPath`, which enumerates ungranted Windows ancestor directories. + The existing `feat/cageforge-secure-plugin` branch supplies useful native provisioning and QEMU patterns, but its protected plugin lifecycle is not this feature's launch model. In particular, replacing a plugin worker cannot isolate commands launched by diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java b/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java index bd9244a025..4f1ce3bb72 100644 --- a/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java @@ -32,10 +32,10 @@ public static void main(String[] args) throws Exception { if (!"child".equals(System.getenv("BOSS_SANDBOX_VALUE"))) { throw new AssertionError("Inherited TOML environment was not overridden"); } - if (!Path.of("").toRealPath().equals(project.toRealPath())) { - throw new AssertionError("Wrong session working directory"); - } - Files.writeString(project.resolve(mode + "-allowed"), "allowed"); + // Use the actual cwd. Windows toRealPath enumerates ancestors that the + // sandbox is deliberately not allowed to list. The host checks that both + // relative writes landed in the selected project, not somewhere else. + Files.writeString(Path.of(mode + "-allowed"), "allowed", StandardOpenOption.CREATE_NEW); Path approvedFile = Path.of(args[4]); if (!"approved-input".equals(Files.readString(approvedFile))) { throw new AssertionError("Explicit file read grant did not work: " + mode); diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt index b6d3147ddc..a31f964f4f 100644 --- a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt @@ -19,7 +19,14 @@ import kotlin.test.assertTrue class CommandNativeSecurityTest { @get:Rule - val temporary = TemporaryFolder() + val temporary = + object : TemporaryFolder(windowsFixtureParent()) { + override fun after() { + // Windows keeps an ACL journal until explicit uninstall. CI retains + // these files in its disposable staging directory until then. + if (File.separatorChar != '\\') super.after() + } + } private val launcher = CageforgeSessionLauncher() private val javaHome = Path.of(System.getProperty("java.home")).toRealPath() @@ -74,8 +81,8 @@ class CommandNativeSecurityTest { } } } - assertTrue(Files.exists(project.resolve("root-allowed"))) - assertTrue(Files.exists(project.resolve("descendant-allowed"))) + assertEquals("allowed", Files.readString(project.resolve("root-allowed"))) + assertEquals("allowed", Files.readString(project.resolve("descendant-allowed"))) assertFalse(Files.exists(outside.resolve("root-escape"))) assertFalse(Files.exists(outside.resolve("descendant-escape"))) assertEquals("approved-input", Files.readString(approvedFile)) @@ -160,4 +167,13 @@ class CommandNativeSecurityTest { } private fun quote(path: Path): String = "\"${path.toString().replace("\\", "\\\\").replace("\"", "\\\"")}\"" + + private fun windowsFixtureParent(): File? { + if (File.separatorChar != '\\') return null + val path = + checkNotNull(System.getenv("BOSS_NATIVE_FIXTURE_ROOT")) { + "Windows native tests require BOSS_NATIVE_FIXTURE_ROOT retained until WindowsSetup.uninstall()" + } + return File(path).also { check(it.isAbsolute && it.isDirectory) { "Invalid native fixture directory: $path" } } + } } From 4cc73bae858faee9847eed8b02e6bf92c6dcb88d Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Wed, 23 Sep 2026 14:50:31 +0500 Subject: [PATCH 13/23] fix(test): restore Windows sandbox state before fixture cleanup Co-authored-by: codex --- .../workflows/cageforge-command-sessions.yml | 5 -- docs/cageforge-command-sessions.md | 14 ++--- .../boss/sandbox/CommandNativeSecurityTest.kt | 58 +++++++++++++------ 3 files changed, 47 insertions(+), 30 deletions(-) diff --git a/.github/workflows/cageforge-command-sessions.yml b/.github/workflows/cageforge-command-sessions.yml index ec3337257c..6072471864 100644 --- a/.github/workflows/cageforge-command-sessions.yml +++ b/.github/workflows/cageforge-command-sessions.yml @@ -60,11 +60,6 @@ jobs: if [[ "$RUNNER_OS" == Windows ]]; then classes=$(cygpath -m "$classes") separator=';' - # ACL restoration happens during uninstall, after JUnit has finished. - # Keep fixtures in the disposable stage until runner cleanup, not in - # TemporaryFolder's per-test deletion lifecycle. - mkdir "$stage/native-fixtures" - echo "BOSS_NATIVE_FIXTURE_ROOT=$(cygpath -m "$stage/native-fixtures")" >> "$GITHUB_ENV" fi classpath="$classes" while IFS= read -r -d '' jar; do diff --git a/docs/cageforge-command-sessions.md b/docs/cageforge-command-sessions.md index c866de0358..8f0d323101 100644 --- a/docs/cageforge-command-sessions.md +++ b/docs/cageforge-command-sessions.md @@ -88,13 +88,13 @@ the published binding and real backend on Linux, macOS and Windows. Linux enforc runs in a prepared QEMU guest with the consumer compiled on the host. GUI tests must exercise opt-in, review, denial, failure, output and termination through Compose. -On Windows, native tests require `BOSS_NATIVE_FIXTURE_ROOT` to name an existing -absolute directory in disposable test storage. Fixtures stay there after JUnit -finishes: Cageforge restores journaled ACLs during explicit `WindowsSetup.uninstall`, -not when a child exits. Do not delete those files before uninstall succeeds. CI -retains them in its staging directory until the runner is disposed. The cwd probe -writes relative paths and the host verifies their contents in the selected project; -it does not use `toRealPath`, which enumerates ungranted Windows ancestor directories. +On Windows, each native test restores the explicitly installed `WindowsSetup` +before JUnit removes that test's temporary files, because Cageforge restores +journaled ACLs during uninstall. The Java probe grants only its classes and the +runtime files it opens; it does not recursively grant the entire JDK. The cwd +probe writes relative paths and the host verifies their contents in the selected +project; it does not use `toRealPath`, which enumerates ungranted Windows ancestor +directories. The existing `feat/cageforge-secure-plugin` branch supplies useful native provisioning and QEMU patterns, but its protected plugin lifecycle is not this feature's launch diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt index a31f964f4f..0a4f073f8e 100644 --- a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt @@ -1,7 +1,11 @@ package ai.rever.boss.sandbox import ai.cageforge.CageforgeConfigurationException +import ai.cageforge.WindowsSetup +import ai.cageforge.WindowsSetupState import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder @@ -19,19 +23,26 @@ import kotlin.test.assertTrue class CommandNativeSecurityTest { @get:Rule - val temporary = - object : TemporaryFolder(windowsFixtureParent()) { - override fun after() { - // Windows keeps an ACL journal until explicit uninstall. CI retains - // these files in its disposable staging directory until then. - if (File.separatorChar != '\\') super.after() - } - } + val temporary = TemporaryFolder() private val launcher = CageforgeSessionLauncher() private val javaHome = Path.of(System.getProperty("java.home")).toRealPath() private val executable = javaHome.resolve("bin/" + if (File.separatorChar == '\\') "java.exe" else "java") + @Before + fun ensureWindowsSetup() { + if (WindowsSetup.isSupported() && WindowsSetup.status() == WindowsSetupState.MISSING) { + WindowsSetup.install() + } + } + + @After + fun restoreWindowsSetupBeforeTemporaryCleanup() { + if (WindowsSetup.isSupported() && WindowsSetup.status() == WindowsSetupState.READY) { + WindowsSetup.uninstall() + } + } + // The pure-Java child needs only its classes, not the host's JUnit/Kotlin/JNI jars. private val probeClasspath = Path @@ -62,6 +73,10 @@ class CommandNativeSecurityTest { val command = command(project, arguments, listOf(approvedFile)) CommandNativeTestRunner.stage("preparing root policy") val plan = launcher.prepare(command) + assertFalse( + plan.permissionsJson.contains(outside.resolve("secret").toString()), + "The host permission request must not grant the denied secret: ${plan.permissionsJson}", + ) // A disk edit cannot replace the already reviewed command/policy snapshot. Files.writeString(command.policyFile, "malformed replacement") runBlocking { @@ -125,7 +140,23 @@ class CommandNativeSecurityTest { additionalReadPaths: List = emptyList(), ): SandboxCommand { val classpathRoots = probeClasspath.split(File.pathSeparator).map { Path.of(it).toRealPath() } - val roots = (classpathRoots + listOf(javaHome) + additionalReadPaths).distinct() + val runtimeRoots = + if (File.separatorChar == '\\') { + val bin = javaHome.resolve("bin") + val binLibraries = + Files.list(bin).use { paths -> + paths.filter { it.fileName.toString().endsWith(".dll", ignoreCase = true) }.toList() + } + binLibraries + + listOf( + bin.resolve("server/jvm.dll"), + javaHome.resolve("lib/jvm.cfg"), + javaHome.resolve("lib/modules"), + ).filter(Files::isRegularFile) + } else { + listOf(javaHome) + } + val roots = (classpathRoots + runtimeRoots + additionalReadPaths).distinct() val rules = roots.joinToString(",\n") { "{ target = \"absolute\", path = ${quote(it)}, access = \"read\" }" } // Platform overlays are validated using their target's path syntax, even on another OS. val macosRuntime = @@ -167,13 +198,4 @@ class CommandNativeSecurityTest { } private fun quote(path: Path): String = "\"${path.toString().replace("\\", "\\\\").replace("\"", "\\\"")}\"" - - private fun windowsFixtureParent(): File? { - if (File.separatorChar != '\\') return null - val path = - checkNotNull(System.getenv("BOSS_NATIVE_FIXTURE_ROOT")) { - "Windows native tests require BOSS_NATIVE_FIXTURE_ROOT retained until WindowsSetup.uninstall()" - } - return File(path).also { check(it.isAbsolute && it.isDirectory) { "Invalid native fixture directory: $path" } } - } } From cabab3b1e781ddfe881e1697e363c01bfb9a2d8f Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Wed, 23 Sep 2026 15:01:28 +0500 Subject: [PATCH 14/23] fix(sandbox): allow Cageforge permission escalation Co-authored-by: codex --- docs/cageforge-command-sessions.md | 7 +++++-- .../ai/rever/boss/sandbox/SandboxPolicySnapshot.kt | 4 +++- .../ai/rever/boss/sandbox/SandboxPolicySnapshotTest.kt | 9 +++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/cageforge-command-sessions.md b/docs/cageforge-command-sessions.md index 8f0d323101..74e6018549 100644 --- a/docs/cageforge-command-sessions.md +++ b/docs/cageforge-command-sessions.md @@ -16,8 +16,11 @@ Edits to the TOML file affect the next preparation, never a running session. A p can launch once. A failed launch has no unsandboxed retry or fallback. The final child profile `boss-command-session` is reserved. It enforces preflight -approval and captured stdin/stdout/stderr. It inherits the selected policy; native -Cageforge resolves all filesystem, environment, network and OS-specific rules. +approval for the initial launch and selects Cageforge's mode required for +on-demand escalation. It also captures stdin/stdout/stderr. It inherits the +selected policy; native Cageforge resolves all filesystem, environment, network +and OS-specific rules. The BOSS MCP request and GUI approval loop is still +integration work, described below. The project directory is the resolution context even when the TOML file is elsewhere. There is no automatic discovery or execution of repository-provided commands. diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshot.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshot.kt index 6c7152ac5b..c76d460b9e 100644 --- a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshot.kt +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshot.kt @@ -72,7 +72,9 @@ internal class SandboxPolicySnapshot private constructor( appendLine("[profiles.$LAUNCH_PROFILE]") appendLine("inherits = [${tomlString(profile)}]") appendLine("[profiles.$LAUNCH_PROFILE.approval]") - appendLine("mode = \"preflight\"") + // The first launch still gets an explicit host approval, while future + // MCP permission requests use Cageforge's on-demand escalation API. + appendLine("mode = \"preflight-and-on-demand\"") appendLine("persistence = \"session\"") appendLine("[profiles.$LAUNCH_PROFILE.command]") appendLine("program = ${tomlString(argv.first())}") diff --git a/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshotTest.kt b/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshotTest.kt index 35255aacd3..30b0cb00a7 100644 --- a/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshotTest.kt +++ b/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshotTest.kt @@ -35,6 +35,15 @@ class SandboxPolicySnapshotTest { assertNotEquals(snapshot.digest, SandboxPolicySnapshot.read(command).digest) } + @Test + fun `host profile permits Cageforge escalation without changing the selected profile`() { + val snapshot = SandboxPolicySnapshot.read(command()) + assertTrue(snapshot.toml.contains("[profiles.${SandboxPolicySnapshot.LAUNCH_PROFILE}.approval]")) + assertTrue(snapshot.toml.contains("mode = \"preflight-and-on-demand\"")) + assertTrue(snapshot.toml.contains("inherits = [\"node\"]")) + assertTrue(snapshot.toml.contains("[profiles.node]")) + } + @Test fun `approval identifies arguments and project as well as policy`() { val command = command() From ae2116561421dee653b914722ea73cda4501bdd2 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Wed, 23 Sep 2026 15:08:41 +0500 Subject: [PATCH 15/23] test(sandbox): verify Cageforge escalation boundary Co-authored-by: codex --- .../boss/sandbox/CommandSecurityProbe.java | 15 +++ .../boss/sandbox/CommandNativeSecurityTest.kt | 97 ++++++++++++++++++- 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java b/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java index 4f1ce3bb72..5df47b92e9 100644 --- a/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java @@ -13,6 +13,21 @@ public final class CommandSecurityProbe { public static void main(String[] args) throws Exception { String mode = args[0]; + if (mode.equals("baseline")) { + try { + Files.readString(Path.of(args[1])); + throw new AssertionError("Baseline sandbox read a file outside its policy"); + } catch (IOException expected) { + // The host confirms this existing file is readable outside the sandbox. + } + System.out.println("BASELINE_OK"); + return; + } + if (mode.equals("escalated")) { + String value = Files.readString(Path.of(args[1])); + System.out.println("ESCALATION_OK:" + value); + return; + } Path project = Path.of(args[1]); if (mode.equals("heartbeat")) { while (true) { diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt index 0a4f073f8e..c2e829953b 100644 --- a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt @@ -1,6 +1,9 @@ package ai.rever.boss.sandbox +import ai.cageforge.Cageforge import ai.cageforge.CageforgeConfigurationException +import ai.cageforge.PermissionApprover +import ai.cageforge.RuntimeContext import ai.cageforge.WindowsSetup import ai.cageforge.WindowsSetupState import kotlinx.coroutines.runBlocking @@ -125,6 +128,66 @@ class CommandNativeSecurityTest { assertEquals(stoppedSize, Files.size(heartbeat), "Descendant survived boundary termination") } + @Test(timeout = 90000) + fun cageforgeEscalationLaunchesOnlyAfterAnExplicitAdditionalGrant() { + val project = temporary.newFolder("escalation-project").toPath() + Files.createDirectory(project.resolve(".git")) + val privateDirectory = temporary.newFolder("escalation-private").toPath() + val approvedFile = Files.writeString(privateDirectory.resolve("approved-input"), "approved") + assertEquals("approved", Files.readString(approvedFile)) + val command = command(project, listOf("baseline", approvedFile.toString())) + val toml = escalationPolicy(command, project) + val context = RuntimeContext(project) + val baseRequest = + Cageforge.permissionRequest( + toml, + "escalation-test", + context, + toolId = "boss-command-session", + configDigest = "native-escalation-test", + ) + val baseGrant = PermissionApprover().approve(baseRequest) + + Cageforge + .fromToml( + toml, + "escalation-test", + context, + baseGrant, + baseRequest, + ).use { runtime -> + runtime.launchProcess().use { firstLaunch -> + assertEquals(0, waitForExit(firstLaunch)) + } + + runtime + .requestEscalation( + listOf("read" to approvedFile.toString()), + emptyList(), + "Read the explicitly approved input file", + ).use { escalation -> + assertTrue(escalation.filesystem.contains("read" to approvedFile.toString())) + assertTrue(escalation.json.contains(approvedFile.toString())) + val escalationGrant = PermissionApprover().approveEscalation(escalation) + val argv = + listOf( + executable.toString(), + "-cp", + probeClasspath, + CommandSecurityProbe::class.java.name, + "escalated", + approvedFile.toString(), + ) + runtime.launchEscalated(escalation, escalationGrant, argv).use { escalated -> + val output = requireNotNull(escalated.stdout).bufferedReader().readText() + assertEquals(0, escalated.waitFor().exitCode) + assertTrue(output.contains("ESCALATION_OK:approved"), output) + } + } + } + assertEquals("approved", Files.readString(approvedFile)) + } + @Test fun invalidInheritanceFailsBeforeLaunch() { val project = temporary.newFolder("invalid").toPath() @@ -197,5 +260,37 @@ class CommandNativeSecurityTest { return SandboxCommand(project, policy, "cli", argv) } - private fun quote(path: Path): String = "\"${path.toString().replace("\\", "\\\\").replace("\"", "\\\"")}\"" + private fun quote(path: Path): String = quoteText(path.toString()) +} + +private fun waitForExit(process: Process): Int { + if (!process.waitFor(30, TimeUnit.SECONDS)) return -1 + return process.exitValue() } + +private fun escalationPolicy( + command: SandboxCommand, + project: Path, +): String { + val policy = Files.readString(command.policyFile) + val args = command.argv.drop(1).joinToString(", ", transform = ::quoteText) + return policy + + """ + + [profiles.escalation-test] + inherits = ["${command.profile}"] + [profiles.escalation-test.approval] + mode = "preflight-and-on-demand" + persistence = "session" + [profiles.escalation-test.command] + program = ${quoteText(command.argv.first())} + args = [$args] + working_directory = ${quoteText(project.toString())} + [profiles.escalation-test.command.stdio] + stdin = "pipe" + stdout = "pipe" + stderr = "pipe" + """.trimIndent() +} + +private fun quoteText(value: String): String = "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" From e284e773cd2f28c2529e36ae46753771f769b16a Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Wed, 23 Sep 2026 15:13:08 +0500 Subject: [PATCH 16/23] test(sandbox): initialize Cageforge before escalation request Co-authored-by: codex --- .../kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt index c2e829953b..e015e5d343 100644 --- a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt @@ -138,13 +138,13 @@ class CommandNativeSecurityTest { val command = command(project, listOf("baseline", approvedFile.toString())) val toml = escalationPolicy(command, project) val context = RuntimeContext(project) + Cageforge.checkToml(toml, "escalation-test", context) val baseRequest = Cageforge.permissionRequest( toml, "escalation-test", context, toolId = "boss-command-session", - configDigest = "native-escalation-test", ) val baseGrant = PermissionApprover().approve(baseRequest) From 1b8f53eb2da1bc2b5e21e077e92324b6b62c9143 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Wed, 23 Sep 2026 15:17:50 +0500 Subject: [PATCH 17/23] test(sandbox): compare escaped capabilities structurally Co-authored-by: codex --- .../kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt index e015e5d343..7cbc4722c6 100644 --- a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt @@ -166,8 +166,8 @@ class CommandNativeSecurityTest { emptyList(), "Read the explicitly approved input file", ).use { escalation -> - assertTrue(escalation.filesystem.contains("read" to approvedFile.toString())) - assertTrue(escalation.json.contains(approvedFile.toString())) + assertEquals(listOf("read" to approvedFile.toString()), escalation.filesystem) + assertTrue(escalation.network.isEmpty()) val escalationGrant = PermissionApprover().approveEscalation(escalation) val argv = listOf( From 4ba924f17af9d8c475123823f479d034a933d8e2 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Wed, 23 Sep 2026 16:04:30 +0500 Subject: [PATCH 18/23] feat(sandbox): own consented command sessions through app shutdown Co-authored-by: codex --- .../boss/sandbox/SandboxOperationResult.kt | 25 ++ .../rever/boss/sandbox/SandboxSessionPlan.kt | 3 + .../boss/sandbox/SandboxSessionService.kt | 127 +++++++++++ .../boss/sandbox/CommandNativeSecurityTest.kt | 41 +++- .../sandbox/SandboxOperationResultTest.kt | 21 ++ .../boss/sandbox/SandboxSessionServiceTest.kt | 213 ++++++++++++++++++ 6 files changed, 421 insertions(+), 9 deletions(-) create mode 100644 modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxOperationResult.kt create mode 100644 modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionService.kt create mode 100644 modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxOperationResultTest.kt create mode 100644 modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxSessionServiceTest.kt diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxOperationResult.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxOperationResult.kt new file mode 100644 index 0000000000..c343170b91 --- /dev/null +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxOperationResult.kt @@ -0,0 +1,25 @@ +package ai.rever.boss.sandbox + +import ai.cageforge.CageforgeException +import kotlinx.coroutines.CancellationException +import java.io.IOException + +/** Adapter boundary: present expected validation/native/I/O failures without swallowing cancellation or VM errors. */ +suspend fun sandboxOperationResult(action: suspend () -> T): Result = + try { + Result.success(action()) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: CageforgeException) { + Result.failure(failure) + } catch (failure: IOException) { + Result.failure(failure) + } catch (failure: IllegalArgumentException) { + Result.failure(failure) + } catch (failure: IllegalStateException) { + Result.failure(failure) + } catch (failure: SecurityException) { + Result.failure(failure) + } catch (failure: UnsatisfiedLinkError) { + Result.failure(failure) + } diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionPlan.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionPlan.kt index 50ffad5f9a..83936719ba 100644 --- a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionPlan.kt +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionPlan.kt @@ -16,6 +16,9 @@ class SandboxSessionPlan internal constructor( val approvalDigest: String get() = snapshot.digest private val consumed = AtomicBoolean() + internal fun review(reason: String): SandboxPermissionReview = + SandboxPermissionReview(approvalDigest, projectDirectory.toString(), argv, permissionsJson, reason, false) + internal fun claim(approvedDigest: String) { require(approvedDigest == approvalDigest) { "Approval does not match the command and policy shown" } check(consumed.compareAndSet(false, true)) { "This session plan has already been used; prepare a new session" } diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionService.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionService.kt new file mode 100644 index 0000000000..dbdf30abf4 --- /dev/null +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionService.kt @@ -0,0 +1,127 @@ +package ai.rever.boss.sandbox + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean + +/** Application-owned sessions. GUI and CLI requests must pass through the same consent boundary. */ +class SandboxSessionService internal constructor( + val consent: SandboxConsentQueue, + private val backend: SandboxSessionBackend, +) { + constructor() : this(SandboxConsentQueue(), NativeSandboxSessionBackend()) + + // Serialize acquisition with shutdown: no native launch can escape application ownership. + private val lifecycle = Mutex() + private val closed = AtomicBoolean() + private val mutableSessions = MutableStateFlow>(emptyList()) + val sessions: StateFlow> = mutableSessions.asStateFlow() + + /** Returns null for denial/timeout. Preparing and showing the review never starts a process. */ + suspend fun start( + command: SandboxCommand, + reason: String, + ): SandboxSessionEntry? { + require(reason.isNotBlank() && reason.length <= MAX_REASON_CHARACTERS) { + "Provide a short reason for this command" + } + val captured = command.copy(argv = command.argv.toList()) + return lifecycle.withLock { + check(!closed.get()) { "Sandbox sessions are closed" } + check(sessions.value.size < MAX_SESSIONS) { "Remove a finished sandbox session before starting another" } + val plan = withContext(Dispatchers.IO) { backend.prepare(captured) } + val review = plan.review(reason) + consent.request(review)?.let { permit -> + consent.consume(permit, review) + acquire(plan, review) + } + } + } + + /** Finished output stays available until explicitly removed; running sessions cannot be forgotten. */ + suspend fun remove(sessionId: String) { + lifecycle.withLock { + val entry = sessions.value.single { it.id == sessionId } + check(!entry.session.output.value.running) { "Stop the sandbox session before removing it" } + mutableSessions.value = sessions.value.filterNot { it.id == sessionId } + } + } + + /** Revokes pending/remembered consent immediately, then waits for every owned native boundary. */ + suspend fun shutdown() { + closed.set(true) + consent.close() + withContext(NonCancellable) { + lifecycle.withLock { + supervisorScope { + sessions.value + .map { entry -> + // Enter every non-cancellable stop before awaiting any failure. + async(start = CoroutineStart.UNDISPATCHED) { entry.session.stop() } + }.awaitAll() + } + } + } + } + + private suspend fun acquire( + plan: SandboxSessionPlan, + review: SandboxPermissionReview, + ): SandboxSessionEntry { + // JNI acquisition is synchronous. Cancellation must not discard a successfully acquired owner. + val session = withContext(NonCancellable) { withContext(Dispatchers.IO) { backend.launch(plan) } } + var transferred = false + try { + currentCoroutineContext().ensureActive() + check(!closed.get()) { "Sandbox sessions are closed" } + val entry = SandboxSessionEntry(review, session) + mutableSessions.value = sessions.value + entry + transferred = true + return entry + } finally { + if (!transferred) session.stop() + } + } + + companion object { + const val MAX_SESSIONS = 8 + const val MAX_REASON_CHARACTERS = 2048 + } +} + +class SandboxSessionEntry internal constructor( + val review: SandboxPermissionReview, + val session: ManagedSandboxSession, +) { + val id: String = UUID.randomUUID().toString() +} + +internal interface SandboxSessionBackend { + fun prepare(command: SandboxCommand): SandboxSessionPlan + + fun launch(plan: SandboxSessionPlan): ManagedSandboxSession +} + +private class NativeSandboxSessionBackend : SandboxSessionBackend { + private val launcher = CageforgeSessionLauncher() + + override fun prepare(command: SandboxCommand): SandboxSessionPlan = launcher.prepare(command) + + override fun launch(plan: SandboxSessionPlan): ManagedSandboxSession = + launcher + .launch(plan, plan.approvalDigest) + .manage() +} diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt index 7cbc4722c6..bdc523ef2d 100644 --- a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt @@ -6,6 +6,8 @@ import ai.cageforge.PermissionApprover import ai.cageforge.RuntimeContext import ai.cageforge.WindowsSetup import ai.cageforge.WindowsSetupState +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before @@ -111,17 +113,38 @@ class CommandNativeSecurityTest { val project = temporary.newFolder("tree").toPath() Files.createDirectory(project.resolve(".git")) CommandNativeTestRunner.stage("preparing descendant termination policy") - val plan = launcher.prepare(command(project, listOf("tree", project.toString()))) val heartbeat = project.resolve("heartbeat") - CommandNativeTestRunner.stage("launching heartbeat boundary") - launcher.launch(plan, plan.approvalDigest).use { - CommandNativeTestRunner.stage("waiting for descendant heartbeat") - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20) - while ((!Files.exists(heartbeat) || Files.size(heartbeat) < 2) && System.nanoTime() < deadline) { - Thread.sleep(25) + runBlocking { + val service = SandboxSessionService() + try { + val start = + async { + service.start( + command(project, listOf("tree", project.toString())), + "Native descendant cleanup test", + ) + } + val review = + service.consent.requests + .first { it.isNotEmpty() } + .single() + assertTrue(service.sessions.value.isEmpty(), "Review must precede native launch") + assertFalse(Files.exists(heartbeat), "No process may run before approval") + CommandNativeTestRunner.stage("approving and launching heartbeat boundary") + service.consent.decide(review.id, SandboxConsentChoice.ONCE) + val entry = requireNotNull(start.await()) + CommandNativeTestRunner.stage("waiting for descendant heartbeat") + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20) + while ((!Files.exists(heartbeat) || Files.size(heartbeat) < 2) && System.nanoTime() < deadline) { + Thread.sleep(25) + } + assertTrue(Files.exists(heartbeat) && Files.size(heartbeat) >= 2, "Descendant did not start") + CommandNativeTestRunner.stage("closing heartbeat boundary through app service") + service.shutdown() + assertFalse(entry.session.output.value.running) + } finally { + service.shutdown() } - assertTrue(Files.exists(heartbeat) && Files.size(heartbeat) >= 2, "Descendant did not start") - CommandNativeTestRunner.stage("closing heartbeat boundary") } val stoppedSize = Files.size(heartbeat) Thread.sleep(300) diff --git a/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxOperationResultTest.kt b/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxOperationResultTest.kt new file mode 100644 index 0000000000..fd3e63ed2f --- /dev/null +++ b/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxOperationResultTest.kt @@ -0,0 +1,21 @@ +package ai.rever.boss.sandbox + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.Test +import java.io.IOException +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +class SandboxOperationResultTest { + @Test + fun `adapter retains failures but rethrows cancellation and VM errors`(): Unit = + runBlocking { + val failure = IOException("Cannot read policy") + assertSame(failure, sandboxOperationResult { throw failure }.exceptionOrNull()) + assertFailsWith { sandboxOperationResult { throw CancellationException() } } + assertFailsWith { + sandboxOperationResult { throw OutOfMemoryError("test sentinel") } + } + } +} diff --git a/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxSessionServiceTest.kt b/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxSessionServiceTest.kt new file mode 100644 index 0000000000..2c847d1097 --- /dev/null +++ b/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxSessionServiceTest.kt @@ -0,0 +1,213 @@ +package ai.rever.boss.sandbox + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.nio.file.Files +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SandboxSessionServiceTest { + @get:Rule + val temporary = TemporaryFolder() + + @Test(timeout = 10000) + fun `denial never crosses the native launch boundary`() = + runBlocking { + val backend = TestBackend() + val service = SandboxSessionService(SandboxConsentQueue(), backend) + try { + val start = async { service.start(command(), "Run the selected tool") } + decide(service, SandboxConsentChoice.DENY) + assertNull(start.await()) + assertEquals(0, backend.launches) + assertTrue(service.sessions.value.isEmpty()) + } finally { + service.shutdown() + } + } + + @Test(timeout = 10000) + fun `approved session is retained and shutdown awaits boundary cleanup`(): Unit = + runBlocking { + val backend = TestBackend() + val service = SandboxSessionService(SandboxConsentQueue(), backend) + try { + val start = async { service.start(command(), "Run the selected tool") } + decide(service, SandboxConsentChoice.ONCE) + val entry = requireNotNull(start.await()) + assertEquals(listOf(entry), service.sessions.value) + assertFailsWith { service.remove(entry.id) } + service.shutdown() + service.shutdown() + assertTrue(backend.closed.get()) + assertFalse(entry.session.output.value.running) + service.remove(entry.id) + assertTrue(service.sessions.value.isEmpty()) + assertFailsWith { service.start(command(), "No launch after shutdown") } + } finally { + service.shutdown() + } + } + + @Test(timeout = 10000) + fun `cancellation during synchronous launch cannot discard the acquired process`() = + runBlocking { + val backend = TestBackend(blockLaunch = true) + val service = SandboxSessionService(SandboxConsentQueue(), backend) + try { + val start = async { service.start(command(), "Cancellation test") } + decide(service, SandboxConsentChoice.ONCE) + withTimeout(5000) { backend.entered.await() } + start.cancel() + backend.release.countDown() + start.join() + assertTrue(backend.closed.get()) + assertTrue(service.sessions.value.isEmpty()) + } finally { + backend.release.countDown() + service.shutdown() + } + } + + @Test(timeout = 10000) + fun `closing the app denies pending consent without launching`() = + runBlocking { + val backend = TestBackend() + val service = SandboxSessionService(SandboxConsentQueue(), backend) + val start = async { service.start(command(), "Pending launch") } + withTimeout(5000) { service.consent.requests.first { it.isNotEmpty() } } + service.shutdown() + assertNull(start.await()) + assertEquals(0, backend.launches) + assertTrue( + service.consent.requests.value + .isEmpty(), + ) + } + + @Test(timeout = 10000) + fun `cancelling a pending review removes it and does not launch`() = + runBlocking { + val backend = TestBackend() + val service = SandboxSessionService(SandboxConsentQueue(), backend) + try { + val start = async { service.start(command(), "Pending launch") } + withTimeout(5000) { service.consent.requests.first { it.isNotEmpty() } } + start.cancelAndJoin() + assertTrue( + service.consent.requests.value + .isEmpty(), + ) + assertEquals(0, backend.launches) + } finally { + service.shutdown() + } + } + + @Test(timeout = 10000) + fun `native failure propagates without a fallback or a retained session`() = + runBlocking { + val backend = TestBackend(failLaunch = true) + val service = SandboxSessionService(SandboxConsentQueue(), backend) + try { + val start = + async { + assertFailsWith { service.start(command(), "Launch failure test") } + } + decide(service, SandboxConsentChoice.ONCE) + assertEquals("Native launch failed", start.await().message) + assertEquals(1, backend.launches) + assertTrue(service.sessions.value.isEmpty()) + } finally { + service.shutdown() + } + } + + private fun command(): SandboxCommand { + val project = temporary.newFolder().toPath() + val policy = project.resolve("cageforge.toml") + Files.writeString(policy, "[profiles.tool]\n") + return SandboxCommand(project, policy, "tool", listOf("tool", "arg with spaces")) + } + + private suspend fun decide( + service: SandboxSessionService, + choice: SandboxConsentChoice, + ) { + val request = + withTimeout(5000) { + service.consent.requests + .first { it.isNotEmpty() } + .single() + } + assertEquals(listOf("tool", "arg with spaces"), request.review.argv) + service.consent.decide(request.id, choice) + } + + private class TestBackend( + private val blockLaunch: Boolean = false, + private val failLaunch: Boolean = false, + ) : SandboxSessionBackend { + val entered = CompletableDeferred() + val release = CountDownLatch(1) + val closed = AtomicBoolean() + var launches = 0 + + override fun prepare(command: SandboxCommand): SandboxSessionPlan = + SandboxSessionPlan(SandboxPolicySnapshot.read(command), "native-digest", "{}") + + override fun launch(plan: SandboxSessionPlan): ManagedSandboxSession { + launches++ + entered.complete(Unit) + if (blockLaunch) check(release.await(5, java.util.concurrent.TimeUnit.SECONDS)) + if (failLaunch) throw IOException("Native launch failed") + val process = WaitingProcess() + return ManagedSandboxSession( + process, + AutoCloseable { + process.destroy() + closed.set(true) + }, + ) + } + } + + private class WaitingProcess : Process() { + private val done = CountDownLatch(1) + + override fun getOutputStream() = ByteArrayOutputStream() + + override fun getInputStream() = ByteArrayInputStream(byteArrayOf()) + + override fun getErrorStream() = ByteArrayInputStream(byteArrayOf()) + + override fun waitFor(): Int { + done.await() + return 0 + } + + override fun exitValue(): Int { + check(done.count == 0L) + return 0 + } + + override fun destroy() { + done.countDown() + } + } +} From bab763a4d212b7f0b10cf03369386af705f831a8 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Wed, 23 Sep 2026 16:04:47 +0500 Subject: [PATCH 19/23] feat(sandbox): add desktop command session and consent dialogs Co-authored-by: codex --- composeApp/build.gradle.kts | 1 + .../ai/rever/boss/app/BossAppDialogs.kt | 11 ++ .../kotlin/ai/rever/boss/app/BossAppState.kt | 1 + .../components/dialogs/ToolLauncherDialog.kt | 20 ++- .../boss/platform/SandboxSessionDialogs.kt | 12 ++ .../platform/SandboxSessionDialogs.desktop.kt | 50 ++++++ .../rever/boss/sandbox/SandboxCommandHost.kt | 30 ++++ .../boss/sandbox/SandboxConsentDialog.kt | 86 ++++++++++ .../rever/boss/sandbox/SandboxLaunchForm.kt | 21 +++ .../boss/sandbox/SandboxManagerDialog.kt | 151 ++++++++++++++++++ .../rever/boss/sandbox/SandboxWindowModel.kt | 45 ++++++ .../ai/rever/boss/startup/ShutdownSequence.kt | 4 + .../boss/sandbox/SandboxConsentDialogTest.kt | 43 +++++ .../boss/sandbox/SandboxLaunchFormTest.kt | 26 +++ .../boss/sandbox/SandboxManagerDialogTest.kt | 50 ++++++ .../boss/startup/ShutdownSequenceTest.kt | 1 + docs/cageforge-command-sessions.md | 43 ++++- 17 files changed, 585 insertions(+), 10 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/platform/SandboxSessionDialogs.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/platform/SandboxSessionDialogs.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxCommandHost.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxConsentDialog.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxLaunchForm.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxManagerDialog.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxWindowModel.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxConsentDialogTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxLaunchFormTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxManagerDialogTest.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 28c7065d24..ce34012599 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -1012,6 +1012,7 @@ kotlin { desktopMain.dependencies { implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutines.swing) + implementation(project(":boss-command-sandbox")) // Microkernel infrastructure (optional KERNEL mode) // Excluded on Windows ARM64 where protoc is unavailable diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt index 88da19fc0f..3dc24c13f9 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt @@ -54,6 +54,7 @@ import ai.rever.boss.icons.FileIcons import ai.rever.boss.keymap.KeymapSettingsManager import ai.rever.boss.keymap.model.KeymapActions import ai.rever.boss.mcp.McpToolRegistryImpl +import ai.rever.boss.platform.SandboxSessionDialogs import ai.rever.boss.platform.rememberDirectoryPicker import ai.rever.boss.plugin.api.Panel.Companion.left import ai.rever.boss.plugin.api.Panel.Companion.top @@ -438,10 +439,20 @@ internal fun BossAppDialogs(state: BossAppState) { } // The tools launcher's dialog. + SandboxSessionDialogs( + windowId = windowId, + projectDirectory = selectedProject.path, + showManager = state.showSandboxSessions, + onDismiss = { state.showSandboxSessions = false }, + ) if (state.showToolLauncherDialog) { // In the MAIN composition, not inside whichever chrome raised it - see BossAppState. state.draggablePanelComponent.ToolLauncherDialog( onDismiss = { state.showToolLauncherDialog = false }, + onSandboxSessions = { + state.showToolLauncherDialog = false + state.showSandboxSessions = true + }, ) } diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppState.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppState.kt index 6bd2af6cb5..30efcc83a0 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppState.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppState.kt @@ -102,6 +102,7 @@ internal class BossAppState( * `ToolLauncherButton`. */ var showToolLauncherDialog by mutableStateOf(false) + var showSandboxSessions by mutableStateOf(false) /** Window-local operational view of plugin lifecycle problems and their safe remedies. */ var showPluginHealthCenter by mutableStateOf(false) diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ToolLauncherDialog.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ToolLauncherDialog.kt index 7a91ca5295..6ee022969b 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ToolLauncherDialog.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ToolLauncherDialog.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.Icon import androidx.compose.material.Text +import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -123,7 +124,10 @@ private const val SCROLLBAR_ALPHA = 0.7f * does from its icon - custom `onClick` handlers included. */ @Composable -fun BossDraggableComponent.ToolLauncherDialog(onDismiss: () -> Unit) { +fun BossDraggableComponent.ToolLauncherDialog( + onDismiss: () -> Unit, + onSandboxSessions: () -> Unit, +) { var query by remember { mutableStateOf("") } // Every slot, in the order they are drawn down the two rails, so the grid reads the way the @@ -134,6 +138,7 @@ fun BossDraggableComponent.ToolLauncherDialog(onDismiss: () -> Unit) { // freeze the list at whatever was registered when the dialog opened. val allTools = allSidebarTools() val matches = allTools.filter { matchesToolQuery(it, query) } + val matchesSandbox = "Sandbox command sessions".contains(query, ignoreCase = true) // Type-to-open, the way a launcher is expected to behave: the field has focus the moment the // dialog appears, so the first keystroke filters instead of being swallowed, and Enter takes @@ -157,9 +162,12 @@ fun BossDraggableComponent.ToolLauncherDialog(onDismiss: () -> Unit) { val openFirstMatch: () -> Unit = { // Nothing to open when the query matches nothing: Enter on an empty grid should do // nothing rather than close the dialog, which would look like it had opened something. - matches.firstOrNull()?.let { tool -> + val tool = matches.firstOrNull() + if (tool != null) { handleSidebarItemClick(tool) onDismiss() + } else if (matchesSandbox) { + onSandboxSessions() } Unit } @@ -182,7 +190,13 @@ fun BossDraggableComponent.ToolLauncherDialog(onDismiss: () -> Unit) { modifier = Modifier.fillMaxWidth().focusRequester(searchFocus), ) - if (matches.isEmpty()) { + if (matchesSandbox) { + TextButton(onClick = onSandboxSessions) { + Text("Sandbox command sessions", color = BossTheme.colors.textPrimary) + } + } + + if (matches.isEmpty() && !matchesSandbox) { Text( text = if (allTools.isEmpty()) "No tools are loaded" else "No tools match \"$query\"", color = BossTheme.colors.textSecondary, diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/platform/SandboxSessionDialogs.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/platform/SandboxSessionDialogs.kt new file mode 100644 index 0000000000..f592bdb385 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/platform/SandboxSessionDialogs.kt @@ -0,0 +1,12 @@ +package ai.rever.boss.platform + +import androidx.compose.runtime.Composable + +/** Desktop-owned native sessions; ordinary terminal launch remains unchanged. */ +@Composable +internal expect fun SandboxSessionDialogs( + windowId: String, + projectDirectory: String, + showManager: Boolean, + onDismiss: () -> Unit, +) diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/platform/SandboxSessionDialogs.desktop.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/platform/SandboxSessionDialogs.desktop.kt new file mode 100644 index 0000000000..27272637be --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/platform/SandboxSessionDialogs.desktop.kt @@ -0,0 +1,50 @@ +package ai.rever.boss.platform + +import ai.rever.boss.sandbox.SandboxCommandHost +import ai.rever.boss.sandbox.SandboxConsentDialog +import ai.rever.boss.sandbox.SandboxManagerDialog +import ai.rever.boss.sandbox.SandboxWindowModel +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.launch + +@Composable +internal actual fun SandboxSessionDialogs( + windowId: String, + projectDirectory: String, + showManager: Boolean, + onDismiss: () -> Unit, +) { + val host = SandboxCommandHost + val service = host.service + val scope = rememberCoroutineScope() + val sessions by service.sessions.collectAsState() + val requests by service.consent.requests.collectAsState() + val reviewWindow by host.reviewWindow.collectAsState() + val model = remember(projectDirectory) { SandboxWindowModel(projectDirectory) } + DisposableEffect(windowId) { + host.attach(windowId) + onDispose { host.detach(windowId) } + } + val review = requests.firstOrNull()?.takeIf { reviewWindow == windowId } + if (review != null) { + SandboxConsentDialog(review) { service.consent.decide(review.id, it) } + } else if (showManager) { + SandboxManagerDialog( + model.form, + { model.form = it }, + model.busy, + model.message, + sessions, + onStart = { scope.launch { model.start(windowId) } }, + onAction = { action -> scope.launch { model.perform(action) } }, + onRemove = { service.remove(it) }, + onRevoke = { service.consent.revoke() }, + onDismiss = onDismiss, + ) + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxCommandHost.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxCommandHost.kt new file mode 100644 index 0000000000..5ed7495393 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxCommandHost.kt @@ -0,0 +1,30 @@ +package ai.rever.boss.sandbox + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** One owner for every BOSS window and CLI caller. Never persists grants or launches an ordinary process. */ +internal object SandboxCommandHost { + val service = SandboxSessionService() + private val windows = linkedSetOf() + private val mutableReviewWindow = MutableStateFlow(null) + val reviewWindow = mutableReviewWindow.asStateFlow() + + fun attach(windowId: String) = + synchronized(windows) { + windows.add(windowId) + if (mutableReviewWindow.value == null) mutableReviewWindow.value = windowId + } + + fun detach(windowId: String) = + synchronized(windows) { + windows.remove(windowId) + if (mutableReviewWindow.value == windowId) mutableReviewWindow.value = windows.firstOrNull() + } + + fun reviewIn(windowId: String) = + synchronized(windows) { + check(windowId in windows) + mutableReviewWindow.value = windowId + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxConsentDialog.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxConsentDialog.kt new file mode 100644 index 0000000000..8e46a332aa --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxConsentDialog.kt @@ -0,0 +1,86 @@ +package ai.rever.boss.sandbox + +import ai.rever.boss.plugin.ui.BossDialog +import ai.rever.boss.plugin.ui.BossTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Composable +internal fun SandboxConsentDialog( + request: SandboxConsentRequest, + onDecide: (SandboxConsentChoice) -> Unit, +) { + val review = request.review + BossDialog(onDismissRequest = { onDecide(SandboxConsentChoice.DENY) }) { + Surface(color = BossTheme.colors.panel, contentColor = BossTheme.colors.textPrimary) { + Column(Modifier.width(680.dp).padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Approve sandbox permissions?") + Text("No command has been launched for this request. Review the exact command and native policy below.") + SelectionContainer { + Column(Modifier.heightIn(max = 360.dp).verticalScroll(rememberScrollState())) { + Text("Project: ${review.projectDirectory}") + Text( + "Command (executable + argv): ${Json.encodeToString(review.argv)}", + fontFamily = FontFamily.Monospace, + ) + Text("Reason: ${review.reason}") + Text(review.permissionsJson, fontFamily = FontFamily.Monospace) + } + } + if (review.requiresRestart) { + Text("Approval launches a new sandbox boundary; existing permissions are not widened.") + } + Text("Until BOSS closes remembers only this exact command and policy. Restarting BOSS asks again.") + SandboxConsentButtons(request.id, onDecide) + } + } + } +} + +/** Arming is keyed by request identity, so a double click cannot authorize the next request. */ +@Composable +internal fun SandboxConsentButtons( + requestId: String, + onDecide: (SandboxConsentChoice) -> Unit, +) { + key(requestId) { + var armed by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(500) + armed = true + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { onDecide(SandboxConsentChoice.DENY) }) { Text("Deny") } + TextButton( + enabled = armed, + onClick = { if (armed) onDecide(SandboxConsentChoice.UNTIL_APP_CLOSES) }, + ) { Text("Until BOSS closes") } + TextButton(enabled = armed, onClick = { if (armed) onDecide(SandboxConsentChoice.ONCE) }) { + Text("Approve once") + } + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxLaunchForm.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxLaunchForm.kt new file mode 100644 index 0000000000..0619f36a6f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxLaunchForm.kt @@ -0,0 +1,21 @@ +package ai.rever.boss.sandbox + +import kotlinx.serialization.json.Json +import java.nio.file.Path + +/** Arguments are a JSON array, not a shell string; empty arguments and whitespace are preserved. */ +internal data class SandboxLaunchForm( + val project: String, + val policy: String, + val profile: String = "base", + val executable: String = "", + val arguments: String = "[]", +) { + fun command(): SandboxCommand = + SandboxCommand( + Path.of(project), + Path.of(policy), + profile, + listOf(executable) + Json.decodeFromString>(arguments), + ) +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxManagerDialog.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxManagerDialog.kt new file mode 100644 index 0000000000..392a728912 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxManagerDialog.kt @@ -0,0 +1,151 @@ +package ai.rever.boss.sandbox + +import ai.rever.boss.plugin.ui.BossDialog +import ai.rever.boss.plugin.ui.BossTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Divider +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp + +@Composable +internal fun SandboxManagerDialog( + form: SandboxLaunchForm, + onChange: (SandboxLaunchForm) -> Unit, + busy: Boolean, + message: String?, + sessions: List, + onStart: () -> Unit, + onAction: (suspend () -> Unit) -> Unit, + onRemove: suspend (String) -> Unit, + onRevoke: () -> Unit, + onDismiss: () -> Unit, +) { + BossDialog(onDismissRequest = onDismiss) { + Surface(color = BossTheme.colors.panel, contentColor = BossTheme.colors.textPrimary) { + Column( + Modifier + .width(720.dp) + .heightIn(max = 720.dp) + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("Sandbox command sessions") + Text( + "Explicit opt-in. The root executable and its descendants share one Cageforge policy. " + + "Ordinary terminals are unchanged.", + ) + Text("Uses stdin/stdout pipes, not an interactive terminal. Windows requires Cageforge setup first.") + SandboxLaunchFields(form, onChange, !busy) + Row { + TextButton(onClick = onStart, enabled = !busy) { + Text(if (busy) "Waiting for approval..." else "Review and run") + } + TextButton(onClick = onRevoke) { Text("Revoke remembered approvals") } + TextButton(onClick = onDismiss) { Text("Close") } + } + message?.let { Text(it) } + sessions.forEach { entry -> + key(entry.id) { + Divider() + SandboxSessionCard(entry, onAction) { onRemove(entry.id) } + } + } + } + } + } +} + +@Composable +private fun SandboxLaunchFields( + form: SandboxLaunchForm, + onChange: (SandboxLaunchForm) -> Unit, + enabled: Boolean, +) { + OutlinedTextField(form.project, { + onChange(form.copy(project = it)) + }, label = { Text("Project directory (absolute)") }, enabled = enabled, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(form.policy, { + onChange(form.copy(policy = it)) + }, label = { Text("Policy TOML file (absolute)") }, enabled = enabled, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(form.profile, { + onChange(form.copy(profile = it)) + }, label = { Text("TOML profile") }, enabled = enabled, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(form.executable, { + onChange(form.copy(executable = it)) + }, label = { Text("Executable (not a shell command)") }, enabled = enabled, modifier = Modifier.fillMaxWidth()) + OutlinedTextField( + value = form.arguments, + onValueChange = { onChange(form.copy(arguments = it)) }, + label = { Text("Arguments as JSON array, e.g. [\"--version\"]") }, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Composable +private fun SandboxSessionCard( + entry: SandboxSessionEntry, + onAction: (suspend () -> Unit) -> Unit, + onRemove: suspend () -> Unit, +) { + val output by entry.session.output.collectAsState() + var input by remember { mutableStateOf("") } + Text("${entry.review.argv.first()} - ${if (output.running) "running" else "finished (${output.exitCode})"}") + Text("Project: ${entry.review.projectDirectory}") + SelectionContainer { + Column(Modifier.heightIn(max = 220.dp).verticalScroll(rememberScrollState())) { + Text( + "stdout (${output.stdout.discardedCharacters} earlier characters omitted):\n${output.stdout.text}", + fontFamily = FontFamily.Monospace, + ) + Text( + "stderr (${output.stderr.discardedCharacters} earlier characters omitted):\n${output.stderr.text}", + fontFamily = FontFamily.Monospace, + ) + output.failure?.let { Text("Session failed: ${it.message}") } + } + } + if (output.running) { + OutlinedTextField( + input, + { input = it }, + label = { Text("Send a line to stdin") }, + modifier = Modifier.fillMaxWidth(), + ) + Row { + TextButton(onClick = { + val captured = input + onAction { + entry.session.sendInput("$captured\n") + if (input == captured) input = "" + } + }) { Text("Send line") } + TextButton(onClick = { onAction { entry.session.closeInput() } }) { Text("Close stdin") } + TextButton(onClick = { onAction { entry.session.stop() } }) { Text("Stop session and descendants") } + } + } else { + TextButton(onClick = { onAction(onRemove) }) { Text("Remove finished session") } + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxWindowModel.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxWindowModel.kt new file mode 100644 index 0000000000..5f2025a422 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxWindowModel.kt @@ -0,0 +1,45 @@ +package ai.rever.boss.sandbox + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import java.nio.file.Path + +/** Window-local form/error state; process ownership remains with the application service. */ +internal class SandboxWindowModel( + projectDirectory: String, +) { + var form by mutableStateOf( + SandboxLaunchForm(projectDirectory, Path.of(projectDirectory, "cageforge.toml").toString()), + ) + var busy by mutableStateOf(false) + private set + var message by mutableStateOf(null) + private set + + suspend fun start(windowId: String) { + if (busy) return + busy = true + val captured = form + try { + SandboxCommandHost.reviewIn(windowId) + val result = + sandboxOperationResult { + SandboxCommandHost.service.start(captured.command(), "Run from BOSS GUI") + } + message = + result.fold( + { + if (it == null) "Denied or expired. No command was launched." else "Sandbox session started." + }, + { it.message ?: it.javaClass.simpleName }, + ) + } finally { + busy = false + } + } + + suspend fun perform(action: suspend () -> Unit) { + message = sandboxOperationResult(action).exceptionOrNull()?.let { it.message ?: it.javaClass.simpleName } + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/ShutdownSequence.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/ShutdownSequence.kt index ab6b550171..486c6a8273 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/ShutdownSequence.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/ShutdownSequence.kt @@ -7,6 +7,7 @@ import ai.rever.boss.dashboard.RecentFilesManager import ai.rever.boss.performance.PerformanceMonitor import ai.rever.boss.plugin.PluginStoreSetup import ai.rever.boss.plugin.browser.FluckEngine +import ai.rever.boss.sandbox.SandboxCommandHost import ai.rever.boss.services.auth.UserDataStorage import ai.rever.boss.updater.AppUpdateRealtimeService import ai.rever.boss.updater.UpdateCoordinator @@ -53,6 +54,9 @@ object ShutdownSequence { UserDataStorage.flushPendingSaves() } }, + ShutdownStep("stopping sandbox command sessions") { + runBlocking { SandboxCommandHost.service.shutdown() } + }, ShutdownStep("stopping performance monitor") { PerformanceMonitor.stop() }, diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxConsentDialogTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxConsentDialogTest.kt new file mode 100644 index 0000000000..a93f24882f --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxConsentDialogTest.kt @@ -0,0 +1,43 @@ +package ai.rever.boss.sandbox + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +class SandboxConsentDialogTest { + @get:Rule + val rule = createComposeRule() + + @Test + fun `next request rearms both approval scopes but denial is immediate`() { + val requestId = mutableStateOf("first") + val decisions = mutableListOf() + rule.mainClock.autoAdvance = false + rule.setContent { + SandboxConsentButtons(requestId.value) { decisions.add(it) } + } + rule.onNodeWithText("Approve once").assertIsNotEnabled() + rule.onNodeWithText("Until BOSS closes").assertIsNotEnabled() + rule.onNodeWithText("Deny").assertIsEnabled().performClick() + rule.mainClock.advanceTimeBy(600) + rule.onNodeWithText("Approve once").assertIsEnabled().performClick() + rule.runOnIdle { requestId.value = "second" } + rule.mainClock.advanceTimeByFrame() + rule.onNodeWithText("Approve once").assertIsNotEnabled().performClick() + rule.onNodeWithText("Until BOSS closes").assertIsNotEnabled().performClick() + rule.mainClock.advanceTimeBy(600) + rule.onNodeWithText("Until BOSS closes").assertIsEnabled().performClick() + rule.runOnIdle { + assertEquals( + listOf(SandboxConsentChoice.DENY, SandboxConsentChoice.ONCE, SandboxConsentChoice.UNTIL_APP_CLOSES), + decisions, + ) + } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxLaunchFormTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxLaunchFormTest.kt new file mode 100644 index 0000000000..91c0b582c5 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxLaunchFormTest.kt @@ -0,0 +1,26 @@ +package ai.rever.boss.sandbox + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class SandboxLaunchFormTest { + @Test + fun `arguments are exact array entries and never parsed as shell syntax`() { + val form = + SandboxLaunchForm( + "project", + "policy.toml", + executable = "tool", + arguments = "[\"\",\"a b\",\"$(echo x)\"]", + ) + assertEquals(listOf("tool", "", "a b", "$(echo x)"), form.command().argv) + } + + @Test + fun `shell strings and non string array entries are rejected`() { + val form = SandboxLaunchForm("project", "policy.toml", executable = "tool") + assertFailsWith { form.copy(arguments = "--flag value").command() } + assertFailsWith { form.copy(arguments = "[{}]").command() } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxManagerDialogTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxManagerDialogTest.kt new file mode 100644 index 0000000000..7c12a96698 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxManagerDialogTest.kt @@ -0,0 +1,50 @@ +package ai.rever.boss.sandbox + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextReplacement +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +class SandboxManagerDialogTest { + @get:Rule + val rule = createComposeRule() + + @Test + fun `opening or editing the form never launches and a pending review prevents duplicate submission`() { + val form = mutableStateOf(SandboxLaunchForm("project", "policy.toml")) + val busy = mutableStateOf(false) + val submitted = mutableListOf>() + rule.setContent { + SandboxManagerDialog( + form = form.value, + onChange = { form.value = it }, + busy = busy.value, + message = null, + sessions = emptyList(), + onStart = { + submitted.add(form.value.command().argv) + busy.value = true + }, + onAction = {}, + onRemove = {}, + onRevoke = {}, + onDismiss = {}, + ) + } + rule.onNodeWithText("Executable (not a shell command)").performScrollTo().performTextReplacement("node") + rule + .onNodeWithText("Arguments as JSON array, e.g. [\"--version\"]") + .performScrollTo() + .performTextReplacement("[\"a b.js\",\"\"]") + rule.runOnIdle { assertEquals(emptyList(), submitted) } + rule.onNodeWithText("Review and run").performScrollTo().performClick() + rule.onNodeWithText("Waiting for approval...").assertIsNotEnabled().performClick() + rule.runOnIdle { assertEquals(listOf(listOf("node", "a b.js", "")), submitted) } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/startup/ShutdownSequenceTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/startup/ShutdownSequenceTest.kt index fe463a6787..d9ca87c3d3 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/startup/ShutdownSequenceTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/startup/ShutdownSequenceTest.kt @@ -44,6 +44,7 @@ class ShutdownSequenceTest { listOf( "saving Last Session on exit", "flushing debounced recent-files and user-data saves on exit", + "stopping sandbox command sessions", "stopping performance monitor", "closing browser engine", "closing favicon HTTP client", diff --git a/docs/cageforge-command-sessions.md b/docs/cageforge-command-sessions.md index 74e6018549..99ed413671 100644 --- a/docs/cageforge-command-sessions.md +++ b/docs/cageforge-command-sessions.md @@ -6,6 +6,31 @@ shells, Git commands and compilers it creates inside the same native boundary. An MCP call is a transport operation, not a new sandbox boundary. Ordinary terminal and plugin execution are outside this feature and retain their existing behavior. +## Launch from BOSS + +Open **Tools > Sandbox command sessions**. Enter the absolute project directory and +policy TOML path, choose a profile, then enter the executable and a JSON array of +arguments. For example, executable `node` with arguments `["script.js", "a b", ""]` +passes three distinct arguments, including the final empty one. BOSS does not parse +these fields as a shell command. + +**Review and run** prepares the native permission request without starting a process. +The dialog shows the project, exact argv and Cageforge's resolved permissions. Deny, +approve once, or remember that exact review until BOSS closes. Changed commands or +policies still require a new approval. The manager also offers **Revoke remembered +approvals**, which invalidates pending approvals but does not stop already-running +sessions. + +The manager retains bounded stdout/stderr tails, accepts lines on stdin, sends EOF, +and stops the root process together with its descendants. Closing the manager does +not stop sessions; use **Stop session and descendants**, or quit BOSS. Finished +output remains until removed. At most eight sessions are retained. Application +shutdown revokes consent and waits for native cleanup, including launches that were +in progress when shutdown started. There is no implicit Windows setup or elevation. + +The CLI/MCP launch adapters and agent-scoped additional-permission endpoint are still +being integrated. The GUI launch path is not a claim that agent escalation is ready. + ## Policy and approval contract A session selects a project directory, a TOML file, a named CLI profile, and an @@ -19,8 +44,8 @@ The final child profile `boss-command-session` is reserved. It enforces prefligh approval for the initial launch and selects Cageforge's mode required for on-demand escalation. It also captures stdin/stdout/stderr. It inherits the selected policy; native Cageforge resolves all filesystem, environment, network -and OS-specific rules. The BOSS MCP request and GUI approval loop is still -integration work, described below. +and OS-specific rules. MCP additional-permission requests are still integration +work, described below. The project directory is the resolution context even when the TOML file is elsewhere. There is no automatic discovery or execution of repository-provided commands. @@ -79,17 +104,21 @@ The host consent queue implements these two scopes: Denial, timeout, cancellation, queue overflow and application shutdown never grant permission. A stale dialog cannot approve the next request. This consent mechanism -and its unit tests are implemented in the session module; native escalation and -GUI/MCP wiring remain integration work, not verified end-to-end functionality. +and its unit tests are implemented in the session module. Initial GUI launches use +this same queue. Native escalation has a separate API-level security test; its +agent MCP/GUI request loop remains integration work, not verified end-to-end functionality. ## Verification work The command session module separates immutable preparation and native launch from Compose and MCP integration. Ordinary tests cover snapshot identity, argument -handling, bounds and approval replay. Native policy and security tests must exercise +handling, bounds, approval replay, cancellation during native acquisition and +application shutdown. Compose tests cover explicit submission, exact argv and the +request-specific arming of both approval buttons. Native policy and security tests exercise the published binding and real backend on Linux, macOS and Windows. Linux enforcement -runs in a prepared QEMU guest with the consumer compiled on the host. GUI tests must -exercise opt-in, review, denial, failure, output and termination through Compose. +runs in a prepared QEMU guest with the consumer compiled on the host. The descendant +termination probe uses the application session service and its consent queue. +GUI coverage for the full agent escalation flow remains required before completion. On Windows, each native test restores the explicitly installed `WindowsSetup` before JUnit removes that test's temporary files, because Cageforge restores From d0f6b93ad1a22a3d91abc5e72e919cd046abbad5 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Wed, 23 Sep 2026 16:39:48 +0500 Subject: [PATCH 20/23] feat(sandbox): review and launch additional command permissions Co-authored-by: codex --- .../sandbox/CageforgeEscalationLauncher.kt | 101 ++++++++++++++ .../rever/boss/sandbox/SandboxEscalation.kt | 26 ++++ .../boss/sandbox/SandboxEscalationPlan.kt | 8 ++ .../boss/sandbox/SandboxLaunchRequests.kt | 128 ++++++++++++++++++ .../boss/sandbox/SandboxPolicySnapshot.kt | 32 ++++- .../boss/sandbox/SandboxSessionService.kt | 39 +++++- .../boss/sandbox/CommandSecurityProbe.java | 17 ++- .../boss/sandbox/CommandNativeSecurityTest.kt | 74 ++++++++++ .../boss/sandbox/SandboxLaunchRequestsTest.kt | 78 +++++++++++ .../boss/sandbox/SandboxPolicySnapshotTest.kt | 13 ++ .../boss/sandbox/SandboxSessionServiceTest.kt | 84 ++++++++++++ 11 files changed, 589 insertions(+), 11 deletions(-) create mode 100644 modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/CageforgeEscalationLauncher.kt create mode 100644 modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxEscalation.kt create mode 100644 modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxEscalationPlan.kt create mode 100644 modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxLaunchRequests.kt create mode 100644 modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxLaunchRequestsTest.kt diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/CageforgeEscalationLauncher.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/CageforgeEscalationLauncher.kt new file mode 100644 index 0000000000..e55a9b8b52 --- /dev/null +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/CageforgeEscalationLauncher.kt @@ -0,0 +1,101 @@ +package ai.rever.boss.sandbox + +import ai.cageforge.Cageforge +import ai.cageforge.PermissionApprover +import ai.cageforge.PermissionEscalationRequest +import ai.cageforge.RuntimeContext +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.concurrent.atomic.AtomicBoolean + +/** A command-specific runtime has no old process to replace; the requesting agent stays confined. */ +internal class CageforgeEscalationLauncher { + fun prepare( + base: SandboxSessionPlan, + additional: SandboxEscalation, + ): SandboxEscalationPlan { + val snapshot = base.snapshot.forCommand(additional.argv) + val context = RuntimeContext(snapshot.projectDirectory) + Cageforge.checkToml(snapshot.toml, SandboxPolicySnapshot.LAUNCH_PROFILE, context) + return Cageforge + .permissionRequest( + snapshot.toml, + SandboxPolicySnapshot.LAUNCH_PROFILE, + context, + toolId = "boss-command-session", + configDigest = snapshot.digest, + ).use { request -> + // This grant constructs a host-private runtime only. No process may launch until + // the service consumes human approval for the expanded request and exact argv. + PermissionApprover().approve(request).use { grant -> + val runtime = + Cageforge.fromToml( + snapshot.toml, + SandboxPolicySnapshot.LAUNCH_PROFILE, + context, + grant, + request, + ) + prepareOwned(runtime, snapshot, additional) + } + } + } + + private fun prepareOwned( + runtime: Cageforge, + snapshot: SandboxPolicySnapshot, + additional: SandboxEscalation, + ): SandboxEscalationPlan { + var transferred = false + val pendingRuntime = AutoCloseable { if (!transferred) runtime.close() } + return pendingRuntime.use { + val escalation = runtime.requestEscalation(additional.filesystem, additional.network, additional.reason) + val pendingRequest = AutoCloseable { if (!transferred) escalation.close() } + pendingRequest.use { + NativeEscalationPlan(runtime, escalation, snapshot, additional.reason).also { transferred = true } + } + } + } +} + +private class NativeEscalationPlan( + private val runtime: Cageforge, + private val escalation: PermissionEscalationRequest, + snapshot: SandboxPolicySnapshot, + reason: String, +) : SandboxEscalationPlan { + private val consumed = AtomicBoolean() + private var transferred = false + override val review = + SandboxPermissionReview( + MessageDigest + .getInstance("SHA-256") + .digest("${snapshot.digest}\u0000${escalation.json}".toByteArray(StandardCharsets.UTF_8)) + .joinToString("") { "%02x".format(it) }, + snapshot.projectDirectory.toString(), + snapshot.argv, + escalation.json, + reason, + true, + ) + + override fun launch(): ManagedSandboxSession { + check(consumed.compareAndSet(false, true)) { "Escalation plan was already used" } + return PermissionApprover().approveEscalation(escalation).use { grant -> + val session = SandboxSession(runtime.launchEscalated(escalation, grant).asJavaProcess(), runtime) + try { + session.manage().also { transferred = true } + } finally { + if (!transferred) session.close() + } + } + } + + override fun close() { + try { + escalation.close() + } finally { + if (!transferred) runtime.close() + } + } +} diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxEscalation.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxEscalation.kt new file mode 100644 index 0000000000..2edf044692 --- /dev/null +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxEscalation.kt @@ -0,0 +1,26 @@ +package ai.rever.boss.sandbox + +import java.util.Collections + +/** Additional capabilities for one explicit command, never a mutation of the requesting agent. */ +class SandboxEscalation( + argv: List, + filesystem: List>, + network: List, + val reason: String, +) { + val argv: List = Collections.unmodifiableList(argv.toList()) + val filesystem: List> = Collections.unmodifiableList(filesystem.toList()) + val network: List = Collections.unmodifiableList(network.toList()) + + init { + require(reason.isNotBlank() && reason.length <= SandboxSessionService.MAX_REASON_CHARACTERS) + require(filesystem.size + network.size in 1..64) { "Request between one and 64 additional capabilities" } + require( + filesystem.all { (operation, path) -> + operation.length <= 32 && path.length <= 32768 && '\u0000' !in path + }, + ) { "Invalid filesystem capability" } + require(network.all { it.length <= 4096 && '\u0000' !in it }) { "Invalid network capability" } + } +} diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxEscalationPlan.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxEscalationPlan.kt new file mode 100644 index 0000000000..497a820d5a --- /dev/null +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxEscalationPlan.kt @@ -0,0 +1,8 @@ +package ai.rever.boss.sandbox + +/** Owns preparation resources until approval transfers the new native process to the service. */ +internal interface SandboxEscalationPlan : AutoCloseable { + val review: SandboxPermissionReview + + fun launch(): ManagedSandboxSession +} diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxLaunchRequests.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxLaunchRequests.kt new file mode 100644 index 0000000000..c8b216aac5 --- /dev/null +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxLaunchRequests.kt @@ -0,0 +1,128 @@ +package ai.rever.boss.sandbox + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.withContext +import java.util.UUID + +/** Detached, bounded launch tickets for transports whose timeout is shorter than human review. */ +class SandboxLaunchRequests internal constructor( + private val start: suspend (SandboxCommand, String) -> SandboxSessionEntry?, + private val capacity: Int = 8, + private val escalate: (suspend (String, SandboxEscalation) -> SandboxSessionEntry?)? = null, +) { + constructor(service: SandboxSessionService) : this(service::start, escalate = service::startEscalated) + + private class Pending( + var status: SandboxLaunchStatus, + val job: Deferred, + ) + + private val owner = SupervisorJob() + private val scope = CoroutineScope(owner + Dispatchers.IO) + private val lock = Any() + private val pending = linkedMapOf() + private var closed = false + + init { + require(capacity > 0) + } + + /** Submission is not approval. The app owns the request even if the transport disconnects. */ + fun submit( + command: SandboxCommand, + reason: String, + ): String { + val captured = command.copy(argv = command.argv.toList()) + require(reason.isNotBlank() && reason.length <= SandboxSessionService.MAX_REASON_CHARACTERS) + return enqueue { start(captured, reason) } + } + + fun submitEscalation( + parentId: String, + additional: SandboxEscalation, + ): String { + val launch = checkNotNull(escalate) { "Escalation is unavailable" } + return enqueue { launch(parentId, additional) } + } + + private fun enqueue(launch: suspend () -> SandboxSessionEntry?): String = + synchronized(lock) { + check(!closed) { "Sandbox launch requests are closed" } + check(pending.size < capacity) { "Forget a completed sandbox launch request before submitting another" } + val id = UUID.randomUUID().toString() + val job = + scope.async(start = CoroutineStart.LAZY) { + val entry = launch() + synchronized(lock) { + pending.getValue(id).status = + SandboxLaunchStatus( + id, + if (entry == null) SandboxLaunchState.DENIED else SandboxLaunchState.STARTED, + entry?.id, + ) + } + } + pending[id] = Pending(SandboxLaunchStatus(id, SandboxLaunchState.PENDING), job) + job.invokeOnCompletion { failure -> recordFailure(id, failure) } + job.start() + id + } + + fun status(requestId: String): SandboxLaunchStatus = + synchronized(lock) { + requireNotNull(pending[requestId]) { "Unknown sandbox launch request" }.status + } + + /** Forgetting a ticket never stops or removes the session it started. */ + fun forget(requestId: String) { + synchronized(lock) { + val request = requireNotNull(pending[requestId]) { "Unknown sandbox launch request" } + check(request.job.isCompleted) { "The sandbox launch request is still pending" } + pending.remove(requestId) + } + } + + suspend fun shutdown() { + synchronized(lock) { closed = true } + withContext(NonCancellable) { owner.cancelAndJoin() } + } + + private fun recordFailure( + id: String, + failure: Throwable?, + ) { + if (failure == null) return + synchronized(lock) { + pending[id]?.let { request -> + // Once a session was published, the service owns it even if cancellation arrives + // at the last instruction of this coroutine. Do not hide that session's id. + if (request.status.state == SandboxLaunchState.PENDING) { + val state = + if (failure is CancellationException) { + SandboxLaunchState.CANCELLED + } else { + SandboxLaunchState.FAILED + } + request.status = SandboxLaunchStatus(id, state, failure = failure) + } + } + } + } +} + +enum class SandboxLaunchState { PENDING, STARTED, DENIED, FAILED, CANCELLED } + +data class SandboxLaunchStatus( + val id: String, + val state: SandboxLaunchState, + val sessionId: String? = null, + val failure: Throwable? = null, +) diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshot.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshot.kt index c76d460b9e..dfba3a9621 100644 --- a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshot.kt +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshot.kt @@ -14,6 +14,7 @@ internal class SandboxPolicySnapshot private constructor( val profile: String, val argv: List, val toml: String, + private val source: String, ) { val digest: String = MessageDigest @@ -21,6 +22,19 @@ internal class SandboxPolicySnapshot private constructor( .digest("$projectDirectory\u0000$policyFile\u0000$toml".toByteArray(StandardCharsets.UTF_8)) .joinToString("") { "%02x".format(it) } + /** New command, same captured policy bytes. Never reread an agent-editable policy file. */ + fun forCommand(arguments: List): SandboxPolicySnapshot { + val captured = checkedArguments(arguments) + return SandboxPolicySnapshot( + projectDirectory, + policyFile, + profile, + captured, + compose(source, profile, captured, projectDirectory), + source, + ) + } + companion object { const val LAUNCH_PROFILE = "boss-command-session" internal const val MAX_POLICY_BYTES = 1024 * 1024 @@ -35,12 +49,7 @@ internal class SandboxPolicySnapshot private constructor( require(Files.isRegularFile(policy)) { "Policy must be a regular file" } require(command.profile.matches(Regex("[A-Za-z0-9][A-Za-z0-9_-]*"))) { "Invalid profile name" } require(command.profile != LAUNCH_PROFILE) { "$LAUNCH_PROFILE is reserved for the host" } - val argv = command.argv.toList() - require(argv.isNotEmpty() && argv.first().isNotBlank()) { "An executable is required" } - require(argv.none { '\u0000' in it }) { "Command arguments must not contain NUL" } - require(argv.sumOf { it.toByteArray(StandardCharsets.UTF_8).size.toLong() + 1 } <= MAX_ARGUMENT_BYTES) { - "Command arguments exceed 128 KiB" - } + val argv = checkedArguments(command.argv) val bytes = Files.newInputStream(policy).use { it.readNBytes(MAX_POLICY_BYTES + 1) } require(bytes.size <= MAX_POLICY_BYTES) { "Policy exceeds 1 MiB" } val source = @@ -55,9 +64,20 @@ internal class SandboxPolicySnapshot private constructor( command.profile, Collections.unmodifiableList(argv), compose(source, command.profile, argv, project), + source, ) } + private fun checkedArguments(arguments: List): List { + val argv = arguments.toList() + require(argv.isNotEmpty() && argv.first().isNotBlank()) { "An executable is required" } + require(argv.none { '\u0000' in it }) { "Command arguments must not contain NUL" } + require(argv.sumOf { it.toByteArray(StandardCharsets.UTF_8).size.toLong() + 1 } <= MAX_ARGUMENT_BYTES) { + "Command arguments exceed 128 KiB" + } + return Collections.unmodifiableList(argv) + } + // Cageforge owns inheritance, canonical rule replacement and OS overlays. The final // child only binds this session's command and pipes; it never reimplements policy merge. private fun compose( diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionService.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionService.kt index dbdf30abf4..bf37da5d10 100644 --- a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionService.kt +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/SandboxSessionService.kt @@ -46,15 +46,39 @@ class SandboxSessionService internal constructor( val review = plan.review(reason) consent.request(review)?.let { permit -> consent.consume(permit, review) - acquire(plan, review) + acquire(plan, review) { backend.launch(plan) } } } } + /** Run a new command with reviewed additional rights, leaving the original agent unchanged. */ + suspend fun startEscalated( + parentId: String, + additional: SandboxEscalation, + ): SandboxSessionEntry? = + lifecycle.withLock { + check(!closed.get()) { "Sandbox sessions are closed" } + val parent = requireNotNull(sessions.value.singleOrNull { it.id == parentId }) { "Unknown parent session" } + check(parent.session.output.value.running) { "The requesting session has stopped" } + check(sessions.value.size < MAX_SESSIONS) { "Remove a finished sandbox session before starting another" } + val prepared = + withContext(NonCancellable) { + withContext(Dispatchers.IO) { backend.prepareEscalation(parent.plan, additional) } + } + prepared.use { + currentCoroutineContext().ensureActive() + consent.request(it.review)?.let { permit -> + consent.consume(permit, it.review) + check(parent.session.output.value.running) { "The requesting session has stopped" } + acquire(parent.plan, it.review, it::launch) + } + } + } + /** Finished output stays available until explicitly removed; running sessions cannot be forgotten. */ suspend fun remove(sessionId: String) { lifecycle.withLock { - val entry = sessions.value.single { it.id == sessionId } + val entry = requireNotNull(sessions.value.singleOrNull { it.id == sessionId }) { "Unknown sandbox session" } check(!entry.session.output.value.running) { "Stop the sandbox session before removing it" } mutableSessions.value = sessions.value.filterNot { it.id == sessionId } } @@ -80,14 +104,15 @@ class SandboxSessionService internal constructor( private suspend fun acquire( plan: SandboxSessionPlan, review: SandboxPermissionReview, + launch: () -> ManagedSandboxSession, ): SandboxSessionEntry { // JNI acquisition is synchronous. Cancellation must not discard a successfully acquired owner. - val session = withContext(NonCancellable) { withContext(Dispatchers.IO) { backend.launch(plan) } } + val session = withContext(NonCancellable) { withContext(Dispatchers.IO) { launch() } } var transferred = false try { currentCoroutineContext().ensureActive() check(!closed.get()) { "Sandbox sessions are closed" } - val entry = SandboxSessionEntry(review, session) + val entry = SandboxSessionEntry(review, session, plan) mutableSessions.value = sessions.value + entry transferred = true return entry @@ -105,6 +130,7 @@ class SandboxSessionService internal constructor( class SandboxSessionEntry internal constructor( val review: SandboxPermissionReview, val session: ManagedSandboxSession, + internal val plan: SandboxSessionPlan, ) { val id: String = UUID.randomUUID().toString() } @@ -113,6 +139,11 @@ internal interface SandboxSessionBackend { fun prepare(command: SandboxCommand): SandboxSessionPlan fun launch(plan: SandboxSessionPlan): ManagedSandboxSession + + fun prepareEscalation( + base: SandboxSessionPlan, + additional: SandboxEscalation, + ): SandboxEscalationPlan = CageforgeEscalationLauncher().prepare(base, additional) } private class NativeSandboxSessionBackend : SandboxSessionBackend { diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java b/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java index 5df47b92e9..aea84ed6cf 100644 --- a/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/java/ai/rever/boss/sandbox/CommandSecurityProbe.java @@ -23,9 +23,24 @@ public static void main(String[] args) throws Exception { System.out.println("BASELINE_OK"); return; } - if (mode.equals("escalated")) { + if (mode.equals("escalated") || mode.equals("escalated-held")) { String value = Files.readString(Path.of(args[1])); System.out.println("ESCALATION_OK:" + value); + System.out.flush(); + if (mode.equals("escalated-held")) System.in.read(); + return; + } + if (mode.equals("guardian")) { + java.io.BufferedReader input = new java.io.BufferedReader(new java.io.InputStreamReader(System.in)); + while (input.readLine() != null) { + try { + Files.readString(Path.of(args[2])); + throw new AssertionError("Another command's grant widened the requesting agent"); + } catch (IOException expected) { + System.out.println("PARENT_DENIED"); + System.out.flush(); + } + } return; } Path project = Path.of(args[1]); diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt index bdc523ef2d..8b326e12f1 100644 --- a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt @@ -9,6 +9,7 @@ import ai.cageforge.WindowsSetupState import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import org.junit.After import org.junit.Before import org.junit.Rule @@ -220,6 +221,79 @@ class CommandNativeSecurityTest { assertFalse(Files.exists(project.resolve("root-allowed"))) } + @Test(timeout = 120000) + fun serviceEscalationRequiresConsentAndKeepsParentRunning() = + runBlocking { + val project = temporary.newFolder("service-escalation").toPath() + Files.createDirectory(project.resolve(".git")) + val privateDirectory = temporary.newFolder("service-private").toPath() + val approvedFile = Files.writeString(privateDirectory.resolve("approved-input"), "approved") + val parentCommand = command(project, listOf("guardian", project.toString(), approvedFile.toString())) + val service = SandboxSessionService() + try { + val start = async { service.start(parentCommand, "Start requesting agent") } + val initial = + service.consent.requests + .first { it.isNotEmpty() } + .single() + service.consent.decide(initial.id, SandboxConsentChoice.ONCE) + val parent = requireNotNull(start.await()) + assertParentConfined(parent) + val additional = + SandboxEscalation( + parentCommand.argv.dropLast(3) + listOf("escalated-held", approvedFile.toString()), + listOf("read" to approvedFile.toString()), + emptyList(), + "Read approved input for one command", + ) + val denied = async { service.startEscalated(parent.id, additional) } + val denial = + service.consent.requests + .first { it.isNotEmpty() } + .single() + service.consent.decide(denial.id, SandboxConsentChoice.DENY) + assertEquals(null, denied.await()) + assertEquals(1, service.sessions.value.size) + val launch = async { service.startEscalated(parent.id, additional) } + val review = + service.consent.requests + .first { it.isNotEmpty() } + .single() + assertEquals(additional.argv, review.review.argv) + assertTrue(review.review.permissionsJson.contains("approved-input")) + service.consent.decide(review.id, SandboxConsentChoice.ONCE) + val elevated = requireNotNull(launch.await()) + withTimeout(20000) { + elevated.session.output.first { it.stdout.text.contains("ESCALATION_OK:approved") || !it.running } + } + assertParentConfined(parent) + elevated.session.closeInput() + val output = elevated.session.awaitCompletion() + assertEquals(0, output.exitCode, output.stderr.text) + assertTrue(output.stdout.text.contains("ESCALATION_OK:approved"), output.stdout.text) + assertTrue(parent.session.output.value.running, "Additional command must not restart the agent") + assertParentConfined(parent) + } finally { + service.shutdown() + } + } + + private suspend fun assertParentConfined(parent: SandboxSessionEntry) { + val before = parent.session.output.value.stdout.text + parent.session.sendInput("check\n") + val output = + withTimeout(20000) { + parent.session.output.first { it.stdout.text != before || !it.running } + } + assertTrue(output.running, output.stderr.text) + assertTrue( + output.stdout.text + .removePrefix(before) + .contains("PARENT_DENIED"), + output.stdout.text, + ) + } + private fun command( project: Path, arguments: List, diff --git a/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxLaunchRequestsTest.kt b/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxLaunchRequestsTest.kt new file mode 100644 index 0000000000..33d746635f --- /dev/null +++ b/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxLaunchRequestsTest.kt @@ -0,0 +1,78 @@ +package ai.rever.boss.sandbox + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Test +import java.io.IOException +import java.nio.file.Path +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +class SandboxLaunchRequestsTest { + private val command = SandboxCommand(Path.of("project"), Path.of("policy"), "base", listOf("tool")) + + @Test(timeout = 10000) + fun `submission returns before approval and completed tickets require explicit forgetting`(): Unit = + runBlocking { + val answer = CompletableDeferred() + val requests = + SandboxLaunchRequests({ _, _ -> + answer.await() + null + }, capacity = 1) + try { + val id = requests.submit(command, "Test") + assertEquals(SandboxLaunchState.PENDING, requests.status(id).state) + assertFailsWith { requests.forget(id) } + assertFailsWith { requests.submit(command, "Over capacity") } + answer.complete(Unit) + awaitState(requests, id, SandboxLaunchState.DENIED) + // Completion publication can precede the coroutine's final instruction. + withTimeout(5000) { + while (sandboxOperationResult { requests.forget(id) }.isFailure) delay(1) + } + assertFailsWith { requests.status(id) } + } finally { + requests.shutdown() + } + } + + @Test(timeout = 10000) + fun `asynchronous native failure remains observable with its original cause`() = + runBlocking { + val failure = IOException("Native failure") + val requests = SandboxLaunchRequests({ _, _ -> throw failure }) + try { + val id = requests.submit(command, "Test") + awaitState(requests, id, SandboxLaunchState.FAILED) + assertSame(failure, requests.status(id).failure) + } finally { + requests.shutdown() + } + } + + @Test(timeout = 10000) + fun `shutdown cancels pending reviews and rejects new requests`(): Unit = + runBlocking { + val requests = + SandboxLaunchRequests({ _, _ -> + CompletableDeferred().await() + null + }) + val id = requests.submit(command, "Test") + requests.shutdown() + assertEquals(SandboxLaunchState.CANCELLED, requests.status(id).state) + assertFailsWith { requests.submit(command, "After shutdown") } + } + + private suspend fun awaitState( + requests: SandboxLaunchRequests, + id: String, + state: SandboxLaunchState, + ) { + withTimeout(5000) { while (requests.status(id).state != state) delay(1) } + } +} diff --git a/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshotTest.kt b/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshotTest.kt index 30b0cb00a7..d0ea2364ab 100644 --- a/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshotTest.kt +++ b/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxPolicySnapshotTest.kt @@ -35,6 +35,19 @@ class SandboxPolicySnapshotTest { assertNotEquals(snapshot.digest, SandboxPolicySnapshot.read(command).digest) } + @Test + fun `additional commands reuse captured policy and never reread a changed file`() { + val command = command() + val original = SandboxPolicySnapshot.read(command) + Files.writeString(command.policyFile, "malicious replacement") + val derived = original.forCommand(listOf("cargo", "test")) + assertTrue(derived.toml.contains("[profiles.node]")) + assertTrue(!derived.toml.contains("malicious replacement")) + assertEquals(listOf("cargo", "test"), derived.argv) + assertNotEquals(original.digest, derived.digest) + assertFailsWith { original.forCommand(listOf("tool", "\u0000")) } + } + @Test fun `host profile permits Cageforge escalation without changing the selected profile`() { val snapshot = SandboxPolicySnapshot.read(command()) diff --git a/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxSessionServiceTest.kt b/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxSessionServiceTest.kt index 2c847d1097..7364f50671 100644 --- a/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxSessionServiceTest.kt +++ b/modules/boss-command-sandbox/src/test/kotlin/ai/rever/boss/sandbox/SandboxSessionServiceTest.kt @@ -145,6 +145,70 @@ class SandboxSessionServiceTest { return SandboxCommand(project, policy, "tool", listOf("tool", "arg with spaces")) } + @Test(timeout = 10000) + fun `additional command denial and cancellation release preparation without launching`() = + runBlocking { + val backend = TestBackend() + val service = SandboxSessionService(SandboxConsentQueue(), backend) + try { + val initial = async { service.start(command(), "Agent") } + decide(service, SandboxConsentChoice.ONCE) + val parent = requireNotNull(initial.await()) + val request = + SandboxEscalation( + listOf("other", "exact arg"), + listOf("read" to "/input"), + emptyList(), + "Read", + ) + val denied = async { service.startEscalated(parent.id, request) } + val review = + service.consent.requests + .first { it.isNotEmpty() } + .single() + assertEquals(request.argv, review.review.argv) + service.consent.decide(review.id, SandboxConsentChoice.DENY) + assertNull(denied.await()) + assertEquals(1, backend.launches) + assertEquals(1, backend.preparationsClosed) + val cancelled = async { service.startEscalated(parent.id, request) } + service.consent.requests.first { it.isNotEmpty() } + cancelled.cancelAndJoin() + assertEquals(2, backend.preparationsClosed) + assertEquals(1, backend.launches) + assertTrue(parent.session.output.value.running) + } finally { + service.shutdown() + } + } + + @Test(timeout = 10000) + fun `additional command launches only after its own approval and preserves parent`() = + runBlocking { + val backend = TestBackend() + val service = SandboxSessionService(SandboxConsentQueue(), backend) + try { + val initial = async { service.start(command(), "Agent") } + decide(service, SandboxConsentChoice.ONCE) + val parent = requireNotNull(initial.await()) + val request = SandboxEscalation(listOf("other"), listOf("read" to "/input"), emptyList(), "Read") + val pending = async { service.startEscalated(parent.id, request) } + val review = + service.consent.requests + .first { it.isNotEmpty() } + .single() + assertEquals(1, backend.launches) + service.consent.decide(review.id, SandboxConsentChoice.ONCE) + val child = requireNotNull(pending.await()) + assertEquals(request.argv, child.review.argv) + assertEquals(2, backend.launches) + assertTrue(parent.session.output.value.running) + assertEquals(1, backend.preparationsClosed) + } finally { + service.shutdown() + } + } + private suspend fun decide( service: SandboxSessionService, choice: SandboxConsentChoice, @@ -167,6 +231,26 @@ class SandboxSessionServiceTest { val release = CountDownLatch(1) val closed = AtomicBoolean() var launches = 0 + var preparationsClosed = 0 + + override fun prepareEscalation( + base: SandboxSessionPlan, + additional: SandboxEscalation, + ): SandboxEscalationPlan = + object : SandboxEscalationPlan { + override val review = + SandboxSessionPlan( + base.snapshot.forCommand(additional.argv), + "expanded-digest", + "{\"expanded\":true}", + ).review(additional.reason) + + override fun launch() = this@TestBackend.launch(base) + + override fun close() { + preparationsClosed++ + } + } override fun prepare(command: SandboxCommand): SandboxSessionPlan = SandboxSessionPlan(SandboxPolicySnapshot.read(command), "native-digest", "{}") From c80d5eb125e33f94295f4710bc26314b253b66e7 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Wed, 23 Sep 2026 16:39:48 +0500 Subject: [PATCH 21/23] feat(sandbox): expose opt-in startup and consented MCP command tools Co-authored-by: codex --- .../desktopMain/kotlin/ai/rever/boss/main.kt | 17 ++- .../platform/SandboxSessionDialogs.desktop.kt | 40 +++++- .../rever/boss/sandbox/SandboxCommandHost.kt | 14 +- .../boss/sandbox/SandboxConsentDialog.kt | 2 +- .../boss/sandbox/SandboxDisabledDialog.kt | 40 ++++++ .../sandbox/SandboxEscalationArguments.kt | 25 ++++ .../boss/sandbox/SandboxFeatureController.kt | 86 +++++++++++++ .../boss/sandbox/SandboxManagerDialog.kt | 4 +- .../ai/rever/boss/sandbox/SandboxMcpFields.kt | 8 ++ .../boss/sandbox/SandboxMcpOperations.kt | 120 ++++++++++++++++++ .../boss/sandbox/SandboxMcpToolProvider.kt | 78 ++++++++++++ .../ai/rever/boss/sandbox/SandboxMcpTools.kt | 89 +++++++++++++ .../boss/sandbox/SandboxStartupOptions.kt | 15 +++ .../rever/boss/sandbox/SandboxWindowModel.kt | 7 +- .../ai/rever/boss/startup/ShutdownSequence.kt | 2 +- .../boss/sandbox/SandboxDisabledDialogTest.kt | 29 +++++ .../sandbox/SandboxEscalationArgumentsTest.kt | 34 +++++ .../sandbox/SandboxFeatureControllerTest.kt | 39 ++++++ .../boss/sandbox/SandboxManagerDialogTest.kt | 1 + .../sandbox/SandboxMcpToolProviderTest.kt | 58 +++++++++ .../boss/sandbox/SandboxStartupOptionsTest.kt | 32 +++++ docs/cageforge-command-sessions.md | 55 ++++++-- scripts/boss | 3 +- scripts/boss.bat | 2 + scripts/boss.ps1 | 3 +- scripts/test/test-headless-cli.sh | 6 + 26 files changed, 785 insertions(+), 24 deletions(-) create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxDisabledDialog.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxEscalationArguments.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxFeatureController.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxMcpFields.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxMcpOperations.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxMcpToolProvider.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxMcpTools.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxStartupOptions.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxDisabledDialogTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxEscalationArgumentsTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxFeatureControllerTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxMcpToolProviderTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxStartupOptionsTest.kt diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/main.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/main.kt index 6df3a54489..2c4e702423 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/main.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/main.kt @@ -182,10 +182,13 @@ private fun containRenderFault( private val startupScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) fun main(args: Array) { + val sandboxStartup = + ai.rever.boss.sandbox.SandboxStartupOptions + .parse(args) // ------------------------------------------------------------------------- // Phase 1: Headless CLI & credential helper dispatch (before AWT / logging) // ------------------------------------------------------------------------- - when (val earlyResult = CliBootstrap.dispatchHeadless(args)) { + when (val earlyResult = CliBootstrap.dispatchHeadless(sandboxStartup.arguments)) { is CliDispatchResult.Exit -> exitProcess(earlyResult.code) CliDispatchResult.Continue -> Unit } @@ -287,6 +290,10 @@ fun main(args: Array) { // Single-instance check: ensure only one BOSS instance runs if (!SingleInstanceManager.acquireLock()) { logger.info(LogCategory.SYSTEM, "Another BOSS instance is already running") + if (sandboxStartup.enabled) { + System.err.println("BOSS is already running. Enable Sandbox command sessions from its Tools menu.") + exitProcess(1) + } val forwarded = CliBootstrap.forwardToExistingInstance(args) exitProcess(if (forwarded) 0 else 1) } @@ -318,6 +325,12 @@ fun main(args: Array) { // Phase 5: Shutdown hook registration // ------------------------------------------------------------------------- ShutdownSequence.register { kernelBootstrap } + if (sandboxStartup.enabled) { + runBlocking { + ai.rever.boss.sandbox.SandboxCommandHost.feature + .enable() + } + } logger.info(LogCategory.SYSTEM, "Successfully acquired single-instance lock") // ------------------------------------------------------------------------- @@ -338,7 +351,7 @@ fun main(args: Array) { // ------------------------------------------------------------------------- // Phase 7: Post-lock CLI, keyboard interceptor, services & plugins // ------------------------------------------------------------------------- - CliBootstrap.dispatchPostLock(args) + CliBootstrap.dispatchPostLock(sandboxStartup.arguments) AWTKeyboardInterceptor.install() // macOS already read the theme before AWT; other platforms still need this initialization. diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/platform/SandboxSessionDialogs.desktop.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/platform/SandboxSessionDialogs.desktop.kt index 27272637be..aca2b9bc4d 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/platform/SandboxSessionDialogs.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/platform/SandboxSessionDialogs.desktop.kt @@ -2,7 +2,9 @@ package ai.rever.boss.platform import ai.rever.boss.sandbox.SandboxCommandHost import ai.rever.boss.sandbox.SandboxConsentDialog +import ai.rever.boss.sandbox.SandboxDisabledDialog import ai.rever.boss.sandbox.SandboxManagerDialog +import ai.rever.boss.sandbox.SandboxSubsystem import ai.rever.boss.sandbox.SandboxWindowModel import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -20,16 +22,41 @@ internal actual fun SandboxSessionDialogs( onDismiss: () -> Unit, ) { val host = SandboxCommandHost - val service = host.service val scope = rememberCoroutineScope() - val sessions by service.sessions.collectAsState() - val requests by service.consent.requests.collectAsState() - val reviewWindow by host.reviewWindow.collectAsState() - val model = remember(projectDirectory) { SandboxWindowModel(projectDirectory) } + val subsystem by host.feature.active.collectAsState() + val stopping by host.feature.stopping.collectAsState() + val model = remember(projectDirectory, subsystem) { SandboxWindowModel(projectDirectory) } DisposableEffect(windowId) { host.attach(windowId) onDispose { host.detach(windowId) } } + val active = subsystem + if (active != null) { + ActiveSandboxDialogs(active, model, windowId, showManager, onDismiss) + } else if (showManager) { + SandboxDisabledDialog( + stopping, + model.message, + onEnable = { scope.launch { model.perform { host.feature.enable() } } }, + onDismiss = onDismiss, + ) + } +} + +@Composable +private fun ActiveSandboxDialogs( + subsystem: SandboxSubsystem, + model: SandboxWindowModel, + windowId: String, + showManager: Boolean, + onDismiss: () -> Unit, +) { + val host = SandboxCommandHost + val service = subsystem.service + val scope = rememberCoroutineScope() + val sessions by service.sessions.collectAsState() + val requests by service.consent.requests.collectAsState() + val reviewWindow by host.reviewWindow.collectAsState() val review = requests.firstOrNull()?.takeIf { reviewWindow == windowId } if (review != null) { SandboxConsentDialog(review) { service.consent.decide(review.id, it) } @@ -40,11 +67,12 @@ internal actual fun SandboxSessionDialogs( model.busy, model.message, sessions, - onStart = { scope.launch { model.start(windowId) } }, + onStart = { scope.launch { model.start(windowId, service) } }, onAction = { action -> scope.launch { model.perform(action) } }, onRemove = { service.remove(it) }, onRevoke = { service.consent.revoke() }, onDismiss = onDismiss, + onDisable = { scope.launch { model.perform { host.feature.disable() } } }, ) } } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxCommandHost.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxCommandHost.kt index 5ed7495393..fb8d45f1fa 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxCommandHost.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxCommandHost.kt @@ -5,11 +5,23 @@ import kotlinx.coroutines.flow.asStateFlow /** One owner for every BOSS window and CLI caller. Never persists grants or launches an ordinary process. */ internal object SandboxCommandHost { - val service = SandboxSessionService() private val windows = linkedSetOf() private val mutableReviewWindow = MutableStateFlow(null) val reviewWindow = mutableReviewWindow.asStateFlow() + val feature = + SandboxFeatureController( + register = { subsystem -> + ai.rever.boss.mcp.McpToolRegistryImpl.registerProvider( + SandboxMcpToolProvider(subsystem.service, subsystem.requests) { reviewWindow.value != null }, + ) + }, + unregister = { + ai.rever.boss.mcp.McpToolRegistryImpl + .unregisterProvider("boss-command-sandbox") + }, + ) + fun attach(windowId: String) = synchronized(windows) { windows.add(windowId) diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxConsentDialog.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxConsentDialog.kt index 8e46a332aa..edf403ddd3 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxConsentDialog.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxConsentDialog.kt @@ -51,7 +51,7 @@ internal fun SandboxConsentDialog( } } if (review.requiresRestart) { - Text("Approval launches a new sandbox boundary; existing permissions are not widened.") + Text("Additional permissions apply only to this new command. The requesting agent stays unchanged.") } Text("Until BOSS closes remembers only this exact command and policy. Restarting BOSS asks again.") SandboxConsentButtons(request.id, onDecide) diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxDisabledDialog.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxDisabledDialog.kt new file mode 100644 index 0000000000..68b40d4a30 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxDisabledDialog.kt @@ -0,0 +1,40 @@ +package ai.rever.boss.sandbox + +import ai.rever.boss.plugin.ui.BossDialog +import ai.rever.boss.plugin.ui.BossTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +internal fun SandboxDisabledDialog( + stopping: Boolean, + message: String?, + onEnable: () -> Unit, + onDismiss: () -> Unit, +) { + BossDialog(onDismissRequest = onDismiss) { + Surface(color = BossTheme.colors.panel, contentColor = BossTheme.colors.textPrimary) { + Column(Modifier.width(560.dp).padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(if (stopping) "Stopping sandbox sessions..." else "Sandbox command sessions are disabled") + Text("Enable this subsystem for this BOSS run. Its MCP tools are unavailable while disabled.") + Text("Ordinary launches stay unchanged. Every sandbox launch is still an explicit choice.") + Text("Only a sandbox session's root process and descendants are isolated, not BOSS or external agents.") + Text("Restarting BOSS disables the subsystem and forgets its permission approvals.") + message?.let { Text(it) } + Row { + TextButton(onClick = onDismiss) { Text("Close") } + TextButton(onClick = onEnable, enabled = !stopping) { Text("Enable sandbox command sessions") } + } + } + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxEscalationArguments.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxEscalationArguments.kt new file mode 100644 index 0000000000..7e7d4506e1 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxEscalationArguments.kt @@ -0,0 +1,25 @@ +package ai.rever.boss.sandbox + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +internal fun sandboxEscalation(args: JsonObject): SandboxEscalation { + val filesystem = args["filesystem"] + require(filesystem is JsonArray) { "filesystem must be an array of operation/path objects" } + val files = + filesystem.map { + require(it is JsonObject && it.keys == setOf("operation", "path")) { "Invalid filesystem capability" } + it.sandboxString("operation") to it.sandboxString("path") + } + return SandboxEscalation(args.stringArray("argv"), files, args.stringArray("network"), args.sandboxString("reason")) +} + +private fun JsonObject.stringArray(name: String): List { + val value = this[name] + require(value is JsonArray) { "$name must be an array" } + return value.map { + require(it is JsonPrimitive && it.isString) { "$name entries must be strings" } + it.content + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxFeatureController.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxFeatureController.kt new file mode 100644 index 0000000000..bf236edf15 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxFeatureController.kt @@ -0,0 +1,86 @@ +package ai.rever.boss.sandbox + +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** No settings persistence and no eager runtime: every BOSS process starts with sandboxing disabled. */ +internal class SandboxFeatureController( + private val register: (SandboxSubsystem) -> Unit, + private val unregister: () -> Unit, +) { + private val lifecycle = Mutex() + private val mutableActive = MutableStateFlow(null) + private val mutableStopping = MutableStateFlow(false) + val active = mutableActive.asStateFlow() + val stopping = mutableStopping.asStateFlow() + private var closed = false + + suspend fun enable() { + lifecycle.withLock { + check(!closed) { "BOSS is shutting down" } + if (active.value == null) { + val subsystem = SandboxSubsystem() + var transferred = false + try { + register(subsystem) + mutableActive.value = subsystem + transferred = true + } finally { + if (!transferred) { + try { + unregister() + } finally { + subsystem.shutdown() + } + } + } + } + } + } + + suspend fun disable() = + withContext(NonCancellable) { + lifecycle.withLock { disableCurrent() } + } + + suspend fun shutdown() = + withContext(NonCancellable) { + lifecycle.withLock { + closed = true + disableCurrent() + } + } + + private suspend fun disableCurrent() { + active.value?.let { subsystem -> + mutableStopping.value = true + mutableActive.value = null + try { + try { + unregister() + } finally { + subsystem.shutdown() + } + } finally { + mutableStopping.value = false + } + } + } +} + +internal class SandboxSubsystem { + val service = SandboxSessionService() + val requests = SandboxLaunchRequests(service) + + suspend fun shutdown() { + try { + service.shutdown() + } finally { + requests.shutdown() + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxManagerDialog.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxManagerDialog.kt index 392a728912..f499b918f2 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxManagerDialog.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxManagerDialog.kt @@ -40,6 +40,7 @@ internal fun SandboxManagerDialog( onRemove: suspend (String) -> Unit, onRevoke: () -> Unit, onDismiss: () -> Unit, + onDisable: () -> Unit, ) { BossDialog(onDismissRequest = onDismiss) { Surface(color = BossTheme.colors.panel, contentColor = BossTheme.colors.textPrimary) { @@ -54,7 +55,7 @@ internal fun SandboxManagerDialog( Text("Sandbox command sessions") Text( "Explicit opt-in. The root executable and its descendants share one Cageforge policy. " + - "Ordinary terminals are unchanged.", + "Ordinary terminals and externally launched agents are not isolated by this feature.", ) Text("Uses stdin/stdout pipes, not an interactive terminal. Windows requires Cageforge setup first.") SandboxLaunchFields(form, onChange, !busy) @@ -66,6 +67,7 @@ internal fun SandboxManagerDialog( TextButton(onClick = onDismiss) { Text("Close") } } message?.let { Text(it) } + TextButton(onClick = onDisable) { Text("Disable sandboxing and stop all sandbox sessions") } sessions.forEach { entry -> key(entry.id) { Divider() diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxMcpFields.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxMcpFields.kt new file mode 100644 index 0000000000..b32866df73 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxMcpFields.kt @@ -0,0 +1,8 @@ +package ai.rever.boss.sandbox + +internal class SandboxMcpFields( + val required: List, + optional: List = emptyList(), +) { + val all = required + optional +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxMcpOperations.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxMcpOperations.kt new file mode 100644 index 0000000000..d6db62d954 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxMcpOperations.kt @@ -0,0 +1,120 @@ +package ai.rever.boss.sandbox + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.nio.file.Path + +internal class SandboxMcpOperations( + private val service: SandboxSessionService, + private val requests: SandboxLaunchRequests, + private val canReview: () -> Boolean, +) { + fun start(args: JsonObject): JsonElement { + check(canReview()) { "Open a BOSS window to review sandbox permissions before requesting a launch" } + val arguments = args["argv"] + require(arguments is JsonArray && arguments.isNotEmpty()) { "argv must include an executable" } + val argv = + arguments.map { + require(it is JsonPrimitive && it.isString) { "Every argv entry must be a string" } + it.content + } + val command = + SandboxCommand( + Path.of(args.sandboxString("project")), + Path.of(args.sandboxString("policy")), + args.sandboxString("profile"), + argv, + ) + val id = requests.submit(command, args.sandboxString("reason")) + return buildJsonObject { + put("request_id", id) + put("state", "PENDING") + put("next", "Review permissions in BOSS; poll sandbox_request_status with request_id") + } + } + + fun requestStatus(args: JsonObject): JsonElement { + val status = requests.status(args.sandboxString("request_id")) + return buildJsonObject { + put("request_id", status.id) + put("state", status.state.name) + status.sessionId?.let { put("session_id", it) } + status.failure?.let { put("error", it.message ?: it.javaClass.simpleName) } + } + } + + fun requestPermissions(args: JsonObject): JsonElement { + check(canReview()) { "Open a BOSS window to review additional permissions" } + val request = sandboxEscalation(args) + val id = requests.submitEscalation(args.sandboxString("session_id"), request) + return buildJsonObject { + put("request_id", id) + put("state", "PENDING") + put("next", "Review additional permissions in BOSS; poll sandbox_request_status with request_id") + } + } + + fun forget(args: JsonObject): JsonElement { + requests.forget(args.sandboxString("request_id")) + return buildJsonObject { put("forgotten", true) } + } + + fun sessions(): JsonElement = + JsonArray( + service.sessions.value.map { entry -> + buildJsonObject { + put("session_id", entry.id) + put("project", entry.review.projectDirectory) + put("argv", JsonArray(entry.review.argv.map(::JsonPrimitive))) + put("running", entry.session.output.value.running) + } + }, + ) + + fun output(args: JsonObject): JsonElement = outputJson(entry(args).session.output.value) + + suspend fun input(args: JsonObject): JsonElement { + val eof = args["close_stdin"] + require(eof == null || (eof is JsonPrimitive && !eof.isString && eof.booleanOrNull != null)) { + "close_stdin must be a boolean" + } + val close = eof?.booleanOrNull == true + require("text" in args || close) { "Provide text or set close_stdin to true" } + val session = entry(args).session + if ("text" in args) session.sendInput(args.sandboxString("text")) + if (close) session.closeInput() + return buildJsonObject { put("accepted", true) } + } + + suspend fun stop(args: JsonObject): JsonElement { + val session = entry(args).session + session.stop() + return outputJson(session.output.value) + } + + suspend fun remove(args: JsonObject): JsonElement { + service.remove(args.sandboxString("session_id")) + return buildJsonObject { put("removed", true) } + } + + private fun entry(args: JsonObject): SandboxSessionEntry { + val id = args.sandboxString("session_id") + return requireNotNull(service.sessions.value.singleOrNull { it.id == id }) { "Unknown sandbox session" } + } +} + +private fun outputJson(output: SandboxSessionOutput): JsonObject = + buildJsonObject { + put("running", output.running) + output.exitCode?.let { put("exit_code", it) } + put("stdout", output.stdout.text) + put("stderr", output.stderr.text) + put("stdout_discarded_characters", output.stdout.discardedCharacters) + put("stderr_discarded_characters", output.stderr.discardedCharacters) + output.failure?.let { put("error", it.message ?: it.javaClass.simpleName) } + } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxMcpToolProvider.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxMcpToolProvider.kt new file mode 100644 index 0000000000..0dd1dd8433 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxMcpToolProvider.kt @@ -0,0 +1,78 @@ +package ai.rever.boss.sandbox + +import ai.rever.boss.plugin.api.McpToolDefinition +import ai.rever.boss.plugin.api.McpToolProvider + +/** Operator-facing host tools. Agent-scoped endpoints must not expose this global session catalogue. */ +internal class SandboxMcpToolProvider( + service: SandboxSessionService, + requests: SandboxLaunchRequests, + canReview: () -> Boolean, +) : McpToolProvider { + override val providerId = "boss-command-sandbox" + private val operations = SandboxMcpOperations(service, requests, canReview) + + override fun tools(): List = launchTools() + sessionTools() + + private fun launchTools(): List = + listOf( + sandboxMcpTool( + "sandbox_start", + "Request a root command session. Returns a ticket, not approval; review native permissions in BOSS.", + SandboxMcpFields(listOf("project", "policy", "profile", "argv", "reason")), + false, + ) { operations.start(it) }, + sandboxMcpTool( + "sandbox_request_permissions", + "Request extra rights for one command in a new sandbox. Only the human in BOSS can approve.", + SandboxMcpFields(listOf("session_id", "argv", "filesystem", "network", "reason")), + false, + ) { operations.requestPermissions(it) }, + sandboxMcpTool( + "sandbox_request_status", + "Poll a launch ticket; STARTED includes session_id. No polling call launches a process.", + SandboxMcpFields(listOf("request_id")), + true, + ) { operations.requestStatus(it) }, + sandboxMcpTool( + "sandbox_forget_request", + "Forget a completed ticket to release request capacity; does not remove its session.", + SandboxMcpFields(listOf("request_id")), + false, + ) { operations.forget(it) }, + sandboxMcpTool( + "sandbox_sessions", + "List operator-managed sandbox sessions, including GUI launches.", + SandboxMcpFields(emptyList()), + true, + ) { operations.sessions() }, + ) + + private fun sessionTools(): List = + listOf( + sandboxMcpTool( + "sandbox_output", + "Read bounded stdout/stderr tails, exit status and omitted-character counts.", + SandboxMcpFields(listOf("session_id")), + true, + ) { operations.output(it) }, + sandboxMcpTool( + "sandbox_input", + "Write exact UTF-8 text to stdin (at most 16 KiB) and/or close stdin. Does not add a newline.", + SandboxMcpFields(listOf("session_id"), optional = listOf("text", "close_stdin")), + false, + ) { operations.input(it) }, + sandboxMcpTool( + "sandbox_stop", + "Stop the root command and all its descendants; wait for native boundary cleanup.", + SandboxMcpFields(listOf("session_id")), + false, + ) { operations.stop(it) }, + sandboxMcpTool( + "sandbox_remove", + "Remove retained output for a finished session. Running sessions must be stopped first.", + SandboxMcpFields(listOf("session_id")), + false, + ) { operations.remove(it) }, + ) +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxMcpTools.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxMcpTools.kt new file mode 100644 index 0000000000..db4be24917 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxMcpTools.kt @@ -0,0 +1,89 @@ +package ai.rever.boss.sandbox + +import ai.rever.boss.plugin.api.McpToolDefinition +import ai.rever.boss.plugin.api.McpToolHandler +import ai.rever.boss.plugin.api.McpToolResult +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put + +/** The host registry's tool policy remains in force, in addition to native capability consent. */ +internal fun sandboxMcpTool( + name: String, + description: String, + fields: SandboxMcpFields, + readOnly: Boolean, + action: suspend (JsonObject) -> JsonElement, +): McpToolDefinition = + McpToolDefinition( + name = name, + description = description, + inputSchema = sandboxMcpSchema(fields.all, fields.required), + readOnly = readOnly, + handler = + McpToolHandler { args -> + sandboxOperationResult { + val input = Json.parseToJsonElement(args.raw).jsonObject + require(input.keys.all { it in fields.all }) { "Unknown sandbox tool argument" } + require(fields.required.all { it in input }) { "Missing required sandbox tool argument" } + action(input) + }.fold( + { McpToolResult(it.toString()) }, + { McpToolResult(it.message ?: it.javaClass.simpleName, isError = true) }, + ) + }, + ) + +private fun sandboxMcpSchema( + fields: List, + required: List, +): String = + buildJsonObject { + put("type", "object") + put("additionalProperties", false) + put("required", JsonArray(required.map(::JsonPrimitive))) + put( + "properties", + buildJsonObject { + fields.forEach { field -> + put( + field, + buildJsonObject { + val type = + when (field) { + "argv", "filesystem", "network" -> "array" + "close_stdin" -> "boolean" + else -> "string" + } + put("type", type) + if (field == "argv" || field == "network") { + put("items", buildJsonObject { put("type", "string") }) + } + if (field == "filesystem") { + put( + "items", + Json.parseToJsonElement( + sandboxMcpSchema( + listOf("operation", "path"), + listOf("operation", "path"), + ), + ), + ) + } + }, + ) + } + }, + ) + }.toString() + +internal fun JsonObject.sandboxString(name: String): String { + val value = this[name] + require(value is JsonPrimitive && value.isString) { "$name must be a string" } + return value.content +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxStartupOptions.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxStartupOptions.kt new file mode 100644 index 0000000000..900d161036 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxStartupOptions.kt @@ -0,0 +1,15 @@ +package ai.rever.boss.sandbox + +/** Startup-only opt-in. Never consume an argument belonging to a command or executable. */ +internal class SandboxStartupOptions private constructor( + val enabled: Boolean, + val arguments: Array, +) { + companion object { + fun parse(args: Array): SandboxStartupOptions { + val enabled = args.firstOrNull() == "--sandbox" + require(!enabled || args.size == 1) { "Use boss --sandbox alone to enable sandbox sessions at startup" } + return SandboxStartupOptions(enabled, if (enabled) emptyArray() else args) + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxWindowModel.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxWindowModel.kt index 5f2025a422..4b0b1f2634 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxWindowModel.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/sandbox/SandboxWindowModel.kt @@ -17,7 +17,10 @@ internal class SandboxWindowModel( var message by mutableStateOf(null) private set - suspend fun start(windowId: String) { + suspend fun start( + windowId: String, + service: SandboxSessionService, + ) { if (busy) return busy = true val captured = form @@ -25,7 +28,7 @@ internal class SandboxWindowModel( SandboxCommandHost.reviewIn(windowId) val result = sandboxOperationResult { - SandboxCommandHost.service.start(captured.command(), "Run from BOSS GUI") + service.start(captured.command(), "Run from BOSS GUI") } message = result.fold( diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/ShutdownSequence.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/ShutdownSequence.kt index 486c6a8273..62fd81a0b6 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/ShutdownSequence.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/ShutdownSequence.kt @@ -55,7 +55,7 @@ object ShutdownSequence { } }, ShutdownStep("stopping sandbox command sessions") { - runBlocking { SandboxCommandHost.service.shutdown() } + runBlocking { SandboxCommandHost.feature.shutdown() } }, ShutdownStep("stopping performance monitor") { PerformanceMonitor.stop() diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxDisabledDialogTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxDisabledDialogTest.kt new file mode 100644 index 0000000000..c4f35f2b61 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxDisabledDialogTest.kt @@ -0,0 +1,29 @@ +package ai.rever.boss.sandbox + +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +class SandboxDisabledDialogTest { + @get:Rule + val rule = createComposeRule() + + @Test + fun `opening disabled subsystem does not enable it`() { + var enabled = 0 + rule.setContent { SandboxDisabledDialog(false, null, { enabled++ }, {}) } + rule.runOnIdle { assertEquals(0, enabled) } + rule.onNodeWithText("Enable sandbox command sessions").performClick() + rule.runOnIdle { assertEquals(1, enabled) } + } + + @Test + fun `cannot reenable while old native sessions are stopping`() { + rule.setContent { SandboxDisabledDialog(true, null, { error("Must not enable") }, {}) } + rule.onNodeWithText("Enable sandbox command sessions").assertIsNotEnabled().performClick() + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxEscalationArgumentsTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxEscalationArgumentsTest.kt new file mode 100644 index 0000000000..27257c1fb3 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxEscalationArgumentsTest.kt @@ -0,0 +1,34 @@ +package ai.rever.boss.sandbox + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class SandboxEscalationArgumentsTest { + @Test + fun `capabilities and separate arguments are parsed without a shell`() { + val args = + Json + .parseToJsonElement( + """{"argv":["node","a b",""],"filesystem":[{"operation":"read","path":"/input"}], + "network":["example.com:443"],"reason":"Read input"}""", + ).jsonObject + val request = sandboxEscalation(args) + assertEquals(listOf("node", "a b", ""), request.argv) + assertEquals(listOf("read" to "/input"), request.filesystem) + assertEquals(listOf("example.com:443"), request.network) + } + + @Test + fun `malformed filesystem requests cannot carry approval`() { + val args = + Json + .parseToJsonElement( + """{"argv":["node"],"filesystem":[{"operation":"read","path":"/input","approve":true}], + "network":[],"reason":"Read input"}""", + ).jsonObject + assertFailsWith { sandboxEscalation(args) } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxFeatureControllerTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxFeatureControllerTest.kt new file mode 100644 index 0000000000..79cc7d8cfa --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxFeatureControllerTest.kt @@ -0,0 +1,39 @@ +package ai.rever.boss.sandbox + +import kotlinx.coroutines.runBlocking +import org.junit.Test +import java.nio.file.Path +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotSame +import kotlin.test.assertNull + +class SandboxFeatureControllerTest { + @Test + fun `default is off and only explicit enable registers tools with a fresh nonpersistent subsystem`() = + runBlocking { + var registered = 0 + var unregistered = 0 + val feature = SandboxFeatureController({ registered++ }, { unregistered++ }) + assertNull(feature.active.value) + assertEquals(0, registered) + feature.enable() + feature.enable() + val first = requireNotNull(feature.active.value) + assertEquals(1, registered) + feature.disable() + assertNull(feature.active.value) + assertEquals(1, unregistered) + val command = SandboxCommand(Path.of("project"), Path.of("policy"), "base", listOf("tool")) + assertFailsWith { first.service.start(command, "Stale UI") } + assertFailsWith { first.requests.submit(command, "Stale MCP tool") } + feature.enable() + assertNotSame(first, feature.active.value) + feature.shutdown() + assertEquals(2, unregistered) + assertFailsWith { feature.enable() } + val nextRun = SandboxFeatureController({ error("Not enabled") }, {}) + assertNull(nextRun.active.value) + nextRun.shutdown() + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxManagerDialogTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxManagerDialogTest.kt index 7c12a96698..a86e669bbe 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxManagerDialogTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxManagerDialogTest.kt @@ -35,6 +35,7 @@ class SandboxManagerDialogTest { onRemove = {}, onRevoke = {}, onDismiss = {}, + onDisable = {}, ) } rule.onNodeWithText("Executable (not a shell command)").performScrollTo().performTextReplacement("node") diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxMcpToolProviderTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxMcpToolProviderTest.kt new file mode 100644 index 0000000000..2b76d2c6d8 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxMcpToolProviderTest.kt @@ -0,0 +1,58 @@ +package ai.rever.boss.sandbox + +import ai.rever.boss.plugin.api.McpToolArgs +import kotlinx.coroutines.runBlocking +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SandboxMcpToolProviderTest { + @Test + fun `every mutating tool declares mutation and no tool grants approval`() = + runBlocking { + val subsystem = SandboxSubsystem() + try { + val tools = SandboxMcpToolProvider(subsystem.service, subsystem.requests) { true }.tools() + assertEquals( + setOf("sandbox_output", "sandbox_sessions", "sandbox_request_status"), + tools.filter { it.readOnly }.map { it.name }.toSet(), + ) + assertEquals(9, tools.size) + assertTrue(tools.none { it.name.contains("approve") || it.name.contains("enable") }) + val list = tools.single { it.name == "sandbox_sessions" }.handler.call(McpToolArgs(emptyMap(), "{}")) + assertEquals("[]", list.text) + assertFalse(list.isError) + } finally { + subsystem.shutdown() + } + } + + @Test + fun `unknown approval fields and shell strings fail without native preparation`() = + runBlocking { + val subsystem = SandboxSubsystem() + try { + val tools = SandboxMcpToolProvider(subsystem.service, subsystem.requests) { true }.tools() + val start = tools.single { it.name == "sandbox_start" }.handler + val invalid = + listOf( + """{"project":"/x","policy":"/x/p.toml","profile":"base","argv":"sh -c x","reason":"test"}""", + """ + {"project":"/x","policy":"/x/p.toml","profile":"base","argv":["tool"],"reason":"test","approve":true} + """.trimIndent(), + ) + invalid.forEach { assertTrue(start.call(McpToolArgs(emptyMap(), it)).isError) } + assertTrue( + subsystem.service.consent.requests.value + .isEmpty(), + ) + assertTrue( + subsystem.service.sessions.value + .isEmpty(), + ) + } finally { + subsystem.shutdown() + } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxStartupOptionsTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxStartupOptionsTest.kt new file mode 100644 index 0000000000..c42eb0cf6c --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/sandbox/SandboxStartupOptionsTest.kt @@ -0,0 +1,32 @@ +package ai.rever.boss.sandbox + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SandboxStartupOptionsTest { + @Test + fun defaultIsOffAndExplicitStartupIsEnabled() { + assertFalse(SandboxStartupOptions.parse(emptyArray()).enabled) + val enabled = SandboxStartupOptions.parse(arrayOf("--sandbox")) + assertTrue(enabled.enabled) + assertTrue(enabled.arguments.isEmpty()) + } + + @Test + fun commandArgumentsAreNotConsumed() { + val args = arrayOf("mcp", "invoke", "example", "--sandbox") + val options = SandboxStartupOptions.parse(args) + assertFalse(options.enabled) + assertContentEquals(args, options.arguments) + } + + @Test + fun startupFlagCannotSilentlyEnableHeadlessCommands() { + assertFailsWith { + SandboxStartupOptions.parse(arrayOf("--sandbox", "mcp", "list")) + } + } +} diff --git a/docs/cageforge-command-sessions.md b/docs/cageforge-command-sessions.md index 99ed413671..09cea40199 100644 --- a/docs/cageforge-command-sessions.md +++ b/docs/cageforge-command-sessions.md @@ -6,6 +6,21 @@ shells, Git commands and compilers it creates inside the same native boundary. An MCP call is a transport operation, not a new sandbox boundary. Ordinary terminal and plugin execution are outside this feature and retain their existing behavior. +This is process-tree isolation, not application-wide or machine-wide isolation. +An agent launched outside a sandbox session can execute commands directly without +calling BOSS; only commands it explicitly launches through the sandbox tools enter +this boundary. An agent launched as the sandbox root has its child processes +constrained by the same policy even when it does not use MCP. Calls to external +MCP servers or other already-running services do not bring those services inside +the process boundary. Enabling this feature alone does not sandbox an agent. + +The subsystem is off by default. Start BOSS using `boss --sandbox`, or explicitly +enable **Tools > Sandbox command sessions** in an already-running BOSS. The startup +flag is used alone and refuses to silently modify an existing BOSS instance. It +does not redirect ordinary command execution. Disabling the subsystem unregisters +its MCP tools, revokes its approvals and stops its sandbox sessions. The setting is +not persisted; the next BOSS run is disabled unless explicitly enabled again. + ## Launch from BOSS Open **Tools > Sandbox command sessions**. Enter the absolute project directory and @@ -28,8 +43,20 @@ output remains until removed. At most eight sessions are retained. Application shutdown revokes consent and waits for native cleanup, including launches that were in progress when shutdown started. There is no implicit Windows setup or elevation. -The CLI/MCP launch adapters and agent-scoped additional-permission endpoint are still -being integrated. The GUI launch path is not a claim that agent escalation is ready. +When enabled, operator-facing MCP tools also expose the session mechanism through +`boss mcp invoke sandbox_start --args '{"project":"/absolute/project","policy":"/absolute/policy.toml","profile":"base","argv":["tool","arg"],"reason":"Run project checks"}'`. +The result is a ticket, not permission to run. Use `sandbox_request_status` with +`request_id` to poll it while the user reviews permissions in BOSS. A BOSS window +must be available for review. A disconnected caller does not cancel a submitted +request. Completed tickets can be removed with `sandbox_forget_request`; at most +eight tickets are retained independently of the eight-session limit. + +`sandbox_sessions`, `sandbox_output`, `sandbox_input`, `sandbox_stop` and +`sandbox_remove` operate on these sessions. Input is explicit UTF-8 text with no +implicit newline; `close_stdin` sends EOF. No MCP tool enables the subsystem or +approves permissions. These are operator tools, not an agent-scoped endpoint. +The separate agent endpoint/token provisioning remains integration work. The +operator-facing `sandbox_request_permissions` flow is described below. ## Policy and approval contract @@ -81,11 +108,21 @@ support, or compatibility with CLIs that require a controlling terminal. ## Additional permission requests -Since 0.7.0, Cageforge supports explicit permission escalation. The integration must use -its `requestEscalation`, `approveEscalation` and `launchEscalated` APIs, not rewrite -the running process's policy. The native contract requires a new immutable sandbox; -relaunching a session must stop its previous process boundary first. It is not an -in-place permission change or a promise to preserve an agent's in-memory state. +The operator-facing `sandbox_request_permissions` tool accepts `session_id`, exact +`argv`, `filesystem` (`[{"operation":"read","path":"/absolute/input"}]`), `network` +(for example `["example.com:443"]`) and `reason`. It returns a ticket to poll through +`sandbox_request_status`. It has no approval argument. A running parent session and +a BOSS review window are required. + +BOSS derives a command-specific runtime from the parent's captured TOML bytes, never +from a newly read policy file, and uses Cageforge's `requestEscalation`, +`approveEscalation` and `launchEscalated` APIs. The GUI shows the exact command, +reason and full expanded native permission request. Approval starts a new command +boundary. The original agent remains running under its original restrictions. +The command-specific runtime has no previous process to stop; this is not an +in-place expansion or a restart of the agent. Additional rights do not accumulate +into the parent policy. Every additional command is reviewed independently, unless +that exact command and expanded policy were approved until BOSS closes. The MCP request must identify the command, project/session, additional filesystem or network capabilities, and a human-readable reason. The agent requests access; @@ -106,7 +143,9 @@ Denial, timeout, cancellation, queue overflow and application shutdown never gra permission. A stale dialog cannot approve the next request. This consent mechanism and its unit tests are implemented in the session module. Initial GUI launches use this same queue. Native escalation has a separate API-level security test; its -agent MCP/GUI request loop remains integration work, not verified end-to-end functionality. +agent-only endpoint/token provisioning remains integration work. Native service +tests also exercise denial, approval, the additional command's access and the +continued lifetime of the unchanged parent process. ## Verification work diff --git a/scripts/boss b/scripts/boss index 7ea5cc0508..d4b33dd676 100755 --- a/scripts/boss +++ b/scripts/boss @@ -224,7 +224,7 @@ case "$1" in esac ;; - status|doctor|mcp|completion) + status|doctor|mcp|completion|--sandbox) forward_to_boss "$@" ;; @@ -248,6 +248,7 @@ Commands: doctor Reports problems in the running BOSS instance (exit 2 when degraded) mcp [args] Discovers and invokes desktop MCP tools (list, describe, invoke) completion Generates shell completion script (bash, zsh, fish) + --sandbox Start BOSS with opt-in sandbox command sessions enabled url Opens a URL in Fluck browser workspace Loads a workspace configuration file Opens a file in the editor diff --git a/scripts/boss.bat b/scripts/boss.bat index 34ecf66792..130e512527 100644 --- a/scripts/boss.bat +++ b/scripts/boss.bat @@ -26,6 +26,7 @@ REM Parse command set "COMMAND=%~1" if /i "%COMMAND%"=="status" goto :cmd_forward_exe +if /i "%COMMAND%"=="--sandbox" goto :cmd_forward_exe if /i "%COMMAND%"=="doctor" goto :cmd_forward_exe if /i "%COMMAND%"=="mcp" goto :cmd_forward_exe if /i "%COMMAND%"=="completion" goto :cmd_forward_exe @@ -164,6 +165,7 @@ echo status Queries status and health of the running BOSS inst echo doctor Reports problems in the running BOSS instance (exit 2 when degraded) echo mcp ^ [args] Discovers and invokes desktop MCP tools (list, describe, invoke) echo completion ^ Generates shell completion script (bash, zsh, fish) +echo --sandbox Start BOSS with opt-in sandbox command sessions enabled echo url ^ Opens a URL in Fluck browser echo workspace ^ Loads a workspace configuration echo file ^ Opens a file in the editor diff --git a/scripts/boss.ps1 b/scripts/boss.ps1 index 3773556115..19e66093dc 100644 --- a/scripts/boss.ps1 +++ b/scripts/boss.ps1 @@ -131,6 +131,7 @@ function Show-Help { Write-Host " doctor Reports problems in the running BOSS instance (exit 2 when degraded)" Write-Host " mcp [args] Discovers and invokes desktop MCP tools (list, describe, invoke)" Write-Host " completion Generates shell completion script (bash, zsh, fish)" + Write-Host " --sandbox Start BOSS with opt-in sandbox command sessions enabled" Write-Host " url Opens a URL in Fluck browser" Write-Host " workspace Loads a workspace configuration" Write-Host " file Opens a file in the editor" @@ -261,7 +262,7 @@ switch ($Command.ToLower()) { } } - { $_ -in "status", "doctor", "mcp", "completion" } { + { $_ -in "status", "doctor", "mcp", "completion", "--sandbox" } { $bossExe = $env:BOSS_EXE if ($bossExe -and -not (Test-Path $bossExe -PathType Leaf)) { [Console]::Error.WriteLine("Error: BOSS_EXE does not name an executable file.") diff --git a/scripts/test/test-headless-cli.sh b/scripts/test/test-headless-cli.sh index db45938227..83eeb798ba 100755 --- a/scripts/test/test-headless-cli.sh +++ b/scripts/test/test-headless-cli.sh @@ -27,3 +27,9 @@ BOSS_BIN="$scratch/missing" bash "$root/scripts/boss" status > "$scratch/out" 2> [[ ! -s "$scratch/out" ]] grep -q 'binary not found' "$scratch/err" echo 'Headless launcher tests passed' +status=0 +BOSS_BIN="$scratch/fake-boss" bash "$root/scripts/boss" --sandbox > "$scratch/out" 2> "$scratch/err" || status=$? +[[ "$status" == 7 ]] +[[ "$(< "$scratch/out")" == '--sandbox' ]] +[[ ! -s "$scratch/err" ]] +echo 'Sandbox startup forwarding test passed' From a57d5e2a6cdf1f6644fd4cdc6db68140db706b5e Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Wed, 23 Sep 2026 16:43:54 +0500 Subject: [PATCH 22/23] test(sandbox): mark Windows escalation isolation phases Co-authored-by: codex --- .../kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt index 8b326e12f1..809a81a65c 100644 --- a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt @@ -238,6 +238,7 @@ class CommandNativeSecurityTest { .single() service.consent.decide(initial.id, SandboxConsentChoice.ONCE) val parent = requireNotNull(start.await()) + CommandNativeTestRunner.stage("checking guardian before any additional grant") assertParentConfined(parent) val additional = SandboxEscalation( @@ -266,12 +267,14 @@ class CommandNativeSecurityTest { withTimeout(20000) { elevated.session.output.first { it.stdout.text.contains("ESCALATION_OK:approved") || !it.running } } + CommandNativeTestRunner.stage("checking guardian while additional command runs") assertParentConfined(parent) elevated.session.closeInput() val output = elevated.session.awaitCompletion() assertEquals(0, output.exitCode, output.stderr.text) assertTrue(output.stdout.text.contains("ESCALATION_OK:approved"), output.stdout.text) assertTrue(parent.session.output.value.running, "Additional command must not restart the agent") + CommandNativeTestRunner.stage("checking guardian after additional command exits") assertParentConfined(parent) } finally { service.shutdown() From ad5a4eb200c76596c878e38f2a99852b2252a0c3 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Wed, 23 Sep 2026 16:56:10 +0500 Subject: [PATCH 23/23] fix(sandbox): reject unsafe concurrent Windows grants Co-authored-by: codex --- docs/cageforge-command-sessions.md | 6 ++ .../sandbox/CageforgeEscalationLauncher.kt | 5 ++ .../boss/sandbox/CommandNativeSecurityTest.kt | 66 ++++++++++++------- 3 files changed, 53 insertions(+), 24 deletions(-) diff --git a/docs/cageforge-command-sessions.md b/docs/cageforge-command-sessions.md index 09cea40199..9ac4b078a2 100644 --- a/docs/cageforge-command-sessions.md +++ b/docs/cageforge-command-sessions.md @@ -124,6 +124,12 @@ in-place expansion or a restart of the agent. Additional rights do not accumulat into the parent policy. Every additional command is reviewed independently, unless that exact command and expanded policy were approved until BOSS closes. +On Windows with Cageforge Java 0.7.1, BOSS rejects concurrent additional-permission +commands before preparation. Native testing exposed a shared filesystem read authority +that let the running parent read a file granted to another command. Initial sandbox +sessions remain available; dynamic command rights on Windows require an upstream +Cageforge isolation fix and a new binding release. This limitation is fail closed. + The MCP request must identify the command, project/session, additional filesystem or network capabilities, and a human-readable reason. The agent requests access; it never supplies the approval decision. BOSS must show the exact command and diff --git a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/CageforgeEscalationLauncher.kt b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/CageforgeEscalationLauncher.kt index e55a9b8b52..10f98fd122 100644 --- a/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/CageforgeEscalationLauncher.kt +++ b/modules/boss-command-sandbox/src/main/kotlin/ai/rever/boss/sandbox/CageforgeEscalationLauncher.kt @@ -4,6 +4,7 @@ import ai.cageforge.Cageforge import ai.cageforge.PermissionApprover import ai.cageforge.PermissionEscalationRequest import ai.cageforge.RuntimeContext +import ai.cageforge.WindowsSetup import java.nio.charset.StandardCharsets import java.security.MessageDigest import java.util.concurrent.atomic.AtomicBoolean @@ -14,6 +15,10 @@ internal class CageforgeEscalationLauncher { base: SandboxSessionPlan, additional: SandboxEscalation, ): SandboxEscalationPlan { + check(!WindowsSetup.isSupported()) { + "Concurrent additional-permission commands are unavailable on Windows with Cageforge Java 0.7.1: " + + "its shared filesystem read authority can expose the command's grant to the running agent" + } val snapshot = base.snapshot.forCommand(additional.argv) val context = RuntimeContext(snapshot.projectDirectory) Cageforge.checkToml(snapshot.toml, SandboxPolicySnapshot.LAUNCH_PROFILE, context) diff --git a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt index 809a81a65c..661c37c47f 100644 --- a/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt +++ b/modules/boss-command-sandbox/src/nativeSecurityTest/kotlin/ai/rever/boss/sandbox/CommandNativeSecurityTest.kt @@ -240,13 +240,11 @@ class CommandNativeSecurityTest { val parent = requireNotNull(start.await()) CommandNativeTestRunner.stage("checking guardian before any additional grant") assertParentConfined(parent) - val additional = - SandboxEscalation( - parentCommand.argv.dropLast(3) + listOf("escalated-held", approvedFile.toString()), - listOf("read" to approvedFile.toString()), - emptyList(), - "Read approved input for one command", - ) + val additional = readFileEscalation(parentCommand, approvedFile) + if (WindowsSetup.isSupported()) { + assertWindowsEscalationFailsClosed(service, parent, additional) + return@runBlocking + } val denied = async { service.startEscalated(parent.id, additional) } val denial = service.consent.requests @@ -274,29 +272,12 @@ class CommandNativeSecurityTest { assertEquals(0, output.exitCode, output.stderr.text) assertTrue(output.stdout.text.contains("ESCALATION_OK:approved"), output.stdout.text) assertTrue(parent.session.output.value.running, "Additional command must not restart the agent") - CommandNativeTestRunner.stage("checking guardian after additional command exits") assertParentConfined(parent) } finally { service.shutdown() } } - private suspend fun assertParentConfined(parent: SandboxSessionEntry) { - val before = parent.session.output.value.stdout.text - parent.session.sendInput("check\n") - val output = - withTimeout(20000) { - parent.session.output.first { it.stdout.text != before || !it.running } - } - assertTrue(output.running, output.stderr.text) - assertTrue( - output.stdout.text - .removePrefix(before) - .contains("PARENT_DENIED"), - output.stdout.text, - ) - } - private fun command( project: Path, arguments: List, @@ -363,6 +344,43 @@ class CommandNativeSecurityTest { private fun quote(path: Path): String = quoteText(path.toString()) } +private fun readFileEscalation( + parentCommand: SandboxCommand, + approvedFile: Path, +) = SandboxEscalation( + parentCommand.argv.dropLast(3) + listOf("escalated-held", approvedFile.toString()), + listOf("read" to approvedFile.toString()), + emptyList(), + "Read approved input for one command", +) + +private suspend fun assertWindowsEscalationFailsClosed( + service: SandboxSessionService, + parent: SandboxSessionEntry, + additional: SandboxEscalation, +) { + CommandNativeTestRunner.stage("rejecting unsafe Windows concurrent escalation") + assertFailsWith { service.startEscalated(parent.id, additional) } + assertParentConfined(parent) + assertEquals(1, service.sessions.value.size) +} + +private suspend fun assertParentConfined(parent: SandboxSessionEntry) { + val before = parent.session.output.value.stdout.text + parent.session.sendInput("check\n") + val output = + withTimeout(20000) { + parent.session.output.first { it.stdout.text != before || !it.running } + } + assertTrue(output.running, output.stderr.text) + assertTrue( + output.stdout.text + .removePrefix(before) + .contains("PARENT_DENIED"), + output.stdout.text, + ) +} + private fun waitForExit(process: Process): Int { if (!process.waitFor(30, TimeUnit.SECONDS)) return -1 return process.exitValue()