refactor: clarify sandbox cron elevated gating terminology

This commit is contained in:
Christof Salis 2026-01-27 19:31:55 +01:00
parent fed475f9ac
commit 548b44b40d
16 changed files with 129 additions and 49 deletions

View File

@ -134,6 +134,7 @@ Delivery notes:
- If `to` is set, cron auto-delivers the agents 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:<id>`, `user:<id>`) to avoid ambiguity.

View File

@ -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-",

View File

@ -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`.

View File

@ -145,6 +145,24 @@ globally or per-agent, sandboxing doesnt 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.

View File

@ -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

View File

@ -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).

View File

@ -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",
});

View File

@ -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 ??

View File

@ -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;

View File

@ -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";
};

View File

@ -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",
},

View File

@ -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,

View File

@ -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);
}
});

View File

@ -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.

View File

@ -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")])

View File

@ -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")])