Security: validate and sanitize agent overrides

This commit is contained in:
Josh Lehman
2026-03-16 21:08:34 -07:00
parent cf7f401228
commit f69cc431af

View File

@@ -12,7 +12,7 @@ import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import * as commandSecretGatewayModule from "../cli/command-secret-gateway.js";
import type { OpenClawConfig } from "../config/config.js";
import * as configModule from "../config/config.js";
import * as sessionsModule from "../config/sessions.js";
import * as sessionPathsModule from "../config/sessions/paths.js";
import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -20,6 +20,24 @@ import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/chan
import { agentCommand, agentCommandFromIngress } from "./agent.js";
import * as agentDeliveryModule from "./agent/delivery.js";
vi.mock("../logging/subsystem.js", () => {
const createMockLogger = () => ({
subsystem: "test",
isEnabled: vi.fn(() => true),
trace: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
fatal: vi.fn(),
raw: vi.fn(),
child: vi.fn(() => createMockLogger()),
});
return {
createSubsystemLogger: vi.fn(() => createMockLogger()),
};
});
vi.mock("../agents/auth-profiles.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../agents/auth-profiles.js")>();
return {
@@ -28,10 +46,13 @@ vi.mock("../agents/auth-profiles.js", async (importOriginal) => {
};
});
vi.mock("../agents/workspace.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../agents/workspace.js")>();
vi.mock("../agents/workspace.js", () => {
const resolveDefaultAgentWorkspaceDir = () => "/tmp/openclaw-workspace";
return {
...actual,
DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/openclaw-workspace",
DEFAULT_AGENTS_FILENAME: "AGENTS.md",
DEFAULT_IDENTITY_FILENAME: "IDENTITY.md",
resolveDefaultAgentWorkspaceDir,
ensureAgentWorkspace: vi.fn(async ({ dir }: { dir: string }) => ({ dir })),
};
});
@@ -406,13 +427,35 @@ describe("agentCommand", () => {
});
});
it("requires explicit allowModelOverride for ingress runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
await expect(
// Runtime guard for non-TS callers; TS callsites are statically typed.
agentCommandFromIngress(
{
message: "hi",
to: "+1555",
senderIsOwner: false,
} as never,
runtime,
),
).rejects.toThrow("allowModelOverride must be explicitly set for ingress agent runs.");
});
});
it("honors explicit senderIsOwner for ingress runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
await agentCommandFromIngress({ message: "hi", to: "+1555", senderIsOwner: false }, runtime);
await agentCommandFromIngress(
{ message: "hi", to: "+1555", senderIsOwner: false, allowModelOverride: false },
runtime,
);
const ingressCall = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(ingressCall?.senderIsOwner).toBe(false);
expect(ingressCall).not.toHaveProperty("allowModelOverride");
});
});
@@ -463,7 +506,7 @@ describe("agentCommand", () => {
const store = path.join(customStoreDir, "sessions.json");
writeSessionStoreSeed(store, {});
mockConfig(home, store);
const resolveSessionFilePathSpy = vi.spyOn(sessionsModule, "resolveSessionFilePath");
const resolveSessionFilePathSpy = vi.spyOn(sessionPathsModule, "resolveSessionFilePath");
await agentCommand({ message: "resume me", sessionId: "session-custom-123" }, runtime);
@@ -718,6 +761,63 @@ describe("agentCommand", () => {
});
});
it("rejects explicit override values that contain control characters", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, {
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
});
await expect(
agentCommand(
{
message: "use an invalid override",
sessionKey: "agent:main:subagent:invalid-override",
provider: "openai\u001b[31m",
model: "gpt-4.1-mini",
},
runtime,
),
).rejects.toThrow("Provider override contains invalid control characters.");
});
});
it("sanitizes provider/model text in model-allowlist errors", async () => {
const parseModelRefSpy = vi.spyOn(modelSelectionModule, "parseModelRef");
parseModelRefSpy.mockImplementationOnce(() => ({
provider: "anthropic\u001b[31m",
model: "claude-haiku-4-6\u001b[32m",
}));
try {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, {
models: {
"openai/gpt-4.1-mini": {},
},
});
await expect(
agentCommand(
{
message: "use disallowed override",
sessionKey: "agent:main:subagent:sanitized-override-error",
model: "claude-haiku-4-6",
},
runtime,
),
).rejects.toThrow(
'Model override "anthropic/claude-haiku-4-6" is not allowed for agent "main".',
);
});
} finally {
parseModelRefSpy.mockRestore();
}
});
it("keeps stored auth profile overrides during one-off cross-provider runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");