feat(sandbox): add sandbox explain inspector
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
77
docs/gateway/sandbox-vs-tool-policy-vs-elevated.md
Normal file
77
docs/gateway/sandbox-vs-tool-policy-vs-elevated.md
Normal 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 you’re 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 you’re sandboxed, `/elevated on` (or `bash` with `elevated: true`) runs on the host.
|
||||
- If you’re 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"`.
|
||||
|
||||
@@ -78,6 +78,10 @@ Tool allow/deny policies still apply before sandbox rules. If a tool is denied
|
||||
globally or per-agent, sandboxing doesn’t 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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
49
src/commands/sandbox-explain.test.ts
Normal file
49
src/commands/sandbox-explain.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
339
src/commands/sandbox-explain.ts
Normal file
339
src/commands/sandbox-explain.ts
Normal 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`);
|
||||
}
|
||||
Reference in New Issue
Block a user