Inherit tool restrictions for delegated sessions [AI] (#80979)

* fix: inherit tool restrictions for delegated sessions

* addressing review-skill

* addressing review-skill

* addressing review-skill

* addressing review-skill

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing review-skill

* addressing codex review

* addressing claude review

* addressing ci

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi 2026-05-13 10:21:36 +05:30 committed by GitHub
parent aba7652b76
commit 6c918ca85f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 992 additions and 20 deletions

View file

@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Inherit tool restrictions for delegated sessions [AI]. (#80979) Thanks @pgondhi987.
- Telegram: discard legacy long-poll update offsets that cannot be tied to the current bot token, so token rotation no longer leaves bots silently skipping new messages. (#80671) Thanks @sxxtony.
- browser: enforce navigation checks for act interactions [AI]. (#81070) Thanks @pgondhi987.
- Validate node exec event provenance [AI]. (#81071) Thanks @pgondhi987.

View file

@ -2098,6 +2098,8 @@ public struct SessionsPatchParams: Codable, Sendable {
public let spawndepth: AnyCodable?
public let subagentrole: AnyCodable?
public let subagentcontrolscope: AnyCodable?
public let inheritedtoolallow: AnyCodable?
public let inheritedtooldeny: AnyCodable?
public let sendpolicy: AnyCodable?
public let groupactivation: AnyCodable?
@ -2121,6 +2123,8 @@ public struct SessionsPatchParams: Codable, Sendable {
spawndepth: AnyCodable?,
subagentrole: AnyCodable?,
subagentcontrolscope: AnyCodable?,
inheritedtoolallow: AnyCodable?,
inheritedtooldeny: AnyCodable?,
sendpolicy: AnyCodable?,
groupactivation: AnyCodable?)
{
@ -2143,6 +2147,8 @@ public struct SessionsPatchParams: Codable, Sendable {
self.spawndepth = spawndepth
self.subagentrole = subagentrole
self.subagentcontrolscope = subagentcontrolscope
self.inheritedtoolallow = inheritedtoolallow
self.inheritedtooldeny = inheritedtooldeny
self.sendpolicy = sendpolicy
self.groupactivation = groupactivation
}
@ -2167,6 +2173,8 @@ public struct SessionsPatchParams: Codable, Sendable {
case spawndepth = "spawnDepth"
case subagentrole = "subagentRole"
case subagentcontrolscope = "subagentControlScope"
case inheritedtoolallow = "inheritedToolAllow"
case inheritedtooldeny = "inheritedToolDeny"
case sendpolicy = "sendPolicy"
case groupactivation = "groupActivation"
}

View file

@ -71,6 +71,14 @@ import {
startAcpSpawnParentStreamRelay,
} from "./acp-spawn-parent-stream.js";
import { resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js";
import {
findAcpUnsupportedInheritedToolAllow,
findAcpUnsupportedInheritedToolDeny,
formatAcpInheritedToolAllowError,
formatAcpInheritedToolDenyError,
inheritedToolAllowPatch,
inheritedToolDenyPatch,
} from "./inherited-tool-deny.js";
import { AGENT_LANE_SUBAGENT } from "./lanes.js";
import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js";
import { resolveRequesterOriginForChild } from "./spawn-requester-origin.js";
@ -123,6 +131,8 @@ export type SpawnAcpContext = {
/** Trusted provider role ids for the requester in this group turn. */
agentMemberRoleIds?: string[];
sandboxed?: boolean;
inheritedToolAllowlist?: string[];
inheritedToolDenylist?: string[];
};
export const ACP_SPAWN_ERROR_CODES = [
@ -1157,6 +1167,26 @@ export async function spawnAcpDirect(
error: runtimePolicyError,
});
}
const acpUnsupportedInheritedTool = findAcpUnsupportedInheritedToolDeny(
ctx.inheritedToolDenylist,
);
if (acpUnsupportedInheritedTool) {
return createAcpSpawnFailure({
status: "forbidden",
errorCode: "runtime_policy",
error: formatAcpInheritedToolDenyError(acpUnsupportedInheritedTool),
});
}
const acpUnsupportedInheritedAllow = findAcpUnsupportedInheritedToolAllow(
ctx.inheritedToolAllowlist,
);
if (acpUnsupportedInheritedAllow) {
return createAcpSpawnFailure({
status: "forbidden",
errorCode: "runtime_policy",
error: formatAcpInheritedToolAllowError(acpUnsupportedInheritedAllow),
});
}
const spawnMode = resolveSpawnMode({
requestedMode: params.mode,
@ -1291,6 +1321,8 @@ export async function spawnAcpDirect(
key: sessionKey,
spawnedBy: requesterInternalKey,
...subagentEnvelopeState.childSessionPatch,
...inheritedToolAllowPatch(ctx.inheritedToolAllowlist),
...inheritedToolDenyPatch(ctx.inheritedToolDenylist),
...(params.label ? { label: params.label } : {}),
},
timeoutMs: 10_000,

View file

@ -0,0 +1,90 @@
import { isToolAllowedByPolicyName } from "./tool-policy-match.js";
import { normalizeToolName } from "./tool-policy-shared.js";
const ACP_UNSUPPORTED_INHERITED_TOOL_DENY = [
"apply_patch",
"edit",
"exec",
"fs_delete",
"fs_move",
"fs_write",
"process",
"read",
"shell",
"spawn",
"write",
] as const;
// Inherited allowlists are rebuilt from the effective OpenClaw tool surface.
// ACP-only aliases can appear in explicit deny policies, but not in that
// effective allowlist unless a plugin happens to expose matching tool names.
const ACP_REQUIRED_INHERITED_TOOL_ALLOW = [
"apply_patch",
"edit",
"exec",
"process",
"read",
"write",
] as const;
export function normalizeInheritedToolDenylist(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
const result: string[] = [];
for (const entry of value) {
if (typeof entry !== "string") {
continue;
}
const normalized = normalizeToolName(entry);
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
result.push(normalized);
}
return result;
}
export function inheritedToolDenyPatch(value: unknown): { inheritedToolDeny?: string[] } {
const inheritedToolDeny = normalizeInheritedToolDenylist(value);
return inheritedToolDeny.length > 0 ? { inheritedToolDeny } : {};
}
export function normalizeInheritedToolAllowlist(value: unknown): string[] {
return normalizeInheritedToolDenylist(value);
}
export function inheritedToolAllowPatch(value: unknown): { inheritedToolAllow?: string[] } {
const inheritedToolAllow = normalizeInheritedToolAllowlist(value);
return inheritedToolAllow.length > 0 ? { inheritedToolAllow } : {};
}
export function findAcpUnsupportedInheritedToolDeny(value: unknown): string | undefined {
const inheritedToolDeny = normalizeInheritedToolDenylist(value);
if (inheritedToolDeny.length === 0) {
return undefined;
}
return ACP_UNSUPPORTED_INHERITED_TOOL_DENY.find(
(toolName) => !isToolAllowedByPolicyName(toolName, { deny: inheritedToolDeny }),
);
}
export function findAcpUnsupportedInheritedToolAllow(value: unknown): string | undefined {
const inheritedToolAllow = normalizeInheritedToolAllowlist(value);
if (inheritedToolAllow.length === 0) {
return undefined;
}
return ACP_REQUIRED_INHERITED_TOOL_ALLOW.find(
(toolName) => !isToolAllowedByPolicyName(toolName, { allow: inheritedToolAllow }),
);
}
export function formatAcpInheritedToolDenyError(toolName: string): string {
return `runtime="acp" is unavailable because the requester denies ${toolName}. Use runtime="subagent".`;
}
export function formatAcpInheritedToolAllowError(toolName: string): string {
return `runtime="acp" is unavailable because the requester does not allow ${toolName}. Use runtime="subagent".`;
}

View file

@ -414,6 +414,8 @@ export function createOpenClawTools(
config: resolvedConfig,
requesterAgentIdOverride: options?.requesterAgentIdOverride,
workspaceDir: spawnWorkspaceDir,
inheritedToolAllowlist: options?.inheritedToolAllowlist,
inheritedToolDenylist: options?.inheritedToolDenylist,
}),
]),
createSessionsYieldTool({

View file

@ -1,3 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { setPluginToolMeta } from "../../plugins/tools.js";
import { providerAliasCases } from "../test-helpers/provider-alias-cases.js";
@ -46,6 +49,91 @@ describe("applyFinalEffectiveToolPolicy", () => {
expect(filtered.map((tool) => tool.name)).toEqual(["mcp__bundle__fs_read"]);
});
it("filters bundled tools through inherited subagent allowlists", () => {
const agentId = `bundled-inherited-allow-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const sessionKey = `agent:${agentId}:subagent:limited`;
const storePath = path.join(os.tmpdir(), `openclaw-bundled-inherited-allow-${agentId}.json`);
fs.writeFileSync(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "limited-session",
updatedAt: Date.now(),
spawnDepth: 1,
subagentRole: "orchestrator",
subagentControlScope: "children",
inheritedToolAllow: ["mcp__bundle__fs_read"],
},
},
null,
2,
),
"utf-8",
);
const filtered = applyFinalEffectiveToolPolicy({
bundledTools: [makeTool("mcp__bundle__fs_delete"), makeTool("mcp__bundle__fs_read")],
config: {
session: {
store: storePath,
},
},
sessionKey,
warn: () => {},
});
expect(filtered.map((tool) => tool.name)).toEqual(["mcp__bundle__fs_read"]);
});
it("honors configured plugin allow entries alongside inherited bundled tool allows", () => {
const agentId = `bundled-plugin-allow-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const sessionKey = `agent:${agentId}:subagent:limited`;
const storePath = path.join(os.tmpdir(), `openclaw-bundled-plugin-allow-${agentId}.json`);
fs.writeFileSync(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "limited-session",
updatedAt: Date.now(),
spawnDepth: 1,
subagentRole: "orchestrator",
subagentControlScope: "children",
inheritedToolAllow: ["mcp__bundle__fs_read"],
},
},
null,
2,
),
"utf-8",
);
const deniedTool = makeTool("mcp__bundle__fs_delete");
const allowedTool = makeTool("mcp__bundle__fs_read");
setPluginToolMeta(deniedTool, { pluginId: "bundle-mcp", optional: false });
setPluginToolMeta(allowedTool, { pluginId: "bundle-mcp", optional: false });
const filtered = applyFinalEffectiveToolPolicy({
bundledTools: [deniedTool, allowedTool],
config: {
session: {
store: storePath,
},
tools: {
subagents: {
tools: {
allow: ["bundle-mcp"],
},
},
},
},
sessionKey,
warn: () => {},
});
expect(filtered.map((tool) => tool.name)).toEqual(["mcp__bundle__fs_read"]);
});
it("applies channel-normalized per-sender policy to bundled tools", () => {
const filtered = applyFinalEffectiveToolPolicy({
bundledTools: [makeTool("mcp__bundle__exec"), makeTool("mcp__bundle__read")],

View file

@ -3,6 +3,7 @@ import { getPluginToolMeta } from "../../plugins/tools.js";
import {
resolveEffectiveToolPolicy,
resolveGroupToolPolicy,
resolveInheritedToolPolicyForSession,
resolveTrustedGroupId,
resolveSubagentToolPolicyForSession,
} from "../pi-tools.policy.js";
@ -136,6 +137,13 @@ export function applyFinalEffectiveToolPolicy(
store: subagentStore,
})
: undefined;
const inheritedToolPolicy = resolveInheritedToolPolicyForSession(
params.config,
params.sessionKey,
{
store: subagentStore,
},
);
const ownerFiltered = applyOwnerOnlyToolPolicy(
params.bundledTools,
params.senderIsOwner === true,
@ -169,6 +177,7 @@ export function applyFinalEffectiveToolPolicy(
}),
{ policy: params.sandboxToolPolicy, label: "sandbox tools.allow" },
{ policy: subagentPolicy, label: "subagent tools.allow" },
{ policy: inheritedToolPolicy, label: "inherited tools" },
].map((step) => Object.assign({}, step, { suppressUnavailableCoreToolWarning: true }));
return applyToolPolicyPipeline({
tools: ownerFiltered,

View file

@ -125,6 +125,7 @@ import {
import {
resolveEffectiveToolPolicy,
resolveGroupToolPolicy,
resolveInheritedToolPolicyForSession,
resolveSubagentToolPolicyForSession,
} from "../../pi-tools.policy.js";
import { wrapStreamFnTextTransforms } from "../../plugin-text-transforms.js";
@ -763,6 +764,13 @@ function collectAttemptExplicitToolAllowlistSources(params: {
store: subagentStore,
})
: undefined;
const inheritedToolPolicy = resolveInheritedToolPolicyForSession(
params.config,
params.sandboxSessionKey,
{
store: subagentStore,
},
);
return collectExplicitToolAllowlistSources([
{ label: "tools.allow", allow: globalPolicy?.allow },
{ label: "tools.byProvider.allow", allow: globalProviderPolicy?.allow },
@ -777,6 +785,7 @@ function collectAttemptExplicitToolAllowlistSources(params: {
{ label: "group tools.allow", allow: groupPolicy?.allow },
{ label: "sandbox tools.allow", allow: params.sandboxToolPolicy?.allow },
{ label: "subagent tools.allow", allow: subagentPolicy?.allow },
{ label: "inherited tools.allow", allow: inheritedToolPolicy?.allow },
{ label: "runtime toolsAllow", allow: params.toolsAllow, enforceWhenToolsDisabled: true },
]);
}

View file

@ -485,6 +485,56 @@ describe("createOpenClawCodingTools", () => {
expectListIncludes(latestCreateOpenClawToolsOptions().pluginToolDenylist, ["pdf"]);
});
it("passes inherited allowlist entries to OpenClaw plugin discovery", async () => {
const createOpenClawToolsMock = vi.mocked(createOpenClawTools);
createOpenClawToolsMock.mockClear();
const agentId = `inherited-allow-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const storeTemplate = path.join(
os.tmpdir(),
`openclaw-session-store-${agentId}-{agentId}.json`,
);
await writeSessionStore(storeTemplate, agentId, {
[`agent:${agentId}:subagent:limited`]: {
sessionId: "limited-session",
updatedAt: Date.now(),
spawnDepth: 1,
subagentRole: "orchestrator",
subagentControlScope: "children",
inheritedToolAllow: ["custom_plugin_tool", "sessions_spawn"],
},
});
createOpenClawCodingTools({
sessionKey: `agent:${agentId}:subagent:limited`,
config: {
session: {
store: storeTemplate,
},
},
});
expect(createOpenClawToolsMock).toHaveBeenCalledTimes(1);
expectListIncludes(latestCreateOpenClawToolsOptions().pluginToolAllowlist, [
"custom_plugin_tool",
"sessions_spawn",
]);
});
it("passes effective allow-list-restricted tool surface to spawned sessions", () => {
const createOpenClawToolsMock = vi.mocked(createOpenClawTools);
createOpenClawToolsMock.mockClear();
createOpenClawCodingTools({
config: { tools: { allow: ["read", "sessions_spawn"] } },
});
expect(createOpenClawToolsMock).toHaveBeenCalledTimes(1);
const inheritedAllow = latestCreateOpenClawToolsOptions().inheritedToolAllowlist;
expectListIncludes(inheritedAllow, ["read", "sessions_spawn"]);
expect(inheritedAllow?.includes("exec")).toBe(false);
expect(inheritedAllow?.includes("process")).toBe(false);
});
it("records core tool-prep stages for hot-path diagnostics", () => {
const stages: string[] = [];

View file

@ -9,6 +9,7 @@ import {
isToolAllowedByPolicyName,
resolveEffectiveToolPolicy,
resolveGroupToolPolicy,
resolveInheritedToolPolicyForSession,
resolveSubagentToolPolicy,
resolveSubagentToolPolicyForSession,
resolveTrustedGroupId,
@ -370,6 +371,165 @@ describe("resolveSubagentToolPolicy depth awareness", () => {
expect(isToolAllowedByPolicyName("memory_get", policy)).toBe(true);
});
it("resolves inherited tool denies from stored subagent sessions", () => {
const storePath = path.join(
os.tmpdir(),
`openclaw-subagent-inherited-deny-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
);
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.writeFileSync(
storePath,
JSON.stringify(
{
"agent:main:subagent:limited": {
sessionId: "limited-session",
updatedAt: Date.now(),
spawnDepth: 1,
subagentRole: "orchestrator",
subagentControlScope: "children",
inheritedToolDeny: ["bash", "memory_get"],
},
},
null,
2,
),
"utf-8",
);
const cfg = {
...baseCfg,
session: {
store: storePath,
},
} as unknown as OpenClawConfig;
const policy = resolveInheritedToolPolicyForSession(cfg, "agent:main:subagent:limited");
expect(isToolAllowedByPolicyName("exec", policy)).toBe(false);
expect(isToolAllowedByPolicyName("memory_get", policy)).toBe(false);
expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(true);
});
it("resolves inherited tool allows from stored subagent sessions", () => {
const storePath = path.join(
os.tmpdir(),
`openclaw-subagent-inherited-allow-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
);
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.writeFileSync(
storePath,
JSON.stringify(
{
"agent:main:subagent:limited": {
sessionId: "limited-session",
updatedAt: Date.now(),
spawnDepth: 1,
subagentRole: "orchestrator",
subagentControlScope: "children",
inheritedToolAllow: ["sessions_spawn", "memory_search"],
},
},
null,
2,
),
"utf-8",
);
const cfg = {
...baseCfg,
session: {
store: storePath,
},
} as unknown as OpenClawConfig;
const policy = resolveInheritedToolPolicyForSession(cfg, "agent:main:subagent:limited");
expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(true);
expect(isToolAllowedByPolicyName("memory_search", policy)).toBe(true);
expect(isToolAllowedByPolicyName("read", policy)).toBe(false);
expect(isToolAllowedByPolicyName("exec", policy)).toBe(false);
});
it("keeps configured plugin allows separate from inherited tool allows", () => {
const storePath = path.join(
os.tmpdir(),
`openclaw-subagent-inherited-allow-separate-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}.json`,
);
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.writeFileSync(
storePath,
JSON.stringify(
{
"agent:main:subagent:limited": {
sessionId: "limited-session",
updatedAt: Date.now(),
spawnDepth: 1,
subagentRole: "orchestrator",
subagentControlScope: "children",
inheritedToolAllow: ["plugin_tool"],
},
},
null,
2,
),
"utf-8",
);
const cfg = {
...baseCfg,
tools: {
subagents: {
tools: {
allow: ["plugin-id"],
},
},
},
session: {
store: storePath,
},
} as unknown as OpenClawConfig;
const subagentPolicy = resolveSubagentToolPolicyForSession(cfg, "agent:main:subagent:limited");
const inheritedPolicy = resolveInheritedToolPolicyForSession(
cfg,
"agent:main:subagent:limited",
);
expect(subagentPolicy.allow).toEqual(["plugin-id"]);
expect(inheritedPolicy?.allow).toEqual(["plugin_tool"]);
});
it("applies inherited tool policy from stored ACP sessions without subagent metadata", () => {
const storePath = path.join(
os.tmpdir(),
`openclaw-acp-inherited-deny-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
);
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.writeFileSync(
storePath,
JSON.stringify(
{
"agent:main:acp:limited": {
sessionId: "limited-acp-session",
updatedAt: Date.now(),
inheritedToolAllow: ["custom_plugin_tool"],
inheritedToolDeny: ["custom_denied_tool"],
},
},
null,
2,
),
"utf-8",
);
const cfg = {
...baseCfg,
session: {
store: storePath,
},
} as unknown as OpenClawConfig;
const policy = resolveInheritedToolPolicyForSession(cfg, "agent:main:acp:limited");
expect(isToolAllowedByPolicyName("custom_plugin_tool", policy)).toBe(true);
expect(isToolAllowedByPolicyName("custom_denied_tool", policy)).toBe(false);
expect(isToolAllowedByPolicyName("read", policy)).toBe(false);
});
it("defaults to leaf behavior when no depth is provided", () => {
const policy = resolveSubagentToolPolicy(baseCfg);
// Default depth=1, maxSpawnDepth=2 → orchestrator

View file

@ -22,6 +22,8 @@ import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js";
import type { SandboxToolPolicy } from "./sandbox.js";
import {
resolveSubagentCapabilityStore,
resolveStoredSubagentInheritedToolAllowlist,
resolveStoredSubagentInheritedToolDenylist,
resolveStoredSubagentCapabilities,
type SessionCapabilityStore,
type SubagentSessionRole,
@ -84,6 +86,13 @@ function resolveSubagentDenyListForRole(role: SubagentSessionRole): string[] {
return [...SUBAGENT_TOOL_DENY_ALWAYS];
}
function mergeConfiguredSubagentAllow(
allow: string[] | undefined,
alsoAllow: string[] | undefined,
): string[] | undefined {
return allow && alsoAllow ? Array.from(new Set([...allow, ...alsoAllow])) : allow;
}
export function resolveSubagentToolPolicy(cfg?: OpenClawConfig, depth?: number): SandboxToolPolicy {
const configured = cfg?.tools?.subagents?.tools;
const maxSpawnDepth =
@ -99,7 +108,7 @@ export function resolveSubagentToolPolicy(cfg?: OpenClawConfig, depth?: number):
...baseDeny.filter((toolName) => !explicitAllow.has(normalizeToolName(toolName))),
...(Array.isArray(configured?.deny) ? configured.deny : []),
];
const mergedAllow = allow && alsoAllow ? Array.from(new Set([...allow, ...alsoAllow])) : allow;
const mergedAllow = mergeConfiguredSubagentAllow(allow, alsoAllow);
return { allow: mergedAllow, deny };
}
@ -130,10 +139,34 @@ export function resolveSubagentToolPolicyForSession(
),
...(Array.isArray(configured?.deny) ? configured.deny : []),
];
const mergedAllow = allow && alsoAllow ? Array.from(new Set([...allow, ...alsoAllow])) : allow;
const mergedAllow = mergeConfiguredSubagentAllow(allow, alsoAllow);
return { allow: mergedAllow, deny };
}
export function resolveInheritedToolPolicyForSession(
cfg: OpenClawConfig | undefined,
sessionKey: string | undefined | null,
opts?: {
store?: SessionCapabilityStore;
},
): SandboxToolPolicy | undefined {
const inheritedToolAllow = resolveStoredSubagentInheritedToolAllowlist(sessionKey, {
cfg,
store: opts?.store,
});
const inheritedToolDeny = resolveStoredSubagentInheritedToolDenylist(sessionKey, {
cfg,
store: opts?.store,
});
if (inheritedToolAllow.length === 0 && inheritedToolDeny.length === 0) {
return undefined;
}
return {
...(inheritedToolAllow.length > 0 ? { allow: inheritedToolAllow } : {}),
...(inheritedToolDeny.length > 0 ? { deny: inheritedToolDeny } : {}),
};
}
export function filterToolsByPolicy(tools: AnyAgentTool[], policy?: SandboxToolPolicy) {
if (!policy) {
return tools;

View file

@ -38,6 +38,7 @@ import {
isToolAllowedByPolicies,
resolveEffectiveToolPolicy,
resolveGroupToolPolicy,
resolveInheritedToolPolicyForSession,
resolveSubagentToolPolicyForSession,
} from "./pi-tools.policy.js";
import {
@ -77,8 +78,10 @@ import {
applyOwnerOnlyToolPolicy,
collectExplicitAllowlist,
collectExplicitDenylist,
hasRestrictiveAllowPolicy,
mergeAlsoAllowPolicy,
normalizeToolName,
replaceWithEffectiveToolAllowlist,
resolveToolProfilePolicy,
} from "./tool-policy.js";
import {
@ -553,6 +556,13 @@ export function createOpenClawCodingTools(options?: {
store: subagentStore,
})
: undefined;
const inheritedToolPolicy = resolveInheritedToolPolicyForSession(
options?.config,
options?.sessionKey,
{
store: subagentStore,
},
);
const globalPolicyWithToolSearchControls = mergeToolSearchControlAllowlist(globalPolicy);
const globalProviderPolicyWithToolSearchControls =
mergeToolSearchControlAllowlist(globalProviderPolicy);
@ -575,6 +585,7 @@ export function createOpenClawCodingTools(options?: {
senderPolicyWithToolSearchControls,
sandboxToolPolicyWithToolSearchControls,
subagentPolicyWithToolSearchControls,
inheritedToolPolicy,
]);
options?.recordToolPrepStage?.("tool-policy");
const execConfig = resolveExecConfig({ cfg: options?.config, agentId });
@ -745,6 +756,7 @@ export function createOpenClawCodingTools(options?: {
senderPolicy,
sandboxToolPolicy,
subagentPolicy,
inheritedToolPolicy,
options?.runtimeToolAllowlist ? { allow: options.runtimeToolAllowlist } : undefined,
]);
const pluginToolDenylist = collectExplicitDenylist([
@ -758,7 +770,26 @@ export function createOpenClawCodingTools(options?: {
senderPolicy,
sandboxToolPolicy,
subagentPolicy,
inheritedToolPolicy,
]);
const inheritedToolDenylist = [...pluginToolDenylist];
// Passed by reference to sessions_spawn and populated after the final policy
// pass so child sessions inherit the actual parent tool surface.
const inheritedToolAllowlist: string[] = [];
const shouldInheritEffectiveToolAllowlist = [
profilePolicy,
providerProfilePolicy,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
senderPolicy,
sandboxToolPolicy,
subagentPolicy,
inheritedToolPolicy,
options?.runtimeToolAllowlist ? { allow: options.runtimeToolAllowlist } : undefined,
].some(hasRestrictiveAllowPolicy);
const pluginToolsOnly =
includeOpenClawTools || !includePluginTools
? []
@ -885,6 +916,8 @@ export function createOpenClawCodingTools(options?: {
authProfileStore: options?.authProfileStore,
senderIsOwner: options?.senderIsOwner,
sessionId: options?.sessionId,
inheritedToolAllowlist,
inheritedToolDenylist,
onYield: options?.onYield,
allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding,
recordToolPrepStage: options?.recordToolPrepStage,
@ -960,8 +993,12 @@ export function createOpenClawCodingTools(options?: {
}),
{ policy: sandboxToolPolicyWithToolSearchControls, label: "sandbox tools.allow" },
{ policy: subagentPolicyWithToolSearchControls, label: "subagent tools.allow" },
{ policy: inheritedToolPolicy, label: "inherited tools" },
],
});
if (shouldInheritEffectiveToolAllowlist) {
replaceWithEffectiveToolAllowlist(inheritedToolAllowlist, subagentFiltered);
}
options?.recordToolPrepStage?.("authorization-policy");
// Always normalize tool JSON Schemas before handing them to pi-agent/pi-ai.
// Without this, some providers (notably OpenAI) will reject root-level union schemas.

View file

@ -17,6 +17,8 @@ export type SpawnedToolContext = {
agentGroupSpace?: string | null;
agentMemberRoleIds?: string[];
workspaceDir?: string;
inheritedToolAllowlist?: string[];
inheritedToolDenylist?: string[];
};
type NormalizedSpawnedRunMetadata = {

View file

@ -7,6 +7,10 @@ import {
parseAgentSessionKey,
} from "../routing/session-key.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import {
normalizeInheritedToolAllowlist,
normalizeInheritedToolDenylist,
} from "./inherited-tool-deny.js";
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
import { normalizeSubagentSessionKey } from "./subagent-session-key.js";
@ -26,6 +30,8 @@ type SessionCapabilityEntry = {
subagentRole?: unknown;
subagentControlScope?: unknown;
spawnedBy?: unknown;
inheritedToolAllow?: unknown;
inheritedToolDeny?: unknown;
};
export type SessionCapabilityStore = Record<
@ -36,6 +42,8 @@ export type SessionCapabilityStore = Record<
subagentRole?: unknown;
subagentControlScope?: unknown;
spawnedBy?: unknown;
inheritedToolAllow?: unknown;
inheritedToolDeny?: unknown;
}
>;
@ -293,3 +301,43 @@ export function resolveStoredSubagentCapabilities(
canControlChildren: controlScope === "children",
};
}
export function resolveStoredSubagentInheritedToolDenylist(
sessionKey: string | undefined | null,
opts?: {
cfg?: OpenClawConfig;
store?: SessionCapabilityStore;
},
): string[] {
const normalizedSessionKey = normalizeSubagentSessionKey(sessionKey);
if (!normalizedSessionKey || !shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) {
return [];
}
const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts);
const entry = resolveSessionCapabilityEntry({
sessionKey: normalizedSessionKey,
cfg: opts?.cfg,
store,
});
return normalizeInheritedToolDenylist(entry?.inheritedToolDeny);
}
export function resolveStoredSubagentInheritedToolAllowlist(
sessionKey: string | undefined | null,
opts?: {
cfg?: OpenClawConfig;
store?: SessionCapabilityStore;
},
): string[] {
const normalizedSessionKey = normalizeSubagentSessionKey(sessionKey);
if (!normalizedSessionKey || !shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) {
return [];
}
const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts);
const entry = resolveSessionCapabilityEntry({
sessionKey: normalizedSessionKey,
cfg: opts?.cfg,
store,
});
return normalizeInheritedToolAllowlist(entry?.inheritedToolAllow);
}

View file

@ -131,6 +131,30 @@ describe("subagent spawn depth + child limits", () => {
expect(typeof childSession?.spawnedWorkspaceDir).toBe("string");
});
it("persists inherited tool denies on spawned child sessions", async () => {
hoisted.configOverride = createDepthLimitConfig({ maxSpawnDepth: 2 });
const result = await spawnSubagentDirect(
{
task: "hello",
},
{
agentSessionKey: "agent:main:main",
workspaceDir: "/tmp/workspace-main",
inheritedToolAllowlist: ["sessions_spawn", "read", ""],
inheritedToolDenylist: ["bash", "exec", "read", ""],
},
);
const accepted = expectAccepted(result, "run-1");
const childSession = persistedStore?.[accepted.childSessionKey];
if (!childSession) {
throw new Error("Expected persisted child session");
}
expect(childSession.inheritedToolAllow).toEqual(["sessions_spawn", "read"]);
expect(childSession.inheritedToolDeny).toEqual(["exec", "read"]);
});
it("rejects callers when stored spawn depth is already at the configured max", async () => {
hoisted.configOverride = createDepthLimitConfig({ maxSpawnDepth: 2 });
hoisted.depthBySession.set("agent:main:subagent:flat-depth-2", 2);

View file

@ -14,6 +14,12 @@ import { normalizeOptionalString } from "../shared/string-coerce.js";
import type { DeliveryContext } from "../utils/delivery-context.types.js";
import { resolveAgentDir } from "./agent-scope-config.js";
import type { BootstrapContextMode } from "./bootstrap-files.js";
import {
inheritedToolAllowPatch,
inheritedToolDenyPatch,
normalizeInheritedToolAllowlist,
normalizeInheritedToolDenylist,
} from "./inherited-tool-deny.js";
import {
mapToolContextToSpawnedRunMetadata,
normalizeSpawnedRunMetadata,
@ -151,6 +157,8 @@ export type SpawnSubagentContext = {
requesterAgentIdOverride?: string;
/** Explicit workspace directory for subagent to inherit (optional). */
workspaceDir?: string;
inheritedToolAllowlist?: string[];
inheritedToolDenylist?: string[];
};
export type SpawnSubagentResult = {
@ -239,6 +247,14 @@ function buildDirectChildSessionPatch(patch: Record<string, unknown>): Partial<S
if (typeof patch.spawnedWorkspaceDir === "string" && patch.spawnedWorkspaceDir.trim()) {
entry.spawnedWorkspaceDir = patch.spawnedWorkspaceDir.trim();
}
const inheritedToolDeny = normalizeInheritedToolDenylist(patch.inheritedToolDeny);
if (inheritedToolDeny.length > 0) {
entry.inheritedToolDeny = inheritedToolDeny;
}
const inheritedToolAllow = normalizeInheritedToolAllowlist(patch.inheritedToolAllow);
if (inheritedToolAllow.length > 0) {
entry.inheritedToolAllow = inheritedToolAllow;
}
if (typeof patch.thinkingLevel === "string" && patch.thinkingLevel.trim()) {
entry.thinkingLevel = patch.thinkingLevel.trim();
}
@ -899,6 +915,8 @@ export async function spawnSubagentDirect(
spawnDepth: childDepth,
subagentRole: childCapabilities.role === "main" ? null : childCapabilities.role,
subagentControlScope: childCapabilities.controlScope,
...inheritedToolAllowPatch(ctx.inheritedToolAllowlist),
...inheritedToolDenyPatch(ctx.inheritedToolDenylist),
...plan.initialSessionPatch,
};

View file

@ -91,6 +91,36 @@ export type AllowlistResolution = {
export const DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY = "__openclaw_default_plugin_tools__";
export function hasRestrictiveAllowPolicy(policy?: { allow?: string[] }): boolean {
return (
Array.isArray(policy?.allow) &&
policy.allow.some((entry) => {
const normalized = normalizeToolName(entry);
return (
Boolean(normalized) &&
normalized !== "*" &&
normalized !== DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY
);
})
);
}
export function replaceWithEffectiveToolAllowlist(
target: string[],
tools: Array<{ name: string }>,
): void {
target.length = 0;
const seen = new Set<string>();
for (const tool of tools) {
const normalized = normalizeToolName(tool.name);
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
target.push(normalized);
}
}
export function collectExplicitAllowlist(policies: Array<ToolPolicyLike | undefined>): string[] {
const entries: string[] = [];
for (const policy of policies) {

View file

@ -311,6 +311,34 @@ describe("sessions_spawn tool", () => {
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
});
it("passes inherited tool denies to subagent spawns", async () => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
inheritedToolDenylist: ["exec", "read"],
});
await tool.execute("call-inherited-deny", {
task: "build feature",
});
const spawnContext = mockCallArg(hoisted.spawnSubagentDirectMock, 0, 1, "spawnSubagentDirect");
expect(spawnContext.inheritedToolDenylist).toEqual(["exec", "read"]);
});
it("passes inherited tool allow lists to subagent spawns", async () => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
inheritedToolAllowlist: ["sessions_spawn", "read"],
});
await tool.execute("call-inherited-allow", {
task: "build feature",
});
const spawnContext = mockCallArg(hoisted.spawnSubagentDirectMock, 0, 1, "spawnSubagentDirect");
expect(spawnContext.inheritedToolAllowlist).toEqual(["sessions_spawn", "read"]);
});
it("accepts taskName as a stable subagent handle", async () => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
@ -504,6 +532,123 @@ describe("sessions_spawn tool", () => {
expect(hoisted.registerSubagentRunMock).not.toHaveBeenCalled();
});
it("passes inherited tool denies to ACP spawns", async () => {
registerAcpBackendForTest();
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
inheritedToolDenylist: ["custom_control_tool"],
});
await tool.execute("call-acp-inherited-deny", {
runtime: "acp",
task: "investigate",
agentId: "codex",
});
const spawnContext = mockCallArg(hoisted.spawnAcpDirectMock, 0, 1, "spawnAcpDirect");
expect(spawnContext.inheritedToolDenylist).toEqual(["custom_control_tool"]);
});
it("rejects ACP spawns when inherited denies include command tools", async () => {
registerAcpBackendForTest();
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
inheritedToolDenylist: ["exec"],
});
const result = await tool.execute("call-acp-inherited-command-deny", {
runtime: "acp",
task: "investigate",
agentId: "codex",
});
expectDetailFields(result.details, { status: "forbidden", role: "codex" });
expect(JSON.stringify(result.details)).toContain("requester denies exec");
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
});
it("rejects ACP spawns when inherited deny groups or patterns include command tools", async () => {
registerAcpBackendForTest();
const cases = [
{ inheritedToolDenylist: ["group:fs"], expected: "requester denies apply_patch" },
{ inheritedToolDenylist: ["group:runtime"], expected: "requester denies exec" },
{ inheritedToolDenylist: ["exec*"], expected: "requester denies exec" },
{ inheritedToolDenylist: ["*"], expected: "requester denies apply_patch" },
];
for (const testCase of cases) {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
inheritedToolDenylist: testCase.inheritedToolDenylist,
});
const result = await tool.execute("call-acp-inherited-command-group-deny", {
runtime: "acp",
task: "investigate",
agentId: "codex",
});
expectDetailFields(result.details, { status: "forbidden", role: "codex" });
expect(JSON.stringify(result.details)).toContain(testCase.expected);
}
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
});
it("rejects ACP spawns when inherited allows omit command tools", async () => {
registerAcpBackendForTest();
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
inheritedToolAllowlist: ["sessions_spawn", "custom_plugin_tool"],
});
const result = await tool.execute("call-acp-inherited-command-allow", {
runtime: "acp",
task: "investigate",
agentId: "codex",
});
expectDetailFields(result.details, { status: "forbidden", role: "codex" });
expect(JSON.stringify(result.details)).toContain("requester does not allow apply_patch");
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
});
it("accepts ACP spawns when inherited allows include OpenClaw command tools", async () => {
registerAcpBackendForTest();
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
inheritedToolAllowlist: [
"apply_patch",
"edit",
"exec",
"process",
"read",
"sessions_spawn",
"write",
],
});
const result = await tool.execute("call-acp-inherited-command-allow-compatible", {
runtime: "acp",
task: "investigate",
agentId: "codex",
});
expectDetailFields(result.details, {
status: "accepted",
childSessionKey: "agent:codex:acp:1",
});
const spawnContext = mockCallArg(hoisted.spawnAcpDirectMock, 0, 1, "spawnAcpDirect");
expect(spawnContext.inheritedToolAllowlist).toEqual([
"apply_patch",
"edit",
"exec",
"process",
"read",
"sessions_spawn",
"write",
]);
});
it("forwards model override to ACP runtime spawns", async () => {
registerAcpBackendForTest();
const tool = createSessionsSpawnTool({

View file

@ -10,6 +10,12 @@ import { callGateway } from "../../gateway/call.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import { normalizeDeliveryContext } from "../../utils/delivery-context.shared.js";
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
import {
findAcpUnsupportedInheritedToolAllow,
findAcpUnsupportedInheritedToolDeny,
formatAcpInheritedToolAllowError,
formatAcpInheritedToolDenyError,
} from "../inherited-tool-deny.js";
import { optionalStringEnum } from "../schema/typebox.js";
import type { SpawnedToolContext } from "../spawned-context.js";
import { registerSubagentRun } from "../subagent-registry.js";
@ -312,6 +318,28 @@ export function createSessionsSpawnTool(
...roleContext,
});
}
const acpUnsupportedInheritedTool =
runtime === "acp"
? findAcpUnsupportedInheritedToolDeny(opts?.inheritedToolDenylist)
: undefined;
if (acpUnsupportedInheritedTool) {
return jsonResult({
status: "forbidden",
error: formatAcpInheritedToolDenyError(acpUnsupportedInheritedTool),
...roleContext,
});
}
const acpUnsupportedInheritedAllow =
runtime === "acp"
? findAcpUnsupportedInheritedToolAllow(opts?.inheritedToolAllowlist)
: undefined;
if (acpUnsupportedInheritedAllow) {
return jsonResult({
status: "forbidden",
error: formatAcpInheritedToolAllowError(acpUnsupportedInheritedAllow),
...roleContext,
});
}
if (runtime === "acp" && lightContext) {
throw new Error("lightContext is only supported for runtime='subagent'.");
}
@ -374,6 +402,8 @@ export function createSessionsSpawnTool(
agentGroupSpace: opts?.agentGroupSpace,
agentMemberRoleIds: opts?.agentMemberRoleIds,
sandboxed: opts?.sandboxed,
inheritedToolAllowlist: opts?.inheritedToolAllowlist,
inheritedToolDenylist: opts?.inheritedToolDenylist,
},
);
const childSessionKey = result.childSessionKey?.trim();
@ -477,6 +507,8 @@ export function createSessionsSpawnTool(
agentMemberRoleIds: opts?.agentMemberRoleIds,
requesterAgentIdOverride: opts?.requesterAgentIdOverride,
workspaceDir: opts?.workspaceDir,
inheritedToolAllowlist: opts?.inheritedToolAllowlist,
inheritedToolDenylist: opts?.inheritedToolDenylist,
},
);

View file

@ -10,6 +10,7 @@ import {
isToolAllowedByPolicies,
resolveEffectiveToolPolicy,
resolveGroupToolPolicy,
resolveInheritedToolPolicyForSession,
resolveSubagentToolPolicyForSession,
} from "../../agents/pi-tools.policy.js";
import {
@ -760,6 +761,9 @@ export async function dispatchReplyFromConfig(
store: subagentStore,
})
: undefined;
const inheritedToolPolicy = resolveInheritedToolPolicyForSession(cfg, acpDispatchSessionKey, {
store: subagentStore,
});
const messageToolAvailable = isToolAllowedByPolicies("message", [
profilePolicy,
providerProfilePolicy,
@ -769,6 +773,7 @@ export async function dispatchReplyFromConfig(
agentPolicy,
groupPolicy,
subagentPolicy,
inheritedToolPolicy,
]);
const sourceReplyPolicy = resolveSourceReplyVisibilityPolicy({
cfg,

View file

@ -210,6 +210,10 @@ export type SessionEntry = {
subagentRole?: "orchestrator" | "leaf";
/** Explicit control scope assigned at spawn time for subagent control decisions. */
subagentControlScope?: "children" | "none";
/** Session-scoped tool deny entries inherited from the caller that created this session. */
inheritedToolDeny?: string[];
/** Session-scoped tool allow entries inherited from the caller that created this session. */
inheritedToolAllow?: string[];
/** Plugin id that created this session through api.runtime.subagent. */
pluginOwnerId?: string;
systemSent?: boolean;

View file

@ -24,6 +24,7 @@ type ScopedToolsCall = {
messageProvider?: string;
senderIsOwner?: boolean;
surface?: string;
excludeToolNames?: Iterable<string>;
};
type BeforeToolCallHookInput = {
@ -180,6 +181,14 @@ describe("mcp loopback server", () => {
expect(call.messageProvider).toBe("telegram");
expect(call.senderIsOwner).toBe(false);
expect(call.surface).toBe("loopback");
expect(Array.from(call.excludeToolNames ?? [])).toEqual([
"read",
"write",
"edit",
"apply_patch",
"exec",
"process",
]);
});
it("adds empty properties for object schemas that omit properties", async () => {

View file

@ -194,6 +194,8 @@ export const SessionsPatchParamsSchema = Type.Object(
subagentControlScope: Type.Optional(
Type.Union([Type.Literal("children"), Type.Literal("none"), Type.Null()]),
),
inheritedToolAllow: Type.Optional(Type.Union([Type.Array(NonEmptyString), Type.Null()])),
inheritedToolDeny: Type.Optional(Type.Union([Type.Array(NonEmptyString), Type.Null()])),
sendPolicy: Type.Optional(
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
),

View file

@ -486,6 +486,44 @@ describe("gateway sessions patch", () => {
expect(entry.spawnDepth).toBe(2);
});
test("sets inheritedToolDeny for ACP sessions", async () => {
const entry = expectPatchOk(
await runPatch({
storeKey: "agent:main:acp:child",
patch: { key: "agent:main:acp:child", inheritedToolDeny: ["bash", "read", "bash"] },
}),
);
expect(entry.inheritedToolDeny).toEqual(["exec", "read"]);
});
test("sets inheritedToolAllow for ACP sessions", async () => {
const entry = expectPatchOk(
await runPatch({
storeKey: "agent:main:acp:child",
patch: {
key: "agent:main:acp:child",
inheritedToolAllow: ["sessions_spawn", "read", "sessions_spawn"],
},
}),
);
expect(entry.inheritedToolAllow).toEqual(["sessions_spawn", "read"]);
});
test("preserves inheritedToolDeny entries beyond large configured lists", async () => {
const configuredDeny = Array.from({ length: 150 }, (_, index) => `custom_${index}`);
const entry = expectPatchOk(
await runPatch({
storeKey: "agent:main:subagent:child",
patch: {
key: "agent:main:subagent:child",
inheritedToolDeny: [...configuredDeny, "exec"],
},
}),
);
expect(entry.inheritedToolDeny).toHaveLength(151);
expect(entry.inheritedToolDeny?.at(-1)).toBe("exec");
});
test("rejects spawnDepth on non-subagent sessions", async () => {
const result = await runPatch({
patch: { key: MAIN_SESSION_KEY, spawnDepth: 1 },
@ -500,6 +538,20 @@ describe("gateway sessions patch", () => {
expectPatchError(result, "spawnedWorkspaceDir is only supported");
});
test("rejects inheritedToolDeny on non-subagent sessions", async () => {
const result = await runPatch({
patch: { key: MAIN_SESSION_KEY, inheritedToolDeny: ["exec"] },
});
expectPatchError(result, "inheritedToolDeny is only supported");
});
test("rejects inheritedToolAllow on non-subagent sessions", async () => {
const result = await runPatch({
patch: { key: MAIN_SESSION_KEY, inheritedToolAllow: ["read"] },
});
expectPatchError(result, "inheritedToolAllow is only supported");
});
test("normalizes exec/send/group patches", async () => {
const entry = expectPatchOk(
await runPatch({

View file

@ -1,5 +1,9 @@
import { randomUUID } from "node:crypto";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import {
normalizeInheritedToolAllowlist,
normalizeInheritedToolDenylist,
} from "../agents/inherited-tool-deny.js";
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
import {
resolveAllowedModelRef,
@ -228,6 +232,46 @@ export async function applySessionsPatchToStore(params: {
}
}
if ("inheritedToolDeny" in patch) {
const raw = patch.inheritedToolDeny;
if (raw === null) {
delete next.inheritedToolDeny;
} else if (raw !== undefined) {
if (!Array.isArray(raw)) {
return invalid("invalid inheritedToolDeny (use an array of tool names)");
}
if (!supportsSpawnLineage(storeKey)) {
return invalid("inheritedToolDeny is only supported for subagent:* or acp:* sessions");
}
const inheritedToolDeny = normalizeInheritedToolDenylist(raw);
if (inheritedToolDeny.length > 0) {
next.inheritedToolDeny = inheritedToolDeny;
} else {
delete next.inheritedToolDeny;
}
}
}
if ("inheritedToolAllow" in patch) {
const raw = patch.inheritedToolAllow;
if (raw === null) {
delete next.inheritedToolAllow;
} else if (raw !== undefined) {
if (!Array.isArray(raw)) {
return invalid("invalid inheritedToolAllow (use an array of tool names)");
}
if (!supportsSpawnLineage(storeKey)) {
return invalid("inheritedToolAllow is only supported for subagent:* or acp:* sessions");
}
const inheritedToolAllow = normalizeInheritedToolAllowlist(raw);
if (inheritedToolAllow.length > 0) {
next.inheritedToolAllow = inheritedToolAllow;
} else {
delete next.inheritedToolAllow;
}
}
}
if ("label" in patch) {
const raw = patch.label;
if (raw === null) {

View file

@ -3,6 +3,7 @@ import { createOpenClawTools } from "../agents/openclaw-tools.js";
import {
resolveEffectiveToolPolicy,
resolveGroupToolPolicy,
resolveInheritedToolPolicyForSession,
resolveSubagentToolPolicyForSession,
} from "../agents/pi-tools.policy.js";
import {
@ -16,7 +17,9 @@ import {
import {
collectExplicitAllowlist,
collectExplicitDenylist,
hasRestrictiveAllowPolicy,
mergeAlsoAllowPolicy,
replaceWithEffectiveToolAllowlist,
resolveToolProfilePolicy,
} from "../agents/tool-policy.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
@ -81,10 +84,50 @@ export function resolveGatewayScopedTools(params: {
store: subagentStore,
})
: undefined;
const inheritedToolPolicy = resolveInheritedToolPolicyForSession(params.cfg, params.sessionKey, {
store: subagentStore,
});
const excludedToolNames = params.excludeToolNames ? Array.from(params.excludeToolNames) : [];
const surface = params.surface ?? "http";
const gatewayToolsCfg = params.cfg.gateway?.tools;
const defaultGatewayDeny =
surface === "http"
? DEFAULT_GATEWAY_HTTP_TOOL_DENY.filter((name) => !gatewayToolsCfg?.allow?.includes(name))
: [];
const workspaceDir = resolveAgentWorkspaceDir(
params.cfg,
agentId ?? resolveDefaultAgentId(params.cfg),
);
const explicitDenylist = collectExplicitDenylist([
profilePolicy,
providerProfilePolicy,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
subagentPolicy,
inheritedToolPolicy,
defaultGatewayDeny.length > 0 ? { deny: defaultGatewayDeny } : undefined,
Array.isArray(gatewayToolsCfg?.deny) ? { deny: gatewayToolsCfg.deny } : undefined,
excludedToolNames.length > 0 ? { deny: excludedToolNames } : undefined,
]);
const inheritedToolDenylist = [...explicitDenylist];
// Passed by reference to sessions_spawn and populated after the final policy
// pass so child sessions inherit the actual parent tool surface.
const inheritedToolAllowlist: string[] = [];
const shouldInheritEffectiveToolAllowlist = [
profilePolicy,
providerProfilePolicy,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
subagentPolicy,
inheritedToolPolicy,
gatewayRequestedTools.length > 0 ? { allow: gatewayRequestedTools } : undefined,
].some(hasRestrictiveAllowPolicy);
const allTools = createOpenClawTools({
agentSessionKey: params.sessionKey,
@ -108,18 +151,12 @@ export function resolveGatewayScopedTools(params: {
agentProviderPolicy,
groupPolicy,
subagentPolicy,
inheritedToolPolicy,
gatewayRequestedTools.length > 0 ? { allow: gatewayRequestedTools } : undefined,
]),
pluginToolDenylist: collectExplicitDenylist([
profilePolicy,
providerProfilePolicy,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
subagentPolicy,
]),
pluginToolDenylist: explicitDenylist,
inheritedToolAllowlist,
inheritedToolDenylist,
});
const policyFiltered = applyToolPolicyPipeline({
@ -142,23 +179,22 @@ export function resolveGatewayScopedTools(params: {
agentId,
}),
{ policy: subagentPolicy, label: "subagent tools.allow" },
{ policy: inheritedToolPolicy, label: "inherited tools" },
],
});
const surface = params.surface ?? "http";
const gatewayToolsCfg = params.cfg.gateway?.tools;
const defaultGatewayDeny =
surface === "http"
? DEFAULT_GATEWAY_HTTP_TOOL_DENY.filter((name) => !gatewayToolsCfg?.allow?.includes(name))
: [];
const gatewayDenySet = new Set([
...defaultGatewayDeny,
...(Array.isArray(gatewayToolsCfg?.deny) ? gatewayToolsCfg.deny : []),
...(params.excludeToolNames ? Array.from(params.excludeToolNames) : []),
...excludedToolNames,
]);
const tools = policyFiltered.filter((tool) => !gatewayDenySet.has(tool.name));
if (shouldInheritEffectiveToolAllowlist) {
replaceWithEffectiveToolAllowlist(inheritedToolAllowlist, tools);
}
return {
agentId,
tools: policyFiltered.filter((tool) => !gatewayDenySet.has(tool.name)),
tools,
};
}

View file

@ -21,6 +21,8 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
"spawnDepth",
"subagentRole",
"subagentControlScope",
"inheritedToolDeny",
"inheritedToolAllow",
"subagentRecovery",
"pluginOwnerId",
"systemSent",