Skip to content

Linear OAuth token and other MCP header secrets are exposed in child-process argv via --mcp-config #1439

Description

@gundisalwa

Summary

Cyrus passes MCP server definitions — including a live Linear OAuth token and the cyrus-tools bearer key — to the Claude Agent SDK as an in-memory mcpServers object. The SDK serializes that object onto the child claude process's command line as --mcp-config <json>. Process arguments are not a private channel, so the workspace's Linear token is readable in plaintext by local processes.

Where the credential enters

packages/edge-worker/src/McpConfigService.ts:107:

const mcpConfig: Record<string, McpServerConfig> = {
	linear: {
		type: "http",
		url: "https://mcp.linear.app/mcp",
		headers: {
			Authorization: `Bearer ${linearToken}`,
		},
	},

getAuthorizationHeaderValue() (:209) does the same for cyrus-tools (Bearer ${apiKey}), and the docstring at :61 notes SLACK_BOT_TOKEN travels the same way — so this is every MCP header secret, not only Linear's.

That object is handed to the SDK at packages/claude-runner/src/ClaudeRunner.ts:717:

...(Object.keys(mcpServers).length > 0 && { mcpServers }),

Cyrus never builds the --mcp-config string itself — the SDK does that serialization. But Cyrus is what routes long-lived credentials through that parameter, and a mitigation would most naturally live here.

Observed

On a self-hosted 0.2.68 install, ps -axo args shows, for every session child process:

claude --output-format stream-json ... --mcp-config {"mcpServers":{"linear":{"type":"http",
"url":"https://mcp.linear.app/mcp","headers":{"Authorization":"Bearer lin_oauth_<redacted>"}}, ...

Scope of what I actually verified, since it changes how much this matters:

  • Observed directly: the full token is visible in ps output on macOS, and on that same box a non-root user can read root-owned processes' complete arguments (e.g. /usr/sbin/systemstats --daemon) — so argv is not uid-partitioned there.
  • Not reproduced by me: the Linux case. /proc/<pid>/cmdline is world-readable under the default hidepid=0, which would make this readable by any local user, but I had no second host to confirm it on. Treat that as the documented default rather than a tested claim.

mcpConfigPath is not a workaround

It looks like the file-based config route would sidestep argv, but ClaudeRunner.ts:600-614 reads the file and merges its servers into the same in-memory object:

const mcpConfigContent = readFileSync(path, "utf8");
const mcpConfig = JSON.parse(mcpConfigContent);
const servers = mcpConfig.mcpServers || {};
mcpServers = { ...mcpServers, ...servers };

So a secret placed in an mcpConfigPath file lands on argv identically. Worth stating explicitly, since the file-based option reads like the safer one.

Impact

A Linear OAuth token carries the scopes the Cyrus app holds across the selected teams, and these tokens are long-lived, so one read keeps working. Exposure is bounded to local readers, which makes this hardening rather than a remote vulnerability — but "local" is the wrong boundary for a self-hosted deployment on a shared box, a multi-tenant CI runner, or a container where something else shares the PID namespace.

Filing publicly rather than privately on purpose: the --mcp-config mechanism is already documented across the public repo, so there is nothing here an attacker could not read from the source, and private vulnerability reporting is currently disabled on this repository.

Possible directions

I don't know the SDK's interface well enough to recommend one of these confidently:

  • Have the SDK accept a path to a 0600 file and read it, rather than accepting inline JSON (only helps if it does not re-inline).
  • Pass the config over stdin or an inherited fd instead of argv.
  • Keep placeholders in the MCP config (${LINEAR_TOKEN}) resolved in the child from its environment, so argv carries the placeholder rather than the value.

If the argv serialization is entirely the SDK's to change, this may belong upstream against @anthropic-ai/claude-agent-sdk instead — happy to file it there as well if you'd prefer it live in both places.

Observed on cyrus-ai 0.2.68; code read at tag v0.2.68.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions