fix(security): harden allow-always wrapper persistence

This commit is contained in:
Peter Steinberger
2026-02-22 22:54:21 +01:00
parent 4adfe80027
commit 24c954d972
8 changed files with 387 additions and 11 deletions

View File

@@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
- Plugins/Media sandbox: propagate trusted `mediaLocalRoots` through plugin action dispatch (including Discord/Telegram action adapters) so plugin send paths enforce the same agent-scoped local-media sandbox roots as core outbound sends. (#20258, #22718)
- Agents/Workspace guard: map sandbox container-workdir file-tool paths (for example `/workspace/...` and `file:///workspace/...`) to host workspace roots before workspace-only validation, preventing false `Path escapes sandbox root` rejections for sandbox file tools. (#9560)
- Gateway/Exec approvals: expire approval requests immediately when no approval-capable gateway clients are connected and no forwarding targets are available, avoiding delayed approvals after restarts/offline approver windows. (#22144)
- Security/Exec approvals: when approving wrapper commands with allow-always in allowlist mode, persist inner executable paths for known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) and fail closed (no persisted entry) when wrapper unwrapping is not safe, preventing wrapper-path approval bypasses.
- Node/macOS exec host: default headless macOS node `system.run` to local execution and only route through the companion app when `OPENCLAW_NODE_EXEC_HOST=app` is explicitly set, avoiding companion-app filesystem namespace mismatches during exec. (#23547)
- Security/Exec: stop trusting `PATH`-derived directories for safe-bin allowlist checks, add explicit `tools.exec.safeBinTrustedDirs`, and pin safe-bin shell execution to resolved absolute executable paths to prevent binary-shadowing approval bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
- Slack/Threading: sessions: keep parent-session forking and thread-history context active beyond first turn by removing first-turn-only gates in session init, thread-history fetch, and reply prompt context injection. (#23843, #23090) Thanks @vincentkoc and @Taskle.

View File

@@ -279,6 +279,7 @@ Notes:
- `system.notify` respects notification permission state on the macOS app.
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped `--env` values are reduced to an explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.
- On Windows node hosts in allowlist mode, shell-wrapper runs via `cmd.exe /c` require approval (allowlist entry alone does not auto-allow the wrapper form).
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
- Node hosts ignore `PATH` overrides and strip dangerous startup/shell keys (`DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`). If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`.

View File

@@ -107,6 +107,7 @@ Notes:
- Choosing “Always Allow” in the prompt adds that command to the allowlist.
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`) and then merged with the apps environment.
- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped environment overrides are reduced to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.
## Deep links

View File

@@ -161,6 +161,9 @@ On macOS companion-app approvals, raw shell text containing shell control or exp
the shell binary itself is allowlisted.
For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped env overrides are reduced to a
small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
For allow-always decisions in allowlist mode, known dispatch wrappers
(`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper
paths. If a wrapper cannot be safely unwrapped, no allowlist entry is persisted automatically.
Default safe bins: `jq`, `cut`, `uniq`, `head`, `tail`, `tr`, `wc`.

View File

@@ -4,6 +4,7 @@ import {
isWindowsPlatform,
matchAllowlist,
resolveAllowlistCandidatePath,
resolveCommandResolutionFromArgv,
splitCommandChain,
type ExecCommandAnalysis,
type CommandResolution,
@@ -16,6 +17,11 @@ import {
validateSafeBinArgv,
} from "./exec-safe-bin-policy.js";
import { isTrustedSafeBinPath } from "./exec-safe-bin-trust.js";
import {
DISPATCH_WRAPPER_EXECUTABLES,
basenameLower,
unwrapKnownDispatchWrapperInvocation,
} from "./exec-wrapper-resolution.js";
function hasShellLineContinuation(command: string): boolean {
return /\\(?:\r\n|\n|\r)/.test(command);
@@ -255,6 +261,27 @@ function isShellWrapperSegment(segment: ExecCommandSegment): boolean {
return false;
}
function isDispatchWrapperSegment(segment: ExecCommandSegment): boolean {
const candidates = [
normalizeExecutableName(segment.resolution?.executableName),
normalizeExecutableName(segment.resolution?.rawExecutable),
normalizeExecutableName(segment.argv[0]),
];
for (const candidate of candidates) {
if (!candidate) {
continue;
}
if (DISPATCH_WRAPPER_EXECUTABLES.has(candidate)) {
return true;
}
const base = basenameLower(candidate);
if (DISPATCH_WRAPPER_EXECUTABLES.has(base)) {
return true;
}
}
return false;
}
function extractShellInlineCommand(argv: string[]): string | null {
for (let i = 1; i < argv.length; i += 1) {
const token = argv[i];
@@ -296,6 +323,30 @@ function collectAllowAlwaysPatterns(params: {
depth: number;
out: Set<string>;
}) {
if (params.depth >= 3) {
return;
}
if (isDispatchWrapperSegment(params.segment)) {
const unwrappedArgv = unwrapKnownDispatchWrapperInvocation(params.segment.argv);
if (!unwrappedArgv || unwrappedArgv.length === 0) {
return;
}
collectAllowAlwaysPatterns({
segment: {
raw: unwrappedArgv.join(" "),
argv: unwrappedArgv,
resolution: resolveCommandResolutionFromArgv(unwrappedArgv, params.cwd, params.env),
},
cwd: params.cwd,
env: params.env,
platform: params.platform,
depth: params.depth + 1,
out: params.out,
});
return;
}
const candidatePath = resolveAllowlistCandidatePath(params.segment.resolution, params.cwd);
if (!candidatePath) {
return;
@@ -304,9 +355,6 @@ function collectAllowAlwaysPatterns(params: {
params.out.add(candidatePath);
return;
}
if (params.depth >= 3) {
return;
}
const inlineCommand = extractShellInlineCommand(params.segment.argv);
if (!inlineCommand) {
return;

View File

@@ -293,6 +293,17 @@ describe("exec approvals command resolution", () => {
expect(resolution?.rawExecutable).toBe("bash");
expect(resolution?.executableName.toLowerCase()).toContain("bash");
});
it("unwraps nice wrapper argv to resolve the effective executable", () => {
const resolution = resolveCommandResolutionFromArgv([
"/usr/bin/nice",
"bash",
"-lc",
"echo hi",
]);
expect(resolution?.rawExecutable).toBe("bash");
expect(resolution?.executableName.toLowerCase()).toContain("bash");
});
});
describe("exec approvals shell parsing", () => {
@@ -1486,4 +1497,93 @@ describe("resolveAllowAlwaysPatterns", () => {
});
expect(patterns).toEqual([whoami]);
});
it("unwraps known dispatch wrappers before shell wrappers", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const whoami = makeExecutable(dir, "whoami");
const patterns = resolveAllowAlwaysPatterns({
segments: [
{
raw: "/usr/bin/nice /bin/zsh -lc whoami",
argv: ["/usr/bin/nice", "/bin/zsh", "-lc", "whoami"],
resolution: {
rawExecutable: "/usr/bin/nice",
resolvedPath: "/usr/bin/nice",
executableName: "nice",
},
},
],
cwd: dir,
env: makePathEnv(dir),
platform: process.platform,
});
expect(patterns).toEqual([whoami]);
expect(patterns).not.toContain("/usr/bin/nice");
});
it("fails closed for unresolved dispatch wrappers", () => {
const patterns = resolveAllowAlwaysPatterns({
segments: [
{
raw: "sudo /bin/zsh -lc whoami",
argv: ["sudo", "/bin/zsh", "-lc", "whoami"],
resolution: {
rawExecutable: "sudo",
resolvedPath: "/usr/bin/sudo",
executableName: "sudo",
},
},
],
platform: process.platform,
});
expect(patterns).toEqual([]);
});
it("prevents allow-always bypass for dispatch-wrapper + shell-wrapper chains", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const echo = makeExecutable(dir, "echo");
makeExecutable(dir, "id");
const safeBins = resolveSafeBins(undefined);
const env = makePathEnv(dir);
const first = evaluateShellAllowlist({
command: "/usr/bin/nice /bin/zsh -lc 'echo warmup-ok'",
allowlist: [],
safeBins,
cwd: dir,
env,
platform: process.platform,
});
const persisted = resolveAllowAlwaysPatterns({
segments: first.segments,
cwd: dir,
env,
platform: process.platform,
});
expect(persisted).toEqual([echo]);
const second = evaluateShellAllowlist({
command: "/usr/bin/nice /bin/zsh -lc 'id > marker'",
allowlist: [{ pattern: echo }],
safeBins,
cwd: dir,
env,
platform: process.platform,
});
expect(second.allowlistSatisfied).toBe(false);
expect(
requiresExecApproval({
ask: "on-miss",
security: "allowlist",
analysisOk: second.analysisOk,
allowlistSatisfied: second.allowlistSatisfied,
}),
).toBe(true);
});
});

View File

@@ -5,6 +5,30 @@ export const MAX_DISPATCH_WRAPPER_DEPTH = 4;
export const POSIX_SHELL_WRAPPERS = new Set(["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"]);
export const WINDOWS_CMD_WRAPPERS = new Set(["cmd.exe", "cmd"]);
export const POWERSHELL_WRAPPERS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]);
export const DISPATCH_WRAPPER_EXECUTABLES = new Set([
"chrt",
"chrt.exe",
"doas",
"doas.exe",
"env",
"env.exe",
"ionice",
"ionice.exe",
"nice",
"nice.exe",
"nohup",
"nohup.exe",
"setsid",
"setsid.exe",
"stdbuf",
"stdbuf.exe",
"sudo",
"sudo.exe",
"taskset",
"taskset.exe",
"timeout",
"timeout.exe",
]);
const POSIX_INLINE_COMMAND_FLAGS = new Set(["-lc", "-c", "--command"]);
const POWERSHELL_INLINE_COMMAND_FLAGS = new Set(["-c", "-command", "--command"]);
@@ -21,6 +45,10 @@ const ENV_OPTIONS_WITH_VALUE = new Set([
"--block-signal",
]);
const ENV_FLAG_OPTIONS = new Set(["-i", "--ignore-environment", "-0", "--null"]);
const NICE_OPTIONS_WITH_VALUE = new Set(["-n", "--adjustment", "--priority"]);
const STDBUF_OPTIONS_WITH_VALUE = new Set(["-i", "--input", "-o", "--output", "-e", "--error"]);
const TIMEOUT_FLAG_OPTIONS = new Set(["--foreground", "--preserve-status", "-v", "--verbose"]);
const TIMEOUT_OPTIONS_WITH_VALUE = new Set(["-k", "--kill-after", "-s", "--signal"]);
type ShellWrapperKind = "posix" | "cmd" | "powershell";
@@ -122,20 +150,198 @@ export function unwrapEnvInvocation(argv: string[]): string[] | null {
return idx < argv.length ? argv.slice(idx) : null;
}
function unwrapNiceInvocation(argv: string[]): string[] | null {
let idx = 1;
let expectsOptionValue = false;
while (idx < argv.length) {
const token = argv[idx]?.trim() ?? "";
if (!token) {
idx += 1;
continue;
}
if (expectsOptionValue) {
expectsOptionValue = false;
idx += 1;
continue;
}
if (token === "--") {
idx += 1;
break;
}
if (token.startsWith("-") && token !== "-") {
const lower = token.toLowerCase();
const [flag] = lower.split("=", 2);
if (/^-\d+$/.test(lower)) {
idx += 1;
continue;
}
if (NICE_OPTIONS_WITH_VALUE.has(flag)) {
if (!lower.includes("=") && lower === flag) {
expectsOptionValue = true;
}
idx += 1;
continue;
}
if (lower.startsWith("-n") && lower.length > 2) {
idx += 1;
continue;
}
return null;
}
break;
}
if (expectsOptionValue) {
return null;
}
return idx < argv.length ? argv.slice(idx) : null;
}
function unwrapNohupInvocation(argv: string[]): string[] | null {
let idx = 1;
while (idx < argv.length) {
const token = argv[idx]?.trim() ?? "";
if (!token) {
idx += 1;
continue;
}
if (token === "--") {
idx += 1;
break;
}
if (token.startsWith("-") && token !== "-") {
const lower = token.toLowerCase();
if (lower === "--help" || lower === "--version") {
idx += 1;
continue;
}
return null;
}
break;
}
return idx < argv.length ? argv.slice(idx) : null;
}
function unwrapStdbufInvocation(argv: string[]): string[] | null {
let idx = 1;
let expectsOptionValue = false;
while (idx < argv.length) {
const token = argv[idx]?.trim() ?? "";
if (!token) {
idx += 1;
continue;
}
if (expectsOptionValue) {
expectsOptionValue = false;
idx += 1;
continue;
}
if (token === "--") {
idx += 1;
break;
}
if (token.startsWith("-") && token !== "-") {
const lower = token.toLowerCase();
const [flag] = lower.split("=", 2);
if (STDBUF_OPTIONS_WITH_VALUE.has(flag)) {
if (!lower.includes("=")) {
expectsOptionValue = true;
}
idx += 1;
continue;
}
return null;
}
break;
}
if (expectsOptionValue) {
return null;
}
return idx < argv.length ? argv.slice(idx) : null;
}
function unwrapTimeoutInvocation(argv: string[]): string[] | null {
let idx = 1;
let expectsOptionValue = false;
while (idx < argv.length) {
const token = argv[idx]?.trim() ?? "";
if (!token) {
idx += 1;
continue;
}
if (expectsOptionValue) {
expectsOptionValue = false;
idx += 1;
continue;
}
if (token === "--") {
idx += 1;
break;
}
if (token.startsWith("-") && token !== "-") {
const lower = token.toLowerCase();
const [flag] = lower.split("=", 2);
if (TIMEOUT_FLAG_OPTIONS.has(flag)) {
idx += 1;
continue;
}
if (TIMEOUT_OPTIONS_WITH_VALUE.has(flag)) {
if (!lower.includes("=")) {
expectsOptionValue = true;
}
idx += 1;
continue;
}
return null;
}
break;
}
if (expectsOptionValue || idx >= argv.length) {
return null;
}
idx += 1; // duration
return idx < argv.length ? argv.slice(idx) : null;
}
export function unwrapKnownDispatchWrapperInvocation(argv: string[]): string[] | null | undefined {
const token0 = argv[0]?.trim();
if (!token0) {
return undefined;
}
const base = basenameLower(token0);
const normalizedBase = base.endsWith(".exe") ? base.slice(0, -4) : base;
switch (normalizedBase) {
case "env":
return unwrapEnvInvocation(argv);
case "nice":
return unwrapNiceInvocation(argv);
case "nohup":
return unwrapNohupInvocation(argv);
case "stdbuf":
return unwrapStdbufInvocation(argv);
case "timeout":
return unwrapTimeoutInvocation(argv);
case "chrt":
case "doas":
case "ionice":
case "setsid":
case "sudo":
case "taskset":
return null;
default:
return undefined;
}
}
export function unwrapDispatchWrappersForResolution(
argv: string[],
maxDepth = MAX_DISPATCH_WRAPPER_DEPTH,
): string[] {
let current = argv;
for (let depth = 0; depth < maxDepth; depth += 1) {
const token0 = current[0]?.trim();
if (!token0) {
const unwrapped = unwrapKnownDispatchWrapperInvocation(current);
if (unwrapped === undefined) {
break;
}
if (basenameLower(token0) !== "env") {
break;
}
const unwrapped = unwrapEnvInvocation(current);
if (!unwrapped || unwrapped.length === 0) {
break;
}
@@ -213,8 +419,8 @@ function extractShellWrapperCommandInternal(
}
const base0 = basenameLower(token0);
if (base0 === "env") {
const unwrapped = unwrapEnvInvocation(argv);
if (DISPATCH_WRAPPER_EXECUTABLES.has(base0)) {
const unwrapped = unwrapKnownDispatchWrapperInvocation(argv);
if (!unwrapped) {
return { isWrapper: false, command: null };
}

View File

@@ -36,6 +36,22 @@ describe("system run command helpers", () => {
);
});
test("extractShellCommandFromArgv unwraps known dispatch wrappers before shell wrappers", () => {
expect(extractShellCommandFromArgv(["/usr/bin/nice", "/bin/bash", "-lc", "echo hi"])).toBe(
"echo hi",
);
expect(
extractShellCommandFromArgv([
"/usr/bin/timeout",
"--signal=TERM",
"5",
"zsh",
"-lc",
"echo hi",
]),
).toBe("echo hi");
});
test("extractShellCommandFromArgv supports fish and pwsh wrappers", () => {
expect(extractShellCommandFromArgv(["fish", "-c", "echo hi"])).toBe("echo hi");
expect(extractShellCommandFromArgv(["pwsh", "-Command", "Get-Date"])).toBe("Get-Date");