Skip to content

Commit 589f1c0

Browse files
hugenshensteipeteNIO
authored
feat(security): audit open ingress control-plane tool exposure (#100965)
* fix(security): audit open control-plane exposure Co-authored-by: NIO <[email protected]> * chore: keep sweep changelog neutral --------- Co-authored-by: Peter Steinberger <[email protected]> Co-authored-by: NIO <[email protected]>
1 parent abd6cbb commit 589f1c0

5 files changed

Lines changed: 191 additions & 47 deletions

File tree

docs/gateway/security/audit-checks.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ exhaustive):
128128
| `security.exposure.open_channels_with_exec` | warn/critical | Shared/public rooms can reach exec-enabled agents | `channels.*.dmPolicy`, `channels.*.groupPolicy`, `tools.exec.*`, `agents.list[].tools.exec.*` | no |
129129
| `security.exposure.open_groups_with_elevated` | critical | Open DMs/groups + elevated tools create high-impact prompt-injection paths | top-level or nested DM policy paths, account overrides, `channels.*.groupPolicy` | no |
130130
| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open DMs/groups can reach command/file tools without sandbox/workspace guards | DM/group policy paths, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no |
131+
| `security.exposure.open_groups_with_control_plane_tools` | critical | Open DMs/groups can reach gateway/cron control-plane tools | DM/group policy paths, `tools.allow`, `tools.alsoAllow`, `tools.profile`, `gateway`, `cron` | no |
131132
| `security.trust_model.multi_user_heuristic` | warn | Config looks multi-user while gateway trust model is personal-assistant | split trust boundaries, or shared-user hardening (`sandbox.mode`, tool deny/workspace scoping) | no |
132133
| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no |
133134
| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no |

src/agents/agent-tools.policy.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,10 @@ export function resolveConfiguredToolPolicies(params: {
155155
}): SandboxToolPolicy[] {
156156
const policies: SandboxToolPolicy[] = [];
157157
const profile = params.agentTools?.profile ?? params.cfg.tools?.profile;
158-
const profilePolicy = resolveToolProfilePolicy(profile);
158+
const profileAlsoAllow =
159+
resolveExplicitProfileAlsoAllow(params.agentTools) ??
160+
resolveExplicitProfileAlsoAllow(params.cfg.tools);
161+
const profilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(profile), profileAlsoAllow);
159162
if (profilePolicy) {
160163
policies.push(profilePolicy);
161164
}

src/security/audit-extra.sync.ts

Lines changed: 57 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,11 @@ import {
55
normalizeStringifiedOptionalString,
66
} from "@openclaw/normalization-core/string-coerce";
77
import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/string-normalization";
8-
import { pickSandboxToolPolicy } from "../agents/sandbox-tool-policy.js";
8+
import { resolveConfiguredToolPolicies } from "../agents/agent-tools.policy.js";
99
import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js";
1010
import { isDangerousNetworkMode, normalizeNetworkMode } from "../agents/sandbox/network-mode.js";
11-
import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js";
12-
import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
1311
import { getBlockedBindReason } from "../agents/sandbox/validate-sandbox-security.js";
1412
import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js";
15-
import { resolveToolProfilePolicy } from "../agents/tool-policy.js";
1613
import { formatCliCommand } from "../cli/command-format.js";
1714
import type { GatewayAuthConfig } from "../config/types.gateway.js";
1815
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -25,6 +22,7 @@ import {
2522
resolveNodeCommandAllowlist,
2623
} from "../gateway/node-command-policy.js";
2724
import { collectAuditModelRefs } from "./audit-model-refs.js";
25+
import { GATEWAY_CONTROL_PLANE_TOOLS } from "./dangerous-tools.js";
2826

2927
/**
3028
* Synchronous security audit collector functions.
@@ -279,36 +277,6 @@ function listKnownNodeCommands(cfg: OpenClawConfig): Set<string> {
279277
return out;
280278
}
281279

282-
function resolveToolPolicies(params: {
283-
cfg: OpenClawConfig;
284-
agentTools?: AgentToolsConfig;
285-
sandboxMode?: "off" | "non-main" | "all";
286-
agentId?: string | null;
287-
}): SandboxToolPolicy[] {
288-
const policies: SandboxToolPolicy[] = [];
289-
const profile = params.agentTools?.profile ?? params.cfg.tools?.profile;
290-
const profilePolicy = resolveToolProfilePolicy(profile);
291-
if (profilePolicy) {
292-
policies.push(profilePolicy);
293-
}
294-
295-
const globalPolicy = pickSandboxToolPolicy(params.cfg.tools ?? undefined);
296-
if (globalPolicy) {
297-
policies.push(globalPolicy);
298-
}
299-
300-
const agentPolicy = pickSandboxToolPolicy(params.agentTools);
301-
if (agentPolicy) {
302-
policies.push(agentPolicy);
303-
}
304-
305-
if (params.sandboxMode === "all") {
306-
policies.push(resolveSandboxToolPolicyForAgent(params.cfg, params.agentId ?? undefined));
307-
}
308-
309-
return policies;
310-
}
311-
312280
function looksLikeNodeCommandPattern(value: string): boolean {
313281
if (!value) {
314282
return false;
@@ -500,15 +468,14 @@ function listPotentialMultiUserSignals(cfg: OpenClawConfig): string[] {
500468
return Array.from(out);
501469
}
502470

503-
function collectRiskyToolExposureContexts(cfg: OpenClawConfig): {
504-
riskyContexts: string[];
505-
hasRuntimeRisk: boolean;
506-
} {
507-
const contexts: Array<{
508-
label: string;
509-
agentId?: string;
510-
tools?: AgentToolsConfig;
511-
}> = [{ label: "agents.defaults" }];
471+
type AuditAgentToolContext = {
472+
label: string;
473+
agentId?: string;
474+
tools?: AgentToolsConfig;
475+
};
476+
477+
function listAuditAgentToolContexts(cfg: OpenClawConfig): AuditAgentToolContext[] {
478+
const contexts: AuditAgentToolContext[] = [{ label: "agents.defaults" }];
512479
for (const agent of cfg.agents?.list ?? []) {
513480
if (!agent || typeof agent !== "object" || typeof agent.id !== "string") {
514481
continue;
@@ -519,12 +486,18 @@ function collectRiskyToolExposureContexts(cfg: OpenClawConfig): {
519486
tools: agent.tools,
520487
});
521488
}
489+
return contexts;
490+
}
522491

492+
function collectRiskyToolExposureContexts(cfg: OpenClawConfig): {
493+
riskyContexts: string[];
494+
hasRuntimeRisk: boolean;
495+
} {
523496
const riskyContexts: string[] = [];
524497
let hasRuntimeRisk = false;
525-
for (const context of contexts) {
498+
for (const context of listAuditAgentToolContexts(cfg)) {
526499
const sandboxMode = resolveSandboxConfigForAgent(cfg, context.agentId).mode;
527-
const policies = resolveToolPolicies({
500+
const policies = resolveConfiguredToolPolicies({
528501
cfg,
529502
agentTools: context.tools,
530503
sandboxMode,
@@ -555,6 +528,30 @@ function collectRiskyToolExposureContexts(cfg: OpenClawConfig): {
555528
return { riskyContexts, hasRuntimeRisk };
556529
}
557530

531+
function collectControlPlaneToolExposureContexts(cfg: OpenClawConfig): string[] {
532+
const exposedContexts: string[] = [];
533+
for (const context of listAuditAgentToolContexts(cfg)) {
534+
const sandboxMode = resolveSandboxConfigForAgent(cfg, context.agentId).mode;
535+
const policies = resolveConfiguredToolPolicies({
536+
cfg,
537+
agentTools: context.tools,
538+
sandboxMode,
539+
agentId: context.agentId ?? null,
540+
});
541+
const controlPlaneTools = GATEWAY_CONTROL_PLANE_TOOLS.filter((tool) =>
542+
isToolAllowedByPolicies(tool, policies),
543+
);
544+
if (controlPlaneTools.length === 0) {
545+
continue;
546+
}
547+
const profile = context.tools?.profile ?? cfg.tools?.profile ?? "none";
548+
exposedContexts.push(
549+
`${context.label} (profile=${profile}; controlPlane=[${controlPlaneTools.join(", ")}])`,
550+
);
551+
}
552+
return exposedContexts;
553+
}
554+
558555
// --------------------------------------------------------------------------
559556
// Exported collectors
560557
// --------------------------------------------------------------------------
@@ -1208,6 +1205,21 @@ export function collectExposureMatrixFindings(cfg: OpenClawConfig): SecurityAudi
12081205
});
12091206
}
12101207

1208+
const controlPlaneContexts = collectControlPlaneToolExposureContexts(cfg);
1209+
if (controlPlaneContexts.length > 0) {
1210+
findings.push({
1211+
checkId: "security.exposure.open_groups_with_control_plane_tools",
1212+
severity: "critical",
1213+
title: "Open group/DM policy with gateway/cron control-plane tools exposed",
1214+
detail:
1215+
`Found inbound policy="open" at:\n${openInboundPolicies.map((p) => `- ${p}`).join("\n")}\n` +
1216+
`Control-plane tool exposure contexts:\n${controlPlaneContexts.map((line) => `- ${line}`).join("\n")}\n` +
1217+
"Prompt injection in open conversations can trigger persistent gateway config changes or scheduled automation.",
1218+
remediation:
1219+
'For open groups or DMs, deny control-plane tools (`gateway`, `cron`) and prefer tools.profile="messaging". Tighten dmPolicy/groupPolicy to pairing or allowlist when possible.',
1220+
});
1221+
}
1222+
12111223
return findings;
12121224
}
12131225

src/security/audit-trust-model.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,124 @@ describe("security audit trust model findings", () => {
231231
).toBe(false);
232232
},
233233
},
234+
{
235+
name: "flags open groupPolicy when coding profile exposes cron",
236+
cfg: {
237+
channels: { whatsapp: { groupPolicy: "open" } },
238+
tools: { elevated: { enabled: false }, profile: "coding" },
239+
} satisfies OpenClawConfig,
240+
assert: (findings: ReturnType<typeof audit>) => {
241+
const finding = findings.find(
242+
(entry) => entry.checkId === "security.exposure.open_groups_with_control_plane_tools",
243+
);
244+
expect(finding?.severity).toBe("critical");
245+
expect(finding?.detail).toContain("channels.whatsapp.groupPolicy");
246+
expect(finding?.detail).toContain("controlPlane=[cron]");
247+
expect(finding?.detail).not.toContain("controlPlane=[gateway, cron]");
248+
},
249+
},
250+
{
251+
name: "flags open dmPolicy when gateway is explicitly allowed",
252+
cfg: {
253+
channels: { slack: { dmPolicy: "open" } },
254+
tools: { elevated: { enabled: false }, allow: ["gateway"] },
255+
} satisfies OpenClawConfig,
256+
assert: (findings: ReturnType<typeof audit>) => {
257+
const finding = findings.find(
258+
(entry) => entry.checkId === "security.exposure.open_groups_with_control_plane_tools",
259+
);
260+
expect(finding?.severity).toBe("critical");
261+
expect(finding?.detail).toContain("channels.slack.dmPolicy");
262+
expect(finding?.detail).toContain("controlPlane=[gateway]");
263+
},
264+
},
265+
{
266+
name: "flags global alsoAllow that widens a restrictive profile",
267+
cfg: {
268+
channels: { slack: { dmPolicy: "open" } },
269+
tools: {
270+
elevated: { enabled: false },
271+
profile: "messaging",
272+
alsoAllow: ["cron"],
273+
},
274+
} satisfies OpenClawConfig,
275+
assert: (findings: ReturnType<typeof audit>) => {
276+
const finding = findings.find(
277+
(entry) => entry.checkId === "security.exposure.open_groups_with_control_plane_tools",
278+
);
279+
expect(finding?.detail).toContain(
280+
"agents.defaults (profile=messaging; controlPlane=[cron])",
281+
);
282+
},
283+
},
284+
{
285+
name: "reports per-agent control-plane exposure",
286+
cfg: {
287+
channels: { whatsapp: { groupPolicy: "open" } },
288+
tools: { elevated: { enabled: false }, profile: "messaging" },
289+
agents: {
290+
list: [{ id: "ops", tools: { profile: "messaging", alsoAllow: ["gateway"] } }],
291+
},
292+
} satisfies OpenClawConfig,
293+
assert: (findings: ReturnType<typeof audit>) => {
294+
const finding = findings.find(
295+
(entry) => entry.checkId === "security.exposure.open_groups_with_control_plane_tools",
296+
);
297+
expect(finding?.detail).toContain(
298+
"agents.list.ops (profile=messaging; controlPlane=[gateway])",
299+
);
300+
expect(finding?.detail).not.toContain("agents.defaults (profile=messaging");
301+
},
302+
},
303+
{
304+
name: "does not flag control-plane exposure when gateway and cron are denied",
305+
cfg: {
306+
channels: { whatsapp: { groupPolicy: "open" } },
307+
tools: {
308+
elevated: { enabled: false },
309+
profile: "coding",
310+
deny: ["gateway", "cron"],
311+
},
312+
} satisfies OpenClawConfig,
313+
assert: (findings: ReturnType<typeof audit>) => {
314+
expect(
315+
findings.some(
316+
(finding) =>
317+
finding.checkId === "security.exposure.open_groups_with_control_plane_tools",
318+
),
319+
).toBe(false);
320+
},
321+
},
322+
{
323+
name: "does not classify other owner-only tools as control-plane exposure",
324+
cfg: {
325+
channels: { whatsapp: { groupPolicy: "open" } },
326+
tools: { elevated: { enabled: false }, allow: ["nodes", "computer"] },
327+
} satisfies OpenClawConfig,
328+
assert: (findings: ReturnType<typeof audit>) => {
329+
expect(
330+
findings.some(
331+
(finding) =>
332+
finding.checkId === "security.exposure.open_groups_with_control_plane_tools",
333+
),
334+
).toBe(false);
335+
},
336+
},
337+
{
338+
name: "does not flag control-plane exposure when inbound policy is not open",
339+
cfg: {
340+
channels: { whatsapp: { groupPolicy: "allowlist" } },
341+
tools: { elevated: { enabled: false }, profile: "coding" },
342+
} satisfies OpenClawConfig,
343+
assert: (findings: ReturnType<typeof audit>) => {
344+
expect(
345+
findings.some(
346+
(finding) =>
347+
finding.checkId === "security.exposure.open_groups_with_control_plane_tools",
348+
),
349+
).toBe(false);
350+
},
351+
},
234352
] as const;
235353

236354
for (const testCase of cases) {

src/security/dangerous-tools.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,19 @@ export const DEFAULT_GATEWAY_HTTP_TOOL_DENY = [
3535
"computer",
3636
] as const;
3737

38+
/**
39+
* Persistent control-plane tools that can change Gateway configuration or
40+
* create scheduled automation.
41+
*/
42+
export const GATEWAY_CONTROL_PLANE_TOOLS = ["cron", "gateway"] as const;
43+
3844
/**
3945
* Core tools that require sender owner identity on Gateway-scoped surfaces.
4046
* `gateway.tools.allow` can remove the default HTTP deny only for owner/trusted-operator
4147
* callers; non-owner identity-bearing callers must not receive server-credential wrappers.
4248
*/
43-
export const GATEWAY_OWNER_ONLY_CORE_TOOLS = ["cron", "gateway", "nodes", "computer"] as const;
49+
export const GATEWAY_OWNER_ONLY_CORE_TOOLS = [
50+
...GATEWAY_CONTROL_PLANE_TOOLS,
51+
"nodes",
52+
"computer",
53+
] as const;

0 commit comments

Comments
 (0)