diff --git a/CHANGELOG.md b/CHANGELOG.md index 5054c3542..efb246185 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 3fbfd8159..c167216ab 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -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 diff --git a/docs/docs.json b/docs/docs.json index 19bb44dad..4441e1d88 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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" diff --git a/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md new file mode 100644 index 000000000..0f0546a60 --- /dev/null +++ b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md @@ -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.` (and optionally `agents.list[].tools.elevated.allowFrom.`) + +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"`. + diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index c4c403780..5aec128e2 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -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 diff --git a/docs/multi-agent-sandbox-tools.md b/docs/multi-agent-sandbox-tools.md index f0f3eb6ed..589bbe450 100644 --- a/docs/multi-agent-sandbox-tools.md +++ b/docs/multi-agent-sandbox-tools.md @@ -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`. --- diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md index 6079394d5..96562f681 100644 --- a/docs/tools/elevated.md +++ b/docs/tools/elevated.md @@ -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 diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index 6279f2be0..16c0850fa 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -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."); }); it("does not default to elevated when not allowed", async () => { diff --git a/src/agents/bash-tools.ts b/src/agents/bash-tools.ts index 9360ad277..f9d0c2508 100644 --- a/src/agents/bash-tools.ts +++ b/src/agents/bash-tools.ts @@ -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. / agents.list[].tools.elevated.allowFrom.)"); + } + throw new Error( + [ + `elevated is not available right now (runtime=${runtime}).`, + `Failing gates: ${gates.join(", ")}`, + "Fix-it keys:", + "- tools.elevated.enabled", + "- tools.elevated.allowFrom.", + "- agents.list[].tools.elevated.enabled", + "- agents.list[].tools.elevated.allowFrom.", + ].join("\n"), + ); } logInfo( `bash: elevated command (${sessionId.slice(0, 8)}) ${truncateMiddle( diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index a9e7710d5..c7820541e 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -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 ( diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 6c38b4eba..dbd794767 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -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 }); diff --git a/src/agents/sandbox-explain.test.ts b/src/agents/sandbox-explain.test.ts new file mode 100644 index 000000000..0f51800c6 --- /dev/null +++ b/src/agents/sandbox-explain.test.ts @@ -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"); + }); +}); + diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 94945b2ec..c6aad1067 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -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 diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 1f86df32d..8ee37bc32 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -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(); }); }); diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index f90945cab..0e129c928 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -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(); }); }); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index b66af6286..110289590 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -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.).", + ); + } + lines.push("Fix-it keys:"); + lines.push("- tools.elevated.enabled"); + lines.push("- tools.elevated.allowFrom."); + lines.push("- agents.list[].tools.elevated.enabled"); + lines.push("- agents.list[].tools.elevated.allowFrom."); + 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, diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index bbc8a4f2d..6a04e734e 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -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., 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 ( diff --git a/src/cli/sandbox-cli.ts b/src/cli/sandbox-cli.ts index e03806dff..c562b8a9d 100644 --- a/src/cli/sandbox-cli.ts +++ b/src/cli/sandbox-cli.ts @@ -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 ", "Session key to inspect (defaults to agent main)") + .option("--agent ", "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, + ), + ), + ); } diff --git a/src/commands/sandbox-explain.test.ts b/src/commands/sandbox-explain.test.ts new file mode 100644 index 000000000..77b836cf8 --- /dev/null +++ b/src/commands/sandbox-explain.test.ts @@ -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(); + 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[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"); + }); +}); diff --git a/src/commands/sandbox-explain.ts b/src/commands/sandbox-explain.ts new file mode 100644 index 000000000..5fde276ea --- /dev/null +++ b/src/commands/sandbox-explain.ts @@ -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 | undefined>; + discordFallback?: Array; +}): Array | 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 { + 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 | undefined + >, + discordFallback, + }) + : undefined; + const agentAllow = provider + ? resolveElevatedAllowListForProvider({ + provider, + allowFrom: elevatedAgent?.allowFrom as unknown as Record< + string, + Array | undefined + >, + }) + : undefined; + + const allowTokens = (values?: Array) => + (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`); +}