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

@@ -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"] },
},
});
});

View File

@@ -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: {

View File

@@ -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,
};
}

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 () => {
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();

View File

@@ -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),

View File

@@ -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");

View File

@@ -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[];

View File

@@ -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,