fix(agents): subagent hard-deny list cannot be overridden by allow config (#120025)

* fix(agents): make subagent hard-deny list non-overridable and deny message tool

The always-deny list for subagent sessions (gateway, cron, message, sessions_send,
conversations_*) could be overridden by ordinary allow/alsoAllow config entries,
letting a configured subagent profile re-enable direct user delivery outside the
announce chain. The hard-deny layer now applies unconditionally; message joins the
list so resumed/visible subagent sessions cannot send directly either (hidden
launches already disabled it at spawn time).

* chore: re-fire CI

* chore: re-fire CI against fixed main baseline

* test(agents): workspace authority reflects non-overridable subagent deny list

The delegating-worker rejection case relied on alsoAllow bypassing the
subagent hard-deny list; with the bypass closed the policy owner blocks
sessions_spawn and the worker stays confined, so the guard has nothing to
reject.
This commit is contained in:
Peter Steinberger
2026-08-07 03:58:02 -07:00
committed by GitHub
parent e910324f10
commit 8994c7799b
5 changed files with 112 additions and 21 deletions
+10 -8
View File
@@ -573,14 +573,16 @@ Sub-agents use the same profile and tool-policy pipeline as the parent or
target agent first. After that, OpenClaw applies the sub-agent restriction
layer.
Sub-agents always lose `gateway`, `agents_list`, `session_status`, and
`cron` regardless of depth or role (system-level/interactive tools, or
tools the main agent should coordinate). Leaf sub-agents (default depth-1
behavior, and always at depth 2) additionally lose `subagents`,
`sessions_list`, `sessions_history`, and `sessions_spawn`. Sub-agents never
get the `message` tool — it is disabled at spawn time, not filtered by
this deny list — and `sessions_send` stays denied so sub-agents
communicate only through the announce chain.
Sub-agents always lose `gateway`, `agents_list`, `session_status`, `cron`,
`message`, `sessions_send`, and the `conversations_*` tools regardless of
depth or role (system-level/interactive tools, direct delivery surfaces, or
tools the main agent should coordinate). This hard-deny layer is derived from
the persisted sub-agent session envelope on every turn, including resumed and
visible dashboard sessions; ordinary `allow`/`alsoAllow` entries cannot override
it. Hidden launches also disable `message` before tool construction as defense in
depth. Leaf sub-agents (default depth-1 behavior, and always at depth 2)
additionally lose `subagents`, `sessions_list`, `sessions_history`, and
`sessions_spawn`, so sub-agent communication stays on the announce chain.
`sessions_history` remains a bounded, sanitized recall view here too — it
is not a raw transcript dump.
@@ -1497,6 +1497,41 @@ describe("createOpenClawCodingTools", () => {
expectListIncludes(latestCreateOpenClawToolsOptions().pluginToolDenylist, ["pdf"]);
});
it("removes message from persisted visible child sessions on every turn", async () => {
const storeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-visible-subagent-message-"));
const storeTemplate = path.join(storeDir, "{agentId}", "sessions.json");
const agentId = "visible-subagent-message";
const childSessionKey = `agent:${agentId}:dashboard:child`;
const rootSessionKey = `agent:${agentId}:dashboard:root`;
try {
await writeSessionStore(storeTemplate, agentId, {
[childSessionKey]: {
sessionId: "visible-child",
updatedAt: Date.now(),
spawnDepth: 1,
spawnedBy: `agent:${agentId}:main`,
subagentRole: "leaf",
subagentControlScope: "none",
},
[rootSessionKey]: {
sessionId: "root-dashboard",
updatedAt: Date.now(),
spawnDepth: 0,
},
});
const firstChildTurn = createToolsForStoredSession(storeTemplate, childSessionKey);
const resumedChildTurn = createToolsForStoredSession(storeTemplate, childSessionKey);
const rootTurn = createToolsForStoredSession(storeTemplate, rootSessionKey);
expect(toolNameList(firstChildTurn)).not.toContain("message");
expect(toolNameList(resumedChildTurn)).not.toContain("message");
expect(toolNameList(rootTurn)).toContain("message");
} finally {
await fs.rm(storeDir, { recursive: true, force: true });
}
});
it("passes inherited allowlist entries to OpenClaw plugin discovery", async () => {
const createOpenClawToolsMock = vi.mocked(createOpenClawTools);
createOpenClawToolsMock.mockClear();
+58
View File
@@ -313,6 +313,64 @@ describe("resolveSubagentToolPolicyForSession", () => {
expect(isToolAllowedByPolicyName("memory_get", policy)).toBe(true);
});
it.each(["allow", "alsoAllow"] as const)(
"does not let configured %s entries re-enable hard-denied tools",
async (allowField) => {
const storePath = createSessionStorePath(`openclaw-subagent-hard-deny-${allowField}`);
const sessionKeys = {
leaf: "agent:main:subagent:hard-deny-leaf",
orchestrator: "agent:main:subagent:hard-deny-orchestrator",
} as const;
await writeSessionEntries(storePath, {
[sessionKeys.leaf]: {
sessionId: "hard-deny-leaf",
updatedAt: Date.now(),
spawnDepth: 2,
subagentRole: "leaf",
subagentControlScope: "none",
},
[sessionKeys.orchestrator]: {
sessionId: "hard-deny-orchestrator",
updatedAt: Date.now(),
spawnDepth: 1,
subagentRole: "orchestrator",
subagentControlScope: "children",
},
});
const hardDeniedTools = [
"gateway",
"agents_list",
"session_status",
"automations",
"cron",
"message",
"sessions_send",
"conversations_list",
"conversations_send",
"conversations_turn",
];
const cfg = {
...baseCfg,
session: { store: storePath },
tools: {
subagents: {
tools: {
[allowField]: [...hardDeniedTools, "memory_search"],
},
},
},
} as unknown as OpenClawConfig;
for (const sessionKey of Object.values(sessionKeys)) {
const policy = resolveSubagentToolPolicyForSession(cfg, sessionKey);
for (const toolName of hardDeniedTools) {
expect(isToolAllowedByPolicyName(toolName, policy), toolName).toBe(false);
}
expect(isToolAllowedByPolicyName("memory_search", policy)).toBe(true);
}
},
);
it("resolves inherited tool denies from stored subagent sessions", async () => {
const storePath = createSessionStorePath("openclaw-subagent-inherited-deny");
await writeSessionEntries(storePath, {
+4 -12
View File
@@ -36,11 +36,7 @@ import {
type SubagentSessionRole,
} from "./subagent-capabilities.js";
import { isToolAllowedByPolicyName } from "./tool-policy-match.js";
import {
mergeAlsoAllowPolicy,
normalizeToolName,
resolveToolProfilePolicy,
} from "./tool-policy.js";
import { mergeAlsoAllowPolicy, resolveToolProfilePolicy } from "./tool-policy.js";
import { AUTOMATIONS_TOOL_NAME } from "./tools/automations-tool-name.js";
export { resolveProviderToolPolicy };
@@ -56,7 +52,8 @@ const SUBAGENT_TOOL_DENY_ALWAYS = [
// Status/scheduling - main agent coordinates
"session_status",
AUTOMATIONS_TOOL_NAME,
// Direct session sends - subagents communicate through announce chain
// Direct user/session sends - subagents communicate through announce chain
"message",
"sessions_send",
"conversations_list",
"conversations_send",
@@ -105,13 +102,8 @@ export function resolveSubagentToolPolicyForSession(
});
const allow = Array.isArray(configured?.allow) ? configured.allow : undefined;
const alsoAllow = Array.isArray(configured?.alsoAllow) ? configured.alsoAllow : undefined;
const explicitAllow = new Set(
[...(allow ?? []), ...(alsoAllow ?? [])].map((toolName) => normalizeToolName(toolName)),
);
const deny = [
...resolveSubagentDenyListForRole(capabilities.role).filter(
(toolName) => !explicitAllow.has(normalizeToolName(toolName)),
),
...resolveSubagentDenyListForRole(capabilities.role),
...(Array.isArray(configured?.deny) ? configured.deny : []),
];
const mergedAllow = mergeConfiguredSubagentAllow(allow, alsoAllow);
@@ -102,6 +102,9 @@ describe("resolveSandboxWorkspaceAuthority", () => {
});
expect(elevated.confinementError).toContain("elevated execution");
// Config-driven delegation cannot happen anymore: the subagent hard-deny
// list is non-overridable, so alsoAllow cannot re-enable sessions_spawn.
// The worker stays confined (no error) instead of being rejected.
const delegatingConfig = configWithSandbox({ mode: "all", workspaceAccess: "rw" });
delegatingConfig.tools!.sandbox!.tools!.allow = [...SAFE_WORKBOARD_TOOLS, "sessions_spawn"];
delegatingConfig.tools!.subagents = { tools: { alsoAllow: ["sessions_spawn"] } };
@@ -110,7 +113,8 @@ describe("resolveSandboxWorkspaceAuthority", () => {
agentId: "main",
sessionKey: "agent:main:subagent:workboard-card",
});
expect(delegating.confinementError).toContain("sessions_spawn");
expect(delegating.confinementError).toBeUndefined();
expect(delegating.sandboxed).toBe(true);
});
it("uses the runtime session visibility clamp", () => {