chore: merge origin/main into main

This commit is contained in:
Peter Steinberger
2026-02-22 13:42:52 +00:00
304 changed files with 17041 additions and 5502 deletions

View File

@@ -11,7 +11,6 @@ import {
} from "./exec-approvals-analysis.js";
import type { ExecAllowlistEntry } from "./exec-approvals.js";
import {
SAFE_BIN_GENERIC_PROFILE,
SAFE_BIN_PROFILES,
type SafeBinProfile,
validateSafeBinArgv,
@@ -41,7 +40,6 @@ export function isSafeBinUsage(params: {
platform?: string | null;
trustedSafeBinDirs?: ReadonlySet<string>;
safeBinProfiles?: Readonly<Record<string, SafeBinProfile>>;
safeBinGenericProfile?: SafeBinProfile;
isTrustedSafeBinPathFn?: typeof isTrustedSafeBinPath;
}): boolean {
// Windows host exec uses PowerShell, which has different parsing/expansion rules.
@@ -75,8 +73,10 @@ export function isSafeBinUsage(params: {
}
const argv = params.argv.slice(1);
const safeBinProfiles = params.safeBinProfiles ?? SAFE_BIN_PROFILES;
const genericSafeBinProfile = params.safeBinGenericProfile ?? SAFE_BIN_GENERIC_PROFILE;
const profile = safeBinProfiles[execName] ?? genericSafeBinProfile;
const profile = safeBinProfiles[execName];
if (!profile) {
return false;
}
return validateSafeBinArgv(argv, profile);
}
@@ -93,6 +93,7 @@ function evaluateSegments(
params: {
allowlist: ExecAllowlistEntry[];
safeBins: Set<string>;
safeBinProfiles?: Readonly<Record<string, SafeBinProfile>>;
cwd?: string;
platform?: string | null;
trustedSafeBinDirs?: ReadonlySet<string>;
@@ -122,6 +123,7 @@ function evaluateSegments(
argv: segment.argv,
resolution: segment.resolution,
safeBins: params.safeBins,
safeBinProfiles: params.safeBinProfiles,
platform: params.platform,
trustedSafeBinDirs: params.trustedSafeBinDirs,
});
@@ -147,6 +149,7 @@ export function evaluateExecAllowlist(params: {
analysis: ExecCommandAnalysis;
allowlist: ExecAllowlistEntry[];
safeBins: Set<string>;
safeBinProfiles?: Readonly<Record<string, SafeBinProfile>>;
cwd?: string;
platform?: string | null;
trustedSafeBinDirs?: ReadonlySet<string>;
@@ -165,6 +168,7 @@ export function evaluateExecAllowlist(params: {
const result = evaluateSegments(chainSegments, {
allowlist: params.allowlist,
safeBins: params.safeBins,
safeBinProfiles: params.safeBinProfiles,
cwd: params.cwd,
platform: params.platform,
trustedSafeBinDirs: params.trustedSafeBinDirs,
@@ -184,6 +188,7 @@ export function evaluateExecAllowlist(params: {
const result = evaluateSegments(params.analysis.segments, {
allowlist: params.allowlist,
safeBins: params.safeBins,
safeBinProfiles: params.safeBinProfiles,
cwd: params.cwd,
platform: params.platform,
trustedSafeBinDirs: params.trustedSafeBinDirs,
@@ -354,6 +359,7 @@ export function evaluateShellAllowlist(params: {
command: string;
allowlist: ExecAllowlistEntry[];
safeBins: Set<string>;
safeBinProfiles?: Readonly<Record<string, SafeBinProfile>>;
cwd?: string;
env?: NodeJS.ProcessEnv;
trustedSafeBinDirs?: ReadonlySet<string>;
@@ -384,6 +390,7 @@ export function evaluateShellAllowlist(params: {
analysis,
allowlist: params.allowlist,
safeBins: params.safeBins,
safeBinProfiles: params.safeBinProfiles,
cwd: params.cwd,
platform: params.platform,
trustedSafeBinDirs: params.trustedSafeBinDirs,
@@ -419,6 +426,7 @@ export function evaluateShellAllowlist(params: {
analysis,
allowlist: params.allowlist,
safeBins: params.safeBins,
safeBinProfiles: params.safeBinProfiles,
cwd: params.cwd,
platform: params.platform,
trustedSafeBinDirs: params.trustedSafeBinDirs,

View File

@@ -29,7 +29,11 @@ import {
type ExecAllowlistEntry,
type ExecApprovalsFile,
} from "./exec-approvals.js";
import { SAFE_BIN_PROFILE_FIXTURES, SAFE_BIN_PROFILES } from "./exec-safe-bin-policy.js";
import {
SAFE_BIN_PROFILE_FIXTURES,
SAFE_BIN_PROFILES,
resolveSafeBinProfiles,
} from "./exec-safe-bin-policy.js";
function makePathEnv(binDir: string): NodeJS.ProcessEnv {
if (process.platform !== "win32") {
@@ -798,6 +802,53 @@ describe("exec approvals safe bins", () => {
expect(defaults.has("grep")).toBe(false);
});
it("does not auto-allow unprofiled safe-bin entries", () => {
if (process.platform === "win32") {
return;
}
const result = evaluateShellAllowlist({
command: "python3 -c \"print('owned')\"",
allowlist: [],
safeBins: normalizeSafeBins(["python3"]),
cwd: "/tmp",
});
expect(result.analysisOk).toBe(true);
expect(result.allowlistSatisfied).toBe(false);
});
it("allows caller-defined custom safe-bin profiles", () => {
if (process.platform === "win32") {
return;
}
const safeBinProfiles = resolveSafeBinProfiles({
echo: {
maxPositional: 1,
},
});
const allow = isSafeBinUsage({
argv: ["echo", "hello"],
resolution: {
rawExecutable: "echo",
resolvedPath: "/bin/echo",
executableName: "echo",
},
safeBins: normalizeSafeBins(["echo"]),
safeBinProfiles,
});
const deny = isSafeBinUsage({
argv: ["echo", "hello", "world"],
resolution: {
rawExecutable: "echo",
resolvedPath: "/bin/echo",
executableName: "echo",
},
safeBins: normalizeSafeBins(["echo"]),
safeBinProfiles,
});
expect(allow).toBe(true);
expect(deny).toBe(false);
});
it("blocks sort output flags independent of file existence", () => {
if (process.platform === "win32") {
return;

View File

@@ -37,6 +37,8 @@ export type SafeBinProfileFixture = {
deniedFlags?: readonly string[];
};
export type SafeBinProfileFixtures = Readonly<Record<string, SafeBinProfileFixture>>;
const NO_FLAGS: ReadonlySet<string> = new Set();
const toFlagSet = (flags?: readonly string[]): ReadonlySet<string> => {
@@ -63,8 +65,6 @@ function compileSafeBinProfiles(
) as Record<string, SafeBinProfile>;
}
export const SAFE_BIN_GENERIC_PROFILE_FIXTURE: SafeBinProfileFixture = {};
export const SAFE_BIN_PROFILE_FIXTURES: Record<string, SafeBinProfileFixture> = {
jq: {
maxPositional: 1,
@@ -184,11 +184,81 @@ export const SAFE_BIN_PROFILE_FIXTURES: Record<string, SafeBinProfileFixture> =
},
};
export const SAFE_BIN_GENERIC_PROFILE = compileSafeBinProfile(SAFE_BIN_GENERIC_PROFILE_FIXTURE);
export const SAFE_BIN_PROFILES: Record<string, SafeBinProfile> =
compileSafeBinProfiles(SAFE_BIN_PROFILE_FIXTURES);
function normalizeSafeBinProfileName(raw: string): string | null {
const name = raw.trim().toLowerCase();
return name.length > 0 ? name : null;
}
function normalizeFixtureLimit(raw: number | undefined): number | undefined {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return undefined;
}
const next = Math.trunc(raw);
return next >= 0 ? next : undefined;
}
function normalizeFixtureFlags(
flags: readonly string[] | undefined,
): readonly string[] | undefined {
if (!Array.isArray(flags) || flags.length === 0) {
return undefined;
}
const normalized = Array.from(
new Set(flags.map((flag) => flag.trim()).filter((flag) => flag.length > 0)),
).toSorted((a, b) => a.localeCompare(b));
return normalized.length > 0 ? normalized : undefined;
}
function normalizeSafeBinProfileFixture(fixture: SafeBinProfileFixture): SafeBinProfileFixture {
const minPositional = normalizeFixtureLimit(fixture.minPositional);
const maxPositionalRaw = normalizeFixtureLimit(fixture.maxPositional);
const maxPositional =
minPositional !== undefined &&
maxPositionalRaw !== undefined &&
maxPositionalRaw < minPositional
? minPositional
: maxPositionalRaw;
return {
minPositional,
maxPositional,
allowedValueFlags: normalizeFixtureFlags(fixture.allowedValueFlags),
deniedFlags: normalizeFixtureFlags(fixture.deniedFlags),
};
}
export function normalizeSafeBinProfileFixtures(
fixtures?: SafeBinProfileFixtures | null,
): Record<string, SafeBinProfileFixture> {
const normalized: Record<string, SafeBinProfileFixture> = {};
if (!fixtures) {
return normalized;
}
for (const [rawName, fixture] of Object.entries(fixtures)) {
const name = normalizeSafeBinProfileName(rawName);
if (!name) {
continue;
}
normalized[name] = normalizeSafeBinProfileFixture(fixture);
}
return normalized;
}
export function resolveSafeBinProfiles(
fixtures?: SafeBinProfileFixtures | null,
): Record<string, SafeBinProfile> {
const normalizedFixtures = normalizeSafeBinProfileFixtures(fixtures);
if (Object.keys(normalizedFixtures).length === 0) {
return SAFE_BIN_PROFILES;
}
return {
...SAFE_BIN_PROFILES,
...compileSafeBinProfiles(normalizedFixtures),
};
}
export function resolveSafeBinDeniedFlags(
fixtures: Readonly<Record<string, SafeBinProfileFixture>> = SAFE_BIN_PROFILE_FIXTURES,
): Record<string, string[]> {

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
isInterpreterLikeSafeBin,
listInterpreterLikeSafeBins,
resolveExecSafeBinRuntimePolicy,
resolveMergedSafeBinProfileFixtures,
} from "./exec-safe-bin-runtime-policy.js";
describe("exec safe-bin runtime policy", () => {
const interpreterCases: Array<{ bin: string; expected: boolean }> = [
{ bin: "python3", expected: true },
{ bin: "python3.12", expected: true },
{ bin: "node", expected: true },
{ bin: "node20", expected: true },
{ bin: "ruby3.2", expected: true },
{ bin: "bash", expected: true },
{ bin: "myfilter", expected: false },
{ bin: "jq", expected: false },
];
for (const testCase of interpreterCases) {
it(`classifies interpreter-like safe bin '${testCase.bin}'`, () => {
expect(isInterpreterLikeSafeBin(testCase.bin)).toBe(testCase.expected);
});
}
it("lists interpreter-like bins from a mixed set", () => {
expect(listInterpreterLikeSafeBins(["jq", "python3", "myfilter", "node"])).toEqual([
"node",
"python3",
]);
});
it("merges and normalizes safe-bin profile fixtures", () => {
const merged = resolveMergedSafeBinProfileFixtures({
global: {
safeBinProfiles: {
" MyFilter ": {
deniedFlags: ["--file", " --file ", ""],
},
},
},
local: {
safeBinProfiles: {
myfilter: {
maxPositional: 0,
},
},
},
});
expect(merged).toEqual({
myfilter: {
maxPositional: 0,
},
});
});
it("computes unprofiled interpreter entries separately from custom profiled bins", () => {
const policy = resolveExecSafeBinRuntimePolicy({
local: {
safeBins: ["python3", "myfilter"],
safeBinProfiles: {
myfilter: { maxPositional: 0 },
},
},
});
expect(policy.safeBins.has("python3")).toBe(true);
expect(policy.safeBins.has("myfilter")).toBe(true);
expect(policy.unprofiledSafeBins).toEqual(["python3"]);
expect(policy.unprofiledInterpreterSafeBins).toEqual(["python3"]);
});
});

View File

@@ -0,0 +1,127 @@
import { resolveSafeBins } from "./exec-approvals-allowlist.js";
import {
normalizeSafeBinProfileFixtures,
resolveSafeBinProfiles,
type SafeBinProfile,
type SafeBinProfileFixture,
type SafeBinProfileFixtures,
} from "./exec-safe-bin-policy.js";
import { getTrustedSafeBinDirs } from "./exec-safe-bin-trust.js";
export type ExecSafeBinConfigScope = {
safeBins?: string[] | null;
safeBinProfiles?: SafeBinProfileFixtures | null;
};
const INTERPRETER_LIKE_SAFE_BINS = new Set([
"ash",
"bash",
"bun",
"cmd",
"cmd.exe",
"cscript",
"dash",
"deno",
"fish",
"ksh",
"lua",
"node",
"nodejs",
"perl",
"php",
"powershell",
"powershell.exe",
"pypy",
"pwsh",
"pwsh.exe",
"python",
"python2",
"python3",
"ruby",
"sh",
"wscript",
"zsh",
]);
const INTERPRETER_LIKE_PATTERNS = [
/^python\d+(?:\.\d+)?$/,
/^ruby\d+(?:\.\d+)?$/,
/^perl\d+(?:\.\d+)?$/,
/^php\d+(?:\.\d+)?$/,
/^node\d+(?:\.\d+)?$/,
];
function normalizeSafeBinName(raw: string): string {
const trimmed = raw.trim().toLowerCase();
if (!trimmed) {
return "";
}
const tail = trimmed.split(/[\\/]/).at(-1);
return tail ?? trimmed;
}
export function isInterpreterLikeSafeBin(raw: string): boolean {
const normalized = normalizeSafeBinName(raw);
if (!normalized) {
return false;
}
if (INTERPRETER_LIKE_SAFE_BINS.has(normalized)) {
return true;
}
return INTERPRETER_LIKE_PATTERNS.some((pattern) => pattern.test(normalized));
}
export function listInterpreterLikeSafeBins(entries: Iterable<string>): string[] {
return Array.from(entries)
.map((entry) => normalizeSafeBinName(entry))
.filter((entry) => entry.length > 0 && isInterpreterLikeSafeBin(entry))
.toSorted();
}
export function resolveMergedSafeBinProfileFixtures(params: {
global?: ExecSafeBinConfigScope | null;
local?: ExecSafeBinConfigScope | null;
}): Record<string, SafeBinProfileFixture> | undefined {
const global = normalizeSafeBinProfileFixtures(params.global?.safeBinProfiles);
const local = normalizeSafeBinProfileFixtures(params.local?.safeBinProfiles);
if (Object.keys(global).length === 0 && Object.keys(local).length === 0) {
return undefined;
}
return {
...global,
...local,
};
}
export function resolveExecSafeBinRuntimePolicy(params: {
global?: ExecSafeBinConfigScope | null;
local?: ExecSafeBinConfigScope | null;
pathEnv?: string | null;
}): {
safeBins: Set<string>;
safeBinProfiles: Readonly<Record<string, SafeBinProfile>>;
trustedSafeBinDirs: ReadonlySet<string>;
unprofiledSafeBins: string[];
unprofiledInterpreterSafeBins: string[];
} {
const safeBins = resolveSafeBins(params.local?.safeBins ?? params.global?.safeBins);
const safeBinProfiles = resolveSafeBinProfiles(
resolveMergedSafeBinProfileFixtures({
global: params.global,
local: params.local,
}),
);
const unprofiledSafeBins = Array.from(safeBins)
.filter((entry) => !safeBinProfiles[entry])
.toSorted();
const trustedSafeBinDirs = params.pathEnv
? getTrustedSafeBinDirs({ pathEnv: params.pathEnv })
: getTrustedSafeBinDirs();
return {
safeBins,
safeBinProfiles,
trustedSafeBinDirs,
unprofiledSafeBins,
unprofiledInterpreterSafeBins: listInterpreterLikeSafeBins(unprofiledSafeBins),
};
}

View File

@@ -11,6 +11,8 @@
"BASH_ENV",
"ENV",
"SHELL",
"SHELLOPTS",
"PS4",
"GCONV_PATH",
"IFS",
"SSLKEYLOGFILE"

View File

@@ -1,9 +1,14 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
isDangerousHostEnvOverrideVarName,
isDangerousHostEnvVarName,
normalizeEnvVarKey,
sanitizeHostExecEnv,
sanitizeSystemRunEnvOverrides,
} from "./host-env-security.js";
describe("isDangerousHostEnvVarName", () => {
@@ -11,6 +16,8 @@ describe("isDangerousHostEnvVarName", () => {
expect(isDangerousHostEnvVarName("BASH_ENV")).toBe(true);
expect(isDangerousHostEnvVarName("bash_env")).toBe(true);
expect(isDangerousHostEnvVarName("SHELL")).toBe(true);
expect(isDangerousHostEnvVarName("SHELLOPTS")).toBe(true);
expect(isDangerousHostEnvVarName("ps4")).toBe(true);
expect(isDangerousHostEnvVarName("DYLD_INSERT_LIBRARIES")).toBe(true);
expect(isDangerousHostEnvVarName("ld_preload")).toBe(true);
expect(isDangerousHostEnvVarName("BASH_FUNC_echo%%")).toBe(true);
@@ -48,17 +55,37 @@ describe("sanitizeHostExecEnv", () => {
HOME: "/tmp/evil-home",
ZDOTDIR: "/tmp/evil-zdotdir",
BASH_ENV: "/tmp/pwn.sh",
SHELLOPTS: "xtrace",
PS4: "$(touch /tmp/pwned)",
SAFE: "ok",
},
});
expect(env.PATH).toBe("/usr/bin:/bin");
expect(env.BASH_ENV).toBeUndefined();
expect(env.SHELLOPTS).toBeUndefined();
expect(env.PS4).toBeUndefined();
expect(env.SAFE).toBe("ok");
expect(env.HOME).toBe("/tmp/trusted-home");
expect(env.ZDOTDIR).toBe("/tmp/trusted-zdotdir");
});
it("drops dangerous inherited shell trace keys", () => {
const env = sanitizeHostExecEnv({
baseEnv: {
PATH: "/usr/bin:/bin",
SHELLOPTS: "xtrace",
PS4: "$(touch /tmp/pwned)",
OK: "1",
},
});
expect(env.PATH).toBe("/usr/bin:/bin");
expect(env.OK).toBe("1");
expect(env.SHELLOPTS).toBeUndefined();
expect(env.PS4).toBeUndefined();
});
it("drops non-portable env key names", () => {
const env = sanitizeHostExecEnv({
baseEnv: {
@@ -94,3 +121,72 @@ describe("normalizeEnvVarKey", () => {
expect(normalizeEnvVarKey(" ")).toBeNull();
});
});
describe("sanitizeSystemRunEnvOverrides", () => {
it("keeps overrides for non-shell commands", () => {
const overrides = sanitizeSystemRunEnvOverrides({
shellWrapper: false,
overrides: {
OPENCLAW_TEST: "1",
TOKEN: "abc",
},
});
expect(overrides).toEqual({
OPENCLAW_TEST: "1",
TOKEN: "abc",
});
});
it("drops non-allowlisted overrides for shell wrappers", () => {
const overrides = sanitizeSystemRunEnvOverrides({
shellWrapper: true,
overrides: {
OPENCLAW_TEST: "1",
TOKEN: "abc",
LANG: "C",
LC_ALL: "C",
},
});
expect(overrides).toEqual({
LANG: "C",
LC_ALL: "C",
});
});
});
describe("shell wrapper exploit regression", () => {
it("blocks SHELLOPTS/PS4 chain after sanitization", async () => {
const bashPath = "/bin/bash";
if (process.platform === "win32" || !fs.existsSync(bashPath)) {
return;
}
const marker = path.join(os.tmpdir(), `openclaw-ps4-marker-${process.pid}-${Date.now()}`);
try {
fs.unlinkSync(marker);
} catch {
// no-op
}
const filteredOverrides = sanitizeSystemRunEnvOverrides({
shellWrapper: true,
overrides: {
SHELLOPTS: "xtrace",
PS4: `$(touch ${marker})`,
},
});
const env = sanitizeHostExecEnv({
overrides: filteredOverrides,
baseEnv: {
PATH: process.env.PATH ?? "/usr/bin:/bin",
},
});
await new Promise<void>((resolve, reject) => {
const child = spawn(bashPath, ["-lc", "echo SAFE"], { env, stdio: "ignore" });
child.once("error", reject);
child.once("close", () => resolve());
});
expect(fs.existsSync(marker)).toBe(false);
});
});

View File

@@ -19,10 +19,23 @@ export const HOST_DANGEROUS_ENV_PREFIXES: readonly string[] = Object.freeze(
export const HOST_DANGEROUS_OVERRIDE_ENV_KEY_VALUES: readonly string[] = Object.freeze(
(HOST_ENV_SECURITY_POLICY.blockedOverrideKeys ?? []).map((key) => key.toUpperCase()),
);
export const HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEY_VALUES: readonly string[] = Object.freeze([
"TERM",
"LANG",
"LC_ALL",
"LC_CTYPE",
"LC_MESSAGES",
"COLORTERM",
"NO_COLOR",
"FORCE_COLOR",
]);
export const HOST_DANGEROUS_ENV_KEYS = new Set<string>(HOST_DANGEROUS_ENV_KEY_VALUES);
export const HOST_DANGEROUS_OVERRIDE_ENV_KEYS = new Set<string>(
HOST_DANGEROUS_OVERRIDE_ENV_KEY_VALUES,
);
export const HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEYS = new Set<string>(
HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEY_VALUES,
);
export function normalizeEnvVarKey(
rawKey: string,
@@ -105,3 +118,31 @@ export function sanitizeHostExecEnv(params?: {
return merged;
}
export function sanitizeSystemRunEnvOverrides(params?: {
overrides?: Record<string, string> | null;
shellWrapper?: boolean;
}): Record<string, string> | undefined {
const overrides = params?.overrides ?? undefined;
if (!overrides) {
return undefined;
}
if (!params?.shellWrapper) {
return overrides;
}
const filtered: Record<string, string> = {};
for (const [rawKey, value] of Object.entries(overrides)) {
if (typeof value !== "string") {
continue;
}
const key = normalizeEnvVarKey(rawKey, { portable: true });
if (!key) {
continue;
}
if (!HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEYS.has(key.toUpperCase())) {
continue;
}
filtered[key] = value;
}
return Object.keys(filtered).length > 0 ? filtered : undefined;
}