mirror of
https://github.com/clawdbot/clawdbot.git
synced 2026-01-31 19:37:45 +01:00
feat: add per-agent elevated controls
This commit is contained in:
@@ -1234,8 +1234,24 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
Per-agent override (further restrict):
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "family",
|
||||
tools: {
|
||||
elevated: { enabled: false }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `tools.elevated` is **global** (not per-agent). Availability is based on sender allowlists.
|
||||
- `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can only further restrict (both must allow).
|
||||
- `/elevated on|off` stores state per session key; inline directives apply to a single message.
|
||||
- Elevated `bash` runs on the host and bypasses sandboxing.
|
||||
- Tool policy still applies; if `bash` is denied, elevated cannot be used.
|
||||
|
||||
@@ -172,7 +172,7 @@ Also consider agent workspace access inside the sandbox:
|
||||
- `agents.defaults.sandbox.workspaceAccess: "ro"` mounts the agent workspace read-only at `/agent` (disables `write`/`edit`)
|
||||
- `agents.defaults.sandbox.workspaceAccess: "rw"` mounts the agent workspace read/write at `/workspace`
|
||||
|
||||
Important: `tools.elevated` is a **global**, sender-based escape hatch that runs bash on the host. Keep `tools.elevated.allowFrom` tight and don’t enable it for strangers. See [Elevated Mode](/tools/elevated).
|
||||
Important: `tools.elevated` is the global baseline escape hatch that runs bash on the host. Keep `tools.elevated.allowFrom` tight and don’t enable it for strangers. You can further restrict elevated per agent via `agents.list[].tools.elevated`. See [Elevated Mode](/tools/elevated).
|
||||
|
||||
## Per-agent access profiles (multi-agent)
|
||||
|
||||
|
||||
@@ -172,13 +172,14 @@ The filtering order is:
|
||||
Each level can further restrict tools, but cannot grant back denied tools from earlier levels.
|
||||
If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent.
|
||||
|
||||
### Elevated Mode (global)
|
||||
`tools.elevated` is **global** and **sender-based** (per-provider allowlist). It is **not** configurable per agent.
|
||||
### Elevated Mode
|
||||
`tools.elevated` is the global baseline (sender-based allowlist). `agents.list[].tools.elevated` can further restrict elevated for specific agents (both must allow).
|
||||
|
||||
Mitigation patterns:
|
||||
- Deny `bash` for untrusted agents (`agents.list[].tools.deny: ["bash"]`)
|
||||
- Avoid allowlisting senders that route to restricted agents
|
||||
- Disable elevated globally (`tools.elevated.enabled: false`) if you only want sandboxed execution
|
||||
- Disable elevated per agent (`agents.list[].tools.elevated.enabled: false`) for sensitive profiles
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ read_when:
|
||||
- Only `on|off` are accepted; anything else returns a hint and does not change state.
|
||||
|
||||
## What it controls (and what it doesn’t)
|
||||
- **Global availability gate**: `tools.elevated` is global (not per-agent). If disabled or sender not allowlisted, elevated is unavailable everywhere.
|
||||
- **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow).
|
||||
- **Per-session state**: `/elevated on|off` sets the elevated level for the current session key.
|
||||
- **Inline directive**: `/elevated on` inside a message applies to that message only.
|
||||
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
|
||||
@@ -42,8 +42,10 @@ Note:
|
||||
## Availability + allowlists
|
||||
- Feature gate: `tools.elevated.enabled` (default can be off via config even if the code supports it).
|
||||
- Sender allowlist: `tools.elevated.allowFrom` with per-provider allowlists (e.g. `discord`, `whatsapp`).
|
||||
- Both must pass; otherwise elevated is treated as unavailable.
|
||||
- Discord fallback: if `tools.elevated.allowFrom.discord` is omitted, the `discord.dm.allowFrom` list is used as a fallback. Set `tools.elevated.allowFrom.discord` (even `[]`) to override.
|
||||
- Per-agent gate: `agents.list[].tools.elevated.enabled` (optional; can only further restrict).
|
||||
- Per-agent allowlist: `agents.list[].tools.elevated.allowFrom` (optional; when set, the sender must match **both** global + per-agent allowlists).
|
||||
- Discord fallback: if `tools.elevated.allowFrom.discord` is omitted, the `discord.dm.allowFrom` list is used as a fallback. Set `tools.elevated.allowFrom.discord` (even `[]`) to override. Per-agent allowlists do **not** use the fallback.
|
||||
- All gates must pass; otherwise elevated is treated as unavailable.
|
||||
|
||||
## Logging + status
|
||||
- Elevated bash calls are logged at info level.
|
||||
|
||||
@@ -39,7 +39,7 @@ Notes:
|
||||
- Returns `status: "running"` with a `sessionId` when backgrounded.
|
||||
- Use `process` to poll/log/write/kill/clear background sessions.
|
||||
- If `process` is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`.
|
||||
- `elevated` is gated by `tools.elevated` (global sender allowlist) and runs on the host.
|
||||
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and runs on the host.
|
||||
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
|
||||
|
||||
### `process`
|
||||
|
||||
@@ -85,6 +85,10 @@ describe("resolveAgentConfig", () => {
|
||||
tools: {
|
||||
allow: ["read"],
|
||||
deny: ["bash", "write", "edit"],
|
||||
elevated: {
|
||||
enabled: false,
|
||||
allowFrom: { whatsapp: ["+15555550123"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -94,6 +98,10 @@ describe("resolveAgentConfig", () => {
|
||||
expect(result?.tools).toEqual({
|
||||
allow: ["read"],
|
||||
deny: ["bash", "write", "edit"],
|
||||
elevated: {
|
||||
enabled: false,
|
||||
allowFrom: { whatsapp: ["+15555550123"] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -33,6 +33,40 @@ describe("Agent-specific tool filtering", () => {
|
||||
expect(toolNames).not.toContain("Bash");
|
||||
});
|
||||
|
||||
it("should keep global tool policy when agent only sets tools.elevated", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
tools: {
|
||||
deny: ["write"],
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
workspace: "~/clawd",
|
||||
tools: {
|
||||
elevated: {
|
||||
enabled: true,
|
||||
allowFrom: { whatsapp: ["+15555550123"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const tools = createClawdbotCodingTools({
|
||||
config: cfg,
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir: "/tmp/test",
|
||||
agentDir: "/tmp/agent",
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t) => t.name);
|
||||
expect(toolNames).toContain("Bash");
|
||||
expect(toolNames).toContain("Read");
|
||||
expect(toolNames).not.toContain("Write");
|
||||
});
|
||||
|
||||
it("should apply agent-specific tool policy", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
tools: {
|
||||
|
||||
@@ -348,11 +348,13 @@ function resolveEffectiveToolPolicy(params: {
|
||||
params.config && agentId
|
||||
? resolveAgentConfig(params.config, agentId)
|
||||
: undefined;
|
||||
const hasAgentTools = agentConfig?.tools !== undefined;
|
||||
const agentTools = agentConfig?.tools;
|
||||
const hasAgentToolPolicy =
|
||||
Array.isArray(agentTools?.allow) || Array.isArray(agentTools?.deny);
|
||||
const globalTools = params.config?.tools;
|
||||
return {
|
||||
agentId,
|
||||
policy: hasAgentTools ? agentConfig?.tools : globalTools,
|
||||
policy: hasAgentToolPolicy ? agentTools : globalTools,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -528,6 +528,145 @@ describe("directive behavior", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects per-agent elevated when disabled", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/elevated on",
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
SessionKey: "agent:restricted:main",
|
||||
},
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "restricted",
|
||||
tools: {
|
||||
elevated: { enabled: false },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1222"] },
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["+1222"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toBe("elevated is not available right now.");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("requires per-agent allowlist in addition to global", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/elevated on",
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
SessionKey: "agent:work:main",
|
||||
},
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "work",
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1333"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1222", "+1333"] },
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["+1222", "+1333"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toBe("elevated is not available right now.");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("allows elevated when both global and per-agent allowlists match", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/elevated on",
|
||||
From: "+1333",
|
||||
To: "+1333",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1333",
|
||||
SessionKey: "agent:work:main",
|
||||
},
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "work",
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1333"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1222", "+1333"] },
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["+1222", "+1333"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Elevated mode enabled");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when elevated is used in direct runtime", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
@@ -676,6 +815,51 @@ describe("directive behavior", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows elevated off in status when per-agent elevated is disabled", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/status",
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
SessionKey: "agent:restricted:main",
|
||||
},
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "restricted",
|
||||
tools: {
|
||||
elevated: { enabled: false },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1222"] },
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["+1222"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Elevated: off");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("acks queue directive and persists override", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
resolveAgentConfig,
|
||||
resolveAgentDir,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveAgentWorkspaceDir,
|
||||
@@ -205,6 +206,43 @@ function isApprovedElevatedSender(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveElevatedPermissions(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId: string;
|
||||
ctx: MsgContext;
|
||||
provider: string;
|
||||
}): { enabled: boolean; allowed: boolean } {
|
||||
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 discordFallback =
|
||||
params.provider === "discord"
|
||||
? params.cfg.discord?.dm?.allowFrom
|
||||
: undefined;
|
||||
const globalAllowed = isApprovedElevatedSender({
|
||||
provider: params.provider,
|
||||
ctx: params.ctx,
|
||||
allowFrom: globalConfig?.allowFrom,
|
||||
discordFallback,
|
||||
});
|
||||
if (!globalAllowed) return { enabled, allowed: false };
|
||||
|
||||
const agentAllowed = agentConfig?.allowFrom
|
||||
? isApprovedElevatedSender({
|
||||
provider: params.provider,
|
||||
ctx: params.ctx,
|
||||
allowFrom: agentConfig.allowFrom,
|
||||
})
|
||||
: true;
|
||||
return { enabled, allowed: globalAllowed && agentAllowed };
|
||||
}
|
||||
|
||||
export async function getReplyFromConfig(
|
||||
ctx: MsgContext,
|
||||
opts?: GetReplyOptions,
|
||||
@@ -391,21 +429,13 @@ export async function getReplyFromConfig(
|
||||
sessionCtx.Provider?.trim().toLowerCase() ??
|
||||
ctx.Provider?.trim().toLowerCase() ??
|
||||
"";
|
||||
const elevatedConfig = cfg.tools?.elevated;
|
||||
const discordElevatedFallback =
|
||||
messageProviderKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined;
|
||||
const elevatedEnabled = elevatedConfig?.enabled !== false;
|
||||
const elevatedAllowed =
|
||||
elevatedEnabled &&
|
||||
Boolean(
|
||||
messageProviderKey &&
|
||||
isApprovedElevatedSender({
|
||||
provider: messageProviderKey,
|
||||
ctx,
|
||||
allowFrom: elevatedConfig?.allowFrom,
|
||||
discordFallback: discordElevatedFallback,
|
||||
}),
|
||||
);
|
||||
const { enabled: elevatedEnabled, allowed: elevatedAllowed } =
|
||||
resolveElevatedPermissions({
|
||||
cfg,
|
||||
agentId,
|
||||
ctx,
|
||||
provider: messageProviderKey,
|
||||
});
|
||||
if (
|
||||
directives.hasElevatedDirective &&
|
||||
(!elevatedEnabled || !elevatedAllowed)
|
||||
@@ -573,7 +603,7 @@ export async function getReplyFromConfig(
|
||||
resolvedVerboseLevel: (currentVerboseLevel ?? "off") as VerboseLevel,
|
||||
resolvedReasoningLevel: (currentReasoningLevel ??
|
||||
"off") as ReasoningLevel,
|
||||
resolvedElevatedLevel: currentElevatedLevel,
|
||||
resolvedElevatedLevel,
|
||||
resolveDefaultThinkingLevel: async () =>
|
||||
currentThinkLevel ??
|
||||
(agentCfg?.thinkingDefault as ThinkLevel | undefined),
|
||||
|
||||
@@ -993,6 +993,39 @@ describe("legacy config detection", () => {
|
||||
expect((res.config as { agent?: unknown }).agent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts per-agent tools.elevated overrides", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+15555550123"] },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "work",
|
||||
workspace: "~/clawd-work",
|
||||
tools: {
|
||||
elevated: {
|
||||
enabled: false,
|
||||
allowFrom: { whatsapp: ["+15555550123"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config?.agents?.list?.[0]?.tools?.elevated).toEqual({
|
||||
enabled: false,
|
||||
allowFrom: { whatsapp: ["+15555550123"] },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects telegram.requireMention", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
|
||||
@@ -843,6 +843,13 @@ export type QueueConfig = {
|
||||
export type AgentToolsConfig = {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
/** Per-agent elevated bash gate (can only further restrict global tools.elevated). */
|
||||
elevated?: {
|
||||
/** Enable or disable elevated mode for this agent (default: true). */
|
||||
enabled?: boolean;
|
||||
/** Approved senders for /elevated (per-provider allowlists). */
|
||||
allowFrom?: AgentElevatedAllowFromConfig;
|
||||
};
|
||||
sandbox?: {
|
||||
tools?: {
|
||||
allow?: string[];
|
||||
|
||||
@@ -749,6 +749,12 @@ const AgentToolsSchema = z
|
||||
.object({
|
||||
allow: z.array(z.string()).optional(),
|
||||
deny: z.array(z.string()).optional(),
|
||||
elevated: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: ElevatedAllowFromSchema,
|
||||
})
|
||||
.optional(),
|
||||
sandbox: z
|
||||
.object({
|
||||
tools: ToolPolicySchema,
|
||||
|
||||
Reference in New Issue
Block a user