feat(sandbox): add sandbox explain inspector
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 });
|
||||
|
||||
67
src/agents/sandbox-explain.test.ts
Normal file
67
src/agents/sandbox-explain.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user