feat: fallback elevated allowlist to discord dms

This commit is contained in:
Peter Steinberger
2026-01-04 05:31:00 +00:00
parent be9fa124df
commit d2da305190
5 changed files with 203 additions and 5 deletions

View File

@@ -442,10 +442,10 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
`agent.elevated` controls elevated (host) bash access:
- `enabled`: allow elevated mode (default true)
- `allowFrom`: per-surface allowlists (required to enable; empty = disabled)
- `allowFrom`: per-surface allowlists (empty = disabled)
- `whatsapp`: E.164 numbers
- `telegram`: chat ids or usernames
- `discord`: user ids or usernames
- `discord`: user ids or usernames (falls back to `discord.dm.allowFrom` if omitted)
- `signal`: E.164 numbers
- `imessage`: handles/chat ids
- `webchat`: session ids or usernames

View File

@@ -24,6 +24,7 @@ read_when:
- Feature gate: `agent.elevated.enabled` (default can be off via config even if the code supports it).
- Sender allowlist: `agent.elevated.allowFrom` with per-surface allowlists (e.g. `discord`, `whatsapp`).
- Both must pass; otherwise elevated is treated as unavailable.
- Discord fallback: if `agent.elevated.allowFrom.discord` is omitted, the `discord.dm.allowFrom` list is used as a fallback. Set `agent.elevated.allowFrom.discord` (even `[]`) to override.
## Logging + status
- Elevated bash calls are logged at info level.

View File

@@ -299,6 +299,38 @@ describe("directive parsing", () => {
});
});
it("rejects invalid elevated level", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{
Body: "/elevated maybe",
From: "+1222",
To: "+1222",
Surface: "whatsapp",
SenderE164: "+1222",
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
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("Unrecognized elevated level");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("acks queue directive and persists override", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
@@ -751,4 +783,49 @@ describe("directive parsing", () => {
expect(call?.thinkLevel).toBe("low");
});
});
it("passes elevated defaults when sender is approved", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "done" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
await getReplyFromConfig(
{
Body: "hello",
From: "+1004",
To: "+2000",
Surface: "whatsapp",
SenderE164: "+1004",
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1004"] },
},
},
whatsapp: {
allowFrom: ["*"],
},
session: { store: storePath },
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
expect(call?.bashElevated).toEqual({
enabled: true,
allowed: true,
defaultLevel: "on",
});
});
});
});

View File

@@ -222,6 +222,112 @@ describe("trigger handling", () => {
});
});
it("rejects elevated inline directive for unapproved sender", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "please /elevated on now",
From: "+2000",
To: "+2000",
Surface: "whatsapp",
SenderE164: "+2000",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("elevated is not available right now.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("falls back to discord dm allowFrom for elevated approval", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
discord: {
dm: {
allowFrom: ["steipete"],
},
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "/elevated on",
From: "discord:123",
To: "user:123",
Surface: "discord",
SenderName: "steipete",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<
string,
{ elevatedLevel?: string }
>;
expect(store.main?.elevatedLevel).toBe("on");
});
});
it("treats explicit discord elevated allowlist as override", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
elevated: {
allowFrom: { discord: [] },
},
},
discord: {
dm: {
allowFrom: ["steipete"],
},
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "/elevated on",
From: "discord:123",
To: "user:123",
Surface: "discord",
SenderName: "steipete",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("elevated is not available right now.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("returns a context overflow fallback when the embedded agent throws", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockRejectedValue(

View File

@@ -98,14 +98,20 @@ function stripSenderPrefix(value?: string) {
function resolveElevatedAllowList(
allowFrom: AgentElevatedAllowFromConfig | undefined,
surface: string,
discordFallback?: Array<string | number>,
): Array<string | number> | undefined {
switch (surface) {
case "whatsapp":
return allowFrom?.whatsapp;
case "telegram":
return allowFrom?.telegram;
case "discord":
return allowFrom?.discord;
case "discord": {
const hasExplicit = Boolean(
allowFrom && Object.prototype.hasOwnProperty.call(allowFrom, "discord"),
);
if (hasExplicit) return allowFrom?.discord;
return discordFallback;
}
case "signal":
return allowFrom?.signal;
case "imessage":
@@ -121,8 +127,13 @@ function isApprovedElevatedSender(params: {
surface: string;
ctx: MsgContext;
allowFrom?: AgentElevatedAllowFromConfig;
discordFallback?: Array<string | number>;
}): boolean {
const rawAllow = resolveElevatedAllowList(params.allowFrom, params.surface);
const rawAllow = resolveElevatedAllowList(
params.allowFrom,
params.surface,
params.discordFallback,
);
if (!rawAllow || rawAllow.length === 0) return false;
const allowTokens = rawAllow
@@ -250,6 +261,8 @@ export async function getReplyFromConfig(
ctx.Surface?.trim().toLowerCase() ??
"";
const elevatedConfig = agentCfg?.elevated;
const discordElevatedFallback =
surfaceKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined;
const elevatedEnabled = elevatedConfig?.enabled !== false;
const elevatedAllowed =
elevatedEnabled &&
@@ -259,6 +272,7 @@ export async function getReplyFromConfig(
surface: surfaceKey,
ctx,
allowFrom: elevatedConfig?.allowFrom,
discordFallback: discordElevatedFallback,
}),
);
if (