diff --git a/docs/.generated/config-baseline.counts.json b/docs/.generated/config-baseline.counts.json index 4590624557ef..1129b568124a 100644 --- a/docs/.generated/config-baseline.counts.json +++ b/docs/.generated/config-baseline.counts.json @@ -1,5 +1,5 @@ { - "core": 2355, - "channel": 3584, - "plugin": 3957 + "core": 2353, + "channel": 3596, + "plugin": 3982 } diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 1190d999f033..ddb2fe2cb2f7 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -ce06876d3bb5dfce16f3d321623a792baa41c01f06e3972887b0555460ca7873 config-baseline.json -b969d726e663afc6ad8d9a2ceabf8c5d26c61402d711d10f969fab5bab1c3d76 config-baseline.core.json -3300f931abce160c1d6768af1a358683b8f77af479e8b728aeefd38039a61507 config-baseline.channel.json -91f13b695cfae24bff25c09ff8d091f357c13bf01b220c0640216aa5081e4db2 config-baseline.plugin.json +0e5dc75714d23fda74b4723a760c5ad6fbef688b8509d1ec27c9ecc8d1ab4cbb config-baseline.json +a69e64bd65e0f0376d41ea00368975fc03ee1f436b7b62f0377d978ae3ef730b config-baseline.core.json +2cb50df6362fe4a1d0e96659028240330e36e20fdf921ca733a7eb187b6f5dc8 config-baseline.channel.json +fb222f3d84def06d427a7f43e132ae8e1ed2e0ce62b6c1bc4459ff23770d1843 config-baseline.plugin.json diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 9be20b466642..53fbf6e7de78 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -103,7 +103,7 @@ Sandbox settings live in `~/.openclaw/openclaw.json` under `agents.defaults.sand "defaults": { "sandbox": { "mode": "all", // off, non-main, all - "backend": "docker", // docker, ssh, openshell (plugin-provided) + "backend": "docker", // docker, podman, ssh; openshell is plugin-provided "scope": "agent", // session, agent, shared "docker": { "image": "openclaw-sandbox:bookworm-slim", diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index c519be22bdd4..3e7a45987419 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -717,13 +717,14 @@ Gateway or node host and check `openclaw nodes pending` again. definitions: { maintainer: { sessions: { others: "write" }, // none | view | suggest | write - agents: "*", + agents: ["roboclaw"], scopes: ["operator.read", "operator.write", "operator.approvals"], }, guest: { sessions: { others: "view" }, - agents: ["guest-agent"], + agents: ["roboclaw"], scopes: ["operator.read", "operator.write"], + sandbox: "required", // inherit (default) | required }, }, }, @@ -817,7 +818,7 @@ Gateway or node host and check `openclaw nodes pending` again. - `gateway.auth.mode: "trusted-proxy"`: delegate browser/user auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)). This mode expects a **non-loopback** proxy source by default; same-host loopback reverse proxies require explicit `gateway.auth.trustedProxy.allowLoopback = true`. Internal same-host callers can use `gateway.auth.password` as a local direct fallback; `gateway.auth.token` remains mutually exclusive with trusted-proxy mode. - `gateway.auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`). HTTP API endpoints do **not** use that Tailscale header auth; they follow the gateway's normal HTTP auth mode instead. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`. - `gateway.auth.identityScopes`: maps a verified trusted-proxy user or Tailscale WhoIs login to connection-only operator scopes. Email keys match case-insensitively; other identities match exactly. For trusted-proxy Control UI connections, `x-openclaw-scopes` caps device enrollment or upgrade requests and the final device-plus-identity session scopes. Grants do not create or modify pairing records. Token, password, and no-auth connections have no verified identity and receive no grant. -- `gateway.roles`: optional named operator roles for authenticated user profiles on team Gateways. Every definition specifies `sessions.others` (`none`, `view`, `suggest`, or `write`), allowed session-creation and agent-run `agents` (`"*"` or an array of agent IDs), and a closed `scopes` ceiling that also applies to identity-authenticated HTTP requests and signed Control UI plugin grants. The administrator-scoped `users.setRole` Gateway method assigns or clears a profile's role and immediately disconnects its active clients so they reconnect with current authority. Identity-authenticated operator sessions do not receive reusable, person-unbound device/bootstrap tokens while roles are configured, and identity-less device-token or bootstrap-token operator authentication is rejected; reconnect through trusted-proxy or other supported verified identity instead. `default` is required, must name a configured definition, and applies to unassigned profiles. `sessions.others: "none"` also denies Gateway-wide `usage.cost`; audit diagnostics and other `operator.write` control-plane capabilities remain shared-domain surfaces, not hostile-tenant isolation. Omitting `roles` leaves existing solo and shared-secret deployments unchanged. See [Operator scopes](/gateway/operator-scopes#named-operator-roles). +- `gateway.roles`: optional named operator roles for authenticated user profiles on team Gateways. Every definition specifies `sessions.others` (`none`, `view`, `suggest`, or `write`), allowed session-creation and agent-run `agents` (`"*"` or an array of agent IDs), and a closed `scopes` ceiling that also applies to identity-authenticated HTTP requests and signed Control UI plugin grants. Optional `sandbox` is `"inherit"` by default or `"required"` to sandbox sessions created under that role even when the agent's sandbox mode is `"off"`. This requirement is recorded once from the authenticated creator, cannot be changed through role updates or session mutation, and does not affect existing sessions. A sandbox-required person cannot start a host-execution session, including through an invitation; unavailable sandbox backends fail closed, and elevated or host-target overrides cannot escape. The administrator-scoped `users.setRole` Gateway method assigns or clears a profile's role and immediately disconnects its active clients so they reconnect with current authority. Identity-authenticated operator sessions do not receive reusable, person-unbound device/bootstrap tokens while roles are configured, and identity-less device-token or bootstrap-token operator authentication is rejected; reconnect through trusted-proxy or other supported verified identity instead. `default` is required, must name a configured definition, and applies to unassigned profiles. `sessions.others: "none"` also denies Gateway-wide `usage.cost`; audit diagnostics and other `operator.write` control-plane capabilities remain shared-domain surfaces, not hostile-tenant isolation. Omitting `roles` leaves existing solo and shared-secret deployments unchanged. See [Operator scopes](/gateway/operator-scopes#named-operator-roles). - `gateway.auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`. - On the async Tailscale Serve Control UI path, failed attempts for the same `{scope, clientIp}` are serialized before the failure write. Concurrent bad attempts from the same client can therefore trip the limiter on the second request instead of both racing through as plain mismatches. - `gateway.auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments). diff --git a/docs/gateway/operator-scopes.md b/docs/gateway/operator-scopes.md index 8493da4c8ba6..ef60afabe53b 100644 --- a/docs/gateway/operator-scopes.md +++ b/docs/gateway/operator-scopes.md @@ -46,9 +46,9 @@ already holds `operator.admin`. ## Named operator roles Team Gateways can bind authenticated user profiles to named operator roles. -Each role combines exactly three closed policies: access to other people's -sessions, agents available for session creation and agent runs, and a maximum -set of operator scopes. +Each role combines four closed policies: access to other people's sessions, +agents available for session creation and agent runs, a maximum set of operator +scopes, and whether newly created sessions require sandboxing. ```json5 { @@ -58,13 +58,14 @@ set of operator scopes. definitions: { maintainer: { sessions: { others: "write" }, - agents: "*", + agents: ["roboclaw"], scopes: ["operator.read", "operator.write", "operator.approvals"], }, guest: { sessions: { others: "view" }, - agents: ["guest-agent"], + agents: ["roboclaw"], scopes: ["operator.read", "operator.write"], + sandbox: "required", }, }, }, @@ -108,6 +109,24 @@ holding `operator.admin` retain their administrative session access. Set `agents: "*"` to allow session creation and agent runs on every agent, list agent IDs to allow only those agents, or use an empty array to disallow both. The allowlist also applies when a run targets an already-existing session. + +The optional `sandbox` policy defaults to `"inherit"`, which keeps the agent's +configured sandbox mode. Set `sandbox: "required"` to sandbox every new session +created by an authenticated person with that role, even when the agent's +sandbox mode is `"off"`. The example lets maintainers use host execution on +`roboclaw` while guest-created sessions on the same agent remain sandboxed. + +The Gateway records the creator's sandbox requirement once when the session is +created. Existing sessions are unaffected, and role changes, session sharing, +maintainer participation, and `sessions.patch` cannot remove or replace the +requirement. A person whose role requires sandboxing cannot start a run in an +existing host-execution session, even when explicitly invited. Required sessions +fail if their sandbox backend is unavailable or provisioning fails; they never +fall back to the Gateway or a node. `/elevated`, `exec` host overrides, and +configured host targets cannot bypass this restriction. The agent's managed +GitHub identity is not injected into sandboxed execution: `GH_CONFIG_DIR` is +absent, and `GH_TOKEN` and `GITHUB_TOKEN` are blanked. + The role's `scopes` list intersects scopes granted through connection auth, identity grants, pairing, scope upgrades, and authenticated trusted-proxy HTTP requests. It cannot grant scopes the connection did not already receive. diff --git a/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md index 3fd94cf795d3..5d0466435565 100644 --- a/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md +++ b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md @@ -7,9 +7,9 @@ status: active OpenClaw has three related but different controls: -1. **Sandbox** (`agents.defaults.sandbox.*` / `agents.entries.*.sandbox.*`) decides **where tools run** (sandbox backend vs host). +1. **Sandbox** (`agents.defaults.sandbox.*`, `agents.entries.*.sandbox.*`, or a required creator-role policy) decides **where tools run** (sandbox backend vs host). 2. **Tool policy** (`tools.*`, `tools.sandbox.tools.*`, `agents.entries.*.tools.*`) decides **which tools are available/allowed**. -3. **Elevated** (`tools.elevated.*`, `agents.entries.*.tools.elevated.*`) is an **exec-only escape hatch** to run outside the sandbox when you are sandboxed (`gateway` by default, or `node` when the exec target is configured to `node`). +3. **Elevated** (`tools.elevated.*`, `agents.entries.*.tools.elevated.*`) is an **exec-only escape hatch** from ordinary sandboxing (`gateway` by default, or `node` when the exec target is configured to `node`). It cannot bypass a creator role's required sandbox. ## Quick debug @@ -33,12 +33,17 @@ It prints: Sandboxing is controlled by `agents.defaults.sandbox.mode`: -- `"off"`: everything runs on the host. +- `"off"`: sessions run on the host unless their creator's operator role requires sandboxing. - `"non-main"`: only non-main sessions are sandboxed (common "surprise" for groups/channels). - `"all"`: everything is sandboxed. `agents.defaults.sandbox.workspaceAccess` controls what the sandbox can see: `"none"`, `"ro"`, or `"rw"`. +An operator role with `sandbox: "required"` overrides agent mode, cannot be +escaped through elevated execution or host overrides, and fails closed when its +sandbox cannot be provisioned. See +[Named operator roles](/gateway/operator-scopes#named-operator-roles). + See [Sandboxing](/gateway/sandboxing) for the full matrix (scope, workspace mounts, images). ### Bind mounts (security quick check) @@ -117,7 +122,7 @@ For sandboxed MCP servers, the sandbox tool policy is a second allow gate. If `m Elevated does **not** grant extra tools; it only affects `exec`. -- If you are sandboxed, `/elevated on` (or `exec` with `elevated: true`) runs outside the sandbox (approvals may still apply). +- If you are ordinarily sandboxed, `/elevated on` (or `exec` with `elevated: true`) runs outside the sandbox (approvals may still apply). Creator-role-required sandboxes reject elevated execution. - Use `/elevated full` to skip exec approvals for the session. - If you are already running direct, elevated is effectively a no-op (still gated). - Elevated is **not** skill-scoped and does **not** override tool allow/deny. @@ -137,7 +142,7 @@ See [Elevated Mode](/tools/elevated). Fix-it keys (pick one): -- Disable sandbox: `agents.defaults.sandbox.mode=off` (or per-agent `agents.entries.*.sandbox.mode=off`) +- Disable ordinary sandboxing: `agents.defaults.sandbox.mode=off` (or per-agent `agents.entries.*.sandbox.mode=off`); this does not override a creator role's required sandbox. - Allow the tool inside sandbox: - remove it from `tools.sandbox.tools.deny` (or per-agent `agents.entries.*.tools.sandbox.tools.deny`) - or add it to `tools.sandbox.tools.allow` (or per-agent allow) diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 9bb8cf366f81..155817e35739 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -6,7 +6,7 @@ read_when: "You want a dedicated explanation of sandboxing or need to tune agent status: active --- -OpenClaw can run tool execution inside a sandbox backend to reduce blast radius. Sandboxing is off by default and controlled by `agents.defaults.sandbox` (global) or `agents.entries.*.sandbox` (per-agent). The Gateway process always stays on the host; only tool execution moves into the sandbox when enabled. +OpenClaw can run tool execution inside a sandbox backend to reduce blast radius. Sandboxing is off by default and controlled by `agents.defaults.sandbox` (global), `agents.entries.*.sandbox` (per-agent), or a required creator-role sandbox policy. The Gateway process always stays on the host; only tool execution moves into the sandbox when enabled. This is not a perfect security boundary, but it materially limits filesystem and process access when the model does something dumb. @@ -20,7 +20,7 @@ This is not a perfect security boundary, but it materially limits filesystem and Not sandboxed: - The Gateway process itself. -- Any tool explicitly allowed to run outside the sandbox via `tools.elevated`. Elevated exec bypasses sandboxing and runs on the configured escape path (`gateway` by default, or `node` when the exec target is `node`). If sandboxing is off, `tools.elevated` changes nothing since exec already runs on the host. See [Elevated Mode](/tools/elevated). +- Any tool explicitly allowed to run outside an ordinary sandbox via `tools.elevated`. Elevated exec uses the configured escape path (`gateway` by default, or `node` when the exec target is `node`), but cannot escape a session whose creator role requires sandboxing. If sandboxing is off, `tools.elevated` changes nothing since exec already runs on the host. See [Elevated Mode](/tools/elevated). ## Modes, scope, and backend @@ -34,10 +34,17 @@ Three independent settings control sandbox behavior: **Mode** controls when sandboxing applies: -- `off`: no sandboxing. +- `off`: no agent-wide sandboxing; sessions whose creator role requires a sandbox still run sandboxed. - `non-main`: sandbox every session except the agent's main session. The main session key is always `agent::main` (or `global` when `session.scope` is `"global"`); it is not configurable. Group/channel sessions use their own keys, so they always count as non-main and get sandboxed. - `all`: every session runs in a sandbox. +Set a named operator role's `sandbox` policy to `"required"` to sandbox that +role's newly created sessions regardless of agent mode. The creator requirement +is immutable for the session; unavailable backends fail closed, and elevated +execution or Gateway/node host overrides cannot bypass it. The default +`"inherit"` preserves existing agent-mode behavior. See +[Named operator roles](/gateway/operator-scopes#named-operator-roles). + **Scope** controls how many containers/environments are created: - `agent`: one container per agent. diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md index d87b71c446a5..4b098e9fef52 100644 --- a/docs/tools/elevated.md +++ b/docs/tools/elevated.md @@ -6,7 +6,7 @@ read_when: title: "Elevated mode" --- -When an agent runs inside a sandbox, its `exec` commands are confined to the sandbox environment. **Elevated mode** lets the agent break out and run commands outside the sandbox instead, with configurable approval gates. +When an agent runs inside a sandbox, its `exec` commands are confined to the sandbox environment. **Elevated mode** lets the agent break out of ordinary agent-configured sandboxing and run commands outside the sandbox instead, with configurable approval gates. Sessions whose creator role requires sandboxing cannot use elevated mode to escape. Elevated mode only changes behavior when the agent is **sandboxed**. For unsandboxed agents, exec already runs on the host. @@ -102,6 +102,7 @@ Allowlist entry formats: ## What elevated does not control - **Tool policy**: if `exec` is denied by tool policy, elevated cannot override it. +- **Required role sandboxing**: if the authenticated session creator's operator role required a sandbox, elevated mode cannot run commands on the Gateway or a node. - **Host selection policy**: elevated does not turn `auto` into a free cross-host override. It uses the configured/session exec target rules, choosing `node` only when the target is already `node`. - **Separate from `/exec`**: the `/exec` directive adjusts per-session exec defaults (host, security, ask, node) for authorized senders and does not require elevated mode. diff --git a/src/agents/bash-tools.exec-run.ts b/src/agents/bash-tools.exec-run.ts index 81514845f042..b2d76c66bbb7 100644 --- a/src/agents/bash-tools.exec-run.ts +++ b/src/agents/bash-tools.exec-run.ts @@ -237,7 +237,8 @@ export function createExecTool( : elevatedDefaults?.defaultLevel === "on" ? "ask" : "off"; - const effectiveDefaultMode = elevatedAllowed ? elevatedDefaultMode : "off"; + const effectiveDefaultMode = + elevatedAllowed && !defaults?.sandboxRequired ? elevatedDefaultMode : "off"; const elevatedMode = typeof params.elevated === "boolean" ? params.elevated @@ -291,6 +292,7 @@ export function createExecTool( requestedTarget, elevatedRequested, sandboxAvailable: Boolean(defaults?.sandbox), + sandboxRequired: defaults?.sandboxRequired, }); const host: ExecHost = target.effectiveHost; diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index cf7448ca1e1c..05a69fef2b74 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -255,9 +255,23 @@ export function resolveExecTarget(params: { requestedTarget?: ExecTarget | null; elevatedRequested: boolean; sandboxAvailable: boolean; + sandboxRequired?: boolean; }) { - const configuredTarget = params.configuredTarget ?? "auto"; + const sandboxRequired = params.sandboxRequired === true; + if (sandboxRequired && !params.sandboxAvailable) { + throw new Error("This session requires a sandbox, but its sandbox runtime is unavailable."); + } + if (sandboxRequired && params.elevatedRequested) { + throw new Error("Elevated execution is unavailable because this session requires a sandbox."); + } + // Session isolation outranks every agent, session, and request-scoped host preference. + const configuredTarget = sandboxRequired ? "auto" : (params.configuredTarget ?? "auto"); const requestedTarget = params.requestedTarget ?? null; + if (sandboxRequired && (requestedTarget === "gateway" || requestedTarget === "node")) { + throw new Error( + `exec host not allowed (requested ${renderExecTargetLabel(requestedTarget)}; this session requires a sandbox).`, + ); + } if ( requestedTarget && !isRequestedExecTargetAllowed({ diff --git a/src/agents/bash-tools.exec-support.ts b/src/agents/bash-tools.exec-support.ts index 23d0c41e2ed2..0962428864d0 100644 --- a/src/agents/bash-tools.exec-support.ts +++ b/src/agents/bash-tools.exec-support.ts @@ -96,7 +96,8 @@ export function createExecHostResolver(defaults?: ExecToolDefaults) { : elevatedDefaults?.defaultLevel === "on" ? "ask" : "off"; - const effectiveDefaultMode = elevatedAllowed ? elevatedDefaultMode : "off"; + const effectiveDefaultMode = + elevatedAllowed && !defaults?.sandboxRequired ? elevatedDefaultMode : "off"; const elevatedMode = typeof params.elevated === "boolean" ? params.elevated @@ -111,6 +112,7 @@ export function createExecHostResolver(defaults?: ExecToolDefaults) { requestedTarget, elevatedRequested: elevatedMode !== "off", sandboxAvailable: Boolean(defaults?.sandbox), + sandboxRequired: defaults?.sandboxRequired, }).effectiveHost; }; } diff --git a/src/agents/bash-tools.exec-target.test.ts b/src/agents/bash-tools.exec-target.test.ts index 374066eaff2b..7e4524214f1f 100644 --- a/src/agents/bash-tools.exec-target.test.ts +++ b/src/agents/bash-tools.exec-target.test.ts @@ -265,4 +265,60 @@ describe("resolveExecTarget", () => { "exec host not allowed (requested gateway; configured host is node; set tools.exec.host=gateway or auto to allow this override).", ); }); + + describe("required session sandbox", () => { + it.each(["gateway", "node"] as const)( + "rejects explicit host=%s even when the configured host matches", + (host) => { + expect(() => + resolveExecTarget({ + configuredTarget: host, + requestedTarget: host, + elevatedRequested: false, + sandboxAvailable: true, + sandboxRequired: true, + }), + ).toThrow(/sandbox|required|not allowed/i); + }, + ); + + it.each(["gateway", "node"] as const)( + "keeps an implicit request sandboxed despite configured host=%s", + (host) => { + expect( + resolveExecTarget({ + configuredTarget: host, + elevatedRequested: false, + sandboxAvailable: true, + sandboxRequired: true, + }), + ).toMatchObject({ + configuredTarget: "auto", + effectiveHost: "sandbox", + }); + }, + ); + + it("rejects elevated requests before they can select the gateway", () => { + expect(() => + resolveExecTarget({ + configuredTarget: "auto", + elevatedRequested: true, + sandboxAvailable: true, + sandboxRequired: true, + }), + ).toThrow(/sandbox|required|elevated/i); + }); + + it("fails closed when the required sandbox runtime is unavailable", () => { + expect(() => + resolveExecTarget({ + configuredTarget: "auto", + elevatedRequested: false, + sandboxAvailable: false, + sandboxRequired: true, + }), + ).toThrow(/sandbox|required|unavailable/i); + }); + }); }); diff --git a/src/agents/bash-tools.exec-types.ts b/src/agents/bash-tools.exec-types.ts index 698ee40b6bd0..6df1d1452d32 100644 --- a/src/agents/bash-tools.exec-types.ts +++ b/src/agents/bash-tools.exec-types.ts @@ -55,6 +55,8 @@ export type ExecToolDefaults = { approvalFollowupMode?: "agent" | "direct"; approvalRunningNoticeMs?: number; sandbox?: BashSandboxConfig; + /** Immutable session policy that forbids execution outside its provisioned sandbox. */ + sandboxRequired?: boolean; elevated?: ExecElevatedDefaults; allowBackground?: boolean; /** Final run-local availability of the process continuation tool. */ diff --git a/src/agents/bash-tools.github-identity.test.ts b/src/agents/bash-tools.github-identity.test.ts index 135aafe0c7ed..1ad423aa63f6 100644 --- a/src/agents/bash-tools.github-identity.test.ts +++ b/src/agents/bash-tools.github-identity.test.ts @@ -77,6 +77,53 @@ describe("exec GitHub identity", () => { } }); + it("keeps required sandbox execution isolated from host overrides, elevation, and GitHub credentials", async () => { + setTestEnvValue("GH_TOKEN", "ambient-token"); + setTestEnvValue("GITHUB_TOKEN", "ambient-fallback"); + storeMocks.readSecretStoreExecEnvironment.mockReturnValue({ env: {} }); + const buildExecSpec = vi.fn(async ({ env }: { env: Record }) => ({ + argv: [process.execPath, "-e", "process.stdout.write('sandbox-ok')"], + env, + stdinMode: "pipe-closed" as const, + })); + const tool = createExecTool({ + host: "gateway", + security: "full", + ask: "off", + allowBackground: false, + sandboxRequired: true, + sandbox: { + containerName: "required-sandbox", + workspaceDir: process.cwd(), + containerWorkdir: "/workspace", + buildExecSpec, + }, + elevated: { enabled: true, allowed: true, defaultLevel: "full" }, + preparedRunEnvironment: prepareGitHubToolEnvironment({ + config: { tools: { github: { profileId: "ghp_99999999999999999999999999999999" } } }, + agentId: "main", + }), + }); + + for (const host of ["gateway", "node"] as const) { + await expect( + tool.execute(`required-denied-${host}`, { command: "echo denied", host }), + ).rejects.toThrow(/not allowed/i); + } + await expect( + tool.execute("required-denied-elevation", { command: "echo denied", elevated: true }), + ).rejects.toThrow(/requires a sandbox/i); + + const result = await tool.execute("required-sandbox", { command: "echo sandbox-ok" }); + + expect(result.details.status).toBe("completed"); + expect(buildExecSpec).toHaveBeenCalledOnce(); + const sandboxEnv = buildExecSpec.mock.calls[0]![0].env; + expect(sandboxEnv.GH_TOKEN).toBe(""); + expect(sandboxEnv.GITHUB_TOKEN).toBe(""); + expect(sandboxEnv).not.toHaveProperty("GH_CONFIG_DIR"); + }); + it.each([ { previewName: "GH_TOKEN", otherName: "GITHUB_TOKEN" }, { previewName: "GITHUB_TOKEN", otherName: "GH_TOKEN" }, diff --git a/src/agents/core-coding-tools.ts b/src/agents/core-coding-tools.ts index 9e0d5d6e84a3..202a2fda0047 100644 --- a/src/agents/core-coding-tools.ts +++ b/src/agents/core-coding-tools.ts @@ -248,6 +248,7 @@ export function createCoreCodingTools(options: CoreCodingToolsOptions): AnyAgent shell.push( createLazyExecTool({ ...options.execDefaults, + ...(sandbox?.required ? { sandboxRequired: true } : {}), cwd: options.codingRoot, sandbox: sandbox ? { diff --git a/src/agents/embedded-agent-runner.buildembeddedsandboxinfo.test.ts b/src/agents/embedded-agent-runner.buildembeddedsandboxinfo.test.ts index b0c028524d23..90af78b435f5 100644 --- a/src/agents/embedded-agent-runner.buildembeddedsandboxinfo.test.ts +++ b/src/agents/embedded-agent-runner.buildembeddedsandboxinfo.test.ts @@ -101,6 +101,24 @@ describe("buildEmbeddedSandboxInfo", () => { }); }); + it("never advertises elevated host execution for a required sandbox", () => { + const sandbox = createSandboxContext({ required: true }); + + expect( + buildEmbeddedSandboxInfo(sandbox, { + enabled: true, + allowed: true, + defaultLevel: "full", + fullAccessAvailable: true, + })?.elevated, + ).toEqual({ + allowed: false, + defaultLevel: "off", + fullAccessAvailable: false, + fullAccessBlockedReason: "host-policy", + }); + }); + it("keeps full-access unavailability truth when provided", () => { // Runtime-level blocks are authoritative and must not be overwritten by // host exec policy that appears permissive. diff --git a/src/agents/embedded-agent-runner/sandbox-info.ts b/src/agents/embedded-agent-runner/sandbox-info.ts index 06a840fb2983..7a835c6afb4f 100644 --- a/src/agents/embedded-agent-runner/sandbox-info.ts +++ b/src/agents/embedded-agent-runner/sandbox-info.ts @@ -105,12 +105,15 @@ export function buildEmbeddedSandboxInfo( return undefined; } const elevatedConfigured = execElevated?.enabled === true; - const elevatedAllowed = Boolean(execElevated?.enabled && execElevated.allowed); - const fullAccess = resolveEmbeddedFullAccessState({ - execElevated, - execPolicy, - hostPolicy, - }); + const elevatedAllowed = + !sandbox.required && Boolean(execElevated?.enabled && execElevated.allowed); + const fullAccess = sandbox.required + ? { available: false, blockedReason: "host-policy" as const } + : resolveEmbeddedFullAccessState({ + execElevated, + execPolicy, + hostPolicy, + }); return { enabled: true, workspaceDir: sandbox.workspaceDir, @@ -123,7 +126,7 @@ export function buildEmbeddedSandboxInfo( ? { elevated: { allowed: elevatedAllowed, - defaultLevel: execElevated?.defaultLevel ?? "off", + defaultLevel: sandbox.required ? "off" : (execElevated?.defaultLevel ?? "off"), fullAccessAvailable: fullAccess.available, ...(fullAccess.blockedReason ? { fullAccessBlockedReason: fullAccess.blockedReason } diff --git a/src/agents/exec-defaults.test.ts b/src/agents/exec-defaults.test.ts index ebf7b6deb93b..2d09098c4255 100644 --- a/src/agents/exec-defaults.test.ts +++ b/src/agents/exec-defaults.test.ts @@ -1,10 +1,15 @@ // Verifies exec host, sandbox, and approval-default resolution for embedded agents. -import { beforeEach, describe, expect, it, vi } from "vitest"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import type { SessionEntry } from "../config/sessions.js"; +import { replaceSessionEntry } from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import * as execApprovals from "../infra/exec-approvals.js"; import { resolveExecDefaults, resolveNodeExecEligibility } from "./exec-defaults.js"; +const execStoreDirs = useAutoCleanupTempDirTracker(afterEach); + function withDefaultAgent(config: OpenClawConfig): OpenClawConfig { return { ...config, @@ -53,6 +58,47 @@ describe("resolveExecDefaults", () => { expect(defaults.canRequestNode).toBe(false); }); + it.each(["gateway", "node"] as const)( + "keeps required sessions sandboxed and hides nodes despite configured host=%s", + async (host) => { + const sessionKey = "agent:main:guest"; + const storePath = path.join(execStoreDirs.make("openclaw-required-exec-"), "sessions.json"); + const sessionEntry = { + sessionId: "guest-session", + updatedAt: 1, + sandbox: "required" as const, + }; + await replaceSessionEntry({ sessionKey, storePath }, sessionEntry); + const cfg = withDefaultAgent({ + session: { store: storePath }, + agents: { defaults: { sandbox: { mode: "off" } } }, + tools: { exec: { host } }, + }); + + expect(resolveExecDefaults({ cfg, sessionKey, sandboxAvailable: true })).toMatchObject({ + host: "auto", + effectiveHost: "sandbox", + canRequestNode: false, + }); + expect(resolveExecDefaults({ cfg, sessionEntry, sandboxAvailable: true })).toMatchObject({ + host: "auto", + effectiveHost: "sandbox", + canRequestNode: false, + }); + expect( + resolveExecDefaults({ + cfg, + sessionKey, + sandboxAvailable: true, + elevatedRequested: true, + }).effectiveHost, + ).toBe("sandbox"); + expect(resolveNodeExecEligibility({ cfg, sessionKey, sandboxAvailable: true }).canExec).toBe( + false, + ); + }, + ); + it("keeps node routing available when exec host is auto without sandbox", () => { const defaults = resolveExecDefaults({ cfg: withDefaultAgent({ diff --git a/src/agents/exec-defaults.ts b/src/agents/exec-defaults.ts index f57960d70964..00c92c50f4bf 100644 --- a/src/agents/exec-defaults.ts +++ b/src/agents/exec-defaults.ts @@ -29,7 +29,7 @@ import { resolveSessionPermissionCoreToolPolicy } from "./session-permission-exe /** Session-scoped exec fields that may be carried across an isolated runtime boundary. */ export type ExecSessionDefaults = Pick< SessionEntry, - "execHost" | "execSecurity" | "execAsk" | "execNode" | "execCwd" | "permissionMode" + "execHost" | "execSecurity" | "execAsk" | "execNode" | "execCwd" | "permissionMode" | "sandbox" >; // Resolved exec config layers come from global config, agent config, legacy @@ -159,18 +159,17 @@ export function resolveExecDefaults(params: { agentExec, globalExec, } = resolveExecConfigState(params); - const sandboxAvailable = - params.sandboxAvailable ?? - (params.sessionKey - ? resolveSandboxRuntimeStatus({ - cfg, - sessionKey: params.sessionKey, - }).sandboxed - : false); + const sandboxRuntime = params.sessionKey + ? resolveSandboxRuntimeStatus({ cfg, sessionKey: params.sessionKey }) + : undefined; + const sandboxRequired = + params.sessionEntry?.sandbox === "required" || sandboxRuntime?.sandboxRequired === true; + const sandboxAvailable = params.sandboxAvailable ?? sandboxRuntime?.sandboxed ?? false; const resolved = resolveExecTarget({ configuredTarget: host, - elevatedRequested: params.elevatedRequested === true, + elevatedRequested: params.elevatedRequested === true && !sandboxRequired, sandboxAvailable, + sandboxRequired, }); const defaultSecurity = resolved.effectiveHost === "sandbox" ? "deny" : "full"; const sessionPermissionPolicy = params.sessionEntry?.permissionMode @@ -215,7 +214,7 @@ export function resolveExecDefaults(params: { ? modePolicy.mode : resolveExecModeFromPolicy({ security, ask }); return { - host, + host: resolved.configuredTarget, effectiveHost: resolved.effectiveHost, mode, security, @@ -226,7 +225,7 @@ export function resolveExecDefaults(params: { agentExec?.node ?? globalExec?.node, canRequestNode: isRequestedExecTargetAllowed({ - configuredTarget: host, + configuredTarget: resolved.configuredTarget, requestedTarget: "node", sandboxAvailable, }), diff --git a/src/agents/sandbox.resolveSandboxContext.test.ts b/src/agents/sandbox.resolveSandboxContext.test.ts index 1d5dfa43e9e9..4e27b626103a 100644 --- a/src/agents/sandbox.resolveSandboxContext.test.ts +++ b/src/agents/sandbox.resolveSandboxContext.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { replaceSessionEntry } from "../config/sessions/session-accessor.js"; import type { SkillUsagePath } from "../skills/types.js"; import { registerSandboxBackend } from "./sandbox/backend.js"; import { ensureSandboxWorkspaceForSession, resolveSandboxContext } from "./sandbox/context.js"; @@ -174,6 +175,63 @@ describe("resolveSandboxContext", () => { } }, 15_000); + it("provisions and marks a required sandbox when the agent sandbox mode is off", async () => { + const sessionKey = "agent:main:guest"; + const workspaceDir = await createSandboxFixtureDir("required-sandbox"); + const storePath = path.join(workspaceDir, "agents", "main", "sessions", "sessions.json"); + const entry = { sessionId: "guest-session", updatedAt: 1, sandbox: "required" as const }; + await replaceSessionEntry({ sessionKey, storePath }, entry); + const backendFactory = vi.fn(async () => ({ + id: "required-backend", + runtimeId: "required-runtime", + runtimeLabel: "Required Runtime", + workdir: "/workspace", + buildExecSpec: async () => ({ + argv: ["required-backend", "exec"], + env: {}, + stdinMode: "pipe-closed" as const, + }), + runShellCommand: async () => ({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }), + })); + const restore = registerSandboxBackend("required-backend", backendFactory); + + try { + const sandbox = await resolveSandboxContext({ + config: { + session: { store: storePath }, + agents: { + defaults: { + sandbox: { + mode: "off", + backend: "required-backend", + scope: "session", + workspaceAccess: "rw", + prune: { idleHours: 0, maxAgeDays: 0 }, + }, + }, + list: [{ id: "main" }], + }, + }, + sessionKey, + workspaceDir, + }); + + expect(sandbox).toMatchObject({ + enabled: true, + required: true, + backendId: "required-backend", + sessionKey, + }); + expect(backendFactory).toHaveBeenCalledOnce(); + } finally { + restore(); + } + }, 15_000); + it("treats main session aliases as main in non-main mode", async () => { const cfg: OpenClawConfig = { session: { mainKey: "work" }, @@ -404,6 +462,49 @@ describe("resolveSandboxContext", () => { } }, 15_000); + it("fails closed when a required sandbox cannot be provisioned with agent sandbox mode off", async () => { + const sessionKey = "agent:main:guest"; + const workspaceDir = await createSandboxFixtureDir("required-sandbox-failure"); + const storePath = path.join(workspaceDir, "agents", "main", "sessions", "sessions.json"); + const entry = { sessionId: "guest-session", updatedAt: 1, sandbox: "required" as const }; + await replaceSessionEntry({ sessionKey, storePath }, entry); + const backendFailure = new Error("Required sandbox backend unavailable"); + const restore = registerSandboxBackend("required-broken-backend", async () => { + throw backendFailure; + }); + + try { + await expect( + resolveSandboxContext({ + config: { + session: { store: storePath }, + agents: { + defaults: { + sandbox: { + mode: "off", + backend: "required-broken-backend", + scope: "session", + workspaceAccess: "rw", + prune: { idleHours: 0, maxAgeDays: 0 }, + }, + }, + list: [{ id: "main" }], + }, + }, + sessionKey, + workspaceDir, + }), + ).rejects.toMatchObject({ + code: "sandbox_provisioning", + backendId: "required-broken-backend", + message: "Required sandbox backend unavailable", + cause: backendFailure, + }); + } finally { + restore(); + } + }, 15_000); + it("keeps sandbox registry failures inside the provisioning boundary", async () => { const registryFailure = new Error("sandbox registry write failed"); updateRegistryMock.mockRejectedValueOnce(registryFailure); diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index 2e3346a7ca72..65bcee6663d9 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -319,6 +319,7 @@ async function resolveProvisionedSandboxContext( const sandboxContext: SandboxContext = { enabled: true, + ...(runtime.sandboxRequired ? { required: true } : {}), backendId: backend.id, sessionKey: rawSessionKey, workspaceDir, diff --git a/src/agents/sandbox/runtime-status.ts b/src/agents/sandbox/runtime-status.ts index 396f092c7c76..bb070d199e97 100644 --- a/src/agents/sandbox/runtime-status.ts +++ b/src/agents/sandbox/runtime-status.ts @@ -10,6 +10,8 @@ import { canonicalizeMainSessionAlias, resolveAgentMainSessionKey, } from "../../config/sessions/main-session.js"; +import { resolveSessionStorePathCore } from "../../config/sessions/paths.js"; +import { loadSessionEntryReadOnly } from "../../config/sessions/session-accessor.sqlite-entry.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveSessionAgentId } from "../agent-scope.js"; import { auditSandboxToolPolicyBlock, escapeControlCharsVisible } from "../tool-policy-audit.js"; @@ -20,7 +22,15 @@ import { } from "./tool-policy.js"; import type { SandboxConfig, SandboxToolPolicyResolved } from "./types.js"; -function shouldSandboxSession(cfg: SandboxConfig, sessionKey: string, mainSessionKey: string) { +function shouldSandboxSession( + cfg: SandboxConfig, + sessionKey: string, + mainSessionKey: string, + sandboxRequired: boolean, +) { + if (sandboxRequired) { + return true; + } if (cfg.mode === "off") { return false; } @@ -70,6 +80,7 @@ export function resolveSandboxRuntimeStatus(params: { classificationSessionKey: string; mainSessionKey: string; mode: SandboxConfig["mode"]; + sandboxRequired: boolean; sandboxed: boolean; toolPolicy: SandboxToolPolicyResolved; } { @@ -88,16 +99,24 @@ export function resolveSandboxRuntimeStatus(params: { const cfg = params.cfg; const sandboxCfg = resolveSandboxConfigForAgent(cfg, classificationAgentId); const mainSessionKey = resolveMainSessionKeyForSandbox({ cfg, agentId: classificationAgentId }); - const sandboxed = classificationSessionKey - ? shouldSandboxSession( - sandboxCfg, - resolveComparableSessionKeyForSandbox({ - cfg, + const comparableSessionKey = resolveComparableSessionKeyForSandbox({ + cfg, + agentId: classificationAgentId, + sessionKey: classificationSessionKey, + }); + // Creation owns this immutable requirement; current callers and agent mode cannot relax it. + const sandboxRequired = classificationSessionKey + ? loadSessionEntryReadOnly({ + agentId: classificationAgentId, + clone: false, + sessionKey: comparableSessionKey, + storePath: resolveSessionStorePathCore(cfg?.session?.store, { agentId: classificationAgentId, - sessionKey: classificationSessionKey, }), - mainSessionKey, - ) + })?.sandbox === "required" + : false; + const sandboxed = classificationSessionKey + ? shouldSandboxSession(sandboxCfg, comparableSessionKey, mainSessionKey, sandboxRequired) : false; return { agentId, @@ -106,6 +125,7 @@ export function resolveSandboxRuntimeStatus(params: { classificationSessionKey, mainSessionKey, mode: sandboxCfg.mode, + sandboxRequired, sandboxed, toolPolicy: resolveSandboxToolPolicyForAgent(cfg, classificationAgentId), }; @@ -198,11 +218,15 @@ export function formatSandboxToolPolicyBlockedMessage(params: { lines.push(`Session: ${redactSessionKey(runtime.sessionKey)}`); lines.push(`Reason: ${reasons.join(" + ")}`); lines.push("Fix:"); - lines.push(`- agents.defaults.sandbox.mode=off (disable sandbox)`); + lines.push( + runtime.sandboxRequired + ? "- This session requires a sandbox; create a new session under an authorized role." + : "- agents.defaults.sandbox.mode=off (disable sandbox)", + ); for (const fix of fixes) { lines.push(`- ${fix}`); } - if (runtime.mode === "non-main") { + if (runtime.mode === "non-main" && !runtime.sandboxRequired) { lines.push("- Use the agent main session instead of a non-main session."); } const explainCommand = runtime.sessionKey diff --git a/src/agents/sandbox/tool-policy.test.ts b/src/agents/sandbox/tool-policy.test.ts index 91c3f11aa777..bd2adbf1bca7 100644 --- a/src/agents/sandbox/tool-policy.test.ts +++ b/src/agents/sandbox/tool-policy.test.ts @@ -1,8 +1,11 @@ // Sandbox tool policy tests cover effective allow/deny merging and blocked-tool // guidance for sandboxed agent sessions. -import { describe, expect, it } from "vitest"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; import type { OpenClawConfig } from "../../config/config.js"; import { migratePersistedImplicitMainRoster } from "../../config/legacy.roster.js"; +import { replaceSessionEntry } from "../../config/sessions/session-accessor.js"; import { resolveSandboxConfigForAgent as resolveSandboxConfigForAgentBase } from "./config.js"; import { formatSandboxToolPolicyBlockedMessage as formatSandboxToolPolicyBlockedMessageBase, @@ -13,6 +16,8 @@ import { resolveSandboxToolPolicyForAgent as resolveSandboxToolPolicyForAgentBase, } from "./tool-policy.js"; +const sandboxStoreDirs = useAutoCleanupTempDirTracker(afterEach); + function loadedConfig(config: OpenClawConfig | undefined): OpenClawConfig { return migratePersistedImplicitMainRoster(config ?? {}).config as OpenClawConfig; } @@ -238,6 +243,38 @@ describe("sandbox/tool-policy", () => { ).toBe(true); }); + it("forces a persisted sandbox requirement even when the agent sandbox mode is off", async () => { + const sessionKey = "agent:main:guest"; + const storePath = path.join( + sandboxStoreDirs.make("openclaw-required-sandbox-"), + "agents", + "main", + "sessions", + "sessions.json", + ); + const entry = { sessionId: "guest-session", updatedAt: 1, sandbox: "required" as const }; + await replaceSessionEntry({ sessionKey, storePath }, entry); + const cfg: OpenClawConfig = { + session: { store: storePath }, + agents: { + defaults: { sandbox: { mode: "off", scope: "session" } }, + list: [{ id: "main" }], + }, + }; + + expect(resolveSandboxRuntimeStatus({ cfg, sessionKey })).toMatchObject({ + sandboxRequired: true, + sandboxed: true, + }); + const blockedMessage = formatSandboxToolPolicyBlockedMessage({ + cfg, + sessionKey, + toolName: "browser", + }); + expect(blockedMessage).toContain("create a new session under an authorized role"); + expect(blockedMessage).not.toContain("sandbox.mode=off"); + }); + it("classifies a borrowed runtime key under its own sandbox agent", () => { const cfg = { agents: { diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts index b0d307f9f17c..fae63e2903d4 100644 --- a/src/agents/sandbox/types.ts +++ b/src/agents/sandbox/types.ts @@ -96,6 +96,8 @@ export type SandboxBrowserContext = { export type SandboxContext = { enabled: boolean; + /** Immutable creator policy: this session may never escape to a host execution target. */ + required?: true; backendId: SandboxBackendId; sessionKey: string; workspaceDir: string; diff --git a/src/auto-reply/reply/bash-command.stop.test.ts b/src/auto-reply/reply/bash-command.stop.test.ts index afd352fae5ed..3f9c8c1d3d53 100644 --- a/src/auto-reply/reply/bash-command.stop.test.ts +++ b/src/auto-reply/reply/bash-command.stop.test.ts @@ -197,6 +197,7 @@ describe("handleBashChatCommand stop", () => { classificationSessionKey: "agent:target:telegram:direct:target-session", mainSessionKey: "agent:target:main", mode: "non-main", + sandboxRequired: false, sandboxed: true, toolPolicy: { allow: [], diff --git a/src/auto-reply/reply/get-reply-fast-path.ts b/src/auto-reply/reply/get-reply-fast-path.ts index 9c0711773c65..e317d62aaaaa 100644 --- a/src/auto-reply/reply/get-reply-fast-path.ts +++ b/src/auto-reply/reply/get-reply-fast-path.ts @@ -247,6 +247,7 @@ export function initFastReplySessionState(params: { createdVia: existingEntry.createdVia, createdActor: existingEntry.createdActor, createdAt: existingEntry.createdAt, + ...(existingEntry.sandbox === "required" ? { sandbox: "required" as const } : {}), spawnDepth: existingEntry.spawnDepth, subagentRole: existingEntry.subagentRole, subagentControlScope: existingEntry.subagentControlScope, diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 94a17cc28cb1..8837d432e48e 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -1618,6 +1618,7 @@ describe("initSessionState RawBody", () => { createdVia: "spawn", createdActor: { type: "agent", id: "agent:main:main" }, createdAt: staleStartedAt - 1_000, + sandbox: "required", spawnDepth: 1, subagentRole: "leaf", subagentControlScope: "none", @@ -3613,6 +3614,7 @@ describe("initSessionState preserves behavior overrides across /new and /reset", parentSessionKey: "agent:main:main", parentSessionId: "parent-session", forkedFromParent: true, + sandbox: "required", spawnDepth: 2, subagentRole: "orchestrator", subagentControlScope: "children", diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 7cabd3d83382..630127bf0d73 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -413,6 +413,7 @@ function resolveReplySessionRolloverState(entry: SessionEntry): Partial & { SessionCreation?: { via: import("../config/sessions/session-entry-provenance.js").SessionCreatedVia; actor?: import("../config/sessions/session-entry-provenance.js").SessionCreatedActor; + sandbox?: "required"; }; SenderUsername?: string; SenderTag?: string; diff --git a/src/config/schema.help.core.ts b/src/config/schema.help.core.ts index ae1dd7d4a8df..70539211deaf 100644 --- a/src/config/schema.help.core.ts +++ b/src/config/schema.help.core.ts @@ -134,17 +134,19 @@ export const CORE_FIELD_HELP: Record = { "gateway.auth.trustedProxy.deviceAutoApprove.scopes": "Maximum scopes granted to auto-approved browser devices. Requested scopes are capped to this list; requests without scopes receive this list. Explicitly listing operator.admin lets every proxy-authenticated user auto-approve full admin and makes scope-less requests receive full admin automatically; it also triggers a critical security audit finding and Gateway startup warning.", "gateway.roles": - "Optional profile-bound operator roles for team Gateways. Each named role limits access to other people's sessions, session and run agents, and granted operator scopes; omitting this section preserves existing operator behavior.", + "Optional profile-bound operator roles for team Gateways. Each named role controls access to other people's sessions, sandbox isolation, session and run agents, and granted operator scopes; omitting this section preserves existing operator behavior.", "gateway.roles.default": "Required role assigned to authenticated profiles without a valid explicit assignment whenever operator roles are configured. Its name must match a configured role definition.", "gateway.roles.definitions": - "Nonempty administrator-named role definitions bundling the closed session-sharing, agent-access, and operator-scope policies applied to authenticated user profiles.", + "Nonempty administrator-named role definitions bundling the closed session-sharing, sandbox-isolation, agent-access, and operator-scope policies applied to authenticated user profiles.", "gateway.roles.definitions.*": - "One named operator role. Every definition must explicitly provide its session-sharing policy, allowed session and run agents, and operator-scope ceiling.", + "One named operator role. Every definition must explicitly provide its session-sharing policy, allowed session and run agents, and operator-scope ceiling, and can require sandbox isolation for newly created sessions.", "gateway.roles.definitions.*.sessions": "Session-sharing permissions granted to this role for sessions created by other authenticated people; a person's own sessions remain owner-accessible.", "gateway.roles.definitions.*.sessions.others": 'Access to other people\'s sessions: "none" hides them, "view" allows reading, "suggest" permits the suggestion flow, and "write" permits participation. Explicit session membership can grant additional access.', + "gateway.roles.definitions.*.sandbox": + 'Execution isolation for newly created sessions: "inherit" (default) uses the agent policy; "required" permanently requires a sandbox, even when the agent sandbox mode is off, and fails closed if the backend is unavailable.', "gateway.roles.definitions.*.agents": 'Agents available when this role creates sessions or starts runs: set "*" to allow every agent, list agent IDs to allow only those agents, or use an empty list to disable both.', "gateway.roles.definitions.*.scopes": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 79bfc9df8d7c..c520701100c3 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -142,6 +142,7 @@ export const FIELD_LABELS: Record = { "gateway.roles.definitions.*": "Operator Role Definition", "gateway.roles.definitions.*.sessions": "Operator Role Session Access", "gateway.roles.definitions.*.sessions.others": "Operator Role Access to Other Sessions", + "gateway.roles.definitions.*.sandbox": "Operator Role Sandbox Isolation", "gateway.roles.definitions.*.agents": "Operator Role Allowed Agents", "gateway.roles.definitions.*.scopes": "Operator Role Scope Ceiling", "gateway.trustedProxies": "Gateway Trusted Proxy CIDRs", diff --git a/src/config/sessions/session-accessor.types.ts b/src/config/sessions/session-accessor.types.ts index f34c985b3b01..f71c738eec60 100644 --- a/src/config/sessions/session-accessor.types.ts +++ b/src/config/sessions/session-accessor.types.ts @@ -710,6 +710,7 @@ export type SessionMessageCutMutationParams = { creation?: { via: import("./session-entry-provenance.js").SessionCreatedVia; actor?: import("./session-entry-provenance.js").SessionCreatedActor; + sandbox?: "required"; }; entryId: string; env?: NodeJS.ProcessEnv; diff --git a/src/config/sessions/session-entry-provenance.ts b/src/config/sessions/session-entry-provenance.ts index 3edec977eec2..ac2804a376be 100644 --- a/src/config/sessions/session-entry-provenance.ts +++ b/src/config/sessions/session-entry-provenance.ts @@ -67,11 +67,18 @@ export function buildSessionCreationStamp(params: { via: SessionCreatedVia; actor?: SessionCreatedActor; now?: number; -}): { createdVia: SessionCreatedVia; createdActor?: SessionCreatedActor; createdAt: number } { + sandbox?: "required"; +}): { + createdVia: SessionCreatedVia; + createdActor?: SessionCreatedActor; + createdAt: number; + sandbox?: "required"; +} { return { createdVia: params.via, ...(params.actor ? { createdActor: params.actor } : {}), createdAt: params.now ?? Date.now(), + ...(params.sandbox === "required" ? { sandbox: "required" as const } : {}), }; } diff --git a/src/config/sessions/session-snapshot-merge.test.ts b/src/config/sessions/session-snapshot-merge.test.ts index 233dc0c9d6e7..81102f55be2f 100644 --- a/src/config/sessions/session-snapshot-merge.test.ts +++ b/src/config/sessions/session-snapshot-merge.test.ts @@ -28,6 +28,24 @@ describe("session snapshot merge", () => { }); }); + it.each([undefined, "inherit"] as const)( + "preserves a required creation sandbox against a %s patch", + (sandbox) => { + const existing: SessionEntry = { + ...initial, + sandbox: "required", + }; + const patch: Partial = {}; + Object.assign(patch, { sandbox }); + + expect(mergeSessionEntry(existing, patch)).toMatchObject({ sandbox: "required" }); + }, + ); + + it("does not add a creation sandbox to an existing unstamped session", () => { + expect(mergeSessionEntry(initial, { sandbox: "required" })).not.toHaveProperty("sandbox"); + }); + it("keeps a concurrently changed model pair", () => { const next = { ...initial, model: "claude-sonnet-4-6", updatedAt: 2 }; const current = { diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index d5bf2d5522e0..c373f6a8ce42 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -392,6 +392,8 @@ type SessionEntryCore = SessionRestartRecoveryState & createdVia?: SessionCreatedVia; /** Actor that caused node creation, with an optional profile, session, or sender id; written once. */ createdActor?: SessionCreatedActor; + /** Creation-only sandbox requirement; existing unstamped sessions always remain unstamped. */ + sandbox?: "required"; /** Mutable responsibility, projected from SQLite; absent means createdActor owns the session. */ owner?: SessionOwnerAssignment; /** Earliest external prompt actors, projected from the participant table. */ @@ -786,13 +788,18 @@ function mergeSessionEntryWithPolicy( (existing.sessionId === sessionId ? existing.sessionStartedAt : updatedAt), }; - // Node creation and exact fork ancestry are write-once; patches may only fill absent values. + // Node creation and exact fork ancestry are write-once; sandbox policy cannot be added later. if (existing.createdVia !== undefined) { next.createdVia = existing.createdVia; } if (existing.createdActor !== undefined) { next.createdActor = existing.createdActor; } + if (existing.sandbox === "required") { + next.sandbox = existing.sandbox; + } else { + delete next.sandbox; + } if (existing.createdAt !== undefined) { next.createdAt = existing.createdAt; } diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 4dad3910a99e..b4734570519f 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -541,12 +541,14 @@ export type GatewayToolsConfig = { allow?: string[]; }; -/** Closed session, agent, and operator-scope policy for one named team role. */ +/** Closed session, sandbox, agent, and operator-scope policy for one named team role. */ export type GatewayOperatorRoleDefinition = { sessions: { /** Maximum access to another person's sessions without explicit membership. */ others: "none" | "view" | "suggest" | "write"; }; + /** Require sandbox isolation for newly created sessions, or inherit agent policy by default. */ + sandbox?: "inherit" | "required"; /** Agent IDs available for session creation and runs, or all agents when set to "*". */ agents: "*" | string[]; /** Ceiling applied to the authenticated profile's granted operator scopes. */ diff --git a/src/config/zod-schema.gateway-auth.test.ts b/src/config/zod-schema.gateway-auth.test.ts index a158280a6bd9..8371dcac6b7a 100644 --- a/src/config/zod-schema.gateway-auth.test.ts +++ b/src/config/zod-schema.gateway-auth.test.ts @@ -86,6 +86,16 @@ describe("gateway operator role config", () => { }, ); + test.each(["inherit", "required"])("accepts the closed sandbox policy %s", (sandbox) => { + const result = OpenClawSchema.safeParse({ + gateway: { + roles: { default: "guest", definitions: { guest: { ...validRole, sandbox } } }, + }, + }); + + expect(result.success).toBe(true); + }); + test.each([ { name: "all agents and explicit admin scope", agents: "*", scopes: ["operator.admin"] }, { name: "an empty agent allowlist", agents: [], scopes: ["operator.read"] }, @@ -101,6 +111,7 @@ describe("gateway operator role config", () => { test.each([ { name: "unknown session permission", role: { ...validRole, sessions: { others: "edit" } } }, + { name: "unknown sandbox policy", role: { ...validRole, sandbox: "optional" } }, { name: "unknown operator scope", role: { ...validRole, scopes: ["operator.superuser"] } }, { name: "resource wildcard expression", role: { ...validRole, agents: "agent:*" } }, { name: "wildcard in an agent allowlist", role: { ...validRole, agents: ["*"] } }, diff --git a/src/config/zod-schema.gateway.ts b/src/config/zod-schema.gateway.ts index 322dbf139cb0..12f588a03ece 100644 --- a/src/config/zod-schema.gateway.ts +++ b/src/config/zod-schema.gateway.ts @@ -32,6 +32,7 @@ const OperatorScopeSchema = z.enum([ ]); const GatewayOperatorRoleDefinitionSchema = z.strictObject({ sessions: z.strictObject({ others: z.enum(["none", "view", "suggest", "write"]) }), + sandbox: z.enum(["inherit", "required"]).optional(), agents: z.union([ z.literal("*"), z diff --git a/src/gateway/agent-turn/agent-session-persist.test.ts b/src/gateway/agent-turn/agent-session-persist.test.ts index c956979babc8..3e2d3fe08a11 100644 --- a/src/gateway/agent-turn/agent-session-persist.test.ts +++ b/src/gateway/agent-turn/agent-session-persist.test.ts @@ -1,10 +1,87 @@ import { describe, expect, it, vi } from "vitest"; +import { loadSessionEntry } from "../../config/sessions/session-accessor.js"; import { getAgentEventLifecycleGeneration } from "../../infra/agent-events.js"; +import { ensureProfileForEmail } from "../../state/user-profiles.js"; import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; import type { AgentSessionPatchBuild } from "../server-methods/agent-session-patch.js"; import { persistAgentSessionPhase } from "./agent-session-persist.js"; describe("persistAgentSessionPhase", () => { + it("sandboxes a new synthetic run using its host-minted operator identity", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async (state) => { + const profile = ensureProfileForEmail("synthetic-sandbox-creator@example.com"); + const sessionKey = "agent:main:synthetic-sandbox"; + const runId = "synthetic-sandbox-run"; + const storePath = state.statePath("agents", "main", "sessions", "sessions.json"); + const patchBuild: AgentSessionPatchBuild = { + patch: { sessionId: runId, updatedAt: 1 }, + spawnedBy: undefined, + groupId: undefined, + groupChannel: undefined, + groupSpace: undefined, + freshSessionRotatedSinceLoad: false, + isNewSession: true, + rotatedSessionId: false, + usableRequestedSessionId: undefined, + freshness: undefined, + }; + + const result = await persistAgentSessionPhase({ + request: { message: "sandboxed", idempotencyKey: runId }, + cfg: { + gateway: { + roles: { + default: "guest", + definitions: { + guest: { + sessions: { others: "view" }, + agents: ["main"], + scopes: ["operator.write"], + sandbox: "required", + }, + }, + }, + }, + }, + storePath, + canonicalSessionKey: sessionKey, + sessionAgentId: "main", + mainSessionKey: "agent:main:main", + creation: { via: "run" }, + operatorRoleActor: { kind: "operator", profileId: profile.id }, + lifecycleGeneration: getAgentEventLifecycleGeneration(), + isRestartRecoveryResumeRun: false, + runId, + agentId: "main", + suppressVisibleSessionEffects: false, + initialPatchBuild: patchBuild, + buildSessionPatch: () => patchBuild, + initialSessionPersistedBeforeGatewayAdmission: false, + touchInteraction: false, + bestEffortDeliver: false, + expectedSession: undefined, + maintenanceConfig: undefined, + abortForLifecycleRotation: () => false, + assertGatewayWorkAdmissionAllowed: vi.fn(), + respondToGatewayAdmissionOutcome: () => false, + updateAdmissionState: vi.fn(), + getAdmittedSessionId: () => runId, + setCronContinuationClaim: vi.fn(), + setMainRestartRecoveryOwnerLease: vi.fn(), + respond: vi.fn(), + }); + + expect(result?.sessionEntry).toMatchObject({ + createdVia: "run", + createdActor: { type: "human", id: profile.id }, + sandbox: "required", + }); + expect(loadSessionEntry({ agentId: "main", sessionKey, storePath })).toMatchObject({ + sandbox: "required", + }); + }); + }); + it("surfaces session creation authorization failures before concurrent lifecycle rotation", async () => { await withOpenClawTestState({ scenario: "minimal" }, async (state) => { const sessionKey = "agent:main:role-denied"; diff --git a/src/gateway/agent-turn/agent-session-persist.ts b/src/gateway/agent-turn/agent-session-persist.ts index e6ca8952465b..4a1b4487dcfc 100644 --- a/src/gateway/agent-turn/agent-session-persist.ts +++ b/src/gateway/agent-turn/agent-session-persist.ts @@ -29,7 +29,7 @@ import { recordSessionCreated } from "../../sessions/session-state-events.js"; import { getGeneratedMediaTaskIdsForSessionKey } from "../../tasks/task-status-access.js"; import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js"; import { errorShapeFromError } from "../error-shape.js"; -import { authorizeGatewaySessionCreation } from "../operator-role-policy.js"; +import { authorizeGatewaySessionCreation, resolveCreatorSandbox } from "../operator-role-policy.js"; import { assertExpectedExistingSession, ExpectedExistingSessionChangedError, @@ -304,11 +304,29 @@ export async function persistAgentSessionPhase(params: { previousSessionId && nextSessionId && previousSessionId !== nextSessionId ? { previousSessionId } : {}; + const operatorRoleActor = params.operatorRoleActor; + // Host-owned synthetic runs retain their verified operator only in the + // private role actor; recover it solely for a newly required sandbox. + const delegatedCreation = + !freshEntry && + !params.creation.actor && + params.cfg.gateway?.roles && + operatorRoleActor?.kind === "operator" + ? { + ...params.creation, + actor: { type: "human" as const, id: operatorRoleActor.profileId }, + } + : params.creation; + const sandbox = freshEntry + ? undefined + : resolveCreatorSandbox(params.cfg, delegatedCreation); const effectivePatch = freshEntry ? { ...lifecyclePatch, ...rotationLineage } : { ...lifecyclePatch, - ...buildSessionCreationStamp(params.creation), + ...buildSessionCreationStamp( + sandbox ? { ...delegatedCreation, sandbox } : params.creation, + ), }; createdNewEntry = freshEntry === undefined; const merged = withSqliteSessionFileMarker({ diff --git a/src/gateway/operator-role-policy.test.ts b/src/gateway/operator-role-policy.test.ts index f01bc994f75e..23600144370c 100644 --- a/src/gateway/operator-role-policy.test.ts +++ b/src/gateway/operator-role-policy.test.ts @@ -6,6 +6,7 @@ import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { authorizeGatewaySessionCreation, invalidateOperatorRolePolicy, + resolveCreatorSandbox, resolveOperatorRolePolicy, resolveOperatorRolePolicyForProfile, } from "./operator-role-policy.js"; @@ -95,6 +96,39 @@ describe("operator role policy", () => { }); }); + it("requires sandboxing only for the trusted human creator's resolved role", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + const profile = ensureProfileForEmail("role-sandbox-creator@example.com"); + const cfg = roleConfig(); + const guest = cfg.gateway?.roles?.definitions.guest; + if (!guest) { + throw new Error("missing guest role"); + } + guest.sandbox = "required"; + + expect(resolveCreatorSandbox(cfg, { actor: { type: "human", id: profile.id } })).toBe( + "required", + ); + expect( + resolveCreatorSandbox(cfg, { actor: { type: "agent", id: profile.id } }), + ).toBeUndefined(); + expect( + resolveCreatorSandbox(cfg, { actor: { type: "system", id: profile.id } }), + ).toBeUndefined(); + expect(resolveCreatorSandbox(cfg, { actor: { type: "human" } })).toBeUndefined(); + expect( + resolveCreatorSandbox({}, { actor: { type: "human", id: profile.id } }), + ).toBeUndefined(); + + setUserProfileRole(profile.id, "maintainer"); + invalidateOperatorRolePolicy(profile.id); + + expect( + resolveCreatorSandbox(cfg, { actor: { type: "human", id: profile.id } }), + ).toBeUndefined(); + }); + }); + it("falls back from stale assignments to the configured default or denies access", async () => { await withOpenClawTestState({ scenario: "minimal" }, async () => { const profile = ensureProfileForEmail("role-stale@example.com"); diff --git a/src/gateway/operator-role-policy.ts b/src/gateway/operator-role-policy.ts index e4a91376ab04..b621211d2edf 100644 --- a/src/gateway/operator-role-policy.ts +++ b/src/gateway/operator-role-policy.ts @@ -3,6 +3,7 @@ import { errorShape, type ErrorShape, } from "../../packages/gateway-protocol/src/index.js"; +import type { SessionCreatedActor } from "../config/sessions/session-entry-provenance.js"; import type { GatewayOperatorRoleDefinition } from "../config/types.gateway.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -89,6 +90,19 @@ export function resolveOperatorRolePolicyForProfile( return (roles.default ? roles.definitions[roles.default] : undefined) ?? deniedOperatorRole; } +/** Derives immutable session isolation only from its authenticated human creator. */ +export function resolveCreatorSandbox( + cfg: OpenClawConfig, + creation: { actor?: SessionCreatedActor } | undefined, +): "required" | undefined { + const actor = creation?.actor; + return actor?.type === "human" && + actor.id && + resolveOperatorRolePolicyForProfile(actor.id, cfg)?.sandbox === "required" + ? "required" + : undefined; +} + /** Resolves the current named policy from the connection's verified profile identity. */ export function resolveGatewayOperatorRoleActor( client: GatewayClient | null | undefined, diff --git a/src/gateway/server-methods/chat-send-user-turn.test.ts b/src/gateway/server-methods/chat-send-user-turn.test.ts index 872458965fcd..276fece253d4 100644 --- a/src/gateway/server-methods/chat-send-user-turn.test.ts +++ b/src/gateway/server-methods/chat-send-user-turn.test.ts @@ -16,6 +16,8 @@ import { buildPersistedUserTurnMessage, type UserTurnInput, } from "../../sessions/user-turn-transcript.js"; +import { ensureProfileForEmail } from "../../state/user-profiles.js"; +import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; import * as chatAttachments from "../chat-attachments.js"; import { applyChatSendManagedMedia, prepareChatSendUserTurn } from "./chat-send-user-turn.js"; @@ -88,6 +90,63 @@ function createAttachments( } describe("prepareChatSendUserTurn", () => { + it("carries the authenticated guest's required sandbox into session creation", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + const profile = ensureProfileForEmail("chat-sandbox-creator@example.com"); + const { controller } = createUserTurnInputController(); + const prepared = prepareChatSendUserTurn({ + request: { + clientInfo: createClientInfo(), + normalizedAttachments: [], + suppressCommandInterpretation: false, + systemInputProvenance: undefined, + systemProvenanceReceipt: undefined, + }, + session: { + agentId: "main", + clientRunId: "run-1", + sessionKey: "agent:main:dashboard:guest-chat", + cfg: { + gateway: { + roles: { + default: "guest", + definitions: { + guest: { + sessions: { others: "view" }, + agents: "*", + scopes: ["operator.write"], + sandbox: "required", + }, + }, + }, + }, + }, + }, + admission: { + originatingRoute: { originatingChannel: "webchat", explicitDeliverRoute: false }, + }, + attachments: createAttachments(), + client: { + authenticatedUserProfile: { + profileId: profile.id, + displayName: profile.displayName, + hasAvatar: false, + updatedAt: profile.updatedAt, + }, + connect: { scopes: ["operator.write"] }, + } as never, + logGateway: { warn: vi.fn() } as never, + userTurn: controller, + }); + + expect(prepared.ctx.SessionCreation).toEqual({ + via: "operator", + actor: { type: "human", id: profile.id }, + sandbox: "required", + }); + }); + }); + it("assembles command, provenance, sender, and origin facts", async () => { const { controller, readInput } = createUserTurnInputController(); const prepared = prepareChatSendUserTurn({ diff --git a/src/gateway/server-methods/chat-send-user-turn.ts b/src/gateway/server-methods/chat-send-user-turn.ts index 077114f322df..83b99c82267a 100644 --- a/src/gateway/server-methods/chat-send-user-turn.ts +++ b/src/gateway/server-methods/chat-send-user-turn.ts @@ -1,6 +1,7 @@ import path from "node:path"; import type { GatewayClientInfo } from "../../../packages/gateway-protocol/src/client-info.js"; import type { RuntimeMsgContext as MsgContext } from "../../auto-reply/templating.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { MediaFact } from "../../media/media-facts.js"; import type { InputProvenance } from "../../sessions/input-provenance.js"; import type { UserTurnInput } from "../../sessions/user-turn-transcript.js"; @@ -11,6 +12,7 @@ import { INLINE_IMAGE_DURABLE_OMISSION_MARKER, persistInboundImagesForTranscript, } from "../chat-attachments.js"; +import { resolveCreatorSandbox } from "../operator-role-policy.js"; import { isAcpBridgeClient } from "./chat-origin-routing.js"; import type { AdmittedChatSend } from "./chat-send-admission.js"; import type { prepareChatSendAttachments } from "./chat-send-attachments.js"; @@ -81,6 +83,7 @@ function buildChatSendPromptMedia( function buildChatSendMessageContext(params: { agentId: string; + cfg?: OpenClawConfig; client: GatewayRequestHandlerOptions["client"]; clientInfo?: GatewayClientInfo; clientRunId: string; @@ -112,6 +115,8 @@ function buildChatSendMessageContext(params: { : undefined; const { originatingChannel, originatingTo, accountId, messageThreadId, explicitDeliverRoute } = params.originatingRoute; + const creation = resolveOperatorSessionCreation(params.client); + const sandbox = params.cfg ? resolveCreatorSandbox(params.cfg, creation) : undefined; // Current and historical turns must reach the single LLM timestamp boundary // with identical bare text. Stamping this live turn would bust the prompt cache. const ctx: MsgContext = { @@ -148,7 +153,7 @@ function buildChatSendMessageContext(params: { }, ...(params.suppressCommandInterpretation ? { CommandInterpretationSuppressed: true } : {}), MessageSid: params.clientRunId, - SessionCreation: resolveOperatorSessionCreation(params.client), + SessionCreation: { ...creation, ...(sandbox ? { sandbox } : {}) }, ApprovalReviewerDeviceId: queuedFollowupOwnerDeviceId, ...(!isOperatorUiClient(params.clientInfo) ? { @@ -189,7 +194,8 @@ export function prepareChatSendUserTurn(params: { | "systemProvenanceReceipt" | "toolBindings" >; - session: Pick; + session: Pick & + Partial>; admission: Pick; attachments: PreparedChatSendAttachments; client: GatewayRequestHandlerOptions["client"]; @@ -232,6 +238,7 @@ export function prepareChatSendUserTurn(params: { void pluginBoundMediaPromise.catch(() => undefined); const messageContext = buildChatSendMessageContext({ agentId: session.agentId, + cfg: session.cfg, client, clientInfo: request.clientInfo, clientRunId: session.clientRunId, diff --git a/src/gateway/server-methods/session-creation-provenance.ts b/src/gateway/server-methods/session-creation-provenance.ts index 9a915a2fb39d..f293bd693f51 100644 --- a/src/gateway/server-methods/session-creation-provenance.ts +++ b/src/gateway/server-methods/session-creation-provenance.ts @@ -7,6 +7,8 @@ import type { AgentRuntimeIdentity } from "../agent-runtime-identity-token.js"; export type TrustedSessionCreation = { via: SessionCreatedVia; actor?: SessionCreatedActor; + /** Creator-owned isolation requirement resolved only by the trusted Gateway boundary. */ + sandbox?: "required"; /** Exact spawning session retained separately from the stable actor identity. */ requesterSessionKey?: string; /** Immutable completion recipient for a spawn-owned visible session. */ diff --git a/src/gateway/server-methods/sessions-patch-engine.ts b/src/gateway/server-methods/sessions-patch-engine.ts index db88363e1332..c56575d648e3 100644 --- a/src/gateway/server-methods/sessions-patch-engine.ts +++ b/src/gateway/server-methods/sessions-patch-engine.ts @@ -18,7 +18,7 @@ import { disableCronJobsBoundToSessions } from "../../cron/job-session-bindings. import { formatErrorMessage } from "../../infra/errors.js"; import { resolveMissingAgentHarnessSessionError } from "../../sessions/agent-harness-session-key.js"; import { runExclusiveSessionLifecycleMutation } from "../../sessions/session-lifecycle-admission.js"; -import { authorizeGatewaySessionCreation } from "../operator-role-policy.js"; +import { authorizeGatewaySessionCreation, resolveCreatorSandbox } from "../operator-role-policy.js"; import { ADMIN_SCOPE } from "../operator-scopes.js"; import { ensureSessionGroupRegistered } from "../session-groups.js"; import { triggerSessionPatchHook } from "../session-patch-hooks.js"; @@ -133,7 +133,9 @@ async function executeSessionPatchMutations(params: { }): Promise { const { client } = params; const cfg = params.context.getRuntimeConfig(); - const creation = resolveOperatorSessionCreation(client); + const operatorCreation = resolveOperatorSessionCreation(client); + const sandbox = resolveCreatorSandbox(cfg, operatorCreation); + const creation = { ...operatorCreation, ...(sandbox ? { sandbox } : {}) }; const archiveActor = gatewayClientSessionCreator(client); const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; const callerCanManageCron = client === null || callerScopes.includes(ADMIN_SCOPE); diff --git a/src/gateway/server-methods/sessions-rewind.test.ts b/src/gateway/server-methods/sessions-rewind.test.ts index a12794acde39..7f8805dd8670 100644 --- a/src/gateway/server-methods/sessions-rewind.test.ts +++ b/src/gateway/server-methods/sessions-rewind.test.ts @@ -325,6 +325,50 @@ describe("session message-cut methods", () => { expect(rewind).toHaveBeenCalledWith(true, expect.any(Object), undefined); }); + it("stamps a required sandbox on a session fork created by a restricted operator", async () => { + const profile = ensureProfileForEmail("sandbox-required-fork-creator@example.com"); + setUserProfileRole(profile.id, "guest"); + const client = { + connect: { scopes: ["operator.write"] }, + authenticatedUserProfile: { + profileId: profile.id, + displayName: profile.displayName, + hasAvatar: false, + updatedAt: profile.updatedAt, + }, + } as GatewayClient; + const runtimeConfig: GatewayRequestContext["getRuntimeConfig"] = () => ({ + agents: { list: [{ id: "main", default: true }] }, + gateway: { + roles: { + default: "guest", + definitions: { + guest: { + sessions: { others: "view" }, + agents: ["main"], + scopes: ["operator.read", "operator.write"], + sandbox: "required", + }, + }, + }, + }, + }); + + const fork = await invoke("sessions.fork", "user-entry", client, false, runtimeConfig); + const forkKey = (fork.mock.calls[0]?.[1] as { sessionKey?: string } | undefined)?.sessionKey; + + expect(fork).toHaveBeenCalledWith( + true, + expect.objectContaining({ sessionKey: expect.any(String) }), + undefined, + ); + expect(loadSessionEntry({ agentId: "main", sessionKey: forkKey ?? "" })).toMatchObject({ + createdActor: { type: "human", id: profile.id }, + sandbox: "required", + }); + expect(loadSessionEntry({ agentId: "main", sessionKey })).not.toHaveProperty("sandbox"); + }); + it("returns an empty branch list for a not-yet-materialized session", async () => { const respond = vi.fn() as unknown as RespondFn; await expectDefined( diff --git a/src/gateway/server-methods/sessions-rewind.ts b/src/gateway/server-methods/sessions-rewind.ts index 59fbf65b58ab..60d3905d9139 100644 --- a/src/gateway/server-methods/sessions-rewind.ts +++ b/src/gateway/server-methods/sessions-rewind.ts @@ -28,7 +28,7 @@ import { readSessionUpstreamLink, type SessionUpstreamLink, } from "../../sessions/session-upstream-links.js"; -import { authorizeGatewaySessionCreation } from "../operator-role-policy.js"; +import { authorizeGatewaySessionCreation, resolveCreatorSandbox } from "../operator-role-policy.js"; import { buildDashboardSessionKey } from "../session-create-service.js"; import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId, @@ -435,6 +435,7 @@ async function mutateSessionAtMessage( } let result: MessageCutMutationResult; try { + const creation = resolveOperatorSessionCreation(client); result = await (action === "fork" ? forkSessionAtMessage( { @@ -444,7 +445,12 @@ async function mutateSessionAtMessage( sessionStoreKey: current.sessionStoreKey, storePath: current.storePath, targetKey, - creation: resolveOperatorSessionCreation(client), + creation: { + ...creation, + ...(resolveCreatorSandbox(cfg, creation) === "required" + ? { sandbox: "required" } + : {}), + }, }, expectedState, ) diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index 5125694ee9c7..fefef568734a 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -260,6 +260,80 @@ test("sessions.create assigns and registers its requested group", async () => { ); }); +test.each([ + ["sessions.create", "create"], + ["sessions.patch", "patch"], + ["sessions.patchMany", "patch-many"], +] as const)( + "required operator sandbox follows new %s session ownership", + async (method, suffix) => { + const { storePath } = await createSessionStoreDir(); + const profile = ensureProfileForEmail(`sandboxed-session-${suffix}@example.com`); + setUserProfileRole(profile.id, "guest"); + const cfg = { + ...getRuntimeConfig(), + session: { ...getRuntimeConfig().session, store: storePath }, + gateway: { + ...getRuntimeConfig().gateway, + roles: { + default: "guest", + definitions: { + guest: { + sessions: { others: "view" as const }, + agents: ["main"], + scopes: ["operator.read" as const, "operator.write" as const], + sandbox: "required" as const, + }, + }, + }, + }, + }; + const client = { + connect: { role: "operator", scopes: ["operator.read", "operator.write"] }, + authenticatedUserProfile: { + profileId: profile.id, + displayName: profile.displayName, + hasAvatar: false, + updatedAt: profile.updatedAt, + }, + } as never; + const key = `agent:main:dashboard:role-sandbox-${suffix}`; + const request = + method === "sessions.create" + ? { agentId: "main", key } + : method === "sessions.patch" + ? { key, label: "Guest session" } + : { patch: { label: "Guest session" }, targets: [{ key }] }; + + const created = await directSessionReq<{ outcomes?: Array<{ ok: boolean }> }>(method, request, { + client, + context: { getRuntimeConfig: () => cfg }, + }); + + expect(created.ok, JSON.stringify(created.error)).toBe(true); + if (method === "sessions.patchMany") { + expect(created.payload?.outcomes).toEqual([{ ok: true, key }]); + } + expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })).toMatchObject({ + createdActor: { type: "human", id: profile.id }, + sandbox: "required", + }); + + for (const forgedSandbox of [null, "inherit"] as const) { + const forged = await directSessionReq( + "sessions.patch", + { key, sandbox: forgedSandbox }, + { client, context: { getRuntimeConfig: () => cfg } }, + ); + + expect(forged).toMatchObject({ ok: false, error: { code: "INVALID_REQUEST" } }); + } + expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })).toMatchObject({ + sandbox: "required", + }); + }, +); + test("operator role agent allowlists protect creation without blocking existing sessions", async () => { const { storePath } = await createSessionStoreDir(); const profile = ensureProfileForEmail("restricted-session-creator@example.com"); diff --git a/src/gateway/server.sessions.recover.test.ts b/src/gateway/server.sessions.recover.test.ts index 449f90ff974a..f7eef74b75e7 100644 --- a/src/gateway/server.sessions.recover.test.ts +++ b/src/gateway/server.sessions.recover.test.ts @@ -407,6 +407,7 @@ test("sessions.recover rolls over one tombstone and returns its continuation out modelOverride: "gpt-5.6-sol", modelSelectionLocked: true, pinnedAt: 1, + sandbox: "required", spawnedCwd: "/tmp/recovered-worktree", mainRestartRecovery: { cycleId: "cycle-tombstoned", @@ -467,6 +468,7 @@ test("sessions.recover rolls over one tombstone and returns its continuation out modelOverride: "gpt-5.6-sol", previousSessionId: sourceSessionId, providerOverride: "openai", + sandbox: "required", spawnedCwd: "/tmp/recovered-worktree", }); const archivedSource = loadSessionEntry({ agentId: "main", sessionKey: sourceKey, storePath }); diff --git a/src/gateway/server.sessions.reset-models.test.ts b/src/gateway/server.sessions.reset-models.test.ts index 6660bbf9084c..9ee05f95a669 100644 --- a/src/gateway/server.sessions.reset-models.test.ts +++ b/src/gateway/server.sessions.reset-models.test.ts @@ -9,6 +9,7 @@ import { loadSessionEntry, replaceSessionEntry } from "../config/sessions/sessio import type { SessionEntry } from "../config/sessions/types.js"; import { MODEL_SELECTION_LOCKED_RESET_MESSAGE } from "../sessions/model-overrides.js"; import { listSessionStateEventsSince } from "../sessions/session-state-events.js"; +import { ensureProfileForEmail, setUserProfileRole } from "../state/user-profiles.js"; import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; import { testState, writeSessionStore } from "./test-helpers.js"; import { @@ -36,6 +37,7 @@ type ResetSessionEntry = { createdVia?: string; createdActor?: { type: string; id?: string }; createdAt?: number; + sandbox?: "required"; forkSource?: { sessionKey: string; sessionId: string; entryId?: string }; previousSessionId?: string; forkedFromParent?: boolean; @@ -104,6 +106,7 @@ test("sessions.reset stamps provenance when it materializes a missing row", asyn createdActor: { type: "human", id: "profile-reset-creator" }, createdAt: expect.any(Number), }); + expect(reset.payload?.entry).not.toHaveProperty("sandbox"); expect( listSessionStateEventsSince("agent:main:subagent:missing", "main", 0, 20).events, ).toContainEqual( @@ -115,6 +118,50 @@ test("sessions.reset stamps provenance when it materializes a missing row", asyn ); }); +test("sessions.reset stamps the creator's required sandbox only when materializing a new row", async () => { + const { storePath } = await createSessionStoreDir(); + const profile = ensureProfileForEmail("sandboxed-reset-creator@example.test"); + setUserProfileRole(profile.id, "guest"); + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + gateway: { + roles: { + default: "guest", + definitions: { + guest: { + sessions: { others: "none" }, + agents: ["main"], + scopes: ["operator.read", "operator.write"], + sandbox: "required", + }, + }, + }, + }, + }); + + try { + const key = "agent:main:subagent:sandboxed-reset"; + const reset = await directSessionReq<{ entry: ResetSessionEntry }>( + "sessions.reset", + { key }, + { + client: { + authenticatedUserProfile: { profileId: profile.id }, + } as never, + }, + ); + + expect(reset.ok, JSON.stringify(reset.error)).toBe(true); + expect(reset.payload?.entry).toMatchObject({ + createdActor: { type: "human", id: profile.id }, + sandbox: "required", + }); + expect(loadSessionEntry({ sessionKey: key, storePath })?.sandbox).toBe("required"); + } finally { + await writeConfigFile({}); + } +}); + const ownedChildMetadata = { chatType: "group", delivery: normalizeSessionDeliveryState({ @@ -140,6 +187,7 @@ const ownedChildMetadata = { parentSessionKey: "agent:main:main", parentSessionId: "sess-parent", forkedFromParent: true, + sandbox: "required", spawnDepth: 2, subagentRole: "orchestrator", subagentControlScope: "children", diff --git a/src/gateway/session-create-service.ts b/src/gateway/session-create-service.ts index 28f0a07ffb92..c3aa5f83ba00 100644 --- a/src/gateway/session-create-service.ts +++ b/src/gateway/session-create-service.ts @@ -76,7 +76,7 @@ import { import { recordSessionCreated } from "../sessions/session-state-events.js"; import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; -import { authorizeGatewaySessionCreation } from "./operator-role-policy.js"; +import { authorizeGatewaySessionCreation, resolveCreatorSandbox } from "./operator-role-policy.js"; import { ADMIN_SCOPE } from "./operator-scopes.js"; import type { GatewayOperatorRoleActor } from "./server-methods/shared-types.js"; import { buildForkedGatewaySessionEntry } from "./session-create-fork-entry.js"; @@ -352,7 +352,7 @@ export async function createGatewaySession(params: { /** Trusted host actor; only system-owned callers may omit operator identity. */ operatorRoleActor?: GatewayOperatorRoleActor; /** Trusted in-process creation provenance; never populated from public Gateway params. */ - creation?: { via: SessionCreatedVia; actor?: SessionCreatedActor }; + creation?: { via: SessionCreatedVia; actor?: SessionCreatedActor; sandbox?: "required" }; /** Exact harness namespace authorized by the scoped plugin runtime. */ authorizedAgentHarnessId?: string; /** Exact plugin namespace authorized by the scoped plugin runtime. */ @@ -1140,7 +1140,12 @@ export async function createGatewaySession(params: { // Stamp provenance only for genuinely new rows: adopting an existing key // must not restamp write-once node facts (this direct store write bypasses // the merge-level write-once guard), and legacy rows stay "unknown". - ...(params.creation && createdNewEntry ? buildSessionCreationStamp(params.creation) : {}), + ...(params.creation && createdNewEntry + ? buildSessionCreationStamp({ + ...params.creation, + sandbox: resolveCreatorSandbox(params.cfg, params.creation), + }) + : {}), ...(params.visibility && createdNewEntry ? { visibility: params.visibility } : {}), ...(projectId && createdNewEntry ? { projectId } : {}), ...(catalogResolvedModel && catalogAgentRuntime diff --git a/src/gateway/session-recovery-entry.ts b/src/gateway/session-recovery-entry.ts index 301db632b881..f962c17d54fb 100644 --- a/src/gateway/session-recovery-entry.ts +++ b/src/gateway/session-recovery-entry.ts @@ -14,7 +14,11 @@ export function buildRestartRecoverySuccessorEntry(params: { const source = params.source; const entry = mergeSessionEntry(undefined, { ...inheritSessionSelection(source), - ...buildSessionCreationStamp({ via: "operator", actor: params.actor }), + ...buildSessionCreationStamp({ + via: "operator", + actor: params.actor, + ...(source.sandbox ? { sandbox: source.sandbox } : {}), + }), delivery: normalizeSessionDeliveryState(), sessionId: params.sessionId, previousSessionId: source.sessionId, diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index cb32b5778f58..7437f157198c 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -97,7 +97,7 @@ import { forgetActiveSessionForShutdown, noteActiveSessionForShutdown, } from "./active-sessions-shutdown-tracker.js"; -import { authorizeGatewaySessionCreation } from "./operator-role-policy.js"; +import { authorizeGatewaySessionCreation, resolveCreatorSandbox } from "./operator-role-policy.js"; import type { GatewayOperatorRoleActor } from "./server-methods/shared-types.js"; import { findDirectChildSessionsForParent } from "./session-child-sessions.js"; import { @@ -1573,9 +1573,15 @@ export async function performGatewaySessionReset(params: { createdActor: currentEntry.createdActor, createdAt: currentEntry.createdAt, projectId: currentEntry.projectId, + ...(currentEntry.sandbox === "required" ? { sandbox: "required" as const } : {}), } : params.creation - ? buildSessionCreationStamp(params.creation) + ? { + ...buildSessionCreationStamp(params.creation), + ...(resolveCreatorSandbox(cfg, params.creation) === "required" + ? { sandbox: "required" as const } + : {}), + } : {}; const nextEntry: InternalSessionEntry = { sessionId: nextSessionId, diff --git a/src/gateway/session-sharing.sandbox.test.ts b/src/gateway/session-sharing.sandbox.test.ts new file mode 100644 index 000000000000..108329b8ba0c --- /dev/null +++ b/src/gateway/session-sharing.sandbox.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { upsertSessionEntryCore } from "../config/sessions/session-accessor.js"; +import { addSessionMember } from "../config/sessions/session-sharing-store.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; +import { ensureProfileForEmail, setUserProfileRole } from "../state/user-profiles.js"; +import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; +import type { GatewayClient, GatewayRequestContext } from "./server-methods/types.js"; +import { + authorizeResolvedSessionMutation, + resolveSessionMutationAuthorization, + resolveSessionSharingRole, + resolveSessionSharingTarget, + SessionMutationAuthorizationChangedError, +} from "./session-sharing.js"; + +afterEach(() => closeOpenClawAgentDatabasesForTest()); + +function sandboxRoleClient(role: "view" | "write"): GatewayClient { + const profile = ensureProfileForEmail(`sandbox-required-${role}@example.test`); + setUserProfileRole(profile.id, role); + return { + connect: { + minProtocol: 1, + maxProtocol: 1, + client: { id: "openclaw-control-ui", version: "test", platform: "test", mode: "webchat" }, + role: "operator", + scopes: ["operator.read", "operator.write"], + }, + authenticatedUserId: profile.id, + authenticatedUserProfile: { + profileId: profile.id, + displayName: null, + hasAvatar: false, + updatedAt: 1, + }, + }; +} + +describe("session sharing sandbox requirements", () => { + it("denies sandbox-required members host execution without changing session provenance", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + const cfg: OpenClawConfig = { + gateway: { + roles: { + default: "view", + definitions: { + view: { + sessions: { others: "view" }, + agents: "*", + scopes: ["operator.read", "operator.write"], + sandbox: "required", + }, + write: { + sessions: { others: "write" }, + agents: "*", + scopes: ["operator.read", "operator.write"], + }, + }, + }, + }, + }; + const guest = sandboxRoleClient("view"); + const maintainer = sandboxRoleClient("write"); + const guestId = guest.authenticatedUserProfile!.profileId; + const maintainerId = maintainer.authenticatedUserProfile!.profileId; + const hostSessionKey = "agent:main:maintainer-host-session"; + const sandboxSessionKey = "agent:main:guest-sandbox-session"; + + await upsertSessionEntryCore( + { agentId: "main", sessionKey: hostSessionKey }, + { + sessionId: "maintainer-host-session", + updatedAt: 1, + visibility: "shared", + createdActor: { type: "human", id: maintainerId }, + }, + ); + await upsertSessionEntryCore( + { agentId: "main", sessionKey: sandboxSessionKey }, + { + sessionId: "guest-sandbox-session", + updatedAt: 1, + visibility: "shared", + createdActor: { type: "human", id: guestId }, + sandbox: "required", + }, + ); + addSessionMember( + { agentId: "main", sessionKey: hostSessionKey }, + { + identityId: guestId, + addedBy: maintainerId, + expectedSessionId: "maintainer-host-session", + }, + ); + + const hostTarget = resolveSessionSharingTarget({ cfg, sessionKey: hostSessionKey }); + expect(hostTarget).not.toBeNull(); + if (!hostTarget) { + throw new Error("expected persisted maintainer session"); + } + expect(resolveSessionSharingRole({ cfg, client: guest, target: hostTarget })).toBe("member"); + expect( + authorizeResolvedSessionMutation({ + cfg, + client: guest, + sessionKey: hostSessionKey, + agentId: "main", + }), + ).toMatchObject({ code: "FORBIDDEN", message: expect.stringMatching(/sandbox/i) }); + + const context = { getRuntimeConfig: () => cfg } as GatewayRequestContext; + for (const method of [ + "agent", + "chat.send", + "message.action", + "send", + "sessions.dispatch", + "sessions.send", + "sessions.steer", + "talk.client.create", + "talk.session.create", + "tools.invoke", + "wake", + ]) { + const requestParams = method.startsWith("sessions.") + ? { key: hostSessionKey } + : { sessionKey: hostSessionKey }; + expect( + resolveSessionMutationAuthorization({ client: guest, method, requestParams, context }) + .error, + method, + ).toMatchObject({ code: "FORBIDDEN", message: expect.stringMatching(/sandbox/i) }); + } + expect( + resolveSessionMutationAuthorization({ + client: guest, + method: "sessions.patch", + requestParams: { key: hostSessionKey }, + context, + }).error, + ).toBeNull(); + + for (const participant of [guest, maintainer]) { + expect( + resolveSessionMutationAuthorization({ + client: participant, + method: "chat.send", + requestParams: { sessionKey: sandboxSessionKey }, + context, + }).error, + ).toBeNull(); + } + + cfg.gateway!.roles!.definitions.view!.sandbox = "inherit"; + const admittedHostRun = resolveSessionMutationAuthorization({ + client: guest, + method: "chat.send", + requestParams: { sessionKey: hostSessionKey }, + context, + }); + expect(admittedHostRun.error).toBeNull(); + expect(admittedHostRun.authorization).toBeDefined(); + cfg.gateway!.roles!.definitions.view!.sandbox = "required"; + expect(() => admittedHostRun.authorization!.assertCurrent()).toThrow( + SessionMutationAuthorizationChangedError, + ); + + expect( + resolveSessionMutationAuthorization({ + client: guest, + method: "chat.send", + requestParams: { sessionKey: hostSessionKey }, + context: { getRuntimeConfig: () => ({}) } as GatewayRequestContext, + }).error, + ).toBeNull(); + }); + }); +}); diff --git a/src/gateway/session-sharing.ts b/src/gateway/session-sharing.ts index f4fe65f14d43..4e81bc927b00 100644 --- a/src/gateway/session-sharing.ts +++ b/src/gateway/session-sharing.ts @@ -14,6 +14,7 @@ import { authorizeGatewaySessionCreation, operatorSessionCap, resolveGatewayOperatorRoleActor, + resolveOperatorRolePolicy, } from "./operator-role-policy.js"; import { authenticatedProfileUnavailableError, @@ -300,7 +301,7 @@ export function authorizeResolvedSessionMutation(params: { const agentError = authorizeSessionAgentRun({ cfg: params.cfg, client: params.client, - agentId: target.agentId, + target, }); if (agentError) { return agentError; @@ -326,9 +327,27 @@ export function authorizeResolvedSessionMutation(params: { function authorizeSessionAgentRun(params: { cfg: OpenClawConfig; client: GatewayClient | null; - agentId: string; + target: SessionSharingTarget; }): ErrorShape | null { - return authorizeGatewaySessionCreation(params) ?? null; + const agentError = authorizeGatewaySessionCreation({ + cfg: params.cfg, + client: params.client, + agentId: params.target.agentId, + }); + if (agentError) { + return agentError; + } + if ( + params.cfg.gateway?.roles && + params.target.entry.sandbox !== "required" && + resolveOperatorRolePolicy(params.client, params.cfg)?.sandbox === "required" + ) { + return errorShape( + ErrorCodes.FORBIDDEN, + `Your operator role requires a sandboxed session; create a new session instead of running in "${params.target.canonicalKey}".`, + ); + } + return null; } export function authorizeSessionSharingTarget(params: { @@ -477,7 +496,7 @@ export function resolveSessionMutationAuthorization(params: { ? authorizeSessionAgentRun({ cfg: getCfg(), client: params.client, - agentId: target.agentId, + target, }) : null) ?? authorizeIncognitoSessionTarget({ @@ -556,7 +575,7 @@ export function resolveSessionMutationAuthorization(params: { ? authorizeSessionAgentRun({ cfg: currentCfg, client: params.client, - agentId: current.agentId, + target: current, }) : null) ?? authorizeIncognitoSessionTarget({ diff --git a/src/plugins/contracts/session-entry-projection.contract.test.ts b/src/plugins/contracts/session-entry-projection.contract.test.ts index c0d9aea66e8c..7d240fc7887e 100644 --- a/src/plugins/contracts/session-entry-projection.contract.test.ts +++ b/src/plugins/contracts/session-entry-projection.contract.test.ts @@ -324,6 +324,11 @@ describe("plugin session extension SessionEntry projection", () => { description: "reserved context window provenance", sessionEntrySlotKey: "contextTokensSource", }); + api.registerSessionExtension({ + namespace: "sandbox-policy", + description: "reserved creation-only sandbox requirement", + sessionEntrySlotKey: "sandbox", + }); api.registerSessionExtension({ namespace: "pending-final-text", description: "retired pending-final field", @@ -364,6 +369,10 @@ describe("plugin session extension SessionEntry projection", () => { pluginId: "slot-collision", message: "sessionEntrySlotKey is reserved by SessionEntry: contextTokensSource", }, + { + pluginId: "slot-collision", + message: "sessionEntrySlotKey is reserved by SessionEntry: sandbox", + }, { pluginId: "slot-collision", message: "sessionEntrySlotKey is reserved by SessionEntry: pendingFinalDeliveryText", diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 11ccecebb8e4..53b969df7d2b 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -41,6 +41,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "parentSessionId", "createdVia", "createdActor", + "sandbox", "owner", "participants", "participantCount",