diff --git a/CHANGELOG.md b/CHANGELOG.md index ddf291ed5..c8606fc67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -315,6 +315,7 @@ Docs: https://docs.openclaw.ai - Subagents/workspace inheritance: propagate parent workspace directory to spawned subagent runs so child sessions reliably inherit workspace-scoped instructions (`AGENTS.md`, `SOUL.md`, etc.) without exposing workspace override through tool-call arguments. (#39247) Thanks @jasonQin6. - Exec approvals/gateway-node policy: honor explicit `ask=off` from `exec-approvals.json` even when runtime defaults are stricter, so trusted full/off setups stop re-prompting on gateway and node exec paths. Landed from contributor PR #26789 by @pandego. Thanks @pandego. - Exec approvals/config fallback: inherit `ask` from `exec-approvals.json` when `tools.exec.ask` is unset, so local full/off defaults no longer fall back to `on-miss` for exec tool and `nodes run`. Landed from contributor PR #29187 by @Bartok9. Thanks @Bartok9. +- Exec approvals/allow-always shell scripts: persist and match script paths for wrapper invocations like `bash scripts/foo.sh` while still blocking `-c`/`-s` wrapper bypasses. Landed from contributor PR #35137 by @yuweuii. Thanks @yuweuii. ## 2026.3.2 diff --git a/src/infra/exec-approvals-allow-always.test.ts b/src/infra/exec-approvals-allow-always.test.ts index b2d091e98..72db45a33 100644 --- a/src/infra/exec-approvals-allow-always.test.ts +++ b/src/infra/exec-approvals-allow-always.test.ts @@ -127,6 +127,134 @@ describe("resolveAllowAlwaysPatterns", () => { expect(new Set(patterns)).toEqual(new Set([whoami, ls])); }); + it("persists shell script paths for wrapper invocations without inline commands", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const scriptsDir = path.join(dir, "scripts"); + fs.mkdirSync(scriptsDir, { recursive: true }); + const script = path.join(scriptsDir, "save_crystal.sh"); + fs.writeFileSync(script, "echo ok\n"); + + const safeBins = resolveSafeBins(undefined); + const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` }; + const first = evaluateShellAllowlist({ + command: "bash scripts/save_crystal.sh", + allowlist: [], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + const persisted = resolveAllowAlwaysPatterns({ + segments: first.segments, + cwd: dir, + env, + platform: process.platform, + }); + expect(persisted).toEqual([script]); + + const second = evaluateShellAllowlist({ + command: "bash scripts/save_crystal.sh", + allowlist: [{ pattern: script }], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + expect(second.allowlistSatisfied).toBe(true); + + const other = path.join(scriptsDir, "other.sh"); + fs.writeFileSync(other, "echo other\n"); + const third = evaluateShellAllowlist({ + command: "bash scripts/other.sh", + allowlist: [{ pattern: script }], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + expect(third.allowlistSatisfied).toBe(false); + }); + + it("matches persisted shell script paths through dispatch wrappers", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const scriptsDir = path.join(dir, "scripts"); + fs.mkdirSync(scriptsDir, { recursive: true }); + const script = path.join(scriptsDir, "save_crystal.sh"); + fs.writeFileSync(script, "echo ok\n"); + + const safeBins = resolveSafeBins(undefined); + const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` }; + const first = evaluateShellAllowlist({ + command: "/usr/bin/nice bash scripts/save_crystal.sh", + allowlist: [], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + const persisted = resolveAllowAlwaysPatterns({ + segments: first.segments, + cwd: dir, + env, + platform: process.platform, + }); + expect(persisted).toEqual([script]); + + const second = evaluateShellAllowlist({ + command: "/usr/bin/nice bash scripts/save_crystal.sh", + allowlist: [{ pattern: script }], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + expect(second.allowlistSatisfied).toBe(true); + }); + + it("does not treat inline shell commands as persisted script paths", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const scriptsDir = path.join(dir, "scripts"); + fs.mkdirSync(scriptsDir, { recursive: true }); + const script = path.join(scriptsDir, "save_crystal.sh"); + fs.writeFileSync(script, "echo ok\n"); + const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` }; + expectAllowAlwaysBypassBlocked({ + dir, + firstCommand: "bash scripts/save_crystal.sh", + secondCommand: "bash -lc 'scripts/save_crystal.sh'", + env, + persistedPattern: script, + }); + }); + + it("does not treat stdin shell mode as a persisted script path", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const scriptsDir = path.join(dir, "scripts"); + fs.mkdirSync(scriptsDir, { recursive: true }); + const script = path.join(scriptsDir, "save_crystal.sh"); + fs.writeFileSync(script, "echo ok\n"); + const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` }; + expectAllowAlwaysBypassBlocked({ + dir, + firstCommand: "bash scripts/save_crystal.sh", + secondCommand: "bash -s scripts/save_crystal.sh", + env, + persistedPattern: script, + }); + }); + it("does not persist broad shell binaries when no inner command can be derived", () => { const patterns = resolveAllowAlwaysPatterns({ segments: [ diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index 55c06f78d..80d9ee324 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -25,6 +25,7 @@ import { unwrapKnownShellMultiplexerInvocation, unwrapKnownDispatchWrapperInvocation, } from "./exec-wrapper-resolution.js"; +import { expandHomePrefix } from "./home-dir.js"; function hasShellLineContinuation(command: string): boolean { return /\\(?:\r\n|\n|\r)/.test(command); @@ -216,12 +217,30 @@ function evaluateSegments( segment.resolution?.effectiveArgv && segment.resolution.effectiveArgv.length > 0 ? segment.resolution.effectiveArgv : segment.argv; + const allowlistSegment = + effectiveArgv === segment.argv ? segment : { ...segment, argv: effectiveArgv }; const candidatePath = resolveAllowlistCandidatePath(segment.resolution, params.cwd); const candidateResolution = candidatePath && segment.resolution ? { ...segment.resolution, resolvedPath: candidatePath } : segment.resolution; - const match = matchAllowlist(params.allowlist, candidateResolution); + const executableMatch = matchAllowlist(params.allowlist, candidateResolution); + const inlineCommand = extractShellWrapperInlineCommand(allowlistSegment.argv); + const shellScriptCandidatePath = + inlineCommand === null + ? resolveShellWrapperScriptCandidatePath({ + segment: allowlistSegment, + cwd: params.cwd, + }) + : undefined; + const shellScriptMatch = shellScriptCandidatePath + ? matchAllowlist(params.allowlist, { + rawExecutable: shellScriptCandidatePath, + resolvedPath: shellScriptCandidatePath, + executableName: path.basename(shellScriptCandidatePath), + }) + : null; + const match = executableMatch ?? shellScriptMatch; if (match) { matches.push(match); } @@ -327,6 +346,74 @@ function isDispatchWrapperSegment(segment: ExecCommandSegment): boolean { return hasSegmentExecutableMatch(segment, isDispatchWrapperExecutable); } +const SHELL_WRAPPER_OPTIONS_WITH_VALUE = new Set([ + "-c", + "--command", + "-o", + "-O", + "+O", + "--rcfile", + "--init-file", + "--startup-file", +]); + +function resolveShellWrapperScriptCandidatePath(params: { + segment: ExecCommandSegment; + cwd?: string; +}): string | undefined { + if (!isShellWrapperSegment(params.segment)) { + return undefined; + } + + const argv = params.segment.argv; + if (!Array.isArray(argv) || argv.length < 2) { + return undefined; + } + + let idx = 1; + while (idx < argv.length) { + const token = argv[idx]?.trim() ?? ""; + if (!token) { + idx += 1; + continue; + } + if (token === "--") { + idx += 1; + break; + } + if (token === "-c" || token === "--command") { + return undefined; + } + if (/^-[^-]*c[^-]*$/i.test(token)) { + return undefined; + } + if (token === "-s" || /^-[^-]*s[^-]*$/i.test(token)) { + return undefined; + } + if (SHELL_WRAPPER_OPTIONS_WITH_VALUE.has(token)) { + idx += 2; + continue; + } + if (token.startsWith("-") || token.startsWith("+")) { + idx += 1; + continue; + } + break; + } + + const scriptToken = argv[idx]?.trim(); + if (!scriptToken) { + return undefined; + } + if (path.isAbsolute(scriptToken)) { + return scriptToken; + } + + const expanded = scriptToken.startsWith("~") ? expandHomePrefix(scriptToken) : scriptToken; + const base = params.cwd && params.cwd.trim().length > 0 ? params.cwd : process.cwd(); + return path.resolve(base, expanded); +} + function collectAllowAlwaysPatterns(params: { segment: ExecCommandSegment; cwd?: string; @@ -382,6 +469,13 @@ function collectAllowAlwaysPatterns(params: { } const inlineCommand = extractShellWrapperInlineCommand(params.segment.argv); if (!inlineCommand) { + const scriptPath = resolveShellWrapperScriptCandidatePath({ + segment: params.segment, + cwd: params.cwd, + }); + if (scriptPath) { + params.out.add(scriptPath); + } return; } const nested = analyzeShellCommand({