mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
e910324f10
commit
8994c7799b
+10
-8
@@ -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();
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user