From 548b44b40d6d3c08f6f05e3523ee34786a244562 Mon Sep 17 00:00:00 2001 From: Christof Salis Date: Tue, 27 Jan 2026 19:31:55 +0100 Subject: [PATCH] refactor: clarify sandbox cron elevated gating terminology --- docs/automation/cron-jobs.md | 1 + docs/gateway/configuration.md | 15 +++++++ .../sandbox-vs-tool-policy-vs-elevated.md | 14 +++++++ docs/gateway/sandboxing.md | 18 +++++++++ docs/multi-agent-sandbox-tools.md | 39 +++++++++++++++++++ docs/tools/index.md | 1 + src/agents/sandbox-merge.test.ts | 6 +-- src/agents/sandbox/config.ts | 2 +- src/agents/sandbox/constants.ts | 2 +- src/agents/sandbox/types.ts | 2 +- src/agents/tools/cron-tool.test.ts | 32 +++++++-------- src/agents/tools/cron-tool.ts | 14 +++---- src/config/config.sandbox-cron.test.ts | 12 +++--- src/config/types.sandbox.ts | 12 +++--- src/config/zod-schema.agent-defaults.ts | 4 +- src/config/zod-schema.agent-runtime.ts | 4 +- 16 files changed, 129 insertions(+), 49 deletions(-) diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index b117ba827..84d7c43cf 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -134,6 +134,7 @@ Delivery notes: - If `to` is set, cron auto-delivers the agent’s final output even if `deliver` is omitted. - Use `deliver: true` when you want last-route delivery without an explicit `to`. - Use `deliver: false` to keep output internal even if a `to` is present. +- Sandboxed sessions may restrict delivery targets via `agents.defaults.sandbox.cron.delivery` (see [Sandboxing](/gateway/sandboxing) and [Configuration](/gateway/configuration#agentsdefaultssandbox)). Target format reminders: - Slack/Discord/Mattermost (plugin) targets should use explicit prefixes (e.g. `channel:`, `user:`) to avoid ambiguity. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index f5438fb46..a4fa9a06e 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2175,6 +2175,15 @@ Defaults (if enabled): - optional sandboxed browser (Chromium + CDP, noVNC observer) - hardening knobs: `network`, `user`, `pidsLimit`, `memory`, `cpus`, `ulimits`, `seccompProfile`, `apparmorProfile` +Cron policy (only applies when a session is sandboxed and the `cron` tool is allowed): +- `cron.visibility`: `"agent"` (default) scopes cron jobs to the current agent; `"all"` allows cross-agent cron visibility. +- `cron.elevated`: `"off"` (default) never bypasses sandbox cron restrictions; `"on"` bypasses when session elevated is on/ask/full; `"full"` bypasses only when elevated is full. +- `cron.allowMainSessionJobs`: `false` (default) blocks main-session cron jobs and wake events from sandboxed sessions. +- `cron.delivery`: `"last-only"` (default) allows last-route delivery only (no explicit `to`; if `channel` is set it must be `last`); `"off"` disables delivery; `"explicit"` allows explicit delivery targets. + +Notes: +- This does not enable the `cron` tool inside the sandbox. Cron is denied by default in sandbox tool policy; allow it via `tools.sandbox.tools` (or per-agent `agents.list[].tools.sandbox.tools`). + Warning: `scope: "shared"` means a shared container and shared workspace. No cross-session isolation. Use `scope: "session"` for per-session isolation. @@ -2193,6 +2202,12 @@ For package installs, ensure network egress, a writable root FS, and a root user scope: "agent", // session | agent | shared (agent is default) workspaceAccess: "none", // none | ro | rw workspaceRoot: "~/.clawdbot/sandboxes", + cron: { + visibility: "agent", // agent | all + elevated: "off", // off | on | full + allowMainSessionJobs: false, + delivery: "last-only" // off | last-only | explicit + }, docker: { image: "moltbot-sandbox:bookworm-slim", containerPrefix: "moltbot-sbx-", diff --git a/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md index 600c7392a..bf05e5bb2 100644 --- a/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md +++ b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md @@ -90,6 +90,20 @@ Available groups: - `group:nodes`: `nodes` - `group:moltbot`: all built-in Moltbot tools (excludes provider plugins) +### Cron policy inside the sandbox + +If the `cron` tool is allowed, sandboxed sessions can still be restricted by +`agents.defaults.sandbox.cron` (and `agents.list[].sandbox.cron`): +- Visibility: `agents.defaults.sandbox.cron.visibility` can scope cron jobs to the current agent. +- Main session gating: `agents.defaults.sandbox.cron.allowMainSessionJobs: false` blocks main-session cron jobs and wake events from sandboxed sessions. +- Delivery gating: `agents.defaults.sandbox.cron.delivery` can disable delivery or restrict it to last-route delivery. +- Elevated gate: `agents.defaults.sandbox.cron.elevated` can use the session elevated level as an escape signal for cron restrictions. + +Notes: +- Elevated remains exec-only (it does not grant extra tools), but cron policy can consult the session elevated level. +- Fix-it keys live under `agents.defaults.sandbox.cron.*` / `agents.list[].sandbox.cron.*` (see [Configuration](/gateway/configuration#agentsdefaultssandbox)). +- This is in addition to tool policy; if `cron` is denied by sandbox tool policy, allow it via `tools.sandbox.tools` (or per-agent `agents.list[].tools.sandbox.tools`). + ## Elevated: exec-only “run on host” Elevated does **not** grant extra tools; it only affects `exec`. diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 5dc3a33dd..a40493b59 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -145,6 +145,24 @@ globally or per-agent, sandboxing doesn’t bring it back. `/exec` directives only apply for authorized senders and persist per session; to hard-disable `exec`, use tool policy deny (see [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated)). +## Cron policy (sandboxed sessions) + +If the `cron` tool is allowed in a sandboxed session, Clawdbot can still apply +additional restrictions to cron access and delivery via: +- `agents.defaults.sandbox.cron` +- `agents.list[].sandbox.cron` (per-agent override) + +This is useful when you want sandboxed agents to schedule reminders but prevent: +- cross-agent cron visibility (`visibility: "agent"`) +- main-session jobs and wake events (`allowMainSessionJobs: false`) +- explicit delivery targets (`delivery: "last-only"` or `"off"`) + +Note: this does not enable cron in the sandbox. If `cron` is denied by sandbox tool policy, +allow it via `tools.sandbox.tools` (or per-agent `agents.list[].tools.sandbox.tools`). + +See [Configuration](/gateway/configuration#agentsdefaultssandbox) for the exact keys and defaults. +If you see “blocked” errors, also check [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated). + Debugging: - Use `moltbot sandbox explain` to inspect effective sandbox mode, tool policy, and fix-it config keys. - See [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) for the “why is this blocked?” mental model. diff --git a/docs/multi-agent-sandbox-tools.md b/docs/multi-agent-sandbox-tools.md index ff00df93f..4fca6ab6a 100644 --- a/docs/multi-agent-sandbox-tools.md +++ b/docs/multi-agent-sandbox-tools.md @@ -178,6 +178,43 @@ For debugging “why is this blocked?”, see [Sandbox vs Tool Policy vs Elevate --- +### Example 4: Sandbox Cron Policy Per Agent + +This is useful when you want sandboxed agents to schedule cron jobs, but keep them +scoped (and limit delivery): + +```json +{ + "agents": { + "defaults": { + "sandbox": { + "mode": "all", + "cron": { + "visibility": "agent", + "elevated": "off", + "allowMainSessionJobs": false, + "delivery": "last-only" + } + } + }, + "list": [ + { + "id": "admin", + "sandbox": { + "cron": { + "visibility": "all", + "elevated": "full", + "delivery": "explicit" + } + } + } + ] + } +} +``` + +--- + ## Configuration Precedence When both global (`agents.defaults.*`) and agent-specific (`agents.list[].*`) configs exist: @@ -189,12 +226,14 @@ agents.list[].sandbox.mode > agents.defaults.sandbox.mode agents.list[].sandbox.scope > agents.defaults.sandbox.scope agents.list[].sandbox.workspaceRoot > agents.defaults.sandbox.workspaceRoot agents.list[].sandbox.workspaceAccess > agents.defaults.sandbox.workspaceAccess +agents.list[].sandbox.cron.* > agents.defaults.sandbox.cron.* agents.list[].sandbox.docker.* > agents.defaults.sandbox.docker.* agents.list[].sandbox.browser.* > agents.defaults.sandbox.browser.* agents.list[].sandbox.prune.* > agents.defaults.sandbox.prune.* ``` **Notes:** +- `agents.list[].sandbox.cron.*` overrides `agents.defaults.sandbox.cron.*` for that agent. - `agents.list[].sandbox.{docker,browser,prune}.*` overrides `agents.defaults.sandbox.{docker,browser,prune}.*` for that agent (ignored when sandbox scope resolves to `"shared"`). ### Tool Restrictions diff --git a/docs/tools/index.md b/docs/tools/index.md index 7c2274fb4..b104fc959 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -361,6 +361,7 @@ Core actions: Notes: - `add` expects a full cron job object (same schema as `cron.add` RPC). - `update` uses `{ id, patch }`. +- In sandboxed sessions, cron access and delivery may be further restricted by `agents.defaults.sandbox.cron` (and per-agent overrides via `agents.list[].sandbox.cron`). ### `gateway` Restart or apply updates to the running Gateway process (in-place). diff --git a/src/agents/sandbox-merge.test.ts b/src/agents/sandbox-merge.test.ts index 2c6c1750a..5e483b48f 100644 --- a/src/agents/sandbox-merge.test.ts +++ b/src/agents/sandbox-merge.test.ts @@ -131,19 +131,19 @@ describe("sandbox config merges", () => { const resolved = resolveSandboxCronConfig({ globalCron: { visibility: "all", - escape: "elevated", + elevated: "on", allowMainSessionJobs: true, delivery: "explicit", }, agentCron: { visibility: "agent", - escape: "off", + elevated: "off", }, }); expect(resolved).toEqual({ visibility: "agent", - escape: "off", + elevated: "off", allowMainSessionJobs: true, delivery: "explicit", }); diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index e07f3752c..b9aabe1c7 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -132,7 +132,7 @@ export function resolveSandboxCronConfig(params: { return { visibility: agentCron?.visibility ?? globalCron?.visibility ?? DEFAULT_SANDBOX_CRON_POLICY.visibility, - escape: agentCron?.escape ?? globalCron?.escape ?? DEFAULT_SANDBOX_CRON_POLICY.escape, + elevated: agentCron?.elevated ?? globalCron?.elevated ?? DEFAULT_SANDBOX_CRON_POLICY.elevated, allowMainSessionJobs: agentCron?.allowMainSessionJobs ?? globalCron?.allowMainSessionJobs ?? diff --git a/src/agents/sandbox/constants.ts b/src/agents/sandbox/constants.ts index df4049d97..295a03ca8 100644 --- a/src/agents/sandbox/constants.ts +++ b/src/agents/sandbox/constants.ts @@ -39,7 +39,7 @@ export const DEFAULT_TOOL_DENY = [ export const DEFAULT_SANDBOX_CRON_POLICY = { visibility: "agent", - escape: "off", + elevated: "off", allowMainSessionJobs: false, delivery: "last-only", } as const; diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts index 1ae1f0f3f..021c660f8 100644 --- a/src/agents/sandbox/types.ts +++ b/src/agents/sandbox/types.ts @@ -50,7 +50,7 @@ export type SandboxScope = "session" | "agent" | "shared"; export type SandboxCronPolicy = { visibility: "agent" | "all"; - escape: "off" | "elevated" | "elevated-full"; + elevated: "off" | "on" | "full"; allowMainSessionJobs: boolean; delivery: "off" | "last-only" | "explicit"; }; diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 1d646c83f..7c9e0512d 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -5,7 +5,7 @@ const resolveSandboxRuntimeStatusMock = vi.fn(() => ({ sandboxed: false, agentId const resolveSandboxConfigForAgentMock = vi.fn(() => ({ cron: { visibility: "agent", - escape: "off", + elevated: "off", allowMainSessionJobs: false, delivery: "last-only", }, @@ -43,7 +43,7 @@ describe("cron tool", () => { resolveSandboxConfigForAgentMock.mockReturnValue({ cron: { visibility: "agent", - escape: "off", + elevated: "off", allowMainSessionJobs: false, delivery: "last-only", }, @@ -285,7 +285,7 @@ describe("cron tool", () => { expect(call.params).toEqual({ includeDisabled: false, actorAgentId: "agent-123" }); }); - it("skips actor scoping when sandbox escape is elevated", async () => { + it("skips actor scoping when sandbox cron elevated gate is on", async () => { resolveSandboxRuntimeStatusMock.mockReturnValue({ sandboxed: true, agentId: "agent-123", @@ -293,7 +293,7 @@ describe("cron tool", () => { resolveSandboxConfigForAgentMock.mockReturnValue({ cron: { visibility: "agent", - escape: "elevated", + elevated: "on", allowMainSessionJobs: false, delivery: "last-only", }, @@ -311,7 +311,7 @@ describe("cron tool", () => { expect(call.params).toEqual({ includeDisabled: false }); }); - it("keeps actor scoping when escape is elevated-full but elevated is on", async () => { + it("keeps actor scoping when sandbox cron elevated gate is full but elevated is on", async () => { resolveSandboxRuntimeStatusMock.mockReturnValue({ sandboxed: true, agentId: "agent-123", @@ -319,7 +319,7 @@ describe("cron tool", () => { resolveSandboxConfigForAgentMock.mockReturnValue({ cron: { visibility: "agent", - escape: "elevated-full", + elevated: "full", allowMainSessionJobs: false, delivery: "last-only", }, @@ -337,7 +337,7 @@ describe("cron tool", () => { expect(call.params).toEqual({ includeDisabled: false, actorAgentId: "agent-123" }); }); - it("skips actor scoping when escape is elevated-full and elevated is full", async () => { + it("skips actor scoping when sandbox cron elevated gate is full and elevated is full", async () => { resolveSandboxRuntimeStatusMock.mockReturnValue({ sandboxed: true, agentId: "agent-123", @@ -345,7 +345,7 @@ describe("cron tool", () => { resolveSandboxConfigForAgentMock.mockReturnValue({ cron: { visibility: "agent", - escape: "elevated-full", + elevated: "full", allowMainSessionJobs: false, delivery: "last-only", }, @@ -392,7 +392,7 @@ describe("cron tool", () => { resolveSandboxConfigForAgentMock.mockReturnValue({ cron: { visibility: "agent", - escape: "off", + elevated: "off", allowMainSessionJobs: true, delivery: "last-only", }, @@ -424,7 +424,7 @@ describe("cron tool", () => { resolveSandboxConfigForAgentMock.mockReturnValue({ cron: { visibility: "agent", - escape: "off", + elevated: "off", allowMainSessionJobs: true, delivery: "last-only", }, @@ -529,7 +529,7 @@ describe("cron tool", () => { resolveSandboxConfigForAgentMock.mockReturnValue({ cron: { visibility: "all", - escape: "off", + elevated: "off", allowMainSessionJobs: false, delivery: "last-only", }, @@ -586,7 +586,7 @@ describe("cron tool", () => { resolveSandboxConfigForAgentMock.mockReturnValue({ cron: { visibility: "agent", - escape: "off", + elevated: "off", allowMainSessionJobs: true, delivery: "off", }, @@ -615,7 +615,7 @@ describe("cron tool", () => { resolveSandboxConfigForAgentMock.mockReturnValue({ cron: { visibility: "agent", - escape: "off", + elevated: "off", allowMainSessionJobs: true, delivery: "explicit", }, @@ -653,7 +653,7 @@ describe("cron tool", () => { resolveSandboxConfigForAgentMock.mockReturnValue({ cron: { visibility: "agent", - escape: "off", + elevated: "off", allowMainSessionJobs: true, delivery: "last-only", }, @@ -690,7 +690,7 @@ describe("cron tool", () => { resolveSandboxConfigForAgentMock.mockReturnValue({ cron: { visibility: "agent", - escape: "off", + elevated: "off", allowMainSessionJobs: true, delivery: "off", }, @@ -730,7 +730,7 @@ describe("cron tool", () => { resolveSandboxConfigForAgentMock.mockReturnValue({ cron: { visibility: "agent", - escape: "off", + elevated: "off", allowMainSessionJobs: true, delivery: "last-only", }, diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 972b3f71b..dcbf42aea 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -55,7 +55,7 @@ type CronSandboxAccess = { sessionKey?: string; policy: { visibility: "agent" | "all"; - escape: "off" | "elevated" | "elevated-full"; + elevated: "off" | "on" | "full"; allowMainSessionJobs: boolean; delivery: "off" | "last-only" | "explicit"; }; @@ -117,7 +117,7 @@ function resolveCronSandboxAccess(params: { sessionKey: undefined, policy: { visibility: "all", - escape: "off", + elevated: "off", allowMainSessionJobs: true, delivery: "explicit", }, @@ -138,16 +138,12 @@ function resolveCronSandboxAccess(params: { const elevatedOn = elevatedLevel !== "off"; const elevatedFull = elevatedLevel === "full"; - const escapeAllowed = - policy.escape === "elevated" - ? elevatedOn - : policy.escape === "elevated-full" - ? elevatedFull - : false; + const elevatedAllowed = + policy.elevated === "on" ? elevatedOn : policy.elevated === "full" ? elevatedFull : false; return { sandboxed: runtime.sandboxed, - restricted: runtime.sandboxed && !escapeAllowed, + restricted: runtime.sandboxed && !elevatedAllowed, agentId: normalizedAgentId, sessionKey: rawSessionKey, policy, diff --git a/src/config/config.sandbox-cron.test.ts b/src/config/config.sandbox-cron.test.ts index 41ac5fe18..248352798 100644 --- a/src/config/config.sandbox-cron.test.ts +++ b/src/config/config.sandbox-cron.test.ts @@ -10,7 +10,7 @@ describe("sandbox cron config", () => { sandbox: { cron: { visibility: "agent", - escape: "elevated", + elevated: "on", allowMainSessionJobs: false, delivery: "last-only", }, @@ -22,7 +22,7 @@ describe("sandbox cron config", () => { sandbox: { cron: { visibility: "all", - escape: "off", + elevated: "off", allowMainSessionJobs: true, delivery: "explicit", }, @@ -48,7 +48,7 @@ describe("sandbox cron config", () => { sandbox: { cron: { visibility: "everyone", - escape: "nope", + elevated: "nope", delivery: "sometimes", }, }, @@ -58,7 +58,7 @@ describe("sandbox cron config", () => { expect(res.ok).toBe(false); }); - it("accepts elevated-full escape and allowMainSessionJobs", async () => { + it("accepts full elevated gate and allowMainSessionJobs", async () => { vi.resetModules(); const { validateConfigObject } = await import("./config.js"); const res = validateConfigObject({ @@ -67,7 +67,7 @@ describe("sandbox cron config", () => { sandbox: { cron: { visibility: "all", - escape: "elevated-full", + elevated: "full", allowMainSessionJobs: true, delivery: "explicit", }, @@ -78,7 +78,7 @@ describe("sandbox cron config", () => { expect(res.ok).toBe(true); if (res.ok) { - expect(res.config.agents?.defaults?.sandbox?.cron?.escape).toBe("elevated-full"); + expect(res.config.agents?.defaults?.sandbox?.cron?.elevated).toBe("full"); expect(res.config.agents?.defaults?.sandbox?.cron?.allowMainSessionJobs).toBe(true); } }); diff --git a/src/config/types.sandbox.ts b/src/config/types.sandbox.ts index 17d4e04a9..6e5c1238a 100644 --- a/src/config/types.sandbox.ts +++ b/src/config/types.sandbox.ts @@ -75,7 +75,7 @@ export type SandboxPruneSettings = { }; export type SandboxCronVisibility = "agent" | "all"; -export type SandboxCronEscape = "off" | "elevated" | "elevated-full"; +export type SandboxCronElevated = "off" | "on" | "full"; export type SandboxCronDelivery = "off" | "last-only" | "explicit"; export type SandboxCronSettings = { @@ -86,12 +86,12 @@ export type SandboxCronSettings = { */ visibility?: SandboxCronVisibility; /** - * Escape hatch for sandboxed cron access. - * - "off": never escape - * - "elevated": allow escape when session elevated != off - * - "elevated-full": allow escape only when elevated=full + * Elevated gate for sandboxed cron restrictions. + * - "off": never bypass sandbox cron restrictions + * - "on": bypass when the session elevated level is on/ask/full + * - "full": bypass only when the session elevated level is full */ - escape?: SandboxCronEscape; + elevated?: SandboxCronElevated; /** * Allow main-session cron jobs from sandboxed sessions. * Default: false. diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 697939db3..3e15eac93 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -161,9 +161,7 @@ export const AgentDefaultsSchema = z cron: z .object({ visibility: z.union([z.literal("agent"), z.literal("all")]).optional(), - escape: z - .union([z.literal("off"), z.literal("elevated"), z.literal("elevated-full")]) - .optional(), + elevated: z.union([z.literal("off"), z.literal("on"), z.literal("full")]).optional(), allowMainSessionJobs: z.boolean().optional(), delivery: z .union([z.literal("off"), z.literal("last-only"), z.literal("explicit")]) diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index dd95ef72d..dc12a7d24 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -237,9 +237,7 @@ export const AgentSandboxSchema = z cron: z .object({ visibility: z.union([z.literal("agent"), z.literal("all")]).optional(), - escape: z - .union([z.literal("off"), z.literal("elevated"), z.literal("elevated-full")]) - .optional(), + elevated: z.union([z.literal("off"), z.literal("on"), z.literal("full")]).optional(), allowMainSessionJobs: z.boolean().optional(), delivery: z .union([z.literal("off"), z.literal("last-only"), z.literal("explicit")])