diff --git a/CHANGELOG.md b/CHANGELOG.md index 310d438e4db..533e75d7297 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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 diff --git a/src/infra/exec-approvals-allow-always.test.ts b/src/infra/exec-approvals-allow-always.test.ts index 4a3c53c7614..b2d091e986e 100644 --- a/src/infra/exec-approvals-allow-always.test.ts +++ b/src/infra/exec-approvals-allow-always.test.ts @@ -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, + }); + }); }); diff --git a/src/infra/exec-approvals-analysis.ts b/src/infra/exec-approvals-analysis.ts index d67256e891c..f55f7c56c53 100644 --- a/src/infra/exec-approvals-analysis.ts +++ b/src/infra/exec-approvals-analysis.ts @@ -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("&&")) { diff --git a/src/utils/shell-argv.ts b/src/utils/shell-argv.ts index d62b9b08e81..3f75dfa22ef 100644 --- a/src/utils/shell-argv.ts +++ b/src/utils/shell-argv.ts @@ -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; diff --git a/src/utils/utils-misc.test.ts b/src/utils/utils-misc.test.ts index 88f0c311ae2..ae3d09d150e 100644 --- a/src/utils/utils-misc.test.ts +++ b/src/utils/utils-misc.test.ts @@ -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"]); + }); });