fix(agents): add strict format validation to sessions_spawn for agentId

Implements a strict format validation for the agentId parameter in
sessions_spawn to fully resolve the ghost workspace creation bug reported
in #31311.

This fix introduces a regex format gate at the entry point to
immediately reject malformed agentId strings. This prevents error
messages (e.g., 'Agent not found: xyz') or path traversals from being
mangled by normalizeAgentId into seemingly valid IDs (e.g.,
'agent-not-found--xyz'), which was the root cause of the bug.

The validation is placed before normalization and does not interfere
with existing workflows, including delegating to agents that are
allowlisted but not globally configured.

New, non-redundant tests are added to
sessions-spawn.allowlist.test.ts to cover format validation and
ensure no regressions in allowlist behavior.

Fixes #31311
This commit is contained in:
root
2026-03-02 14:39:46 +08:00
committed by Peter Steinberger
parent ade46d8ab7
commit 0c6db05cc0
2 changed files with 143 additions and 1 deletions

View File

@@ -223,4 +223,124 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
expect(details.error).toContain('sandbox="require"');
expect(callGatewayMock).not.toHaveBeenCalled();
});
// ---------------------------------------------------------------------------
// agentId format validation (#31311)
// ---------------------------------------------------------------------------
it("rejects error-message-like strings as agentId (#31311)", async () => {
setSessionsSpawnConfigOverride({
session: { mainKey: "main", scope: "per-sender" },
agents: {
list: [{ id: "main", subagents: { allowAgents: ["*"] } }, { id: "research" }],
},
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
const result = await tool.execute("call-err-msg", {
task: "do thing",
agentId: "Agent not found: xyz",
});
const details = result.details as { status?: string; error?: string };
expect(details.status).toBe("error");
expect(details.error).toContain("Invalid agentId");
expect(details.error).toContain("agents_list");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("rejects agentId containing path separators (#31311)", async () => {
setSessionsSpawnConfigOverride({
session: { mainKey: "main", scope: "per-sender" },
agents: {
list: [{ id: "main", subagents: { allowAgents: ["*"] } }],
},
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
const result = await tool.execute("call-path", {
task: "do thing",
agentId: "../../../etc/passwd",
});
const details = result.details as { status?: string; error?: string };
expect(details.status).toBe("error");
expect(details.error).toContain("Invalid agentId");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("rejects agentId exceeding 64 characters (#31311)", async () => {
setSessionsSpawnConfigOverride({
session: { mainKey: "main", scope: "per-sender" },
agents: {
list: [{ id: "main", subagents: { allowAgents: ["*"] } }],
},
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
const result = await tool.execute("call-long", {
task: "do thing",
agentId: "a".repeat(65),
});
const details = result.details as { status?: string; error?: string };
expect(details.status).toBe("error");
expect(details.error).toContain("Invalid agentId");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("accepts well-formed agentId with hyphens and underscores (#31311)", async () => {
setSessionsSpawnConfigOverride({
session: { mainKey: "main", scope: "per-sender" },
agents: {
list: [{ id: "main", subagents: { allowAgents: ["*"] } }, { id: "my-research_agent01" }],
},
});
callGatewayMock.mockImplementation(async () => ({
runId: "run-1",
status: "accepted",
acceptedAt: 1000,
}));
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
const result = await tool.execute("call-valid", {
task: "do thing",
agentId: "my-research_agent01",
});
const details = result.details as { status?: string };
expect(details.status).toBe("accepted");
});
it("allows allowlisted-but-unconfigured agentId (#31311)", async () => {
setSessionsSpawnConfigOverride({
session: { mainKey: "main", scope: "per-sender" },
agents: {
list: [
{ id: "main", subagents: { allowAgents: ["research"] } },
// "research" is NOT in agents.list — only in allowAgents
],
},
});
callGatewayMock.mockImplementation(async () => ({
runId: "run-1",
status: "accepted",
acceptedAt: 1000,
}));
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
const result = await tool.execute("call-unconfigured", {
task: "do thing",
agentId: "research",
});
const details = result.details as { status?: string };
// Must pass: "research" is in allowAgents even though not in agents.list
expect(details.status).toBe("accepted");
});
});