fix(exec): honor shell comments in allow-always analysis

This commit is contained in:
Peter Steinberger
2026-03-07 23:30:23 +00:00
parent 1aaca517e3
commit 939b18475d
5 changed files with 45 additions and 0 deletions

View File

@@ -303,6 +303,7 @@ Docs: https://docs.openclaw.ai
- Nodes/system.run PowerShell wrapper parsing: treat `pwsh`/`powershell` `-EncodedCommand` forms as shell-wrapper payloads so allowlist mode still requires approval instead of falling back to plain argv analysis. Thanks @tdjackey for reporting.
- Control UI/auth error reporting: map generic browser `Fetch failed` websocket close errors back to actionable gateway auth messages (`gateway token mismatch`, `authentication failed`, `retry later`) so dashboard disconnects stop hiding credential problems. Landed from contributor PR #28608 by @KimGLee. Thanks @KimGLee.
- Media/mime unknown-kind handling: return `undefined` (not `"unknown"`) for missing/unrecognized MIME kinds and use document-size fallback caps for unknown remote media, preventing phantom `<media:unknown>` Signal events from being treated as real messages. (#39199) Thanks @nicolasgrasset.
- Nodes/system.run allow-always persistence: honor shell comment semantics during allowlist analysis so `#`-tailed payloads that never execute are not persisted as trusted follow-up commands. Thanks @tdjackey for reporting.
## 2026.3.2

View File

@@ -302,4 +302,21 @@ describe("resolveAllowAlwaysPatterns", () => {
persistedPattern: echo,
});
});
it("does not persist comment-tailed payload paths that never execute", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const benign = makeExecutable(dir, "benign");
makeExecutable(dir, "payload");
const env = makePathEnv(dir);
expectAllowAlwaysBypassBlocked({
dir,
firstCommand: `${benign} warmup # && payload`,
secondCommand: "payload",
env,
persistedPattern: benign,
});
});
});

View File

@@ -59,6 +59,17 @@ function isEscapedLineContinuation(next: string | undefined): next is string {
return next === "\n" || next === "\r";
}
function isShellCommentStart(source: string, index: number): boolean {
if (source[index] !== "#") {
return false;
}
if (index === 0) {
return true;
}
const prev = source[index - 1];
return Boolean(prev && /\s/.test(prev));
}
function splitShellPipeline(command: string): { ok: boolean; reason?: string; segments: string[] } {
type HeredocSpec = {
delimiter: string;
@@ -246,6 +257,9 @@ function splitShellPipeline(command: string): { ok: boolean; reason?: string; se
emptySegment = false;
continue;
}
if (isShellCommentStart(command, i)) {
break;
}
if ((ch === "\n" || ch === "\r") && pendingHeredocs.length > 0) {
inHeredocBody = true;
@@ -501,6 +515,9 @@ export function splitCommandChainWithOperators(command: string): ShellChainPart[
buf += ch;
continue;
}
if (isShellCommentStart(command, i)) {
break;
}
if (ch === "&" && next === "&") {
if (!pushPart("&&")) {

View File

@@ -59,6 +59,10 @@ export function splitShellArgs(raw: string): string[] | null {
inDouble = true;
continue;
}
// In POSIX shells, "#" starts a comment only when it begins a word.
if (ch === "#" && buf.length === 0) {
break;
}
if (/\s/.test(ch)) {
pushToken();
continue;

View File

@@ -106,4 +106,10 @@ describe("splitShellArgs", () => {
expect(splitShellArgs(`echo "oops`)).toBeNull();
expect(splitShellArgs(`echo 'oops`)).toBeNull();
});
it("stops at unquoted shell comments but keeps quoted hashes literal", () => {
expect(splitShellArgs(`echo hi # comment && whoami`)).toEqual(["echo", "hi"]);
expect(splitShellArgs(`echo "hi # still-literal"`)).toEqual(["echo", "hi # still-literal"]);
expect(splitShellArgs(`echo hi#tail`)).toEqual(["echo", "hi#tail"]);
});
});