feat: add per-agent elevated controls
This commit is contained in:
@@ -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