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:
@@ -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
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user