From d2da3051908da12e4a77bd44d0739a7aa6dbbb99 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 4 Jan 2026 05:31:00 +0000 Subject: [PATCH] feat: fallback elevated allowlist to discord dms --- docs/configuration.md | 4 +- docs/elevated.md | 1 + src/auto-reply/reply.directive.test.ts | 77 ++++++++++++++++++ src/auto-reply/reply.triggers.test.ts | 106 +++++++++++++++++++++++++ src/auto-reply/reply.ts | 20 ++++- 5 files changed, 203 insertions(+), 5 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 0a1460fcd..63bdba416 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -442,10 +442,10 @@ Z.AI models are available as `zai/` (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 diff --git a/docs/elevated.md b/docs/elevated.md index 980c817db..fcffe2de6 100644 --- a/docs/elevated.md +++ b/docs/elevated.md @@ -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. diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index d6ca3752f..5834e7bfa 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -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", + }); + }); + }); }); diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 3e09eac26..191dea728 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -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( diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index c42ead02c..b2257b5e9 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -98,14 +98,20 @@ function stripSenderPrefix(value?: string) { function resolveElevatedAllowList( allowFrom: AgentElevatedAllowFromConfig | undefined, surface: string, + discordFallback?: Array, ): Array | 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; }): 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 (