feat(sandbox): add sandbox explain inspector

This commit is contained in:
Peter Steinberger
2026-01-10 20:28:34 +01:00
parent 4533dd6e5d
commit 9f9098406c
20 changed files with 951 additions and 56 deletions

View File

@@ -13,6 +13,7 @@
- Auto-reply: prefer `RawBody` for command/directive parsing (WhatsApp + Discord) and prevent fallback runs from clobbering concurrent session updates. (#643) — thanks @mcinteerj.
- Cron: `wakeMode: "now"` waits for heartbeat completion (and retries when the main lane is busy). (#666) — thanks @roshanasingh4.
- Agents/OpenAI: fix Responses tool-only → follow-up turn handling (avoid standalone `reasoning` items that trigger 400 “required following item”).
- Sandbox: add `clawdbot sandbox explain` (effective policy inspector + fix-it keys); improve “sandbox jail” tool-policy/elevated errors with actionable config key paths; link to docs.
- Hooks/Gmail: keep Tailscale serve path at `/` while preserving the public path. (#668) — thanks @antons.
- Hooks/Gmail: allow Tailscale target URLs to preserve internal serve paths.
- Auth: update Claude Code keychain credentials in-place during refresh sync; share JSON file helpers; add CLI fallback coverage.

View File

@@ -1,3 +1,10 @@
---
title: Sandbox CLI
summary: "Manage sandbox containers and inspect effective sandbox policy"
read_when: "You are managing sandbox containers or debugging sandbox/tool-policy behavior."
status: active
---
# Sandbox CLI
Manage Docker-based sandbox containers for isolated agent execution.
@@ -8,6 +15,17 @@ Clawdbot can run agents in isolated Docker containers for security. The `sandbox
## Commands
### `clawdbot sandbox explain`
Inspect the **effective** sandbox mode/scope/workspace access, sandbox tool policy, and elevated gates (with fix-it config key paths).
```bash
clawdbot sandbox explain
clawdbot sandbox explain --session agent:main:main
clawdbot sandbox explain --agent work
clawdbot sandbox explain --json
```
### `clawdbot sandbox list`
List all sandbox containers with their status and configuration.
@@ -56,7 +74,7 @@ docker pull clawdbot-sandbox:latest
docker tag clawdbot-sandbox:latest clawdbot-sandbox:bookworm-slim
# Update config to use new image
# Edit clawdbot.config.json: agent.sandbox.docker.image
# Edit config: agents.defaults.sandbox.docker.image (or agents.list[].sandbox.docker.image)
# Recreate containers
clawdbot sandbox recreate --all
@@ -65,7 +83,7 @@ clawdbot sandbox recreate --all
### After changing sandbox configuration
```bash
# Edit clawdbot.config.json: agent.sandbox.*
# Edit config: agents.defaults.sandbox.* (or agents.list[].sandbox.*)
# Recreate to apply new config
clawdbot sandbox recreate --all

View File

@@ -29,6 +29,14 @@
"source": "/cron/",
"destination": "/cron-jobs"
},
{
"source": "/sandbox",
"destination": "/gateway/sandbox-vs-tool-policy-vs-elevated"
},
{
"source": "/sandbox/",
"destination": "/gateway/sandbox-vs-tool-policy-vs-elevated"
},
{
"source": "/model",
"destination": "/models"

View File

@@ -0,0 +1,77 @@
---
title: Sandbox vs Tool Policy vs Elevated
summary: "Why a tool is blocked: sandbox runtime, tool allow/deny policy, and elevated bash gates"
read_when: "You hit 'sandbox jail' or see a tool/elevated refusal and want the exact config key to change."
status: active
---
# Sandbox vs Tool Policy vs Elevated
Clawdbot has three related (but different) controls:
1. **Sandbox** (`agents.defaults.sandbox.*` / `agents.list[].sandbox.*`) decides **where tools run** (Docker vs host).
2. **Tool policy** (`tools.*`, `tools.sandbox.tools.*`, `agents.list[].tools.*`) decides **which tools are available/allowed**.
3. **Elevated** (`tools.elevated.*`, `agents.list[].tools.elevated.*`) is a **bash-only escape hatch** to run on the host when youre sandboxed.
## Quick debug
Use the inspector to see what Clawdbot is *actually* doing:
```bash
clawdbot sandbox explain
clawdbot sandbox explain --session agent:main:main
clawdbot sandbox explain --agent work
clawdbot sandbox explain --json
```
It prints:
- effective sandbox mode/scope/workspace access
- whether the session is currently sandboxed (main vs non-main)
- effective sandbox tool allow/deny (and whether it came from agent/global/default)
- elevated gates and fix-it key paths
## Sandbox: where tools run
Sandboxing is controlled by `agents.defaults.sandbox.mode`:
- `"off"`: everything runs on the host.
- `"non-main"`: only non-main sessions are sandboxed (common “surprise” for groups/channels).
- `"all"`: everything is sandboxed.
See [Sandboxing](/gateway/sandboxing) for the full matrix (scope, workspace mounts, images).
## Tool policy: which tools exist/are callable
Two layers matter:
- **Global/per-agent tool policy**: `tools.allow`/`tools.deny` and `agents.list[].tools.allow`/`agents.list[].tools.deny`
- **Sandbox tool policy** (only applies when sandboxed): `tools.sandbox.tools.allow`/`tools.sandbox.tools.deny` and `agents.list[].tools.sandbox.tools.*`
Rules of thumb:
- `deny` always wins.
- If `allow` is non-empty, everything else is treated as blocked.
## Elevated: bash-only “run on host”
Elevated does **not** grant extra tools; it only affects `bash`.
- If youre sandboxed, `/elevated on` (or `bash` with `elevated: true`) runs on the host.
- If youre already running direct, elevated is effectively a no-op (still gated).
Gates:
- Enablement: `tools.elevated.enabled` (and optionally `agents.list[].tools.elevated.enabled`)
- Sender allowlists: `tools.elevated.allowFrom.<provider>` (and optionally `agents.list[].tools.elevated.allowFrom.<provider>`)
See [Elevated Mode](/tools/elevated).
## Common “sandbox jail” fixes
### “Tool X blocked by sandbox tool policy”
Fix-it keys (pick one):
- Disable sandbox: `agents.defaults.sandbox.mode=off` (or per-agent `agents.list[].sandbox.mode=off`)
- Allow the tool inside sandbox:
- remove it from `tools.sandbox.tools.deny` (or per-agent `agents.list[].tools.sandbox.tools.deny`)
- or add it to `tools.sandbox.tools.allow` (or per-agent allow)
### “I thought this was main, why is it sandboxed?”
In `"non-main"` mode, group/channel keys are *not* main. Use the main session key (shown by `sandbox explain`) or switch mode to `"off"`.

View File

@@ -78,6 +78,10 @@ Tool allow/deny policies still apply before sandbox rules. If a tool is denied
globally or per-agent, sandboxing doesnt bring it back.
`tools.elevated` is an explicit escape hatch that runs `bash` on the host.
Debugging:
- Use `clawdbot 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.
Keep it locked down.
## Multi-agent overrides

View File

@@ -19,6 +19,7 @@ This allows you to run multiple agents with different security profiles:
- Public-facing agents in sandboxes
For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing).
For debugging “why is this blocked?”, see [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) and `clawdbot sandbox explain`.
---

View File

@@ -36,7 +36,7 @@ Note:
## Setting a session default
- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`.
- Confirmation reply is sent (`Elevated mode enabled.` / `Elevated mode disabled.`).
- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies `elevated is not available right now.` and does not change session state.
- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error (runtime sandboxed/direct + failing config key paths) and does not change session state.
- Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level.
## Availability + allowlists

View File

@@ -164,7 +164,7 @@ describe("bash tool backgrounding", () => {
command: "echo hi",
elevated: true,
}),
).rejects.toThrow("elevated is not available right now.");
).rejects.toThrow("tools.elevated.allowFrom.<provider>");
});
it("does not default to elevated when not allowed", async () => {

View File

@@ -194,7 +194,24 @@ export function createBashTool(
: elevatedDefaultOn;
if (elevatedRequested) {
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
throw new Error("elevated is not available right now.");
const runtime = defaults?.sandbox ? "sandboxed" : "direct";
const gates: string[] = [];
if (!elevatedDefaults?.enabled) {
gates.push("enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled)");
} else {
gates.push("allowFrom (tools.elevated.allowFrom.<provider> / agents.list[].tools.elevated.allowFrom.<provider>)");
}
throw new Error(
[
`elevated is not available right now (runtime=${runtime}).`,
`Failing gates: ${gates.join(", ")}`,
"Fix-it keys:",
"- tools.elevated.enabled",
"- tools.elevated.allowFrom.<provider>",
"- agents.list[].tools.elevated.enabled",
"- agents.list[].tools.elevated.allowFrom.<provider>",
].join("\n"),
);
}
logInfo(
`bash: elevated command (${sessionId.slice(0, 8)}) ${truncateMiddle(

View File

@@ -6,12 +6,14 @@ import type {
AgentToolResult,
} from "@mariozechner/pi-agent-core";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import type { ClawdbotConfig } from "../config/config.js";
import {
normalizeThinkLevel,
type ThinkLevel,
} from "../auto-reply/thinking.js";
import { sanitizeContentBlocksImages } from "./tool-images.js";
import { formatSandboxToolPolicyBlockedMessage } from "./sandbox.js";
import type { WorkspaceBootstrapFile } from "./workspace.js";
export type EmbeddedContextFile = { path: string; content: string };
@@ -431,11 +433,26 @@ export function isContextOverflowError(errorMessage?: string): boolean {
export function formatAssistantErrorText(
msg: AssistantMessage,
opts?: { cfg?: ClawdbotConfig; sessionKey?: string },
): string | undefined {
if (msg.stopReason !== "error") return undefined;
const raw = (msg.errorMessage ?? "").trim();
if (!raw) return "LLM request failed with an unknown error.";
const unknownTool =
raw.match(/unknown tool[:\s]+["']?([a-z0-9_-]+)["']?/i) ??
raw.match(
/tool\s+["']?([a-z0-9_-]+)["']?\s+(?:not found|is not available)/i,
);
if (unknownTool?.[1]) {
const rewritten = formatSandboxToolPolicyBlockedMessage({
cfg: opts?.cfg,
sessionKey: opts?.sessionKey,
toolName: unknownTool[1],
});
if (rewritten) return rewritten;
}
// Check for context overflow (413) errors
if (isContextOverflowError(raw)) {
return (

View File

@@ -1607,7 +1607,10 @@ export async function runEmbeddedPiAgent(params: {
const message =
lastAssistant?.errorMessage?.trim() ||
(lastAssistant
? formatAssistantErrorText(lastAssistant)
? formatAssistantErrorText(lastAssistant, {
cfg: params.config,
sessionKey: params.sessionKey ?? params.sessionId,
})
: "") ||
(timedOut
? "LLM request timed out."
@@ -1648,7 +1651,10 @@ export async function runEmbeddedPiAgent(params: {
}> = [];
const errorText = lastAssistant
? formatAssistantErrorText(lastAssistant)
? formatAssistantErrorText(lastAssistant, {
cfg: params.config,
sessionKey: params.sessionKey ?? params.sessionId,
})
: undefined;
if (errorText) replyItems.push({ text: errorText, isError: true });

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import {
formatSandboxToolPolicyBlockedMessage,
resolveSandboxConfigForAgent,
resolveSandboxToolPolicyForAgent,
} from "./sandbox.js";
describe("sandbox explain helpers", () => {
it("prefers agent overrides > global > defaults (sandbox tool policy)", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
sandbox: { mode: "all", scope: "agent" },
},
list: [
{
id: "work",
workspace: "~/clawd-work",
tools: { sandbox: { tools: { allow: ["write"] } } },
},
],
},
tools: { sandbox: { tools: { allow: ["read"], deny: ["browser"] } } },
};
const resolved = resolveSandboxConfigForAgent(cfg, "work");
expect(resolved.tools.allow).toEqual(["write"]);
expect(resolved.tools.deny).toEqual(["browser"]);
const policy = resolveSandboxToolPolicyForAgent(cfg, "work");
expect(policy.allow).toEqual(["write"]);
expect(policy.sources.allow.source).toBe("agent");
expect(policy.deny).toEqual(["browser"]);
expect(policy.sources.deny.source).toBe("global");
});
it("includes config key paths + main-session hint for non-main mode", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
sandbox: { mode: "non-main", scope: "agent" },
},
},
tools: {
sandbox: {
tools: {
deny: ["browser"],
},
},
},
};
const msg = formatSandboxToolPolicyBlockedMessage({
cfg,
sessionKey: "agent:main:whatsapp:group:G1",
toolName: "browser",
});
expect(msg).toBeTruthy();
expect(msg).toContain('Tool "browser" blocked by sandbox tool policy');
expect(msg).toContain("mode=non-main");
expect(msg).toContain("tools.sandbox.tools.deny");
expect(msg).toContain("agents.defaults.sandbox.mode=off");
expect(msg).toContain("Use main session key (direct): agent:main:main");
});
});

View File

@@ -19,12 +19,17 @@ import {
loadConfig,
STATE_DIR_CLAWDBOT,
} from "../config/config.js";
import { normalizeAgentId, normalizeMainKey } from "../routing/session-key.js";
import {
buildAgentMainSessionKey,
normalizeAgentId,
normalizeMainKey,
} from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import {
resolveAgentConfig,
resolveAgentIdFromSessionKey,
resolveSessionAgentId,
} from "./agent-scope.js";
import { syncSkillsToWorkspace } from "./skills.js";
import {
@@ -44,6 +49,24 @@ export type SandboxToolPolicy = {
deny?: string[];
};
export type SandboxToolPolicySource = {
source: "agent" | "global" | "default";
/**
* Config key path hint for humans.
* (Arrays use `agents.list[].…` form.)
*/
key: string;
};
export type SandboxToolPolicyResolved = {
allow: string[];
deny: string[];
sources: {
allow: SandboxToolPolicySource;
deny: SandboxToolPolicySource;
};
};
export type SandboxWorkspaceAccess = "none" | "ro" | "rw";
export type SandboxBrowserConfig = {
@@ -377,6 +400,65 @@ function resolveSandboxAgentId(scopeKey: string): string | undefined {
return resolveAgentIdFromSessionKey(trimmed);
}
export function resolveSandboxToolPolicyForAgent(
cfg?: ClawdbotConfig,
agentId?: string,
): SandboxToolPolicyResolved {
const agentConfig =
cfg && agentId ? resolveAgentConfig(cfg, agentId) : undefined;
const agentAllow = agentConfig?.tools?.sandbox?.tools?.allow;
const agentDeny = agentConfig?.tools?.sandbox?.tools?.deny;
const globalAllow = cfg?.tools?.sandbox?.tools?.allow;
const globalDeny = cfg?.tools?.sandbox?.tools?.deny;
const allowSource = Array.isArray(agentAllow)
? ({
source: "agent",
key: "agents.list[].tools.sandbox.tools.allow",
} satisfies SandboxToolPolicySource)
: Array.isArray(globalAllow)
? ({
source: "global",
key: "tools.sandbox.tools.allow",
} satisfies SandboxToolPolicySource)
: ({
source: "default",
key: "tools.sandbox.tools.allow",
} satisfies SandboxToolPolicySource);
const denySource = Array.isArray(agentDeny)
? ({
source: "agent",
key: "agents.list[].tools.sandbox.tools.deny",
} satisfies SandboxToolPolicySource)
: Array.isArray(globalDeny)
? ({
source: "global",
key: "tools.sandbox.tools.deny",
} satisfies SandboxToolPolicySource)
: ({
source: "default",
key: "tools.sandbox.tools.deny",
} satisfies SandboxToolPolicySource);
return {
allow: Array.isArray(agentAllow)
? agentAllow
: Array.isArray(globalAllow)
? globalAllow
: DEFAULT_TOOL_ALLOW,
deny: Array.isArray(agentDeny)
? agentDeny
: Array.isArray(globalDeny)
? globalDeny
: DEFAULT_TOOL_DENY,
sources: {
allow: allowSource,
deny: denySource,
},
};
}
export function resolveSandboxConfigForAgent(
cfg?: ClawdbotConfig,
agentId?: string,
@@ -396,6 +478,8 @@ export function resolveSandboxConfigForAgent(
perSession: agentSandbox?.perSession ?? agent?.perSession,
});
const toolPolicy = resolveSandboxToolPolicyForAgent(cfg, agentId);
return {
mode: agentSandbox?.mode ?? agent?.mode ?? "off",
scope,
@@ -416,14 +500,8 @@ export function resolveSandboxConfigForAgent(
agentBrowser: agentSandbox?.browser,
}),
tools: {
allow:
agentConfig?.tools?.sandbox?.tools?.allow ??
cfg?.tools?.sandbox?.tools?.allow ??
DEFAULT_TOOL_ALLOW,
deny:
agentConfig?.tools?.sandbox?.tools?.deny ??
cfg?.tools?.sandbox?.tools?.deny ??
DEFAULT_TOOL_DENY,
allow: toolPolicy.allow,
deny: toolPolicy.deny,
},
prune: resolveSandboxPruneConfig({
scope,
@@ -443,6 +521,92 @@ function shouldSandboxSession(
return sessionKey.trim() !== mainKey.trim();
}
export function resolveSandboxRuntimeStatus(params: {
cfg?: ClawdbotConfig;
sessionKey?: string;
}): {
agentId: string;
sessionKey: string;
mainSessionKey: string;
mode: SandboxConfig["mode"];
sandboxed: boolean;
toolPolicy: SandboxToolPolicyResolved;
} {
const sessionKey = params.sessionKey?.trim() ?? "";
const agentId = resolveSessionAgentId({
sessionKey,
config: params.cfg,
});
const cfg = params.cfg;
const sandboxCfg = resolveSandboxConfigForAgent(cfg, agentId);
const mainSessionKey = buildAgentMainSessionKey({
agentId,
mainKey: normalizeMainKey(cfg?.session?.mainKey),
});
const sandboxed = sessionKey
? shouldSandboxSession(sandboxCfg, sessionKey, mainSessionKey)
: false;
return {
agentId,
sessionKey,
mainSessionKey,
mode: sandboxCfg.mode,
sandboxed,
toolPolicy: resolveSandboxToolPolicyForAgent(cfg, agentId),
};
}
export function formatSandboxToolPolicyBlockedMessage(params: {
cfg?: ClawdbotConfig;
sessionKey?: string;
toolName: string;
}): string | undefined {
const tool = params.toolName.trim().toLowerCase();
if (!tool) return undefined;
const runtime = resolveSandboxRuntimeStatus({
cfg: params.cfg,
sessionKey: params.sessionKey,
});
if (!runtime.sandboxed) return undefined;
const deny = new Set(normalizeToolList(runtime.toolPolicy.deny));
const allow = normalizeToolList(runtime.toolPolicy.allow);
const allowSet = allow.length > 0 ? new Set(allow) : null;
const blockedByDeny = deny.has(tool);
const blockedByAllow = allowSet ? !allowSet.has(tool) : false;
if (!blockedByDeny && !blockedByAllow) return undefined;
const reasons: string[] = [];
const fixes: string[] = [];
if (blockedByDeny) {
reasons.push("deny list");
fixes.push(`Remove "${tool}" from ${runtime.toolPolicy.sources.deny.key}.`);
}
if (blockedByAllow) {
reasons.push("allow list");
fixes.push(
`Add "${tool}" to ${runtime.toolPolicy.sources.allow.key} (or set it to [] to allow all).`,
);
}
const lines: string[] = [];
lines.push(
`Tool "${tool}" blocked by sandbox tool policy (mode=${runtime.mode}).`,
);
lines.push(`Session: ${runtime.sessionKey || "(unknown)"}`);
lines.push(`Reason: ${reasons.join(" + ")}`);
lines.push("Fix:");
lines.push(`- agents.defaults.sandbox.mode=off (disable sandbox)`);
for (const fix of fixes) lines.push(`- ${fix}`);
if (runtime.mode === "non-main") {
lines.push(`- Use main session key (direct): ${runtime.mainSessionKey}`);
}
lines.push(`- See: clawdbot sandbox explain --session ${runtime.sessionKey}`);
return lines.join("\n");
}
function slugifySessionKey(value: string) {
const trimmed = value.trim() || "session";
const hash = crypto

View File

@@ -828,7 +828,7 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("elevated is not available right now.");
expect(text).toContain("agents.list[].tools.elevated.enabled");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -875,7 +875,7 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("elevated is not available right now.");
expect(text).toContain("agents.list[].tools.elevated.allowFrom.whatsapp");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});

View File

@@ -556,7 +556,7 @@ describe("trigger handling", () => {
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("elevated is not available right now.");
expect(text).toContain("tools.elevated.enabled");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<
@@ -795,7 +795,7 @@ describe("trigger handling", () => {
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).not.toBe("elevated is not available right now.");
expect(text).not.toContain("elevated is not available right now");
expect(runEmbeddedPiAgent).toHaveBeenCalled();
});
});
@@ -876,7 +876,7 @@ describe("trigger handling", () => {
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("elevated is not available right now.");
expect(text).toContain("tools.elevated.allowFrom.discord");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});

View File

@@ -15,7 +15,10 @@ import {
isEmbeddedPiRunStreaming,
resolveEmbeddedSessionLane,
} from "../agents/pi-embedded.js";
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
import {
ensureSandboxWorkspaceForSession,
resolveSandboxRuntimeStatus,
} from "../agents/sandbox.js";
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
@@ -216,15 +219,22 @@ function resolveElevatedPermissions(params: {
agentId: string;
ctx: MsgContext;
provider: string;
}): { enabled: boolean; allowed: boolean } {
}): { enabled: boolean; allowed: boolean; failures: Array<{ gate: string; key: string }> } {
const globalConfig = params.cfg.tools?.elevated;
const agentConfig = resolveAgentConfig(params.cfg, params.agentId)?.tools
?.elevated;
const globalEnabled = globalConfig?.enabled !== false;
const agentEnabled = agentConfig?.enabled !== false;
const enabled = globalEnabled && agentEnabled;
if (!enabled) return { enabled, allowed: false };
if (!params.provider) return { enabled, allowed: false };
const failures: Array<{ gate: string; key: string }> = [];
if (!globalEnabled) failures.push({ gate: "enabled", key: "tools.elevated.enabled" });
if (!agentEnabled)
failures.push({ gate: "enabled", key: "agents.list[].tools.elevated.enabled" });
if (!enabled) return { enabled, allowed: false, failures };
if (!params.provider) {
failures.push({ gate: "provider", key: "ctx.Provider" });
return { enabled, allowed: false, failures };
}
const discordFallback =
params.provider === "discord"
@@ -236,7 +246,16 @@ function resolveElevatedPermissions(params: {
allowFrom: globalConfig?.allowFrom,
discordFallback,
});
if (!globalAllowed) return { enabled, allowed: false };
if (!globalAllowed) {
failures.push({
gate: "allowFrom",
key:
params.provider === "discord" && discordFallback
? "tools.elevated.allowFrom.discord (or discord.dm.allowFrom fallback)"
: `tools.elevated.allowFrom.${params.provider}`,
});
return { enabled, allowed: false, failures };
}
const agentAllowed = agentConfig?.allowFrom
? isApprovedElevatedSender({
@@ -245,7 +264,44 @@ function resolveElevatedPermissions(params: {
allowFrom: agentConfig.allowFrom,
})
: true;
return { enabled, allowed: globalAllowed && agentAllowed };
if (!agentAllowed) {
failures.push({
gate: "allowFrom",
key: `agents.list[].tools.elevated.allowFrom.${params.provider}`,
});
}
return { enabled, allowed: globalAllowed && agentAllowed, failures };
}
function formatElevatedUnavailableMessage(params: {
runtimeSandboxed: boolean;
failures: Array<{ gate: string; key: string }>;
sessionKey?: string;
}): string {
const lines: string[] = [];
lines.push(
`elevated is not available right now (runtime=${params.runtimeSandboxed ? "sandboxed" : "direct"}).`,
);
if (params.failures.length > 0) {
lines.push(
`Failing gates: ${params.failures
.map((f) => `${f.gate} (${f.key})`)
.join(", ")}`,
);
} else {
lines.push(
"Failing gates: enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled), allowFrom (tools.elevated.allowFrom.<provider>).",
);
}
lines.push("Fix-it keys:");
lines.push("- tools.elevated.enabled");
lines.push("- tools.elevated.allowFrom.<provider>");
lines.push("- agents.list[].tools.elevated.enabled");
lines.push("- agents.list[].tools.elevated.allowFrom.<provider>");
if (params.sessionKey) {
lines.push(`See: clawdbot sandbox explain --session ${params.sessionKey}`);
}
return lines.join("\n");
}
export async function getReplyFromConfig(
@@ -473,19 +529,31 @@ export async function getReplyFromConfig(
sessionCtx.Provider?.trim().toLowerCase() ??
ctx.Provider?.trim().toLowerCase() ??
"";
const { enabled: elevatedEnabled, allowed: elevatedAllowed } =
resolveElevatedPermissions({
cfg,
agentId,
ctx,
provider: messageProviderKey,
});
const elevated = resolveElevatedPermissions({
cfg,
agentId,
ctx,
provider: messageProviderKey,
});
const elevatedEnabled = elevated.enabled;
const elevatedAllowed = elevated.allowed;
const elevatedFailures = elevated.failures;
if (
directives.hasElevatedDirective &&
(!elevatedEnabled || !elevatedAllowed)
) {
typing.cleanup();
return { text: "elevated is not available right now." };
const runtimeSandboxed = resolveSandboxRuntimeStatus({
cfg,
sessionKey: ctx.SessionKey,
}).sandboxed;
return {
text: formatElevatedUnavailableMessage({
runtimeSandboxed,
failures: elevatedFailures,
sessionKey: ctx.SessionKey,
}),
};
}
const requireMention = resolveGroupRequireMention({
@@ -621,6 +689,8 @@ export async function getReplyFromConfig(
storePath,
elevatedEnabled,
elevatedAllowed,
elevatedFailures,
messageProviderKey,
defaultProvider,
defaultModel,
aliasIndex,

View File

@@ -29,10 +29,9 @@ import {
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { resolveSandboxConfigForAgent } from "../../agents/sandbox.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
resolveAgentMainSessionKey,
type SessionEntry,
saveSessionStore,
} from "../../config/sessions.js";
@@ -72,6 +71,31 @@ const withOptions = (line: string, options: string) =>
const formatElevatedRuntimeHint = () =>
`${SYSTEM_MARK} Runtime is direct; sandboxing does not apply.`;
function formatElevatedUnavailableText(params: {
runtimeSandboxed: boolean;
failures?: Array<{ gate: string; key: string }>;
sessionKey?: string;
}): string {
const lines: string[] = [];
lines.push(
`elevated is not available right now (runtime=${params.runtimeSandboxed ? "sandboxed" : "direct"}).`,
);
const failures = params.failures ?? [];
if (failures.length > 0) {
lines.push(
`Failing gates: ${failures.map((f) => `${f.gate} (${f.key})`).join(", ")}`,
);
} else {
lines.push(
"Fix-it keys: tools.elevated.enabled, tools.elevated.allowFrom.<provider>, agents.list[].tools.elevated.*",
);
}
if (params.sessionKey) {
lines.push(`See: clawdbot sandbox explain --session ${params.sessionKey}`);
}
return lines.join("\n");
}
const maskApiKey = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) return "missing";
@@ -452,6 +476,8 @@ export async function handleDirectiveOnly(params: {
storePath?: string;
elevatedEnabled: boolean;
elevatedAllowed: boolean;
elevatedFailures?: Array<{ gate: string; key: string }>;
messageProviderKey?: string;
defaultProvider: string;
defaultModel: string;
aliasIndex: ModelAliasIndex;
@@ -496,22 +522,10 @@ export async function handleDirectiveOnly(params: {
config: params.cfg,
});
const agentDir = resolveAgentDir(params.cfg, activeAgentId);
const runtimeIsSandboxed = (() => {
const sessionKey = params.sessionKey?.trim();
if (!sessionKey) return false;
const agentId = resolveSessionAgentId({
sessionKey,
config: params.cfg,
});
const sandboxCfg = resolveSandboxConfigForAgent(params.cfg, agentId);
if (sandboxCfg.mode === "off") return false;
const mainKey = resolveAgentMainSessionKey({
cfg: params.cfg,
agentId,
});
if (sandboxCfg.mode === "all") return true;
return sessionKey !== mainKey;
})();
const runtimeIsSandboxed = resolveSandboxRuntimeStatus({
cfg: params.cfg,
sessionKey: params.sessionKey,
}).sandboxed;
const shouldHintDirectRuntime =
directives.hasElevatedDirective && !runtimeIsSandboxed;
@@ -709,7 +723,13 @@ export async function handleDirectiveOnly(params: {
if (directives.hasElevatedDirective && !directives.elevatedLevel) {
if (!directives.rawElevatedLevel) {
if (!elevatedEnabled || !elevatedAllowed) {
return { text: "elevated is not available right now." };
return {
text: formatElevatedUnavailableText({
runtimeSandboxed: runtimeIsSandboxed,
failures: params.elevatedFailures,
sessionKey: params.sessionKey,
}),
};
}
const level = currentElevatedLevel ?? "off";
return {
@@ -729,7 +749,13 @@ export async function handleDirectiveOnly(params: {
directives.hasElevatedDirective &&
(!elevatedEnabled || !elevatedAllowed)
) {
return { text: "elevated is not available right now." };
return {
text: formatElevatedUnavailableText({
runtimeSandboxed: runtimeIsSandboxed,
failures: params.elevatedFailures,
sessionKey: params.sessionKey,
}),
};
}
if (

View File

@@ -4,6 +4,7 @@ import {
sandboxListCommand,
sandboxRecreateCommand,
} from "../commands/sandbox.js";
import { sandboxExplainCommand } from "../commands/sandbox-explain.js";
import { defaultRuntime } from "../runtime.js";
// --- Types ---
@@ -19,7 +20,8 @@ Examples:
clawdbot sandbox list --browser # List only browser containers
clawdbot sandbox recreate --all # Recreate all containers
clawdbot sandbox recreate --session main # Recreate specific session
clawdbot sandbox recreate --agent mybot # Recreate agent containers`,
clawdbot sandbox recreate --agent mybot # Recreate agent containers
clawdbot sandbox explain # Explain effective sandbox config`,
list: `
Examples:
@@ -55,6 +57,13 @@ Filter options:
Modifiers:
--browser Only affect browser containers (not regular sandbox)
--force Skip confirmation prompt`,
explain: `
Examples:
clawdbot sandbox explain
clawdbot sandbox explain --session agent:main:main
clawdbot sandbox explain --agent work
clawdbot sandbox explain --json`,
};
function createRunner(
@@ -129,4 +138,26 @@ export function registerSandboxCli(program: Command) {
),
),
);
// --- Explain Command ---
sandbox
.command("explain")
.description("Explain effective sandbox/tool policy for a session/agent")
.option("--session <key>", "Session key to inspect (defaults to agent main)")
.option("--agent <id>", "Agent id to inspect (defaults to derived agent)")
.option("--json", "Output result as JSON", false)
.addHelpText("after", EXAMPLES.explain)
.action(
createRunner((opts) =>
sandboxExplainCommand(
{
session: opts.session as string | undefined,
agent: opts.agent as string | undefined,
json: Boolean(opts.json),
},
defaultRuntime,
),
),
);
}

View File

@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from "vitest";
let mockCfg: unknown = {};
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: vi.fn().mockImplementation(() => mockCfg),
};
});
describe("sandbox explain command", () => {
it("prints JSON shape + fix-it keys", async () => {
mockCfg = {
agents: {
defaults: {
sandbox: { mode: "all", scope: "agent", workspaceAccess: "none" },
},
},
tools: {
sandbox: { tools: { deny: ["browser"] } },
elevated: { enabled: true, allowFrom: { whatsapp: ["*"] } },
},
session: { store: "/tmp/clawdbot-test-sessions-{agentId}.json" },
};
const { sandboxExplainCommand } = await import("./sandbox-explain.js");
const logs: string[] = [];
await sandboxExplainCommand(
{ json: true, session: "agent:main:main" },
{
log: (msg: string) => logs.push(msg),
error: (msg: string) => logs.push(msg),
exit: (_code: number) => {},
} as unknown as Parameters<typeof sandboxExplainCommand>[1],
);
const out = logs.join("");
const parsed = JSON.parse(out);
expect(parsed).toHaveProperty("docsUrl", "https://docs.clawd.bot/sandbox");
expect(parsed).toHaveProperty("sandbox.mode", "all");
expect(parsed).toHaveProperty("sandbox.tools.sources.allow.source");
expect(Array.isArray(parsed.fixIt)).toBe(true);
expect(parsed.fixIt).toContain("agents.defaults.sandbox.mode=off");
expect(parsed.fixIt).toContain("tools.sandbox.tools.deny");
});
});

View File

@@ -0,0 +1,339 @@
import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
resolveAgentMainSessionKey,
resolveMainSessionKey,
resolveStorePath,
} from "../config/sessions.js";
import {
buildAgentMainSessionKey,
normalizeAgentId,
normalizeMainKey,
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveAgentConfig } from "../agents/agent-scope.js";
import {
resolveSandboxConfigForAgent,
resolveSandboxToolPolicyForAgent,
} from "../agents/sandbox.js";
type SandboxExplainOptions = {
session?: string;
agent?: string;
json: boolean;
};
const SANDBOX_DOCS_URL = "https://docs.clawd.bot/sandbox";
const KNOWN_PROVIDER_KEYS = new Set([
"whatsapp",
"telegram",
"discord",
"slack",
"signal",
"imessage",
"webchat",
]);
function normalizeExplainSessionKey(params: {
cfg: ClawdbotConfig;
agentId: string;
session?: string;
}): string {
const raw = (params.session ?? "").trim();
if (!raw) {
return resolveAgentMainSessionKey({
cfg: params.cfg,
agentId: params.agentId,
});
}
if (raw.includes(":")) return raw;
if (raw === "global") return "global";
return buildAgentMainSessionKey({
agentId: params.agentId,
mainKey: normalizeMainKey(raw),
});
}
function inferProviderFromSessionKey(params: {
cfg: ClawdbotConfig;
sessionKey: string;
}): string | undefined {
const parsed = parseAgentSessionKey(params.sessionKey);
if (!parsed) return undefined;
const rest = parsed.rest.trim();
if (!rest) return undefined;
const parts = rest.split(":").filter(Boolean);
if (parts.length === 0) return undefined;
const configuredMainKey = normalizeMainKey(params.cfg.session?.mainKey);
if (parts[0] === configuredMainKey) return undefined;
const candidate = parts[0]?.trim().toLowerCase();
return candidate && KNOWN_PROVIDER_KEYS.has(candidate) ? candidate : undefined;
}
function resolveActiveProvider(params: {
cfg: ClawdbotConfig;
agentId: string;
sessionKey: string;
}): string | undefined {
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.agentId,
});
const store = loadSessionStore(storePath);
const entry = store[params.sessionKey];
const candidate = (
entry?.lastProvider ??
entry?.providerOverride ??
entry?.provider ??
""
)
.trim()
.toLowerCase();
if (candidate && KNOWN_PROVIDER_KEYS.has(candidate)) return candidate;
return inferProviderFromSessionKey({
cfg: params.cfg,
sessionKey: params.sessionKey,
});
}
function resolveElevatedAllowListForProvider(params: {
provider: string;
allowFrom?: Record<string, Array<string | number> | undefined>;
discordFallback?: Array<string | number>;
}): Array<string | number> | undefined {
switch (params.provider) {
case "whatsapp":
return params.allowFrom?.whatsapp;
case "telegram":
return params.allowFrom?.telegram;
case "discord": {
const hasExplicit = Boolean(
params.allowFrom && Object.hasOwn(params.allowFrom, "discord"),
);
if (hasExplicit) return params.allowFrom?.discord;
return params.discordFallback;
}
case "slack":
return params.allowFrom?.slack;
case "signal":
return params.allowFrom?.signal;
case "imessage":
return params.allowFrom?.imessage;
case "webchat":
return params.allowFrom?.webchat;
default:
return undefined;
}
}
export async function sandboxExplainCommand(
opts: SandboxExplainOptions,
runtime: RuntimeEnv,
): Promise<void> {
const cfg = loadConfig();
const defaultAgentId = resolveAgentIdFromSessionKey(resolveMainSessionKey(cfg));
const resolvedAgentId = normalizeAgentId(
opts.agent?.trim()
? opts.agent
: opts.session?.trim()
? resolveAgentIdFromSessionKey(opts.session)
: defaultAgentId,
);
const sessionKey = normalizeExplainSessionKey({
cfg,
agentId: resolvedAgentId,
session: opts.session,
});
const sandboxCfg = resolveSandboxConfigForAgent(cfg, resolvedAgentId);
const toolPolicy = resolveSandboxToolPolicyForAgent(cfg, resolvedAgentId);
const mainSessionKey = resolveAgentMainSessionKey({
cfg,
agentId: resolvedAgentId,
});
const sessionIsSandboxed =
sandboxCfg.mode === "all"
? true
: sandboxCfg.mode === "off"
? false
: sessionKey.trim() !== mainSessionKey.trim();
const provider = resolveActiveProvider({
cfg,
agentId: resolvedAgentId,
sessionKey,
});
const agentConfig = resolveAgentConfig(cfg, resolvedAgentId);
const elevatedGlobal = cfg.tools?.elevated;
const elevatedAgent = agentConfig?.tools?.elevated;
const elevatedGlobalEnabled = elevatedGlobal?.enabled !== false;
const elevatedAgentEnabled = elevatedAgent?.enabled !== false;
const elevatedEnabled = elevatedGlobalEnabled && elevatedAgentEnabled;
const discordFallback =
provider === "discord" ? cfg.discord?.dm?.allowFrom : undefined;
const globalAllow = provider
? resolveElevatedAllowListForProvider({
provider,
allowFrom: elevatedGlobal?.allowFrom as unknown as Record<
string,
Array<string | number> | undefined
>,
discordFallback,
})
: undefined;
const agentAllow = provider
? resolveElevatedAllowListForProvider({
provider,
allowFrom: elevatedAgent?.allowFrom as unknown as Record<
string,
Array<string | number> | undefined
>,
})
: undefined;
const allowTokens = (values?: Array<string | number>) =>
(values ?? []).map((v) => String(v).trim()).filter(Boolean);
const globalAllowTokens = allowTokens(globalAllow);
const agentAllowTokens = allowTokens(agentAllow);
const elevatedAllowedByConfig =
elevatedEnabled &&
Boolean(provider) &&
globalAllowTokens.length > 0 &&
(elevatedAgent?.allowFrom ? agentAllowTokens.length > 0 : true);
const elevatedAlwaysAllowedByConfig =
elevatedAllowedByConfig &&
globalAllowTokens.includes("*") &&
(elevatedAgent?.allowFrom ? agentAllowTokens.includes("*") : true);
const elevatedFailures: Array<{ gate: string; key: string }> = [];
if (!elevatedGlobalEnabled) {
elevatedFailures.push({ gate: "enabled", key: "tools.elevated.enabled" });
}
if (!elevatedAgentEnabled) {
elevatedFailures.push({
gate: "enabled",
key: "agents.list[].tools.elevated.enabled",
});
}
if (provider && globalAllowTokens.length === 0) {
elevatedFailures.push({
gate: "allowFrom",
key:
provider === "discord" && discordFallback
? "tools.elevated.allowFrom.discord (or discord.dm.allowFrom fallback)"
: `tools.elevated.allowFrom.${provider}`,
});
}
if (provider && elevatedAgent?.allowFrom && agentAllowTokens.length === 0) {
elevatedFailures.push({
gate: "allowFrom",
key: `agents.list[].tools.elevated.allowFrom.${provider}`,
});
}
const fixIt: string[] = [];
if (sandboxCfg.mode !== "off") {
fixIt.push("agents.defaults.sandbox.mode=off");
fixIt.push("agents.list[].sandbox.mode=off");
}
fixIt.push("tools.sandbox.tools.allow");
fixIt.push("tools.sandbox.tools.deny");
fixIt.push("agents.list[].tools.sandbox.tools.allow");
fixIt.push("agents.list[].tools.sandbox.tools.deny");
fixIt.push("tools.elevated.enabled");
if (provider) fixIt.push(`tools.elevated.allowFrom.${provider}`);
const payload = {
docsUrl: SANDBOX_DOCS_URL,
agentId: resolvedAgentId,
sessionKey,
mainSessionKey,
sandbox: {
mode: sandboxCfg.mode,
scope: sandboxCfg.scope,
perSession: sandboxCfg.scope === "session",
workspaceAccess: sandboxCfg.workspaceAccess,
workspaceRoot: sandboxCfg.workspaceRoot,
sessionIsSandboxed,
tools: {
allow: toolPolicy.allow,
deny: toolPolicy.deny,
sources: toolPolicy.sources,
},
},
elevated: {
enabled: elevatedEnabled,
provider,
allowedByConfig: elevatedAllowedByConfig,
alwaysAllowedByConfig: elevatedAlwaysAllowedByConfig,
allowFrom: {
global: provider ? globalAllowTokens : undefined,
agent: elevatedAgent?.allowFrom && provider ? agentAllowTokens : undefined,
},
failures: elevatedFailures,
},
fixIt,
} as const;
if (opts.json) {
runtime.log(`${JSON.stringify(payload, null, 2)}\n`);
return;
}
const lines: string[] = [];
lines.push("Effective sandbox:");
lines.push(` agentId: ${payload.agentId}`);
lines.push(` sessionKey: ${payload.sessionKey}`);
lines.push(` mainSessionKey: ${payload.mainSessionKey}`);
lines.push(
` runtime: ${payload.sandbox.sessionIsSandboxed ? "sandboxed" : "direct"}`,
);
lines.push(
` mode=${payload.sandbox.mode} scope=${payload.sandbox.scope} perSession=${payload.sandbox.perSession}`,
);
lines.push(
` workspaceAccess=${payload.sandbox.workspaceAccess} workspaceRoot=${payload.sandbox.workspaceRoot}`,
);
lines.push("");
lines.push("Sandbox tool policy:");
lines.push(
` allow (${payload.sandbox.tools.sources.allow.source}): ${payload.sandbox.tools.allow.join(", ") || "(empty)"}`,
);
lines.push(
` deny (${payload.sandbox.tools.sources.deny.source}): ${payload.sandbox.tools.deny.join(", ") || "(empty)"}`,
);
lines.push("");
lines.push("Elevated:");
lines.push(` enabled: ${payload.elevated.enabled}`);
lines.push(` provider: ${payload.elevated.provider ?? "(unknown)"}`);
lines.push(` allowedByConfig: ${payload.elevated.allowedByConfig}`);
if (payload.elevated.failures.length > 0) {
lines.push(
` failing gates: ${payload.elevated.failures
.map((f) => `${f.gate} (${f.key})`)
.join(", ")}`,
);
}
if (payload.sandbox.mode === "non-main" && payload.sandbox.sessionIsSandboxed) {
lines.push("");
lines.push(
`Hint: sandbox mode is non-main; use main session key to run direct: ${payload.mainSessionKey}`,
);
}
lines.push("");
lines.push("Fix-it:");
for (const key of payload.fixIt) lines.push(` - ${key}`);
lines.push("");
lines.push(`Docs: ${payload.docsUrl}`);
runtime.log(`${lines.join("\n")}\n`);
}