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

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