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:
|
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.
|
||||||
|
|||||||
@@ -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 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)
|
## 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.
|
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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 doesn’t)
|
## 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.
|
- **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.
|
||||||
|
|||||||
@@ -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 it’s a no-op).
|
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
|
||||||
|
|
||||||
### `process`
|
### `process`
|
||||||
|
|||||||
@@ -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"] },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user