diff --git a/CHANGELOG.md b/CHANGELOG.md index 178b995df90..270503705b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/nodes/index.md b/docs/nodes/index.md index a8cdab0dea5..70b1f6cae5f 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -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 ` and `--delivery `. - 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`. diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index ce56aa107bf..a9327970261 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -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 app’s 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 diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 5cc8f697b83..cec00599e2a 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -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`. diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index 9a81328ecab..64defc8f84d 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -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; }) { + 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; diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index 7f508426f74..322749a10cc 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -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); + }); }); diff --git a/src/infra/exec-wrapper-resolution.ts b/src/infra/exec-wrapper-resolution.ts index 05593cf4e4c..c7ac3c7bfa9 100644 --- a/src/infra/exec-wrapper-resolution.ts +++ b/src/infra/exec-wrapper-resolution.ts @@ -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 }; } diff --git a/src/infra/system-run-command.test.ts b/src/infra/system-run-command.test.ts index 22d23d889ec..e7ec9760b89 100644 --- a/src/infra/system-run-command.test.ts +++ b/src/infra/system-run-command.test.ts @@ -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");