fix(exec-approvals): honor allow-always for bash script invocations

Landed from contributor PR #35137 by @yuweuii.

Co-authored-by: yuweuii <82372187+yuweuii@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-03-08 00:39:40 +00:00
parent ca37a4e82e
commit 9d2b292998
3 changed files with 224 additions and 1 deletions

View File

@@ -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

View File

@@ -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: [

View File

@@ -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({