Root cause
Validation & fail-open logic
fix: cleanup suspended Clawdbot CLI processes
AI-assisted change: Claude
How AI contributed: New attack surfacePublished Feb 18, 2026 in
openclaw/openclawAll 35 findings in this repository
Summary OpenClaw CLI process cleanup used system-wide process enumeration and pattern matching to terminate processes without verifying they were owned by the current OpenClaw process. On shared hosts, unrelated processes could be terminated if they matched the pattern. Affected Packages / Versions · Package: openclaw (npm) · Affected: < 2026.2.14 (including the latest published version 2026.2.13) · Fixed: 2026.2.14 (planned next release) Details The CLI runner cleanup helpers could kill processes matched by command-line patterns without validating process ownership. Fix Process cleanup is now scoped to owned processes only by filtering to direct child PIDs of the current process (ppid == process.pid) before sending signals. Hardening follow-ups: · Prefer graceful termination for resume cleanup (SIGTERM, then SIGKILL fallback). · Reduce false negatives from ps argv truncation by preferring wide output (ps -axww) with a fallback. · Tighten command-line token matching to avoid substring matches. Fix Commit(s) · 6084d13b956119e3cf95daaf9a1cae1670ea3557 · eb60e2e1b213740c3c587a7ba4dbf10da620ca66 Release Process Note This advisory is pre-set with patched version 2026.2.14. After 2026.2.14 is published to npm, the remaining step should be to publish this advisory. Thanks @aether-ai-agent for reporting.
The suspended-process sweep added in src/agents/cli-runner/helpers.ts piped ps aux through grep and awk and then ran kill -9 on every matched PID without checking that the process belonged to the current run, and cli-runner.ts invoked that sweep at the start of every CLI agent run. The fix enumerates ps -axww pid and ppid rows and signals only entries whose parent PID equals process.pid.
Root cause
fix: cleanup suspended Clawdbot CLI processes
AI-assisted change: Claude
Fix
fix(security): scope CLI cleanup to owned child PIDs
Fix by Peter Steinberger · no AI marker found
Case facts
@@ -43,6 +43,42 @@ export async function cleanupResumeProcesses( } } +/**+ * Cleanup suspended Clawdbot CLI processes that have accumulated.+ * Only cleans up if there are more than the threshold (default: 10).+ * Uses --session-id pattern to only target Clawdbot processes, not user's Claude Code sessions.+ */+export async function cleanupSuspendedCliProcesses(+ backend: CliBackendConfig,+ threshold = 10,+): Promise<void> {+ if (process.platform === "win32") return;+ const commandToken = path.basename(backend.command ?? "").trim();+ if (!commandToken) return;++ // Pattern includes --session-id to only match Clawdbot processes+ const pattern = `[${commandToken[0]}]${commandToken.slice(1)}.*--session-id`;++ try {+ // Count suspended Clawdbot processes+ const { stdout } = await runExec("bash", [+ "-c",+ `ps aux | grep -E '${pattern}' | grep -E '\\s+T\\s+' | wc -l`,+ ]);+ const count = parseInt(stdout.trim(), 10) || 0;++ if (count > threshold) {+ // Kill suspended Clawdbot processes only+ await runExec("bash", [+ "-c",+ `ps aux | grep -E '${pattern}' | grep -E '\\s+T\\s+' | awk '{print $2}' | xargs kill -9 2>/dev/null`,+ ]);+ }+ } catch {+ // ignore errors - best effort cleanup+ }+}+ export function enqueueCliRun<T>(key: string, task: () => Promise<T>): Promise<T> { const prior = CLI_RUN_QUEUE.get(key) ?? Promise.resolve(); const chained = prior.catch(() => undefined).then(task);@@ -17,7 +17,9 @@ describe("runCliAgent resume cleanup", () => { }); it("kills stale resume processes for codex sessions", async () => {- runExecMock.mockResolvedValue({ stdout: "", stderr: "" });+ // First call is for cleanupSuspendedCliProcesses (returns count 0)+ // Second call is for cleanupResumeProcesses (pkill)+ runExecMock.mockResolvedValue({ stdout: "0", stderr: "" }); runCommandWithTimeoutMock.mockResolvedValueOnce({ stdout: "ok", stderr: "",@@ -43,13 +45,58 @@ describe("runCliAgent resume cleanup", () => { return; } - expect(runExecMock).toHaveBeenCalledTimes(1);- const args = runExecMock.mock.calls[0] ?? [];- expect(args[0]).toBe("pkill");- const pkillArgs = args[1] as string[];+ // First call: cleanupSuspendedCliProcesses (bash to count)+ // Second call: cleanupResumeProcesses (pkill)+ expect(runExecMock).toHaveBeenCalledTimes(2);++ // Verify the pkill call for resume cleanup+ const pkillCall = runExecMock.mock.calls[1] ?? [];+ expect(pkillCall[0]).toBe("pkill");+ const pkillArgs = pkillCall[1] as string[]; expect(pkillArgs[0]).toBe("-f"); expect(pkillArgs[1]).toContain("codex"); expect(pkillArgs[1]).toContain("resume"); expect(pkillArgs[1]).toContain("thread-123"); });++ it("cleans up suspended processes when threshold exceeded", async () => {+ // Return count > 10 to trigger cleanup+ runExecMock+ .mockResolvedValueOnce({ stdout: "15", stderr: "" }) // count suspended+ .mockResolvedValueOnce({ stdout: "", stderr: "" }); // kill command+ runCommandWithTimeoutMock.mockResolvedValueOnce({+ stdout: "ok",+ stderr: "",+ code: 0,+ signal: null,+ killed: false,+ });++ await runCliAgent({+ sessionId: "s1",+ sessionFile: "/tmp/session.jsonl",+ workspaceDir: "/tmp",+ prompt: "hi",+ provider: "claude-cli",+ timeoutMs: 1_000,+ runId: "run-1",+ });++ if (process.platform === "win32") {+ expect(runExecMock).not.toHaveBeenCalled();+ return;+ }++ // cleanupSuspendedCliProcesses: count + kill (2 calls)+ // cleanupResumeProcesses: not called for claude-cli (no resumeArgs)+ expect(runExecMock).toHaveBeenCalledTimes(2);++ // First call: count suspended processes+ const countCall = runExecMock.mock.calls[0] ?? [];+ expect(countCall[0]).toBe("bash");++ // Second call: kill suspended processes+ const killCall = runExecMock.mock.calls[1] ?? [];+ expect(killCall[0]).toBe("bash");+ }); });@@ -13,6 +13,7 @@ import { buildCliArgs, buildSystemPrompt, cleanupResumeProcesses,+ cleanupSuspendedCliProcesses, enqueueCliRun, normalizeCliModel, parseCliJson,@@ -206,6 +207,9 @@ export async function runCliAgent(params: { return next; })(); + // Cleanup suspended processes that have accumulated (regardless of sessionId)+ await cleanupSuspendedCliProcesses(backend);+ if (useResume && cliSessionIdToSend) { await cleanupResumeProcesses(backend, cliSessionIdToSend); }@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { CliBackendConfig } from "../config/types.js"; import { runCliAgent } from "./cli-runner.js";-import { cleanupSuspendedCliProcesses } from "./cli-runner/helpers.js";+import { cleanupResumeProcesses, cleanupSuspendedCliProcesses } from "./cli-runner/helpers.js"; const runCommandWithTimeoutMock = vi.fn(); const runExecMock = vi.fn();@@ -22,12 +22,21 @@ describe("runCliAgent resume cleanup", () => { }); it("kills stale resume processes for codex sessions", async () => {+ const selfPid = process.pid;+ runExecMock .mockResolvedValueOnce({- stdout: " 1 S /bin/launchd\n",+ stdout: " 1 999 S /bin/launchd\n", stderr: "",- }) // cleanupSuspendedCliProcesses (ps)- .mockResolvedValueOnce({ stdout: "", stderr: "" }); // cleanupResumeProcesses (pkill)+ }) // cleanupSuspendedCliProcesses (ps) — ppid 999 != selfPid, no match+ .mockResolvedValueOnce({+ stdout: [+ ` ${selfPid + 1} ${selfPid} codex exec resume thread-123 --color never --sandbox read-only --skip-git-repo-check`,+ ` ${selfPid + 2} 999 codex exec resume thread-123 --color never --sandbox read-only --skip-git-repo-check`,+ ].join("\n"),+ stderr: "",+ }) // cleanupResumeProcesses (ps)+ .mockResolvedValueOnce({ stdout: "", stderr: "" }); // cleanupResumeProcesses (kill) runCommandWithTimeoutMock.mockResolvedValueOnce({ stdout: "ok", stderr: "",@@ -53,14 +62,19 @@ describe("runCliAgent resume cleanup", () => { return; } - expect(runExecMock).toHaveBeenCalledTimes(2);- const pkillCall = runExecMock.mock.calls[1] ?? [];- expect(pkillCall[0]).toBe("pkill");- const pkillArgs = pkillCall[1] as string[];- expect(pkillArgs[0]).toBe("-f");- expect(pkillArgs[1]).toContain("codex");- expect(pkillArgs[1]).toContain("resume");- expect(pkillArgs[1]).toContain("thread-123");+ expect(runExecMock).toHaveBeenCalledTimes(3);++ // Second call: cleanupResumeProcesses ps+ const psCall = runExecMock.mock.calls[1] ?? [];+ expect(psCall[0]).toBe("ps");++ // Third call: kill with only the child PID+ const killCall = runExecMock.mock.calls[2] ?? [];+ expect(killCall[0]).toBe("kill");+ const killArgs = killCall[1] as string[];+ expect(killArgs[0]).toBe("-9");+ expect(killArgs[1]).toBe(String(selfPid + 1));+ expect(killArgs).toHaveLength(2); // only -9 + one PID }); it("falls back to per-agent workspace when workspaceDir is missing", async () => {@@ -165,11 +179,12 @@ describe("cleanupSuspendedCliProcesses", () => { }); it("matches sessionArg-based commands", async () => {+ const selfPid = process.pid; runExecMock .mockResolvedValueOnce({ stdout: [- " 40 T+ claude --session-id thread-1 -p",- " 41 S claude --session-id thread-2 -p",+ ` 40 ${selfPid} T+ claude --session-id thread-1 -p`,+ ` 41 ${selfPid} S claude --session-id thread-2 -p`, ].join("\n"), stderr: "", })@@ -195,11 +210,12 @@ describe("cleanupSuspendedCliProcesses", () => { }); it("matches resumeArgs with positional session id", async () => {+ const selfPid = process.pid; runExecMock .mockResolvedValueOnce({ stdout: [- " 50 T codex exec resume thread-99 --color never --sandbox read-only",- " 51 T codex exec resume other --color never --sandbox read-only",+ ` 50 ${selfPid} T codex exec resume thread-99 --color never --sandbox read-only`,+ ` 51 ${selfPid} T codex exec resume other --color never --sandbox read-only`, ].join("\n"),@@ -47,9 +47,40 @@ export async function cleanupResumeProcesses( } try {- await runExec("pkill", ["-f", pattern]);+ // Use wide output to reduce false negatives from argv truncation.+ const { stdout } = await runExec("ps", ["-axww", "-o", "pid=,ppid=,command="]);+ const patternRegex = new RegExp(pattern);+ const toKill: number[] = [];++ for (const line of stdout.split("\n")) {+ const trimmed = line.trim();+ if (!trimmed) {+ continue;+ }+ const match = /^(\d+)\s+(\d+)\s+(.*)$/.exec(trimmed);+ if (!match) {+ continue;+ }+ const pid = Number(match[1]);+ const ppid = Number(match[2]);+ const cmd = match[3] ?? "";+ if (!Number.isFinite(pid)) {+ continue;+ }+ if (ppid !== process.pid) {+ continue;+ }+ if (!patternRegex.test(cmd)) {+ continue;+ }+ toKill.push(pid);+ }++ if (toKill.length > 0) {+ await runExec("kill", ["-9", ...toKill.map((pid) => String(pid))]);+ } } catch {- // ignore missing pkill or no matches+ // ignore errors - best effort cleanup } } @@ -115,23 +146,28 @@ export async function cleanupSuspendedCliProcesses( } try {- const { stdout } = await runExec("ps", ["-ax", "-o", "pid=,stat=,command="]);+ // Use wide output to reduce false negatives from argv truncation.+ const { stdout } = await runExec("ps", ["-axww", "-o", "pid=,ppid=,stat=,command="]); const suspended: number[] = []; for (const line of stdout.split("\n")) { const trimmed = line.trim(); if (!trimmed) { continue; }- const match = /^(\d+)\s+(\S+)\s+(.*)$/.exec(trimmed);+ const match = /^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(trimmed); if (!match) { continue; } const pid = Number(match[1]);- const stat = match[2] ?? "";- const command = match[3] ?? "";+ const ppid = Number(match[2]);+ const stat = match[3] ?? "";+ const command = match[4] ?? ""; if (!Number.isFinite(pid)) { continue; }+ if (ppid !== process.pid) {+ continue;+ } if (!stat.includes("T")) { continue; }@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent. - Security: fix Chutes manual OAuth login state validation (thanks @aether-ai-agent). (#16058) - macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins. - Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.AI-assisted change 78b6c08bcde1b2867072edf375b231bf6e22d35472c50078d43b70afd4323e21 · Fix 6bf2457a3fd7858ad23d7dcb1001efe0edf7557309435603cef1cf4181120f83