Skip to content

Commit d5214d4

Browse files
committed
fix: Stop embedding Portkey API key in committed config and tighten file perms
The setup/mcp wizards offered "share with the team" destinations they labeled commit-safe (.mcp.json "ok to commit", .claude/settings.json "committed to git") but wrote the live workspace API key into them in plaintext. Every fs.writeFileSync also omitted a mode, so secret-bearing home-dir files (~/.claude/settings.json, shell rc, ~/.codex/config.toml) landed world-readable at 0644. For git-committed scopes the config now references ${PORTKEY_API_KEY} (expanded from the environment at read time, matching how the Codex paths already worked) instead of the literal key; the real secret is persisted to the user's shell rc. All secret-bearing writes now use a writeFileSecure helper that creates files at 0600 and chmods existing ones. UI hints and post-write banners updated to reflect that committed files no longer contain a secret, and the README documents the PORTKEY_API_KEY env-var workflow. Adds regression tests for the env-var reference and 0600 permissions.
1 parent ce28b7c commit d5214d4

7 files changed

Lines changed: 166 additions & 23 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ npx portkey mcp add
3939

4040
Config goes mainly into your shell profile and/or `.claude/` (the wizard explains each choice). Open a **new terminal** (or `source` your profile) after setup, then run `claude` as usual.
4141

42+
> **Shared / committed config (`PORTKEY_API_KEY`).** When you choose a git-tracked destination — `mcp add`**Repo file (.mcp.json)** or `setup`**Project (shared)** (`.claude/settings.json`) — the CLI **never** writes your key into the committed file. It writes the reference `"${PORTKEY_API_KEY}"` (Claude Code expands it from the environment at read time) and stores the real key in your shell profile (owner-only, `0600`). Each teammate just exports their own `PORTKEY_API_KEY`:
43+
>
44+
> ```bash
45+
> export PORTKEY_API_KEY="pk-..." # add to ~/.zshrc, ~/.bashrc, or CI secrets
46+
> ```
47+
>
48+
> All files the CLI writes that hold a secret (shell profile, `~/.claude/settings.json`, `~/.codex/config.toml`) are created with `0600` permissions.
49+
4250
**Later, from your project folder:**
4351
4452
```bash

src/api.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,17 +164,23 @@ export async function fetchModels(portkeyKey, providerSlug, gateway) {
164164
* services.
165165
*
166166
* Never put the key in `Authorization` or use `Authorization: x-portkey-api-key: …`.
167+
*
168+
* `opts.keyRef` — when set (e.g. `"${PORTKEY_API_KEY}"`), the header carries this
169+
* environment-variable reference instead of the literal key. Use it for any
170+
* destination that is committed to git so the secret never lands in a tracked file
171+
* (Claude Code expands `${VAR}` in `.mcp.json` headers at read time).
167172
*/
168-
export function buildClaudeMcpHttpConfig(server, portkeyKey) {
173+
export function buildClaudeMcpHttpConfig(server, portkeyKey, { keyRef = null } = {}) {
169174
const cfg = {
170175
type: "http",
171176
url: server.url,
172177
};
173-
if (!portkeyKey) return cfg;
178+
const headerValue = keyRef || portkeyKey;
179+
if (!headerValue) return cfg;
174180
return {
175181
...cfg,
176182
headers: {
177-
"x-portkey-api-key": portkeyKey,
183+
"x-portkey-api-key": headerValue,
178184
},
179185
};
180186
}

src/commands/claude-code/mcp.js

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,19 @@ import path from "node:path";
33
import os from "node:os";
44
import * as p from "@clack/prompts";
55
import {
6+
VERSION,
67
PORTKEY_DASHBOARD,
8+
PORTKEY_KEY_ENV,
9+
PORTKEY_KEY_ENV_REF,
710
c,
811
ok,
912
err,
1013
info,
1114
warn,
1215
mask,
1316
findProjectRoot,
17+
detectShellRc,
18+
writeShellRc,
1419
getMcpFilePath,
1520
settingsSetMcp,
1621
settingsRemoveMcp,
@@ -46,7 +51,7 @@ function scopeToFilePath(scope, projectRoot) {
4651

4752
function scopeLabel(scope, projectRoot) {
4853
if (scope === "project") {
49-
return `project ${c.dim}(.mcp.json in repo — team can commit)${c.reset}`;
54+
return `project ${c.dim}(.mcp.json in repo — uses ${PORTKEY_KEY_ENV_REF}, safe to commit)${c.reset}`;
5055
}
5156
if (scope === "local") {
5257
return `this project only ${c.dim}(~/.claude.json, not under .claude/settings*)${c.reset}`;
@@ -174,7 +179,7 @@ export async function doMcpAdd(args) {
174179
{
175180
value: "project",
176181
label: "Repo file (.mcp.json)",
177-
hint: "in project root — ok to commit for the team",
182+
hint: `in project root — committed; key read from ${PORTKEY_KEY_ENV} env`,
178183
},
179184
{
180185
value: "user",
@@ -290,11 +295,15 @@ export async function doMcpAdd(args) {
290295
}
291296

292297
// ── Claude write ────────────────────────────────────────────────────────────
298+
// "project" scope writes a git-committed .mcp.json, so reference the key via
299+
// ${PORTKEY_API_KEY} instead of embedding the secret in a tracked file.
300+
const committed = mcpScope === "project";
301+
const keyRef = committed ? PORTKEY_KEY_ENV_REF : null;
293302
const mcpEntries = {};
294303
for (const slug of selected) {
295304
const srv = servers.find((x) => x.slug === slug);
296305
if (!srv) continue;
297-
mcpEntries[`portkey-${slug}`] = buildClaudeMcpHttpConfig(srv, portkeyKey);
306+
mcpEntries[`portkey-${slug}`] = buildClaudeMcpHttpConfig(srv, portkeyKey, { keyRef });
298307
}
299308

300309
if (args.dryRun) {
@@ -308,6 +317,34 @@ export async function doMcpAdd(args) {
308317
projectPath: mcpScope === "local" ? (projectRoot || process.cwd()) : null,
309318
});
310319

320+
// The committed file only resolves if PORTKEY_API_KEY is in the environment.
321+
// Persist the secret to the user's shell rc (owner-only) so it works out of the box.
322+
if (committed && !process.env[PORTKEY_KEY_ENV]) {
323+
const shellRc = detectShellRc();
324+
const isFish = shellRc.endsWith("config.fish");
325+
const isPwsh = shellRc.endsWith(".ps1");
326+
const isNu = shellRc.endsWith(".nu");
327+
let exportLine;
328+
if (isFish) exportLine = `set -gx ${PORTKEY_KEY_ENV} "${portkeyKey}"`;
329+
else if (isPwsh) exportLine = `$env:${PORTKEY_KEY_ENV} = "${portkeyKey}"`;
330+
else if (isNu) exportLine = `$env.${PORTKEY_KEY_ENV} = "${portkeyKey}"`;
331+
else exportLine = `export ${PORTKEY_KEY_ENV}="${portkeyKey}"`;
332+
writeShellRc(shellRc, [
333+
`# ── Portkey + Claude Code (v${VERSION}) ──`,
334+
exportLine,
335+
"# ── End Portkey + Claude Code ──",
336+
].join("\n"));
337+
warn(
338+
`.mcp.json references ${c.bold}${PORTKEY_KEY_ENV_REF}${c.reset} (no secret committed). ` +
339+
`Your key was saved to ${c.bold}${shellRc}${c.reset} — run ${c.bold}source ${shellRc}${c.reset} or open a new terminal.`
340+
);
341+
} else if (committed) {
342+
info(
343+
`.mcp.json references ${c.bold}${PORTKEY_KEY_ENV_REF}${c.reset} — no secret committed. ` +
344+
`Teammates set ${c.bold}${PORTKEY_KEY_ENV}${c.reset} in their environment.`
345+
);
346+
}
347+
311348
console.log();
312349
for (const [name, cfg] of Object.entries(mcpEntries)) {
313350
ok(`${name} ${c.dim}${cfg.url}${c.reset}`);

src/commands/claude-code/setup.js

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
VERSION,
99
PORTKEY_GATEWAY,
1010
PORTKEY_DASHBOARD,
11+
PORTKEY_KEY_ENV,
12+
PORTKEY_KEY_ENV_REF,
1113
c,
1214
ok,
1315
err,
@@ -136,7 +138,7 @@ export async function doSetup(args) {
136138
if (projectRoot) {
137139
locationOptions.push(
138140
{ value: "project-local", label: "Project (private)", hint: ".claude/settings.local.json (gitignored)" },
139-
{ value: "project-shared", label: "Project (shared)", hint: ".claude/settings.json (committed to git)" },
141+
{ value: "project-shared", label: "Project (shared)", hint: ".claude/settings.json (committed; key read from PORTKEY_API_KEY env)" },
140142
);
141143
}
142144
const picked = await p.select({
@@ -884,10 +886,13 @@ export async function doSetup(args) {
884886
ok(`Gateway config written to ${targetFile}`);
885887

886888
} else {
887-
// Write to settings.json
889+
// Write to settings.json. "project-shared" is committed to git, so reference
890+
// the key via ${PORTKEY_API_KEY} (Claude expands it from env at read time)
891+
// instead of embedding the secret in a tracked file.
892+
const committed = location === "project-shared";
888893
const envPairs = {
889894
ANTHROPIC_BASE_URL: gateway,
890-
ANTHROPIC_AUTH_TOKEN: portkeyKey,
895+
ANTHROPIC_AUTH_TOKEN: committed ? PORTKEY_KEY_ENV_REF : portkeyKey,
891896
ANTHROPIC_CUSTOM_HEADERS: extraHeaders,
892897
};
893898
if (setModelMappings) {
@@ -899,23 +904,33 @@ export async function doSetup(args) {
899904
if (model) settingsSetKey(targetFile, "model", model);
900905
ok(`Gateway config written to ${targetFile}`);
901906

902-
// Also write auth token to shell RC so Claude Code can start
907+
// Keep the real secret in the user's shell rc (owner-only), never in the
908+
// committed file. For a committed config the env var must be PORTKEY_API_KEY
909+
// so the ${PORTKEY_API_KEY} reference resolves; otherwise export the token directly.
910+
const shellVar = committed ? PORTKEY_KEY_ENV : "ANTHROPIC_AUTH_TOKEN";
903911
const shellRc = detectShellRc();
904912
const isFish = shellRc.endsWith("config.fish");
905913
const isPwsh = shellRc.endsWith(".ps1");
906914
const isNu = shellRc.endsWith(".nu");
907915
let exportLine;
908-
if (isFish) exportLine = `set -gx ANTHROPIC_AUTH_TOKEN "${portkeyKey}"`;
909-
else if (isPwsh) exportLine = `$env:ANTHROPIC_AUTH_TOKEN = "${portkeyKey}"`;
910-
else if (isNu) exportLine = `$env.ANTHROPIC_AUTH_TOKEN = "${portkeyKey}"`;
911-
else exportLine = `export ANTHROPIC_AUTH_TOKEN="${portkeyKey}"`;
916+
if (isFish) exportLine = `set -gx ${shellVar} "${portkeyKey}"`;
917+
else if (isPwsh) exportLine = `$env:${shellVar} = "${portkeyKey}"`;
918+
else if (isNu) exportLine = `$env.${shellVar} = "${portkeyKey}"`;
919+
else exportLine = `export ${shellVar}="${portkeyKey}"`;
912920

913921
writeShellRc(shellRc, [
914922
`# ── Portkey + Claude Code (v${VERSION}) ──`,
915923
exportLine,
916924
"# ── End Portkey + Claude Code ──",
917925
].join("\n"));
918-
ok(`Auth token also written to ${shellRc}`);
926+
if (committed) {
927+
warn(
928+
`${targetFile} is committed to git and references ${c.bold}${PORTKEY_KEY_ENV_REF}${c.reset} — no secret written to it. ` +
929+
`Your key was saved to ${c.bold}${shellRc}${c.reset}; teammates set ${c.bold}${PORTKEY_KEY_ENV}${c.reset} themselves.`
930+
);
931+
} else {
932+
ok(`Auth token also written to ${shellRc}`);
933+
}
919934
}
920935

921936
// Reload reminder (once, at the end)
@@ -1140,11 +1155,14 @@ async function runMcpSelection(
11401155
mcpScope === "project" ? "project-shared" : "global",
11411156
projectRoot
11421157
);
1158+
// "project" scope writes a git-committed .mcp.json — reference the key via
1159+
// ${PORTKEY_API_KEY} rather than embedding the secret in a tracked file.
1160+
const mcpKeyRef = mcpScope === "project" ? PORTKEY_KEY_ENV_REF : null;
11431161
const mcpEntries = {};
11441162
for (const slug of selected) {
11451163
const srv = servers.find((sv) => sv.slug === slug);
11461164
if (!srv) continue;
1147-
mcpEntries[`portkey-${slug}`] = buildClaudeMcpHttpConfig(srv, portkeyKey);
1165+
mcpEntries[`portkey-${slug}`] = buildClaudeMcpHttpConfig(srv, portkeyKey, { keyRef: mcpKeyRef });
11481166
}
11491167

11501168
settingsSetMcp(mcpFile, mcpEntries, {
@@ -1161,7 +1179,7 @@ async function runMcpSelection(
11611179
}
11621180

11631181
const mcpScopeLabel =
1164-
mcpScope === "project" ? `repo .mcp.json ${c.dim}(team can commit)${c.reset}` :
1182+
mcpScope === "project" ? `repo .mcp.json ${c.dim}(uses ${PORTKEY_KEY_ENV_REF}, safe to commit)${c.reset}` :
11651183
mcpScope === "local" ? `this project only ${c.dim}(~/.claude.json)${c.reset}` :
11661184
`all projects ${c.dim}(~/.claude.json)${c.reset}`;
11671185
ok(`${selected.length} MCP server${selected.length !== 1 ? "s" : ""} added [${mcpScopeLabel}]`);

src/utils.js

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ export const VERSION = pkg.version;
1212
export const PORTKEY_GATEWAY = "https://api.portkey.ai";
1313
export const PORTKEY_DASHBOARD = "https://app.portkey.ai";
1414

15+
/**
16+
* Placeholder written into team-shared / git-committed config instead of the live key.
17+
* Claude Code expands `${VAR}` in `.mcp.json` headers and `settings.json` env values,
18+
* so the secret stays in the environment (e.g. the user's shell rc) and never lands in git.
19+
*/
20+
export const PORTKEY_KEY_ENV = "PORTKEY_API_KEY";
21+
export const PORTKEY_KEY_ENV_REF = "${" + PORTKEY_KEY_ENV + "}";
22+
1523
/** Shown on multiselect prompts — Clack uses arrows, Space toggles, Enter submits */
1624
export const MULTISELECT_HINT =
1725
"↑↓ move highlight · Space = select or deselect · Enter = finish";
@@ -48,6 +56,19 @@ export function mask(key) {
4856
return "***";
4957
}
5058

59+
// ── Secure write ─────────────────────────────────────────────────────────────
60+
61+
/**
62+
* Write a file that may contain a secret (API key / auth token) with owner-only
63+
* permissions (0600). `fs.writeFileSync`'s `mode` only applies when the file is
64+
* created, so we also `chmod` to tighten any pre-existing file on the default
65+
* umask (which would otherwise leave it world-readable at 0644).
66+
*/
67+
export function writeFileSecure(filePath, data) {
68+
fs.writeFileSync(filePath, data, { mode: 0o600 });
69+
try { fs.chmodSync(filePath, 0o600); } catch {}
70+
}
71+
5172
// ── JSON helpers ─────────────────────────────────────────────────────────────
5273

5374
export function jsonRead(filePath, keyPath) {
@@ -65,15 +86,15 @@ export function settingsSetEnv(filePath, pairs) {
6586
if (!data.env) data.env = {};
6687
Object.assign(data.env, pairs);
6788
fs.mkdirSync(path.dirname(filePath), { recursive: true });
68-
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
89+
writeFileSecure(filePath, JSON.stringify(data, null, 2) + "\n");
6990
}
7091

7192
export function settingsSetKey(filePath, key, value) {
7293
let data = {};
7394
try { data = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch {}
7495
data[key] = value;
7596
fs.mkdirSync(path.dirname(filePath), { recursive: true });
76-
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
97+
writeFileSecure(filePath, JSON.stringify(data, null, 2) + "\n");
7798
}
7899

79100
export function settingsRemoveKeys(filePath, envKeys) {
@@ -83,7 +104,7 @@ export function settingsRemoveKeys(filePath, envKeys) {
83104
for (const k of envKeys) delete env[k];
84105
if (Object.keys(env).length === 0) delete data.env;
85106
else data.env = env;
86-
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
107+
writeFileSecure(filePath, JSON.stringify(data, null, 2) + "\n");
87108
}
88109

89110
// ── MCP settings helpers ─────────────────────────────────────────────────────
@@ -122,7 +143,7 @@ export function settingsSetMcp(filePath, servers, { scope = "user", projectPath
122143
if (!target.mcpServers) target.mcpServers = {};
123144
Object.assign(target.mcpServers, servers);
124145
fs.mkdirSync(path.dirname(filePath), { recursive: true });
125-
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
146+
writeFileSecure(filePath, JSON.stringify(data, null, 2) + "\n");
126147
}
127148

128149
/**
@@ -137,7 +158,7 @@ export function settingsRemoveMcp(filePath, names, { scope = "user", projectPath
137158
for (const n of names) delete servers[n];
138159
if (Object.keys(servers).length === 0) delete target.mcpServers;
139160
else target.mcpServers = servers;
140-
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
161+
writeFileSecure(filePath, JSON.stringify(data, null, 2) + "\n");
141162
}
142163

143164
/**
@@ -309,7 +330,8 @@ export function writeShellRc(filePath, block) {
309330
""
310331
);
311332
content += "\n" + block + "\n";
312-
fs.writeFileSync(filePath, content);
333+
// Shell rc may carry an exported API key — keep it owner-only.
334+
writeFileSecure(filePath, content);
313335
}
314336

315337
export function removeShellRcBlock(filePath) {

tests/api.test.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,16 @@ describe("buildClaudeMcpHttpConfig", () => {
167167
url: "https://mcp.portkey.ai/linear/mcp",
168168
});
169169
});
170+
171+
it("uses the env-var reference instead of the live key when keyRef is set (committed scope)", () => {
172+
const cfg = buildClaudeMcpHttpConfig(
173+
{ url: "https://mcp.portkey.ai/linear/mcp" },
174+
"pk-secret-123",
175+
{ keyRef: "${PORTKEY_API_KEY}" }
176+
);
177+
expect(cfg.headers["x-portkey-api-key"]).toBe("${PORTKEY_API_KEY}");
178+
expect(JSON.stringify(cfg)).not.toContain("pk-secret-123");
179+
});
170180
});
171181

172182
// ── portkeyMcpRequiresOAuth ───────────────────────────────────────────────────

tests/utils.test.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,13 @@ import {
1515
settingsReadMcp,
1616
writeShellRc,
1717
removeShellRcBlock,
18+
writeFileSecure,
1819
} from "../src/utils.js";
1920

21+
function mode(file) {
22+
return (fs.statSync(file).mode & 0o777).toString(8);
23+
}
24+
2025
function tmpDir() {
2126
return fs.mkdtempSync(path.join(os.tmpdir(), "portkey-test-"));
2227
}
@@ -140,6 +145,43 @@ describe("jsonRead", () => {
140145
});
141146
});
142147

148+
// ── writeFileSecure (0600 for secret-bearing files) ───────────────────────────
149+
150+
describe("writeFileSecure", () => {
151+
let dir;
152+
beforeEach(() => { dir = tmpDir(); });
153+
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
154+
155+
it("creates new files with owner-only 0600 permissions", () => {
156+
const file = path.join(dir, "secret.json");
157+
writeFileSecure(file, "secret");
158+
expect(mode(file)).toBe("600");
159+
});
160+
161+
it("tightens a pre-existing world-readable (0644) file to 0600", () => {
162+
const file = path.join(dir, "existing.json");
163+
fs.writeFileSync(file, "old", { mode: 0o644 });
164+
expect(mode(file)).toBe("644");
165+
writeFileSecure(file, "new");
166+
expect(mode(file)).toBe("600");
167+
});
168+
169+
it("is used by settings/mcp/shellrc writers so secrets are never world-readable", () => {
170+
const settings = path.join(dir, "settings.json");
171+
settingsSetEnv(settings, { ANTHROPIC_AUTH_TOKEN: "pk-secret" });
172+
expect(mode(settings)).toBe("600");
173+
174+
const mcp = path.join(dir, ".mcp.json");
175+
settingsSetMcp(mcp, { srv: { type: "http", url: "u" } }, { scope: "project" });
176+
expect(mode(mcp)).toBe("600");
177+
178+
const rc = path.join(dir, ".bashrc");
179+
fs.writeFileSync(rc, "# rc", { mode: 0o644 });
180+
writeShellRc(rc, "export ANTHROPIC_AUTH_TOKEN=pk-secret");
181+
expect(mode(rc)).toBe("600");
182+
});
183+
});
184+
143185
// ── settingsSetEnv / settingsRemoveKeys ───────────────────────────────────────
144186

145187
describe("settingsSetEnv + settingsRemoveKeys", () => {

0 commit comments

Comments
 (0)