mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 18:34:33 +00:00
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.
|
- 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/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/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
|
## 2026.3.2
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,134 @@ describe("resolveAllowAlwaysPatterns", () => {
|
|||||||
expect(new Set(patterns)).toEqual(new Set([whoami, ls]));
|
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", () => {
|
it("does not persist broad shell binaries when no inner command can be derived", () => {
|
||||||
const patterns = resolveAllowAlwaysPatterns({
|
const patterns = resolveAllowAlwaysPatterns({
|
||||||
segments: [
|
segments: [
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
unwrapKnownShellMultiplexerInvocation,
|
unwrapKnownShellMultiplexerInvocation,
|
||||||
unwrapKnownDispatchWrapperInvocation,
|
unwrapKnownDispatchWrapperInvocation,
|
||||||
} from "./exec-wrapper-resolution.js";
|
} from "./exec-wrapper-resolution.js";
|
||||||
|
import { expandHomePrefix } from "./home-dir.js";
|
||||||
|
|
||||||
function hasShellLineContinuation(command: string): boolean {
|
function hasShellLineContinuation(command: string): boolean {
|
||||||
return /\\(?:\r\n|\n|\r)/.test(command);
|
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.resolution.effectiveArgv.length > 0
|
||||||
? segment.resolution.effectiveArgv
|
? segment.resolution.effectiveArgv
|
||||||
: segment.argv;
|
: segment.argv;
|
||||||
|
const allowlistSegment =
|
||||||
|
effectiveArgv === segment.argv ? segment : { ...segment, argv: effectiveArgv };
|
||||||
const candidatePath = resolveAllowlistCandidatePath(segment.resolution, params.cwd);
|
const candidatePath = resolveAllowlistCandidatePath(segment.resolution, params.cwd);
|
||||||
const candidateResolution =
|
const candidateResolution =
|
||||||
candidatePath && segment.resolution
|
candidatePath && segment.resolution
|
||||||
? { ...segment.resolution, resolvedPath: candidatePath }
|
? { ...segment.resolution, resolvedPath: candidatePath }
|
||||||
: segment.resolution;
|
: 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) {
|
if (match) {
|
||||||
matches.push(match);
|
matches.push(match);
|
||||||
}
|
}
|
||||||
@@ -327,6 +346,74 @@ function isDispatchWrapperSegment(segment: ExecCommandSegment): boolean {
|
|||||||
return hasSegmentExecutableMatch(segment, isDispatchWrapperExecutable);
|
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: {
|
function collectAllowAlwaysPatterns(params: {
|
||||||
segment: ExecCommandSegment;
|
segment: ExecCommandSegment;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@@ -382,6 +469,13 @@ function collectAllowAlwaysPatterns(params: {
|
|||||||
}
|
}
|
||||||
const inlineCommand = extractShellWrapperInlineCommand(params.segment.argv);
|
const inlineCommand = extractShellWrapperInlineCommand(params.segment.argv);
|
||||||
if (!inlineCommand) {
|
if (!inlineCommand) {
|
||||||
|
const scriptPath = resolveShellWrapperScriptCandidatePath({
|
||||||
|
segment: params.segment,
|
||||||
|
cwd: params.cwd,
|
||||||
|
});
|
||||||
|
if (scriptPath) {
|
||||||
|
params.out.add(scriptPath);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nested = analyzeShellCommand({
|
const nested = analyzeShellCommand({
|
||||||
|
|||||||
Reference in New Issue
Block a user