mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-24 08:21:39 +00:00
fix(security): harden exec approval boundaries
This commit is contained in:
@@ -9,6 +9,10 @@ import {
|
||||
requiresExecApproval,
|
||||
resolveAllowAlwaysPatterns,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import {
|
||||
describeInterpreterInlineEval,
|
||||
detectInterpreterInlineEvalArgv,
|
||||
} from "../infra/exec-inline-eval.js";
|
||||
import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
|
||||
import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js";
|
||||
import { logInfo } from "../logger.js";
|
||||
@@ -48,6 +52,7 @@ export type ProcessGatewayAllowlistParams = {
|
||||
ask: ExecAsk;
|
||||
safeBins: Set<string>;
|
||||
safeBinProfiles: Readonly<Record<string, SafeBinProfile>>;
|
||||
strictInlineEval?: boolean;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
turnSourceChannel?: string;
|
||||
@@ -91,6 +96,21 @@ export async function processGatewayAllowlist(
|
||||
const analysisOk = allowlistEval.analysisOk;
|
||||
const allowlistSatisfied =
|
||||
hostSecurity === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
|
||||
const inlineEvalHit =
|
||||
params.strictInlineEval === true
|
||||
? (allowlistEval.segments
|
||||
.map((segment) =>
|
||||
detectInterpreterInlineEvalArgv(segment.resolution?.effectiveArgv ?? segment.argv),
|
||||
)
|
||||
.find((entry) => entry !== null) ?? null)
|
||||
: null;
|
||||
if (inlineEvalHit) {
|
||||
params.warnings.push(
|
||||
`Warning: strict inline-eval mode requires explicit approval for ${describeInterpreterInlineEval(
|
||||
inlineEvalHit,
|
||||
)}.`,
|
||||
);
|
||||
}
|
||||
let enforcedCommand: string | undefined;
|
||||
if (hostSecurity === "allowlist" && analysisOk && allowlistSatisfied) {
|
||||
const enforced = buildEnforcedShellCommand({
|
||||
@@ -126,6 +146,7 @@ export async function processGatewayAllowlist(
|
||||
);
|
||||
const requiresHeredocApproval =
|
||||
hostSecurity === "allowlist" && analysisOk && allowlistSatisfied && hasHeredocSegment;
|
||||
const requiresInlineEvalApproval = inlineEvalHit !== null;
|
||||
const requiresAsk =
|
||||
requiresExecApproval({
|
||||
ask: hostAsk,
|
||||
@@ -134,6 +155,7 @@ export async function processGatewayAllowlist(
|
||||
allowlistSatisfied,
|
||||
}) ||
|
||||
requiresHeredocApproval ||
|
||||
requiresInlineEvalApproval ||
|
||||
obfuscation.detected;
|
||||
if (requiresHeredocApproval) {
|
||||
params.warnings.push(
|
||||
@@ -226,7 +248,7 @@ export async function processGatewayAllowlist(
|
||||
approvedByAsk = true;
|
||||
} else if (decision === "allow-always") {
|
||||
approvedByAsk = true;
|
||||
if (hostSecurity === "allowlist") {
|
||||
if (hostSecurity === "allowlist" && !requiresInlineEvalApproval) {
|
||||
const patterns = resolveAllowAlwaysPatterns({
|
||||
segments: allowlistEval.segments,
|
||||
cwd: params.workdir,
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
requiresExecApproval,
|
||||
resolveExecApprovalsFromFile,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import {
|
||||
describeInterpreterInlineEval,
|
||||
detectInterpreterInlineEvalArgv,
|
||||
} from "../infra/exec-inline-eval.js";
|
||||
import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
|
||||
import { buildNodeShellCommand } from "../infra/node-shell.js";
|
||||
import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-context.js";
|
||||
@@ -42,6 +46,7 @@ export type ExecuteNodeHostCommandParams = {
|
||||
agentId?: string;
|
||||
security: ExecSecurity;
|
||||
ask: ExecAsk;
|
||||
strictInlineEval?: boolean;
|
||||
timeoutSec?: number;
|
||||
defaultTimeoutSec: number;
|
||||
approvalRunningNoticeMs: number;
|
||||
@@ -129,6 +134,21 @@ export async function executeNodeHostCommand(
|
||||
});
|
||||
let analysisOk = baseAllowlistEval.analysisOk;
|
||||
let allowlistSatisfied = false;
|
||||
const inlineEvalHit =
|
||||
params.strictInlineEval === true
|
||||
? (baseAllowlistEval.segments
|
||||
.map((segment) =>
|
||||
detectInterpreterInlineEvalArgv(segment.resolution?.effectiveArgv ?? segment.argv),
|
||||
)
|
||||
.find((entry) => entry !== null) ?? null)
|
||||
: null;
|
||||
if (inlineEvalHit) {
|
||||
params.warnings.push(
|
||||
`Warning: strict inline-eval mode requires explicit approval for ${describeInterpreterInlineEval(
|
||||
inlineEvalHit,
|
||||
)}.`,
|
||||
);
|
||||
}
|
||||
if (hostAsk === "on-miss" && hostSecurity === "allowlist" && analysisOk) {
|
||||
try {
|
||||
const approvalsSnapshot = await callGatewayTool<{ file: string }>(
|
||||
@@ -176,7 +196,9 @@ export async function executeNodeHostCommand(
|
||||
security: hostSecurity,
|
||||
analysisOk,
|
||||
allowlistSatisfied,
|
||||
}) || obfuscation.detected;
|
||||
}) ||
|
||||
inlineEvalHit !== null ||
|
||||
obfuscation.detected;
|
||||
const invokeTimeoutMs = Math.max(
|
||||
10_000,
|
||||
(typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec) * 1000 +
|
||||
@@ -200,7 +222,10 @@ export async function executeNodeHostCommand(
|
||||
agentId: runAgentId,
|
||||
sessionKey: runSessionKey,
|
||||
approved: approvedByAsk,
|
||||
approvalDecision: approvalDecision ?? undefined,
|
||||
approvalDecision:
|
||||
approvalDecision === "allow-always" && inlineEvalHit !== null
|
||||
? "allow-once"
|
||||
: (approvalDecision ?? undefined),
|
||||
runId: runId ?? undefined,
|
||||
suppressNotifyOnExit: suppressNotifyOnExit === true ? true : undefined,
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ExecToolDefaults = {
|
||||
node?: string;
|
||||
pathPrepend?: string[];
|
||||
safeBins?: string[];
|
||||
strictInlineEval?: boolean;
|
||||
safeBinTrustedDirs?: string[];
|
||||
safeBinProfiles?: Record<string, SafeBinProfileFixture>;
|
||||
agentId?: string;
|
||||
|
||||
@@ -448,6 +448,7 @@ export function createExecTool(
|
||||
agentId,
|
||||
security,
|
||||
ask,
|
||||
strictInlineEval: defaults?.strictInlineEval,
|
||||
timeoutSec: params.timeout,
|
||||
defaultTimeoutSec,
|
||||
approvalRunningNoticeMs,
|
||||
@@ -470,6 +471,7 @@ export function createExecTool(
|
||||
ask,
|
||||
safeBins,
|
||||
safeBinProfiles,
|
||||
strictInlineEval: defaults?.strictInlineEval,
|
||||
agentId,
|
||||
sessionKey: defaults?.sessionKey,
|
||||
turnSourceChannel: defaults?.messageProvider,
|
||||
|
||||
@@ -143,6 +143,7 @@ function resolveExecConfig(params: { cfg?: OpenClawConfig; agentId?: string }) {
|
||||
node: agentExec?.node ?? globalExec?.node,
|
||||
pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend,
|
||||
safeBins: agentExec?.safeBins ?? globalExec?.safeBins,
|
||||
strictInlineEval: agentExec?.strictInlineEval ?? globalExec?.strictInlineEval,
|
||||
safeBinTrustedDirs: agentExec?.safeBinTrustedDirs ?? globalExec?.safeBinTrustedDirs,
|
||||
safeBinProfiles: resolveMergedSafeBinProfileFixtures({
|
||||
global: globalExec,
|
||||
@@ -420,6 +421,7 @@ export function createOpenClawCodingTools(options?: {
|
||||
node: options?.exec?.node ?? execConfig.node,
|
||||
pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend,
|
||||
safeBins: options?.exec?.safeBins ?? execConfig.safeBins,
|
||||
strictInlineEval: options?.exec?.strictInlineEval ?? execConfig.strictInlineEval,
|
||||
safeBinTrustedDirs: options?.exec?.safeBinTrustedDirs ?? execConfig.safeBinTrustedDirs,
|
||||
safeBinProfiles: options?.exec?.safeBinProfiles ?? execConfig.safeBinProfiles,
|
||||
agentId,
|
||||
|
||||
@@ -476,6 +476,7 @@ const TOOLS_HOOKS_TARGET_KEYS = [
|
||||
"tools.alsoAllow",
|
||||
"tools.byProvider",
|
||||
"tools.exec.approvalRunningNoticeMs",
|
||||
"tools.exec.strictInlineEval",
|
||||
"tools.links.enabled",
|
||||
"tools.links.maxLinks",
|
||||
"tools.links.models",
|
||||
|
||||
@@ -563,6 +563,8 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).",
|
||||
"tools.exec.safeBins":
|
||||
"Allow stdin-only safe binaries to run without explicit allowlist entries.",
|
||||
"tools.exec.strictInlineEval":
|
||||
"Require explicit approval for interpreter inline-eval forms such as `python -c`, `node -e`, `ruby -e`, or `osascript -e`. Prevents silent allowlist reuse and downgrades allow-always to ask-each-time for those forms.",
|
||||
"tools.exec.safeBinTrustedDirs":
|
||||
"Additional explicit directories trusted for safe-bin path checks (PATH entries are never auto-trusted).",
|
||||
"tools.exec.safeBinProfiles":
|
||||
|
||||
@@ -197,6 +197,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"tools.sandbox.tools": "Sandbox Tool Allow/Deny Policy",
|
||||
"tools.exec.pathPrepend": "Exec PATH Prepend",
|
||||
"tools.exec.safeBins": "Exec Safe Bins",
|
||||
"tools.exec.strictInlineEval": "Require Inline-Eval Approval",
|
||||
"tools.exec.safeBinTrustedDirs": "Exec Safe Bin Trusted Dirs",
|
||||
"tools.exec.safeBinProfiles": "Exec Safe Bin Profiles",
|
||||
approvals: "Approvals",
|
||||
|
||||
@@ -238,6 +238,11 @@ export type ExecToolConfig = {
|
||||
pathPrepend?: string[];
|
||||
/** Safe stdin-only binaries that can run without allowlist entries. */
|
||||
safeBins?: string[];
|
||||
/**
|
||||
* Require explicit approval for interpreter inline-eval forms (`python -c`, `node -e`, etc.).
|
||||
* Prevents silent allowlist reuse and allow-always persistence for those forms.
|
||||
*/
|
||||
strictInlineEval?: boolean;
|
||||
/** Extra explicit directories trusted for safeBins path checks (never derived from PATH). */
|
||||
safeBinTrustedDirs?: string[];
|
||||
/** Optional custom safe-bin profiles for entries in tools.exec.safeBins. */
|
||||
|
||||
@@ -423,6 +423,7 @@ const ToolExecBaseShape = {
|
||||
node: z.string().optional(),
|
||||
pathPrepend: z.array(z.string()).optional(),
|
||||
safeBins: z.array(z.string()).optional(),
|
||||
strictInlineEval: z.boolean().optional(),
|
||||
safeBinTrustedDirs: z.array(z.string()).optional(),
|
||||
safeBinProfiles: z.record(z.string(), ToolExecSafeBinProfileSchema).optional(),
|
||||
backgroundMs: z.number().int().positive().optional(),
|
||||
|
||||
33
src/infra/exec-inline-eval.test.ts
Normal file
33
src/infra/exec-inline-eval.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
describeInterpreterInlineEval,
|
||||
detectInterpreterInlineEvalArgv,
|
||||
isInterpreterLikeAllowlistPattern,
|
||||
} from "./exec-inline-eval.js";
|
||||
|
||||
describe("exec inline eval detection", () => {
|
||||
it("detects common interpreter eval flags", () => {
|
||||
const cases = [
|
||||
{ argv: ["python3", "-c", "print('hi')"], expected: "python3 -c" },
|
||||
{ argv: ["/usr/bin/node", "--eval", "console.log('hi')"], expected: "node --eval" },
|
||||
{ argv: ["perl", "-E", "say 1"], expected: "perl -e" },
|
||||
{ argv: ["osascript", "-e", "beep"], expected: "osascript -e" },
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const hit = detectInterpreterInlineEvalArgv(testCase.argv);
|
||||
expect(hit).not.toBeNull();
|
||||
expect(describeInterpreterInlineEval(hit!)).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores normal script execution", () => {
|
||||
expect(detectInterpreterInlineEvalArgv(["python3", "script.py"])).toBeNull();
|
||||
expect(detectInterpreterInlineEvalArgv(["node", "script.js"])).toBeNull();
|
||||
});
|
||||
|
||||
it("matches interpreter-like allowlist patterns", () => {
|
||||
expect(isInterpreterLikeAllowlistPattern("/usr/bin/python3")).toBe(true);
|
||||
expect(isInterpreterLikeAllowlistPattern("**/node")).toBe(true);
|
||||
expect(isInterpreterLikeAllowlistPattern("/usr/bin/rg")).toBe(false);
|
||||
});
|
||||
});
|
||||
103
src/infra/exec-inline-eval.ts
Normal file
103
src/infra/exec-inline-eval.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { normalizeExecutableToken } from "./exec-wrapper-resolution.js";
|
||||
|
||||
export type InterpreterInlineEvalHit = {
|
||||
executable: string;
|
||||
normalizedExecutable: string;
|
||||
flag: string;
|
||||
argv: string[];
|
||||
};
|
||||
|
||||
type InterpreterFlagSpec = {
|
||||
names: readonly string[];
|
||||
exactFlags: ReadonlySet<string>;
|
||||
prefixFlags?: readonly string[];
|
||||
};
|
||||
|
||||
const INTERPRETER_INLINE_EVAL_SPECS: readonly InterpreterFlagSpec[] = [
|
||||
{ names: ["python", "python2", "python3", "pypy", "pypy3"], exactFlags: new Set(["-c"]) },
|
||||
{
|
||||
names: ["node", "nodejs", "bun", "deno"],
|
||||
exactFlags: new Set(["-e", "--eval", "-p", "--print"]),
|
||||
},
|
||||
{ names: ["ruby"], exactFlags: new Set(["-e"]) },
|
||||
{ names: ["perl"], exactFlags: new Set(["-e", "-E"]) },
|
||||
{ names: ["php"], exactFlags: new Set(["-r"]) },
|
||||
{ names: ["lua"], exactFlags: new Set(["-e"]) },
|
||||
{ names: ["osascript"], exactFlags: new Set(["-e"]) },
|
||||
];
|
||||
|
||||
const INTERPRETER_INLINE_EVAL_NAMES = new Set(
|
||||
INTERPRETER_INLINE_EVAL_SPECS.flatMap((entry) => entry.names),
|
||||
);
|
||||
|
||||
function findInterpreterSpec(executable: string): InterpreterFlagSpec | null {
|
||||
const normalized = normalizeExecutableToken(executable);
|
||||
for (const spec of INTERPRETER_INLINE_EVAL_SPECS) {
|
||||
if (spec.names.includes(normalized)) {
|
||||
return spec;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function detectInterpreterInlineEvalArgv(
|
||||
argv: string[] | undefined | null,
|
||||
): InterpreterInlineEvalHit | null {
|
||||
if (!Array.isArray(argv) || argv.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const executable = argv[0]?.trim();
|
||||
if (!executable) {
|
||||
return null;
|
||||
}
|
||||
const spec = findInterpreterSpec(executable);
|
||||
if (!spec) {
|
||||
return null;
|
||||
}
|
||||
for (let idx = 1; idx < argv.length; idx += 1) {
|
||||
const token = argv[idx]?.trim();
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
if (token === "--") {
|
||||
break;
|
||||
}
|
||||
const lower = token.toLowerCase();
|
||||
if (spec.exactFlags.has(lower)) {
|
||||
return {
|
||||
executable,
|
||||
normalizedExecutable: normalizeExecutableToken(executable),
|
||||
flag: lower,
|
||||
argv,
|
||||
};
|
||||
}
|
||||
if (spec.prefixFlags?.some((prefix) => lower.startsWith(prefix))) {
|
||||
return {
|
||||
executable,
|
||||
normalizedExecutable: normalizeExecutableToken(executable),
|
||||
flag: lower,
|
||||
argv,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function describeInterpreterInlineEval(hit: InterpreterInlineEvalHit): string {
|
||||
return `${hit.normalizedExecutable} ${hit.flag}`;
|
||||
}
|
||||
|
||||
export function isInterpreterLikeAllowlistPattern(pattern: string | undefined | null): boolean {
|
||||
const trimmed = pattern?.trim().toLowerCase() ?? "";
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeExecutableToken(trimmed);
|
||||
if (INTERPRETER_INLINE_EVAL_NAMES.has(normalized)) {
|
||||
return true;
|
||||
}
|
||||
const basename = trimmed.replace(/\\/g, "/").split("/").pop() ?? trimmed;
|
||||
const withoutExe = basename.endsWith(".exe") ? basename.slice(0, -4) : basename;
|
||||
const strippedWildcards = withoutExe.replace(/[*?[\]{}()]/g, "");
|
||||
return INTERPRETER_INLINE_EVAL_NAMES.has(strippedWildcards);
|
||||
}
|
||||
@@ -2,8 +2,9 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, type Mock, vi } from "vitest";
|
||||
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
|
||||
import type { SystemRunApprovalPlan } from "../infra/exec-approvals.js";
|
||||
import { saveExecApprovals } from "../infra/exec-approvals.js";
|
||||
import { loadExecApprovals, saveExecApprovals } from "../infra/exec-approvals.js";
|
||||
import type { ExecHostResponse } from "../infra/exec-host.js";
|
||||
import { buildSystemRunApprovalPlan } from "./invoke-system-run-plan.js";
|
||||
import { handleSystemRunInvoke, formatSystemRunAllowlistMissMessage } from "./invoke-system-run.js";
|
||||
@@ -1229,4 +1230,65 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
||||
errorLabel: "runCommand should not be called for nested env depth overflow",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires explicit approval for inline eval when strictInlineEval is enabled", async () => {
|
||||
setRuntimeConfigSnapshot({
|
||||
tools: {
|
||||
exec: {
|
||||
strictInlineEval: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const { runCommand, sendInvokeResult, sendNodeEvent } = await runSystemInvoke({
|
||||
preferMacAppExecHost: false,
|
||||
command: ["python3", "-c", "print('hi')"],
|
||||
security: "full",
|
||||
ask: "off",
|
||||
});
|
||||
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expect(sendNodeEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"exec.denied",
|
||||
expect.objectContaining({ reason: "approval-required" }),
|
||||
);
|
||||
expectInvokeErrorMessage(sendInvokeResult, {
|
||||
message: "python3 -c requires explicit approval in strictInlineEval mode",
|
||||
});
|
||||
} finally {
|
||||
clearRuntimeConfigSnapshot();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not persist allow-always interpreter approvals when strictInlineEval is enabled", async () => {
|
||||
setRuntimeConfigSnapshot({
|
||||
tools: {
|
||||
exec: {
|
||||
strictInlineEval: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
await withTempApprovalsHome({
|
||||
approvals: createAllowlistOnMissApprovals(),
|
||||
run: async () => {
|
||||
const { runCommand, sendInvokeResult } = await runSystemInvoke({
|
||||
preferMacAppExecHost: false,
|
||||
command: ["python3", "-c", "print('hi')"],
|
||||
security: "allowlist",
|
||||
ask: "on-miss",
|
||||
approved: true,
|
||||
runCommand: vi.fn(async () => createLocalRunResult("inline-eval-ok")),
|
||||
});
|
||||
|
||||
expect(runCommand).toHaveBeenCalledTimes(1);
|
||||
expectInvokeOk(sendInvokeResult, { payloadContains: "inline-eval-ok" });
|
||||
expect(loadExecApprovals().agents?.main?.allowlist ?? []).toEqual([]);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
clearRuntimeConfigSnapshot();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
type ExecSecurity,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js";
|
||||
import {
|
||||
describeInterpreterInlineEval,
|
||||
detectInterpreterInlineEvalArgv,
|
||||
} from "../infra/exec-inline-eval.js";
|
||||
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
|
||||
import {
|
||||
inspectHostExecEnvOverrides,
|
||||
@@ -91,6 +95,7 @@ type SystemRunPolicyPhase = SystemRunParsePhase & {
|
||||
approvals: ResolvedExecApprovals;
|
||||
security: ExecSecurity;
|
||||
policy: ReturnType<typeof evaluateSystemRunPolicy>;
|
||||
inlineEvalHit: ReturnType<typeof detectInterpreterInlineEvalArgv>;
|
||||
allowlistMatches: ExecAllowlistEntry[];
|
||||
analysisOk: boolean;
|
||||
allowlistSatisfied: boolean;
|
||||
@@ -338,6 +343,15 @@ async function evaluateSystemRunPolicyPhase(
|
||||
skillBins: bins,
|
||||
autoAllowSkills,
|
||||
});
|
||||
const strictInlineEval =
|
||||
agentExec?.strictInlineEval === true || cfg.tools?.exec?.strictInlineEval === true;
|
||||
const inlineEvalHit = strictInlineEval
|
||||
? (segments
|
||||
.map((segment) =>
|
||||
detectInterpreterInlineEvalArgv(segment.resolution?.effectiveArgv ?? segment.argv),
|
||||
)
|
||||
.find((entry) => entry !== null) ?? null)
|
||||
: null;
|
||||
const isWindows = process.platform === "win32";
|
||||
const cmdInvocation = parsed.shellPayload
|
||||
? opts.isCmdExeInvocation(segments[0]?.argv ?? [])
|
||||
@@ -363,6 +377,16 @@ async function evaluateSystemRunPolicyPhase(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (inlineEvalHit && !policy.approvedByAsk) {
|
||||
await sendSystemRunDenied(opts, parsed.execution, {
|
||||
reason: "approval-required",
|
||||
message:
|
||||
`SYSTEM_RUN_DENIED: approval required (` +
|
||||
`${describeInterpreterInlineEval(inlineEvalHit)} requires explicit approval in strictInlineEval mode)`,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fail closed if policy/runtime drift re-allows unapproved shell wrappers.
|
||||
if (security === "allowlist" && parsed.shellPayload && !policy.approvedByAsk) {
|
||||
await sendSystemRunDenied(opts, parsed.execution, {
|
||||
@@ -414,6 +438,7 @@ async function evaluateSystemRunPolicyPhase(
|
||||
approvals,
|
||||
security,
|
||||
policy,
|
||||
inlineEvalHit,
|
||||
allowlistMatches,
|
||||
analysisOk,
|
||||
allowlistSatisfied,
|
||||
@@ -518,7 +543,11 @@ async function executeSystemRunPhase(
|
||||
}
|
||||
}
|
||||
|
||||
if (phase.policy.approvalDecision === "allow-always" && phase.security === "allowlist") {
|
||||
if (
|
||||
phase.policy.approvalDecision === "allow-always" &&
|
||||
phase.security === "allowlist" &&
|
||||
phase.inlineEvalHit === null
|
||||
) {
|
||||
if (phase.policy.analysisOk) {
|
||||
const patterns = resolveAllowAlwaysPatterns({
|
||||
segments: phase.segments,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user