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.
Summary
Cyrus passes MCP server definitions — including a live Linear OAuth token and the
cyrus-toolsbearer key — to the Claude Agent SDK as an in-memorymcpServersobject. The SDK serializes that object onto the childclaudeprocess'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:getAuthorizationHeaderValue()(:209) does the same forcyrus-tools(Bearer ${apiKey}), and the docstring at:61notesSLACK_BOT_TOKENtravels 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:Cyrus never builds the
--mcp-configstring 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 argsshows, for every session child process:Scope of what I actually verified, since it changes how much this matters:
psoutput 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./proc/<pid>/cmdlineis world-readable under the defaulthidepid=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.mcpConfigPathis not a workaroundIt looks like the file-based config route would sidestep argv, but
ClaudeRunner.ts:600-614reads the file and merges its servers into the same in-memory object:So a secret placed in an
mcpConfigPathfile 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-configmechanism 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:
0600file and read it, rather than accepting inline JSON (only helps if it does not re-inline).${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-sdkinstead — 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.