feat: fallback elevated allowlist to discord dms
This commit is contained in:
@@ -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:
|
`agent.elevated` controls elevated (host) bash access:
|
||||||
- `enabled`: allow elevated mode (default true)
|
- `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
|
- `whatsapp`: E.164 numbers
|
||||||
- `telegram`: chat ids or usernames
|
- `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
|
- `signal`: E.164 numbers
|
||||||
- `imessage`: handles/chat ids
|
- `imessage`: handles/chat ids
|
||||||
- `webchat`: session ids or usernames
|
- `webchat`: session ids or usernames
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ read_when:
|
|||||||
- Feature gate: `agent.elevated.enabled` (default can be off via config even if the code supports it).
|
- 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`).
|
- Sender allowlist: `agent.elevated.allowFrom` with per-surface allowlists (e.g. `discord`, `whatsapp`).
|
||||||
- Both must pass; otherwise elevated is treated as unavailable.
|
- 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
|
## Logging + status
|
||||||
- Elevated bash calls are logged at info level.
|
- Elevated bash calls are logged at info level.
|
||||||
|
|||||||
@@ -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 () => {
|
it("acks queue directive and persists override", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
@@ -751,4 +783,49 @@ describe("directive parsing", () => {
|
|||||||
expect(call?.thinkLevel).toBe("low");
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
it("returns a context overflow fallback when the embedded agent throws", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockRejectedValue(
|
vi.mocked(runEmbeddedPiAgent).mockRejectedValue(
|
||||||
|
|||||||
@@ -98,14 +98,20 @@ function stripSenderPrefix(value?: string) {
|
|||||||
function resolveElevatedAllowList(
|
function resolveElevatedAllowList(
|
||||||
allowFrom: AgentElevatedAllowFromConfig | undefined,
|
allowFrom: AgentElevatedAllowFromConfig | undefined,
|
||||||
surface: string,
|
surface: string,
|
||||||
|
discordFallback?: Array<string | number>,
|
||||||
): Array<string | number> | undefined {
|
): Array<string | number> | undefined {
|
||||||
switch (surface) {
|
switch (surface) {
|
||||||
case "whatsapp":
|
case "whatsapp":
|
||||||
return allowFrom?.whatsapp;
|
return allowFrom?.whatsapp;
|
||||||
case "telegram":
|
case "telegram":
|
||||||
return allowFrom?.telegram;
|
return allowFrom?.telegram;
|
||||||
case "discord":
|
case "discord": {
|
||||||
return allowFrom?.discord;
|
const hasExplicit = Boolean(
|
||||||
|
allowFrom && Object.prototype.hasOwnProperty.call(allowFrom, "discord"),
|
||||||
|
);
|
||||||
|
if (hasExplicit) return allowFrom?.discord;
|
||||||
|
return discordFallback;
|
||||||
|
}
|
||||||
case "signal":
|
case "signal":
|
||||||
return allowFrom?.signal;
|
return allowFrom?.signal;
|
||||||
case "imessage":
|
case "imessage":
|
||||||
@@ -121,8 +127,13 @@ function isApprovedElevatedSender(params: {
|
|||||||
surface: string;
|
surface: string;
|
||||||
ctx: MsgContext;
|
ctx: MsgContext;
|
||||||
allowFrom?: AgentElevatedAllowFromConfig;
|
allowFrom?: AgentElevatedAllowFromConfig;
|
||||||
|
discordFallback?: Array<string | number>;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const rawAllow = resolveElevatedAllowList(params.allowFrom, params.surface);
|
const rawAllow = resolveElevatedAllowList(
|
||||||
|
params.allowFrom,
|
||||||
|
params.surface,
|
||||||
|
params.discordFallback,
|
||||||
|
);
|
||||||
if (!rawAllow || rawAllow.length === 0) return false;
|
if (!rawAllow || rawAllow.length === 0) return false;
|
||||||
|
|
||||||
const allowTokens = rawAllow
|
const allowTokens = rawAllow
|
||||||
@@ -250,6 +261,8 @@ export async function getReplyFromConfig(
|
|||||||
ctx.Surface?.trim().toLowerCase() ??
|
ctx.Surface?.trim().toLowerCase() ??
|
||||||
"";
|
"";
|
||||||
const elevatedConfig = agentCfg?.elevated;
|
const elevatedConfig = agentCfg?.elevated;
|
||||||
|
const discordElevatedFallback =
|
||||||
|
surfaceKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined;
|
||||||
const elevatedEnabled = elevatedConfig?.enabled !== false;
|
const elevatedEnabled = elevatedConfig?.enabled !== false;
|
||||||
const elevatedAllowed =
|
const elevatedAllowed =
|
||||||
elevatedEnabled &&
|
elevatedEnabled &&
|
||||||
@@ -259,6 +272,7 @@ export async function getReplyFromConfig(
|
|||||||
surface: surfaceKey,
|
surface: surfaceKey,
|
||||||
ctx,
|
ctx,
|
||||||
allowFrom: elevatedConfig?.allowFrom,
|
allowFrom: elevatedConfig?.allowFrom,
|
||||||
|
discordFallback: discordElevatedFallback,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
|
|||||||
Reference in New Issue
Block a user