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;