fix(security): harden exec approval boundaries

This commit is contained in:
Peter Steinberger
2026-03-22 09:35:16 -07:00
parent e99d44525a
commit a94ec3b79b
29 changed files with 835 additions and 67 deletions

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { saveExecApprovals } from "../infra/exec-approvals.js";
import { withEnvAsync } from "../test-utils/env.js";
import {
collectInstalledSkillsCodeSafetyFindings,
@@ -167,13 +168,17 @@ function successfulProbeResult(url: string) {
async function audit(
cfg: OpenClawConfig,
extra?: Omit<SecurityAuditOptions, "config">,
extra?: Omit<SecurityAuditOptions, "config"> & { preserveExecApprovals?: boolean },
): Promise<SecurityAuditReport> {
if (!extra?.preserveExecApprovals) {
saveExecApprovals({ version: 1, agents: {} });
}
const { preserveExecApprovals: _preserveExecApprovals, ...options } = extra ?? {};
return runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
...extra,
...options,
});
}
@@ -242,6 +247,7 @@ describe("security audit", () => {
let sharedCodeSafetyWorkspaceDir = "";
let sharedExtensionsStateDir = "";
let sharedInstallMetadataStateDir = "";
let previousOpenClawHome: string | undefined;
const makeTmpDir = async (label: string) => {
const dir = path.join(fixtureRoot, `case-${caseId++}-${label}`);
@@ -323,6 +329,9 @@ description: test skill
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-"));
previousOpenClawHome = process.env.OPENCLAW_HOME;
process.env.OPENCLAW_HOME = path.join(fixtureRoot, "home");
await fs.mkdir(process.env.OPENCLAW_HOME, { recursive: true, mode: 0o700 });
channelSecurityRoot = path.join(fixtureRoot, "channel-security");
await fs.mkdir(channelSecurityRoot, { recursive: true, mode: 0o700 });
sharedChannelSecurityStateDir = path.join(channelSecurityRoot, "state-shared");
@@ -343,6 +352,11 @@ description: test skill
});
afterAll(async () => {
if (previousOpenClawHome === undefined) {
delete process.env.OPENCLAW_HOME;
} else {
process.env.OPENCLAW_HOME = previousOpenClawHome;
}
if (!fixtureRoot) {
return;
}
@@ -732,6 +746,105 @@ description: test skill
);
});
it("warns when exec approvals enable autoAllowSkills", async () => {
saveExecApprovals({
version: 1,
defaults: {
autoAllowSkills: true,
},
agents: {},
});
const res = await audit({}, { preserveExecApprovals: true });
expectFinding(res, "tools.exec.auto_allow_skills_enabled", "warn");
saveExecApprovals({ version: 1, agents: {} });
});
it("warns when interpreter allowlists are present without strictInlineEval", async () => {
saveExecApprovals({
version: 1,
agents: {
main: {
allowlist: [{ pattern: "/usr/bin/python3" }],
},
ops: {
allowlist: [{ pattern: "/usr/local/bin/node" }],
},
},
});
const res = await audit(
{
agents: {
list: [{ id: "ops" }],
},
},
{ preserveExecApprovals: true },
);
expectFinding(res, "tools.exec.allowlist_interpreter_without_strict_inline_eval", "warn");
saveExecApprovals({ version: 1, agents: {} });
});
it("suppresses interpreter allowlist warnings when strictInlineEval is enabled", async () => {
saveExecApprovals({
version: 1,
agents: {
main: {
allowlist: [{ pattern: "/usr/bin/python3" }],
},
},
});
const res = await audit(
{
tools: {
exec: {
strictInlineEval: true,
},
},
},
{ preserveExecApprovals: true },
);
expectNoFinding(res, "tools.exec.allowlist_interpreter_without_strict_inline_eval");
saveExecApprovals({ version: 1, agents: {} });
});
it("flags open channel access combined with exec-enabled scopes", async () => {
const res = await audit({
channels: {
discord: {
groupPolicy: "open",
},
},
tools: {
exec: {
security: "allowlist",
host: "gateway",
},
},
});
expectFinding(res, "security.exposure.open_channels_with_exec", "warn");
});
it("escalates open channel exec exposure when full exec is configured", async () => {
const res = await audit({
channels: {
slack: {
dmPolicy: "open",
},
},
tools: {
exec: {
security: "full",
},
},
});
expectFinding(res, "tools.exec.security_full_configured", "critical");
expectFinding(res, "security.exposure.open_channels_with_exec", "critical");
});
it("evaluates loopback control UI and logging exposure findings", async () => {
const cases: Array<{
name: string;

View File

@@ -11,12 +11,15 @@ import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { type ExecApprovalsFile, loadExecApprovals } from "../infra/exec-approvals.js";
import { isInterpreterLikeAllowlistPattern } from "../infra/exec-inline-eval.js";
import {
listInterpreterLikeSafeBins,
resolveMergedSafeBinProfileFixtures,
} from "../infra/exec-safe-bin-runtime-policy.js";
import { normalizeTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js";
import { isBlockedHostnameOrIp, isPrivateNetworkAllowedByPolicy } from "../infra/net/ssrf.js";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
import {
formatPermissionDetail,
formatPermissionRemediation,
@@ -893,8 +896,10 @@ function collectElevatedFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
const globalExecHost = cfg.tools?.exec?.host;
const globalStrictInlineEval = cfg.tools?.exec?.strictInlineEval === true;
const defaultSandboxMode = resolveSandboxConfigForAgent(cfg).mode;
const defaultHostIsExplicitSandbox = globalExecHost === "sandbox";
const approvals = loadExecApprovals();
if (defaultHostIsExplicitSandbox && defaultSandboxMode === "off") {
findings.push({
@@ -935,6 +940,94 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[]
});
}
const effectiveExecScopes = Array.from(
new Map(
[
{
id: DEFAULT_AGENT_ID,
security: cfg.tools?.exec?.security ?? "deny",
host: cfg.tools?.exec?.host ?? "sandbox",
},
...agents
.filter(
(entry): entry is NonNullable<(typeof agents)[number]> =>
Boolean(entry) && typeof entry === "object" && typeof entry.id === "string",
)
.map((entry) => ({
id: entry.id,
security: entry.tools?.exec?.security ?? cfg.tools?.exec?.security ?? "deny",
host: entry.tools?.exec?.host ?? cfg.tools?.exec?.host ?? "sandbox",
})),
].map((entry) => [entry.id, entry] as const),
).values(),
);
const fullExecScopes = effectiveExecScopes.filter((entry) => entry.security === "full");
const execEnabledScopes = effectiveExecScopes.filter((entry) => entry.security !== "deny");
const openExecSurfacePaths = collectOpenExecSurfacePaths(cfg);
if (fullExecScopes.length > 0) {
findings.push({
checkId: "tools.exec.security_full_configured",
severity: openExecSurfacePaths.length > 0 ? "critical" : "warn",
title: "Exec security=full is configured",
detail:
`Full exec trust is enabled for: ${fullExecScopes.map((entry) => entry.id).join(", ")}.` +
(openExecSurfacePaths.length > 0
? ` Open channel access was also detected at:\n${openExecSurfacePaths.map((entry) => `- ${entry}`).join("\n")}`
: ""),
remediation:
'Prefer tools.exec.security="allowlist" with ask prompts, and reserve "full" for tightly scoped break-glass agents only.',
});
}
if (openExecSurfacePaths.length > 0 && execEnabledScopes.length > 0) {
findings.push({
checkId: "security.exposure.open_channels_with_exec",
severity: fullExecScopes.length > 0 ? "critical" : "warn",
title: "Open channels can reach exec-enabled agents",
detail:
`Open DM/group access detected at:\n${openExecSurfacePaths.map((entry) => `- ${entry}`).join("\n")}\n` +
`Exec-enabled scopes:\n${execEnabledScopes.map((entry) => `- ${entry.id}: security=${entry.security}, host=${entry.host}`).join("\n")}`,
remediation:
"Tighten dmPolicy/groupPolicy to pairing or allowlist, or disable exec for agents reachable from shared/public channels.",
});
}
const autoAllowSkillsHits = collectAutoAllowSkillsHits(approvals);
if (autoAllowSkillsHits.length > 0) {
findings.push({
checkId: "tools.exec.auto_allow_skills_enabled",
severity: "warn",
title: "autoAllowSkills is enabled for exec approvals",
detail:
`Implicit skill-bin allowlisting is enabled at:\n${autoAllowSkillsHits.map((entry) => `- ${entry}`).join("\n")}\n` +
"This widens host exec trust beyond explicit manual allowlist entries.",
remediation:
"Disable autoAllowSkills in exec approvals and keep manual allowlists tight when you need explicit host-exec trust.",
});
}
const interpreterAllowlistHits = collectInterpreterAllowlistHits({
approvals,
strictInlineEvalForAgentId: (agentId) => {
if (!agentId || agentId === "*" || agentId === DEFAULT_AGENT_ID) {
return globalStrictInlineEval;
}
const agent = agents.find((entry) => entry?.id === agentId);
return agent?.tools?.exec?.strictInlineEval === true || globalStrictInlineEval;
},
});
if (interpreterAllowlistHits.length > 0) {
findings.push({
checkId: "tools.exec.allowlist_interpreter_without_strict_inline_eval",
severity: "warn",
title: "Interpreter allowlist entries are missing strictInlineEval hardening",
detail: `Interpreter/runtime allowlist entries were found without strictInlineEval enabled:\n${interpreterAllowlistHits.map((entry) => `- ${entry}`).join("\n")}`,
remediation:
"Set tools.exec.strictInlineEval=true (or per-agent tools.exec.strictInlineEval=true) when allowlisting interpreters like python, node, ruby, perl, php, lua, or osascript.",
});
}
const normalizeConfiguredSafeBins = (entries: unknown): string[] => {
if (!Array.isArray(entries)) {
return [];
@@ -1081,6 +1174,73 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[]
return findings;
}
function collectOpenExecSurfacePaths(cfg: OpenClawConfig): string[] {
const channels = asRecord(cfg.channels);
if (!channels) {
return [];
}
const hits = new Set<string>();
const seen = new WeakSet<object>();
const visit = (value: unknown, scope: string) => {
const record = asRecord(value);
if (!record || seen.has(record)) {
return;
}
seen.add(record);
if (record.groupPolicy === "open") {
hits.add(`${scope}.groupPolicy`);
}
if (record.dmPolicy === "open") {
hits.add(`${scope}.dmPolicy`);
}
for (const [key, nested] of Object.entries(record)) {
if (key === "groups" || key === "accounts" || key === "dms") {
visit(nested, `${scope}.${key}`);
continue;
}
if (asRecord(nested)) {
visit(nested, `${scope}.${key}`);
}
}
};
for (const [channelId, channelValue] of Object.entries(channels)) {
visit(channelValue, `channels.${channelId}`);
}
return Array.from(hits).toSorted();
}
function collectAutoAllowSkillsHits(approvals: ExecApprovalsFile): string[] {
const hits: string[] = [];
if (approvals.defaults?.autoAllowSkills === true) {
hits.push("defaults.autoAllowSkills");
}
for (const [agentId, agent] of Object.entries(approvals.agents ?? {})) {
if (agent?.autoAllowSkills === true) {
hits.push(`agents.${agentId}.autoAllowSkills`);
}
}
return hits;
}
function collectInterpreterAllowlistHits(params: {
approvals: ExecApprovalsFile;
strictInlineEvalForAgentId: (agentId: string | undefined) => boolean;
}): string[] {
const hits: string[] = [];
for (const [agentId, agent] of Object.entries(params.approvals.agents ?? {})) {
if (!agent || params.strictInlineEvalForAgentId(agentId)) {
continue;
}
for (const entry of agent.allowlist ?? []) {
if (!isInterpreterLikeAllowlistPattern(entry.pattern)) {
continue;
}
hits.push(`agents.${agentId}.allowlist: ${entry.pattern}`);
}
}
return hits;
}
async function maybeProbeGateway(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;