stop handing a member's turn the owner's mcp config, and close the file

host found a live credential exposure while answering my question about what
else the member branch missed. It was outside the diff, and predates all of it.

MCP-CONFIG WAS NOT BRANCHED. mcpHostPath is module-level, written once at the
owner's bootstrap, and was applied to every turn. Its env block carries
OFFICER_AUTH_TOKEN, a 30-day JWT signing as the owner — so a member's turn would
have spawned their MCP server holding it. Now inside the params.member ternary
alongside the binary and the spawn, for the reason already written there: these
values say whose turn this is and have to move together. A member gets none.
What they should get instead is undecided, and undefined beats the owner's.

THE FILE WAS 0644. Written with a bare writeFileSync into a 755 directory, on a
host where `terminal` is granted to every role by default — so any member could
cat it and hold owner-level API access on loopback. host verified that as a real
member on the production host rather than reasoning about it. Now 0600.

The mode is the only half of that which is code. The token has been
world-readable and stays compromised until rotated, the directory chain above it
is still 755, and neither is fixable from a commit. Both written up for the
owner in COMMS 05, along with why I am stopping here rather than continuing:
the next commit should be the rotation, not more feature work stacked on top of
an open exposure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 22:42:22 +00:00
co-authored by Claude Opus 5
parent fe7bd49bc7
commit df4503180f
3 changed files with 76 additions and 2 deletions
+9 -1
View File
@@ -370,7 +370,15 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
},
...(subModel ? { model: subModel } : {}),
...(resumeId ? { resume: resumeId } : {}),
...(mcpHostPath ? { extraArgs: { 'mcp-config': mcpHostPath } } : {}),
// The owner's MCP config, and only ever the owner's. `mcpHostPath` is module-level, written once at
// this process's bootstrap, and its `env` carries OFFICER_AUTH_TOKEN — a JWT that signs as the owner.
// Handing it to a member's turn would either spawn their MCP server holding the owner's token, or (once
// that file is 0600, which it now is) point their `claude` at a file it cannot read and fail obscurely.
//
// So a member gets no MCP config at all. What they SHOULD get — their own generated config with a token
// scoped to them, or nothing until per-user tools exist — is an open design question; `undefined` is
// the correct answer until it is settled, and is strictly better than the owner's.
...(mcpHostPath && !params.member ? { extraArgs: { 'mcp-config': mcpHostPath } } : {}),
},
});
+9 -1
View File
@@ -129,7 +129,15 @@ function generateMcpConfig(): string {
},
},
};
writeFileSync(join(contextDir, 'mcp-host.json'), JSON.stringify(hostConfig));
// 0600, because this file's `env` block carries OFFICER_AUTH_TOKEN — a 30-day JWT that signs as the owner.
// It was written at the default 0644 inside a 755 directory, and `terminal` is granted to every role by
// default, so any member with a shell could `cat` it and hold owner-level API access against
// OFFICER_API_URL on loopback. Verified as a real member on the production host, not reasoned about.
//
// The mode is only the half of this that is code. The directory chain above it still allows traversal and
// listing, and a token that has been world-readable stays compromised however the file is chmod'ed
// afterwards — it has to be rotated. Both are the owner's, and both are written up in COMMS.
writeFileSync(join(contextDir, 'mcp-host.json'), JSON.stringify(hostConfig), { mode: 0o600 });
return join(contextDir, 'mcp-host.json');
}