Files
openclaw/src/security/audit-exec-surface.test.ts
Peter Steinberger e26fc17c77 fix(security): report canonical agents.entries paths in audit and diagnostics (#113603)
Audit findings, dangerous-flag labels, exec-approval provenance, filesystem
policy drift, and the skill-workshop tool diagnostic printed agents.list.*
paths users cannot paste into openclaw.json: dotted agents.list.<id> is valid
in no shape, and index-keyed agents.list.<n> only names the internal
validation projection. All display surfaces now emit agents.entries.<id>;
the summary agent-id extractor regex follows the new labels. Roster-aware
builders keep an indexed fallback only for id-less malformed legacy rows,
and validation/doctor/legacy-migration paths intentionally keep the legacy
form because they point into the user's actual file. Follow-up deferred
from #113160.
2026-07-25 03:19:07 -07:00

296 lines
8.5 KiB
TypeScript

// Covers exec-surface security audit findings.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { saveExecApprovals } from "../infra/exec-approvals.js";
import { captureEnv } from "../test-utils/env.js";
import { collectSecurityAuditFindings } from "./audit.test-support.js";
import type { SecurityAuditFinding } from "./audit.types.js";
function hasFinding(
checkId:
| "tools.exec.auto_allow_skills_enabled"
| "tools.exec.allowlist_interpreter_without_strict_inline_eval"
| "security.exposure.open_channels_with_exec"
| "tools.exec.security_full_configured"
| "tools.exec.fs_tools_disabled_but_exec_enabled",
severity: "warn" | "critical",
findings: SecurityAuditFinding[],
) {
return findings.some((finding) => finding.checkId === checkId && finding.severity === severity);
}
function requireFinding(
checkId: "tools.exec.fs_tools_disabled_but_exec_enabled",
findings: SecurityAuditFinding[],
) {
const finding = findings.find((entry) => entry.checkId === checkId);
if (!finding) {
throw new Error(`Expected ${checkId} finding`);
}
return finding;
}
describe("security audit exec surface findings", () => {
// Redirect the OpenClaw home (OPENCLAW_HOME wins over HOME/USERPROFILE in
// `resolveRawHomeDir`) to a per-test tempdir so `saveExecApprovals` never
// touches the real `~/.openclaw/exec-approvals.json` on the host running
// the suite.
let envSnapshot: ReturnType<typeof captureEnv> | undefined;
let tempRoot = "";
let tempCaseIndex = 0;
beforeAll(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-exec-approvals-"));
});
beforeEach(async () => {
envSnapshot = captureEnv(["OPENCLAW_HOME", "HOME", "USERPROFILE"]);
const tempDir = path.join(tempRoot, `case-${++tempCaseIndex}`);
await fs.mkdir(path.join(tempDir, ".openclaw"), { recursive: true });
// OPENCLAW_HOME takes precedence over HOME/USERPROFILE in resolveRawHomeDir,
// so all three must point at the tempdir to neutralize whichever the host
// happens to have set.
process.env.OPENCLAW_HOME = tempDir;
process.env.HOME = tempDir;
// Windows uses USERPROFILE for os.homedir()
process.env.USERPROFILE = tempDir;
});
afterEach(() => {
saveExecApprovals({ version: 1, agents: {} });
envSnapshot?.restore();
envSnapshot = undefined;
});
afterAll(async () => {
if (tempRoot) {
await fs.rm(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
}
});
it("warns when exec approvals enable autoAllowSkills", async () => {
saveExecApprovals({
version: 1,
defaults: {
autoAllowSkills: true,
},
agents: {},
});
expect(
hasFinding(
"tools.exec.auto_allow_skills_enabled",
"warn",
await collectSecurityAuditFindings({}),
),
).toBe(true);
});
it("warns when interpreter allowlists are present without strictInlineEval", async () => {
saveExecApprovals({
version: 1,
agents: {
main: {
allowlist: [{ pattern: "/usr/bin/python3" }, { pattern: "/usr/bin/awk" }],
},
ops: {
allowlist: [{ pattern: "/usr/local/bin/node" }, { pattern: "/usr/local/bin/find" }],
},
},
});
expect(
hasFinding(
"tools.exec.allowlist_interpreter_without_strict_inline_eval",
"warn",
await collectSecurityAuditFindings({
agents: {
entries: { ops: {} },
},
} satisfies OpenClawConfig),
),
).toBe(true);
});
it("suppresses interpreter allowlist warnings when strictInlineEval is enabled", async () => {
saveExecApprovals({
version: 1,
agents: {
main: {
allowlist: [{ pattern: "/usr/bin/python3" }, { pattern: "/usr/bin/xargs" }],
},
},
});
expect(
hasFinding(
"tools.exec.allowlist_interpreter_without_strict_inline_eval",
"warn",
await collectSecurityAuditFindings({
tools: {
exec: {
strictInlineEval: true,
},
},
} satisfies OpenClawConfig),
),
).toBe(false);
});
it("honors global strictInlineEval for a named default agent approval scope", async () => {
saveExecApprovals({
version: 1,
agents: {
ops: {
allowlist: [{ pattern: "/usr/bin/python3" }],
},
},
});
expect(
hasFinding(
"tools.exec.allowlist_interpreter_without_strict_inline_eval",
"warn",
await collectSecurityAuditFindings({
agents: {
entries: {
ops: { default: true },
},
},
tools: {
exec: {
strictInlineEval: true,
},
},
} satisfies OpenClawConfig),
),
).toBe(false);
});
it("honors a named default agent strictInlineEval override", async () => {
saveExecApprovals({
version: 1,
agents: {
ops: {
allowlist: [{ pattern: "/usr/bin/python3" }],
},
},
});
expect(
hasFinding(
"tools.exec.allowlist_interpreter_without_strict_inline_eval",
"warn",
await collectSecurityAuditFindings({
agents: {
entries: {
ops: { default: true, tools: { exec: { strictInlineEval: false } } },
},
},
tools: {
exec: {
strictInlineEval: true,
},
},
} satisfies OpenClawConfig),
),
).toBe(true);
});
it("flags open channel access combined with exec-enabled scopes", async () => {
const findings = await collectSecurityAuditFindings({
channels: {
discord: {
groupPolicy: "open",
},
},
tools: {
exec: {
mode: "allowlist",
host: "gateway",
},
},
} satisfies OpenClawConfig);
expect(hasFinding("security.exposure.open_channels_with_exec", "warn", findings)).toBe(true);
});
it("escalates open channel exec exposure when full exec is configured", async () => {
const findings = await collectSecurityAuditFindings({
channels: {
slack: {
dmPolicy: "open",
},
},
tools: {
exec: {
mode: "full",
},
},
} satisfies OpenClawConfig);
expect(hasFinding("tools.exec.security_full_configured", "critical", findings)).toBe(true);
expect(hasFinding("security.exposure.open_channels_with_exec", "critical", findings)).toBe(
true,
);
});
it("warns when filesystem tools are disabled but exec remains available", async () => {
const findings = await collectSecurityAuditFindings({
tools: {
allow: ["read", "exec", "process"],
deny: ["write", "edit", "apply_patch"],
},
} satisfies OpenClawConfig);
const finding = requireFinding("tools.exec.fs_tools_disabled_but_exec_enabled", findings);
expect(finding.severity).toBe("warn");
expect(finding.detail).toContain("tools");
expect(finding.detail).toContain("runtime=[exec, process]");
expect(finding.remediation).toContain("deny exec and process");
});
it("reports canonical agent paths for filesystem policy drift", async () => {
const findings = await collectSecurityAuditFindings({
agents: {
entries: {
ops: {
default: true,
tools: {
allow: ["read", "exec", "process"],
deny: ["write", "edit", "apply_patch"],
},
},
},
},
} satisfies OpenClawConfig);
const finding = requireFinding("tools.exec.fs_tools_disabled_but_exec_enabled", findings);
expect(finding.detail).toContain("agents.entries.ops.tools");
});
it("does not warn when sandbox filesystem policy constrains exec", async () => {
const findings = await collectSecurityAuditFindings({
agents: {
defaults: {
sandbox: {
mode: "all",
workspaceAccess: "ro",
},
},
},
tools: {
allow: ["read", "exec", "process"],
deny: ["write", "edit", "apply_patch"],
},
} satisfies OpenClawConfig);
expect(hasFinding("tools.exec.fs_tools_disabled_but_exec_enabled", "warn", findings)).toBe(
false,
);
});
});