feat: add per-agent elevated controls

This commit is contained in:
Peter Steinberger
2026-01-09 20:42:16 +00:00
parent 1a97aadb6b
commit 5fa26bfec7
13 changed files with 349 additions and 26 deletions

View File

@@ -1234,8 +1234,24 @@ Example:
} }
``` ```
Per-agent override (further restrict):
```json5
{
agents: {
list: [
{
id: "family",
tools: {
elevated: { enabled: false }
}
}
]
}
}
```
Notes: 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 on|off` stores state per session key; inline directives apply to a single message.
- Elevated `bash` runs on the host and bypasses sandboxing. - Elevated `bash` runs on the host and bypasses sandboxing.
- Tool policy still applies; if `bash` is denied, elevated cannot be used. - Tool policy still applies; if `bash` is denied, elevated cannot be used.

View File

@@ -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: "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` - `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 dont 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 dont 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) ## Per-agent access profiles (multi-agent)

View File

@@ -172,13 +172,14 @@ The filtering order is:
Each level can further restrict tools, but cannot grant back denied tools from earlier levels. 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. If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent.
### Elevated Mode (global) ### Elevated Mode
`tools.elevated` is **global** and **sender-based** (per-provider allowlist). It is **not** configurable per agent. `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: Mitigation patterns:
- Deny `bash` for untrusted agents (`agents.list[].tools.deny: ["bash"]`) - Deny `bash` for untrusted agents (`agents.list[].tools.deny: ["bash"]`)
- Avoid allowlisting senders that route to restricted agents - Avoid allowlisting senders that route to restricted agents
- Disable elevated globally (`tools.elevated.enabled: false`) if you only want sandboxed execution - 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
--- ---

View File

@@ -12,7 +12,7 @@ read_when:
- Only `on|off` are accepted; anything else returns a hint and does not change state. - Only `on|off` are accepted; anything else returns a hint and does not change state.
## What it controls (and what it doesnt) ## What it controls (and what it doesnt)
- **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. - **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. - **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. - **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 ## Availability + allowlists
- Feature gate: `tools.elevated.enabled` (default can be off via config even if the code supports it). - 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`). - Sender allowlist: `tools.elevated.allowFrom` with per-provider allowlists (e.g. `discord`, `whatsapp`).
- Both must pass; otherwise elevated is treated as unavailable. - Per-agent gate: `agents.list[].tools.elevated.enabled` (optional; can only further restrict).
- 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 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 ## Logging + status
- Elevated bash calls are logged at info level. - Elevated bash calls are logged at info level.

View File

@@ -39,7 +39,7 @@ Notes:
- Returns `status: "running"` with a `sessionId` when backgrounded. - Returns `status: "running"` with a `sessionId` when backgrounded.
- Use `process` to poll/log/write/kill/clear background sessions. - Use `process` to poll/log/write/kill/clear background sessions.
- If `process` is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`. - 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 its a no-op). - `elevated` only changes behavior when the agent is sandboxed (otherwise its a no-op).
### `process` ### `process`

View File

@@ -85,6 +85,10 @@ describe("resolveAgentConfig", () => {
tools: { tools: {
allow: ["read"], allow: ["read"],
deny: ["bash", "write", "edit"], deny: ["bash", "write", "edit"],
elevated: {
enabled: false,
allowFrom: { whatsapp: ["+15555550123"] },
},
}, },
}, },
], ],
@@ -94,6 +98,10 @@ describe("resolveAgentConfig", () => {
expect(result?.tools).toEqual({ expect(result?.tools).toEqual({
allow: ["read"], allow: ["read"],
deny: ["bash", "write", "edit"], deny: ["bash", "write", "edit"],
elevated: {
enabled: false,
allowFrom: { whatsapp: ["+15555550123"] },
},
}); });
}); });

View File

@@ -33,6 +33,40 @@ describe("Agent-specific tool filtering", () => {
expect(toolNames).not.toContain("Bash"); 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", () => { it("should apply agent-specific tool policy", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
tools: { tools: {

View File

@@ -348,11 +348,13 @@ function resolveEffectiveToolPolicy(params: {
params.config && agentId params.config && agentId
? resolveAgentConfig(params.config, agentId) ? resolveAgentConfig(params.config, agentId)
: undefined; : 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; const globalTools = params.config?.tools;
return { return {
agentId, agentId,
policy: hasAgentTools ? agentConfig?.tools : globalTools, policy: hasAgentToolPolicy ? agentTools : globalTools,
}; };
} }

View File

@@ -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 () => { it("warns when elevated is used in direct runtime", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset(); 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 () => { it("acks queue directive and persists override", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();

View File

@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { import {
resolveAgentConfig,
resolveAgentDir, resolveAgentDir,
resolveAgentIdFromSessionKey, resolveAgentIdFromSessionKey,
resolveAgentWorkspaceDir, resolveAgentWorkspaceDir,
@@ -205,6 +206,43 @@ function isApprovedElevatedSender(params: {
return false; 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( export async function getReplyFromConfig(
ctx: MsgContext, ctx: MsgContext,
opts?: GetReplyOptions, opts?: GetReplyOptions,
@@ -391,21 +429,13 @@ export async function getReplyFromConfig(
sessionCtx.Provider?.trim().toLowerCase() ?? sessionCtx.Provider?.trim().toLowerCase() ??
ctx.Provider?.trim().toLowerCase() ?? ctx.Provider?.trim().toLowerCase() ??
""; "";
const elevatedConfig = cfg.tools?.elevated; const { enabled: elevatedEnabled, allowed: elevatedAllowed } =
const discordElevatedFallback = resolveElevatedPermissions({
messageProviderKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined; cfg,
const elevatedEnabled = elevatedConfig?.enabled !== false; agentId,
const elevatedAllowed = ctx,
elevatedEnabled && provider: messageProviderKey,
Boolean( });
messageProviderKey &&
isApprovedElevatedSender({
provider: messageProviderKey,
ctx,
allowFrom: elevatedConfig?.allowFrom,
discordFallback: discordElevatedFallback,
}),
);
if ( if (
directives.hasElevatedDirective && directives.hasElevatedDirective &&
(!elevatedEnabled || !elevatedAllowed) (!elevatedEnabled || !elevatedAllowed)
@@ -573,7 +603,7 @@ export async function getReplyFromConfig(
resolvedVerboseLevel: (currentVerboseLevel ?? "off") as VerboseLevel, resolvedVerboseLevel: (currentVerboseLevel ?? "off") as VerboseLevel,
resolvedReasoningLevel: (currentReasoningLevel ?? resolvedReasoningLevel: (currentReasoningLevel ??
"off") as ReasoningLevel, "off") as ReasoningLevel,
resolvedElevatedLevel: currentElevatedLevel, resolvedElevatedLevel,
resolveDefaultThinkingLevel: async () => resolveDefaultThinkingLevel: async () =>
currentThinkLevel ?? currentThinkLevel ??
(agentCfg?.thinkingDefault as ThinkLevel | undefined), (agentCfg?.thinkingDefault as ThinkLevel | undefined),

View File

@@ -993,6 +993,39 @@ describe("legacy config detection", () => {
expect((res.config as { agent?: unknown }).agent).toBeUndefined(); 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 () => { it("rejects telegram.requireMention", async () => {
vi.resetModules(); vi.resetModules();
const { validateConfigObject } = await import("./config.js"); const { validateConfigObject } = await import("./config.js");

View File

@@ -843,6 +843,13 @@ export type QueueConfig = {
export type AgentToolsConfig = { export type AgentToolsConfig = {
allow?: string[]; allow?: string[];
deny?: 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?: { sandbox?: {
tools?: { tools?: {
allow?: string[]; allow?: string[];

View File

@@ -749,6 +749,12 @@ const AgentToolsSchema = z
.object({ .object({
allow: z.array(z.string()).optional(), allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(), deny: z.array(z.string()).optional(),
elevated: z
.object({
enabled: z.boolean().optional(),
allowFrom: ElevatedAllowFromSchema,
})
.optional(),
sandbox: z sandbox: z
.object({ .object({
tools: ToolPolicySchema, tools: ToolPolicySchema,