From 1da41e0f17cce8fe554e3f3fa14b3c1bd2eb3e89 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:36:34 +0800 Subject: [PATCH] docs(security): align sandbox boundary claims Generated-by: Codex --- SECURITY.md | 54 +++++-- .../execution-model-composition.test.ts | 148 ++++++++++++++++-- packages/runtime/src/sandbox/README.md | 20 ++- 3 files changed, 200 insertions(+), 22 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 22021811da..d5c1c575ca 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -80,7 +80,7 @@ and they are NOT equally load-bearing. user's OS account: filesystem, network, OS permissions (Keychain / Microphone / Screen recording). -### 2.2 The boundary: the OS user account +### 2.2 OS enforcement boundaries **The only enforcement boundary against an adversarial LLM is the operating system.** Nothing inside the agent process constitutes @@ -94,11 +94,28 @@ because they are useful UX safety nets — they catch accidental output and slow down adversarial output enough for a human to notice — but we do not ship them as guarantees. -Maka does not run tools in a separate process or container by default. The -runtime exposes a macOS Seatbelt command transformer for restricted profiles, -but current product compositions do not yet route command execution through -it, so it is not a product boundary today. Externally isolated runtimes may -supply their own boundary. See +The OS user account is Maka's outer trust envelope. Inside that envelope, +Runtime Host also routes selected agent tool effects through OS-enforced child +process sandboxes when the active session has a restricted managed +`ExecutionBoundary`. On macOS and Linux, non-PTY Bash commands and the +filesystem worker run through Seatbelt and bubblewrap respectively. On +Windows, the AppContainer boundary currently covers the purpose-built +filesystem worker only; arbitrary-shell Bash is unavailable and fails closed +when the active profile requires a command sandbox. + +This is not universal tool containment. Bypass boundaries and unrestricted, +disabled, or external profiles do not add a Maka-managed local sandbox; +external environments may supply their own isolation. Managed PTY Bash is +refused when the active profile requires sandboxing. Client-launched +integrated terminals are host PTYs outside the managed agent execution +boundary, and runtime or attachment resource reads do not execute through the +local filesystem worker. The standard `workspace-write` profile permits the +workspace roots and the configured temporary roots. + +Seatbelt, bubblewrap, and AppContainer are OS enforcement mechanisms. The +in-process checks that decide whether to request or invoke them remain +heuristics, not containment. The exact product coverage and fail-closed +selection rules are documented in [`packages/runtime/src/sandbox/README.md`](./packages/runtime/src/sandbox/README.md). ### 2.3 Boundaries we DO treat as load-bearing @@ -106,7 +123,14 @@ supply their own boundary. See 1. **OS user account.** Tools run with the user's privileges. The user is expected to run Maka as a non-admin account on systems where that matters. -2. **Credential-at-rest boundaries.** The provider credential store +2. **Restricted managed tool sandboxes.** For the product surfaces listed in + §2.2, the active session `ExecutionBoundary` is compiled into a per-command + Seatbelt, bubblewrap, or AppContainer policy. Required enforcement fails + closed rather than silently retrying on the host. This boundary is limited + to the documented tool and platform matrix; it does not include bypass, + unrestricted, disabled, external, managed PTY, or integrated-terminal + execution. +3. **Credential-at-rest boundaries.** The provider credential store writes `credentials.json` as versioned plaintext JSON under the user's workspace directory. Its load-bearing boundary is the OS user account plus filesystem controls: directory mode 0o700, @@ -119,21 +143,21 @@ supply their own boundary. See this boundary anymore. Pre-existing safeStorage-encrypted credential or token files are not imported; users with only those copies must re-authenticate. -3. **Renderer process sandbox + preload IPC bridge.** The +4. **Renderer process sandbox + preload IPC bridge.** The renderer cannot reach files, network, or shell directly. Every IPC handler in `apps/desktop/src/main/main.ts` is the trust boundary between renderer-controlled input and main-process action. Renderer code is treated as semi-trusted: it can read masked / sanitized data, but cleartext secrets never cross the boundary in the main-to-renderer direction (see §4). -4. **Settings sensitive masking.** Tokens, API keys, and proxy +5. **Settings sensitive masking.** Tokens, API keys, and proxy passwords are masked at the IPC store boundary (`maskAppSettings` in `apps/desktop/src/main/settings-ipc-helpers.ts`). Re-submitting the mask sentinel `••••••` is interpreted as "keep current" by the merge logic; an empty string is interpreted as an explicit clear. The Tavily API key follows the same boundary. -5. **Network egress through user-configured proxies.** The +6. **Network egress through user-configured proxies.** The `network.proxy` settings drive Electron's session proxy. Tools that bypass `proxiedFetch` (Tavily lives in main, uses standard fetch) are individually audited. @@ -156,8 +180,10 @@ are welcome as ordinary issues, not security advisories. URLs out of agent tool results before they reach a renderer ``. 4. **`PermissionMode.ask`** as default. Mode names are UX controls, - not security boundaries; even permissive modes remain subject to - the current policy table and OS isolation boundary. + not security boundaries. `ask` and `explore` establish restricted + managed profiles, while `bypass` explicitly does not promise a + Maka-managed local sandbox; the active `ExecutionBoundary` is the + execution authority. 5. **Modal-lifecycle contract test.** Catches the React `useEffect`-before-`if (!open) return null` pattern that can violate React hook ordering. Static analysis only. @@ -204,6 +230,10 @@ boundary in §2.3 was crossed. Examples: result envelopes, log lines). - A tool intended to be permission-gated bypasses the PermissionEngine. +- A non-PTY Bash command or local-path filesystem operation under a + restricted managed boundary escapes its effective Seatbelt, + bubblewrap, or supported AppContainer policy, or silently falls + back to host execution when required enforcement is unavailable. Reports against the §2.4 heuristics are out of scope as security advisories. They are welcome as ordinary issues or pull requests. diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 1e98782511..c4009af5fb 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -21,8 +21,8 @@ import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; -import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; import { test } from 'node:test'; @@ -249,8 +249,9 @@ test('production Host executes current-boundary Bash and refreshes live sandbox const base = await mkdtemp(join(tmpdir(), 'maka-host-managed-bash-')); const root = join(base, 'interactive'); const project = join(base, 'project'); + let outsideRoot: string | undefined; + let sandboxPaths: ManagedSandboxPaths | undefined; const provider = await startProvider(); - provider.configureManagedBashFlow(); const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); const owner = await tryAcquireInteractiveRootOwner(capability); assert.ok(owner); @@ -263,6 +264,16 @@ test('production Host executes current-boundary Bash and refreshes live sandbox }; let composition: Awaited> | undefined; try { + if (process.platform === 'darwin') { + outsideRoot = await mkdtemp(join(homedir(), '.maka-host-sandbox-boundary-')); + sandboxPaths = { + outsideBash: join(outsideRoot, 'bash-denied.txt'), + outsideWrite: join(outsideRoot, 'write-denied.txt'), + workspaceBash: join(project, 'bash-allowed.txt'), + workspaceWrite: join(project, 'write-allowed.txt'), + }; + } + provider.configureManagedBashFlow(sandboxPaths); await mkdir(project); const policy = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); const created = await policy.connectionCatalog.create({ @@ -428,6 +439,77 @@ test('production Host executes current-boundary Bash and refreshes live sandbox const refreshedRequestText = JSON.stringify(refreshedRequests[2]?.body); assert.match(refreshedRequestText, //u); assert.match(refreshedRequestText, /Network: enabled/u); + + if (sandboxPaths) { + const sandboxTurnId = 'hosted-managed-sandbox-turn-3'; + const sandboxTerminal = await waitForTerminal( + composition, + session.id, + sandboxTurnId, + await startTurn( + composition, + session.id, + sandboxTurnId, + 'Exercise the enforced filesystem boundary.', + context, + ), + context, + ); + assert.equal(sandboxTerminal.status, 'completed'); + + const sandboxRequests = provider.requests.filter((request) => request.body.stream === true); + assert.equal(sandboxRequests.length, 8); + assert.match(latestToolResultText(sandboxRequests[4]!.body) ?? '', /macos-seatbelt/u); + assert.match( + latestToolResultText(sandboxRequests[4]!.body) ?? '', + /Operation not permitted/u, + ); + assert.match( + latestToolResultText(sandboxRequests[5]!.body) ?? '', + /sandbox_boundary_required/u, + ); + assert.equal(await fileExists(sandboxPaths.outsideBash), false); + assert.equal(await fileExists(sandboxPaths.outsideWrite), false); + assert.equal(await readFile(sandboxPaths.workspaceBash, 'utf8'), 'bash allowed'); + assert.equal(await readFile(sandboxPaths.workspaceWrite, 'utf8'), 'write allowed'); + + const sandboxEvents = await execution.runtimeEventStore.readRuntimeEvents( + session.id, + sandboxTerminal.runId, + ); + const sandboxResponses = sandboxEvents.filter( + (event) => event.content?.kind === 'function_response', + ); + assert.deepEqual( + sandboxResponses.map((event) => + event.content?.kind === 'function_response' + ? { + name: event.content.name, + isError: event.content.isError === true, + } + : undefined, + ), + [ + { name: 'Bash', isError: true }, + { name: 'Write', isError: true }, + { name: 'Bash', isError: false }, + { name: 'Write', isError: false }, + ], + ); + assert.equal( + sandboxEvents.some( + (event) => event.actions?.stateDelta?.sandboxBoundaryRequest !== undefined, + ), + false, + ); + assert.deepEqual( + await execution.sessionStore.listPendingSandboxBoundaryRequests(session.id), + [], + ); + const sandboxBoundary = await execution.sessionStore.readExecutionBoundary(session.id); + assert.equal(sandboxBoundary.kind, 'managed'); + assert.equal(sandboxBoundary.revision, expanded.boundary.revision); + } } finally { try { await composition?.close(); @@ -435,8 +517,15 @@ test('production Host executes current-boundary Bash and refreshes live sandbox try { await owner.close(); } finally { - await provider.close(); - await rm(base, { recursive: true, force: true }); + try { + await provider.close(); + } finally { + try { + await rm(base, { recursive: true, force: true }); + } finally { + if (outsideRoot) await rm(outsideRoot, { recursive: true, force: true }); + } + } } } } @@ -3851,9 +3940,19 @@ interface ProviderRequest { readonly body: Record; } +interface ManagedSandboxPaths { + readonly outsideBash: string; + readonly outsideWrite: string; + readonly workspaceBash: string; + readonly workspaceWrite: string; +} + type ProviderFlow = | { readonly kind: 'default' } - | { readonly kind: 'managed_bash' } + | { + readonly kind: 'managed_bash'; + readonly sandboxPaths?: ManagedSandboxPaths; + } | { readonly kind: 'client_capability'; readonly groupId: string; @@ -3870,7 +3969,7 @@ type ProviderFlow = async function startProvider(): Promise<{ readonly baseUrl: string; readonly requests: ProviderRequest[]; - configureManagedBashFlow(): void; + configureManagedBashFlow(sandboxPaths?: ManagedSandboxPaths): void; configureClientCapability(input: { groupId: string; toolName: string }): void; configureChildAgentFlow(): void; configureImplementationChildAgentFlow(): void; @@ -3890,9 +3989,12 @@ async function startProvider(): Promise<{ return { baseUrl: `http://127.0.0.1:${address.port}/v1`, requests, - configureManagedBashFlow: () => { + configureManagedBashFlow: (sandboxPaths) => { if (flow.kind !== 'default') throw new Error('Provider flow is already configured'); - flow = { kind: 'managed_bash' }; + flow = { + kind: 'managed_bash', + ...(sandboxPaths ? { sandboxPaths } : {}), + }; }, configureClientCapability: (input) => { if (flow.kind !== 'default') throw new Error('Provider flow is already configured'); @@ -3987,6 +4089,34 @@ async function handleProviderRequest( }); return; } + if (flow.kind === 'managed_bash' && flow.sandboxPaths && streamRequestIndex === 4) { + respondProviderToolCall(response, streamRequestIndex, 'Bash', { + command: `printf denied > ${JSON.stringify(flow.sandboxPaths.outsideBash)}`, + boundary_intent: 'current', + }); + return; + } + if (flow.kind === 'managed_bash' && flow.sandboxPaths && streamRequestIndex === 5) { + respondProviderToolCall(response, streamRequestIndex, 'Write', { + path: flow.sandboxPaths.outsideWrite, + content: 'write denied', + }); + return; + } + if (flow.kind === 'managed_bash' && flow.sandboxPaths && streamRequestIndex === 6) { + respondProviderToolCall(response, streamRequestIndex, 'Bash', { + command: `printf 'bash allowed' > ${JSON.stringify(flow.sandboxPaths.workspaceBash)}`, + boundary_intent: 'current', + }); + return; + } + if (flow.kind === 'managed_bash' && flow.sandboxPaths && streamRequestIndex === 7) { + respondProviderToolCall(response, streamRequestIndex, 'Write', { + path: flow.sandboxPaths.workspaceWrite, + content: 'write allowed', + }); + return; + } if (flow.kind === 'managed_bash') { respondProviderText(response, RESPONSE_TEXT); return; diff --git a/packages/runtime/src/sandbox/README.md b/packages/runtime/src/sandbox/README.md index a1e33854c9..06099b8f56 100644 --- a/packages/runtime/src/sandbox/README.md +++ b/packages/runtime/src/sandbox/README.md @@ -22,7 +22,7 @@ This directory owns platform sandbox selection and command transformation. It translates the profile in an active session `ExecutionBoundary` into an execution request; it does not decide whether a requested boundary expansion is approved and does not execute the request itself. Code and focused tests are the final authority. Windows enforcement work is tracked in -[issue #2142](https://github.com/maka-agent/maka-agent/issues/2142) and specified by the +[issue #2142](https://github.com/apache/maka/issues/2142) and specified by the [Windows sandbox backend RFC](../../../../docs/architecture/windows-sandbox-rfc-v1.md) ([中文](../../../../docs/architecture/windows-sandbox-rfc-v1.zh-CN.md)). @@ -58,6 +58,20 @@ Code and focused tests are the final authority. Windows enforcement work is trac fails closed as unavailable. Other unsupported platforms return `unsupported_platform`. - A backend that receives an invalid or unsupported profile returns a typed failure; it does not silently downgrade to host execution. +## Product coverage + +| Surface | macOS | Linux | Windows | When no Maka-managed sandbox is required | +| ----------------------------------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| Agent Bash, foreground or background without a PTY | Seatbelt | bubblewrap | Restricted managed execution fails closed because the AppContainer broker cannot launch an arbitrary shell | Runs through the detected host shell | +| Agent Bash with a PTY | Refused when the active profile requires sandboxing | Refused when the active profile requires sandboxing | Refused when the active profile requires sandboxing | Runs as a host PTY | +| Local-path `Read`, `Write`, `Edit`, `FormatJson`, `Glob`, `Grep`, and `apply_patch` | Filesystem worker under Seatbelt | Filesystem worker under bubblewrap | Purpose-built filesystem worker under the AppContainer broker | Managed execution uses the worker without an OS sandbox; bypass uses the host-local executor; external uses the injected executor | +| `Read` of runtime or attachment resource refs | Resource service; not a local filesystem-worker operation | Resource service; not a local filesystem-worker operation | Resource service; not a local filesystem-worker operation | Same resource-service path | +| Client `runtime.resource.start` integrated terminal | Host PTY outside the managed agent boundary | Host PTY outside the managed agent boundary | Host PTY outside the managed agent boundary | Same host PTY path | + +`ask` starts with the managed `workspace-write` profile and `explore` starts with a managed read-only profile. Both profiles require a platform sandbox because their filesystem or network policy is restricted. `workspace-write` permits writes to the workspace roots, `:tmpdir`, and `:slash_tmp`; it is not a workspace-only profile. + +A bypass boundary, unrestricted managed profiles, and disabled profiles do not request a Maka-managed local sandbox. When the filesystem worker is wired, managed execution can still use that worker as its backend while `SandboxManager` selects `none`; this is process separation, not OS sandbox enforcement. A bypass boundary uses the host-local executor, while an external boundary delegates filesystem isolation to its injected workspace executor and does not stack a local platform sandbox. Tool availability and permission policy still apply when sandboxing is not required; selecting `none` is not itself permission to execute. + ## Boundaries - The session `ExecutionBoundary` is the authority for whether an operation is currently inside the sandbox boundary. Sandbox selection does not expand that boundary. @@ -82,7 +96,11 @@ Code and focused tests are the final authority. Windows enforcement work is trac - Selection and transformation: `packages/runtime/src/__tests__/sandbox-manager.test.ts` - macOS policy and wrapper: `packages/runtime/src/__tests__/macos-seatbelt.test.ts` - macOS platform behavior: `packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts` +- macOS filesystem-worker behavior: `packages/runtime/src/__tests__/filesystem-worker-smoke.test.ts` - Linux policy and wrapper: `packages/runtime/src/__tests__/linux-sandbox.test.ts` - Linux platform behavior: `packages/runtime/src/__tests__/linux-sandbox-smoke.test.ts` +- Linux filesystem-worker behavior: `packages/runtime/src/__tests__/filesystem-worker-linux-smoke.test.ts` - Windows profile and broker transform: `windows-profile.test.ts` and `windows-sandbox.test.ts` +- Windows filesystem-worker behavior: `packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts` +- Runtime Host product composition: `packages/runtime-host/src/__tests__/execution-model-composition.test.ts` - Public exports and default registration: `sandbox-export.test.ts` and `default-sandbox-manager.test.ts`