Skip to content

Commit 4e2b177

Browse files
authored
Merge pull request #9 from kiran-4444/fix/apikey-as-plaintext
Stop embedding Portkey API key in committed config and tighten file perms
2 parents ce28b7c + a2dcf51 commit 4e2b177

7 files changed

Lines changed: 228 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. 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+
> Config files the CLI creates itself (`~/.claude/settings.json`, `.mcp.json`) are written with owner-only `0600` permissions. Your **shell profile** (`~/.zshrc`, `~/.bashrc`, …) is left untouched — the CLI never changes its permissions — but it **warns** if that file is readable by other users while holding a secret, so you can `chmod 600` it yourself on shared machines.
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: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,20 @@ 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,
19+
warnIfFileGroupOrWorldReadable,
1420
getMcpFilePath,
1521
settingsSetMcp,
1622
settingsRemoveMcp,
@@ -46,7 +52,7 @@ function scopeToFilePath(scope, projectRoot) {
4652

4753
function scopeLabel(scope, projectRoot) {
4854
if (scope === "project") {
49-
return `project ${c.dim}(.mcp.json in repo — team can commit)${c.reset}`;
55+
return `project ${c.dim}(.mcp.json in repo — uses ${PORTKEY_KEY_ENV_REF}, safe to commit)${c.reset}`;
5056
}
5157
if (scope === "local") {
5258
return `this project only ${c.dim}(~/.claude.json, not under .claude/settings*)${c.reset}`;
@@ -174,7 +180,7 @@ export async function doMcpAdd(args) {
174180
{
175181
value: "project",
176182
label: "Repo file (.mcp.json)",
177-
hint: "in project root — ok to commit for the team",
183+
hint: `in project root — committed; key read from ${PORTKEY_KEY_ENV} env`,
178184
},
179185
{
180186
value: "user",
@@ -290,11 +296,15 @@ export async function doMcpAdd(args) {
290296
}
291297

292298
// ── Claude write ────────────────────────────────────────────────────────────
299+
// "project" scope writes a git-committed .mcp.json, so reference the key via
300+
// ${PORTKEY_API_KEY} instead of embedding the secret in a tracked file.
301+
const committed = mcpScope === "project";
302+
const keyRef = committed ? PORTKEY_KEY_ENV_REF : null;
293303
const mcpEntries = {};
294304
for (const slug of selected) {
295305
const srv = servers.find((x) => x.slug === slug);
296306
if (!srv) continue;
297-
mcpEntries[`portkey-${slug}`] = buildClaudeMcpHttpConfig(srv, portkeyKey);
307+
mcpEntries[`portkey-${slug}`] = buildClaudeMcpHttpConfig(srv, portkeyKey, { keyRef });
298308
}
299309

300310
if (args.dryRun) {
@@ -308,6 +318,35 @@ export async function doMcpAdd(args) {
308318
projectPath: mcpScope === "local" ? (projectRoot || process.cwd()) : null,
309319
});
310320

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

src/commands/claude-code/setup.js

Lines changed: 33 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,
@@ -23,6 +25,7 @@ import {
2325
settingsSetKey,
2426
settingsSetMcp,
2527
writeShellRc,
28+
warnIfFileGroupOrWorldReadable,
2629
normalizeProvider,
2730
isClaudeInstalled,
2831
readExistingConfig,
@@ -136,7 +139,7 @@ export async function doSetup(args) {
136139
if (projectRoot) {
137140
locationOptions.push(
138141
{ 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)" },
142+
{ value: "project-shared", label: "Project (shared)", hint: ".claude/settings.json (committed; key read from PORTKEY_API_KEY env)" },
140143
);
141144
}
142145
const picked = await p.select({
@@ -841,6 +844,7 @@ export async function doSetup(args) {
841844
"# ── End Portkey + Codex ──",
842845
].join("\n"));
843846
ok(`PORTKEY_API_KEY written to ${shellRc}`);
847+
warnIfFileGroupOrWorldReadable(shellRc, "your PORTKEY_API_KEY");
844848

845849
const reloadCmd = isPwsh ? `. ${shellRc}` : `source ${shellRc}`;
846850
console.log();
@@ -882,12 +886,16 @@ export async function doSetup(args) {
882886

883887
writeShellRc(targetFile, lines.join("\n"));
884888
ok(`Gateway config written to ${targetFile}`);
889+
warnIfFileGroupOrWorldReadable(targetFile, "your ANTHROPIC_AUTH_TOKEN");
885890

886891
} else {
887-
// Write to settings.json
892+
// Write to settings.json. "project-shared" is committed to git, so reference
893+
// the key via ${PORTKEY_API_KEY} (Claude expands it from env at read time)
894+
// instead of embedding the secret in a tracked file.
895+
const committed = location === "project-shared";
888896
const envPairs = {
889897
ANTHROPIC_BASE_URL: gateway,
890-
ANTHROPIC_AUTH_TOKEN: portkeyKey,
898+
ANTHROPIC_AUTH_TOKEN: committed ? PORTKEY_KEY_ENV_REF : portkeyKey,
891899
ANTHROPIC_CUSTOM_HEADERS: extraHeaders,
892900
};
893901
if (setModelMappings) {
@@ -899,23 +907,34 @@ export async function doSetup(args) {
899907
if (model) settingsSetKey(targetFile, "model", model);
900908
ok(`Gateway config written to ${targetFile}`);
901909

902-
// Also write auth token to shell RC so Claude Code can start
910+
// Keep the real secret in the user's shell rc (owner-only), never in the
911+
// committed file. For a committed config the env var must be PORTKEY_API_KEY
912+
// so the ${PORTKEY_API_KEY} reference resolves; otherwise export the token directly.
913+
const shellVar = committed ? PORTKEY_KEY_ENV : "ANTHROPIC_AUTH_TOKEN";
903914
const shellRc = detectShellRc();
904915
const isFish = shellRc.endsWith("config.fish");
905916
const isPwsh = shellRc.endsWith(".ps1");
906917
const isNu = shellRc.endsWith(".nu");
907918
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}"`;
919+
if (isFish) exportLine = `set -gx ${shellVar} "${portkeyKey}"`;
920+
else if (isPwsh) exportLine = `$env:${shellVar} = "${portkeyKey}"`;
921+
else if (isNu) exportLine = `$env.${shellVar} = "${portkeyKey}"`;
922+
else exportLine = `export ${shellVar}="${portkeyKey}"`;
912923

913924
writeShellRc(shellRc, [
914925
`# ── Portkey + Claude Code (v${VERSION}) ──`,
915926
exportLine,
916927
"# ── End Portkey + Claude Code ──",
917928
].join("\n"));
918-
ok(`Auth token also written to ${shellRc}`);
929+
if (committed) {
930+
warn(
931+
`${targetFile} is committed to git and references ${c.bold}${PORTKEY_KEY_ENV_REF}${c.reset} — no secret written to it. ` +
932+
`Your key was saved to ${c.bold}${shellRc}${c.reset}; teammates set ${c.bold}${PORTKEY_KEY_ENV}${c.reset} themselves.`
933+
);
934+
} else {
935+
ok(`Auth token also written to ${shellRc}`);
936+
}
937+
warnIfFileGroupOrWorldReadable(shellRc, `your ${shellVar}`);
919938
}
920939

921940
// Reload reminder (once, at the end)
@@ -1140,11 +1159,14 @@ async function runMcpSelection(
11401159
mcpScope === "project" ? "project-shared" : "global",
11411160
projectRoot
11421161
);
1162+
// "project" scope writes a git-committed .mcp.json — reference the key via
1163+
// ${PORTKEY_API_KEY} rather than embedding the secret in a tracked file.
1164+
const mcpKeyRef = mcpScope === "project" ? PORTKEY_KEY_ENV_REF : null;
11431165
const mcpEntries = {};
11441166
for (const slug of selected) {
11451167
const srv = servers.find((sv) => sv.slug === slug);
11461168
if (!srv) continue;
1147-
mcpEntries[`portkey-${slug}`] = buildClaudeMcpHttpConfig(srv, portkeyKey);
1169+
mcpEntries[`portkey-${slug}`] = buildClaudeMcpHttpConfig(srv, portkeyKey, { keyRef: mcpKeyRef });
11481170
}
11491171

11501172
settingsSetMcp(mcpFile, mcpEntries, {
@@ -1161,7 +1183,7 @@ async function runMcpSelection(
11611183
}
11621184

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

src/utils.js

Lines changed: 50 additions & 5 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,40 @@ export function mask(key) {
4856
return "***";
4957
}
5058

59+
// ── Secure write ─────────────────────────────────────────────────────────────
60+
61+
/**
62+
* Write a file the CLI itself authors and that may contain a secret (API key /
63+
* auth token) with owner-only permissions (0600). `fs.writeFileSync`'s `mode`
64+
* only applies when the file is created, so we also `chmod` to tighten any
65+
* pre-existing file on the default umask (which would otherwise leave it
66+
* world-readable at 0644).
67+
*
68+
* Use this only for files we own (e.g. `.mcp.json`, `.claude/settings.json`).
69+
* Do NOT use it for user-managed files like shell rc — their permissions are the
70+
* user's choice; warn with `warnIfFileGroupOrWorldReadable` instead.
71+
*/
72+
export function writeFileSecure(filePath, data) {
73+
fs.writeFileSync(filePath, data, { mode: 0o600 });
74+
try { fs.chmodSync(filePath, 0o600); } catch {}
75+
}
76+
77+
/**
78+
* Warn (without modifying anything) when a file we just wrote a secret into is
79+
* readable by group or other. Intended for user-managed files such as shell rc,
80+
* where we deliberately never change permissions implicitly.
81+
*/
82+
export function warnIfFileGroupOrWorldReadable(filePath, secretLabel = "a credential") {
83+
let mode;
84+
try { mode = fs.statSync(filePath).mode & 0o777; } catch { return; }
85+
if (mode & 0o077) {
86+
warn(
87+
`${filePath} is readable by other users (permissions ${mode.toString(8)}) and now contains ${secretLabel}. ` +
88+
`On a shared machine, restrict it with: ${c.bold}chmod 600 ${filePath}${c.reset}`
89+
);
90+
}
91+
}
92+
5193
// ── JSON helpers ─────────────────────────────────────────────────────────────
5294

5395
export function jsonRead(filePath, keyPath) {
@@ -65,15 +107,15 @@ export function settingsSetEnv(filePath, pairs) {
65107
if (!data.env) data.env = {};
66108
Object.assign(data.env, pairs);
67109
fs.mkdirSync(path.dirname(filePath), { recursive: true });
68-
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
110+
writeFileSecure(filePath, JSON.stringify(data, null, 2) + "\n");
69111
}
70112

71113
export function settingsSetKey(filePath, key, value) {
72114
let data = {};
73115
try { data = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch {}
74116
data[key] = value;
75117
fs.mkdirSync(path.dirname(filePath), { recursive: true });
76-
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
118+
writeFileSecure(filePath, JSON.stringify(data, null, 2) + "\n");
77119
}
78120

79121
export function settingsRemoveKeys(filePath, envKeys) {
@@ -83,7 +125,7 @@ export function settingsRemoveKeys(filePath, envKeys) {
83125
for (const k of envKeys) delete env[k];
84126
if (Object.keys(env).length === 0) delete data.env;
85127
else data.env = env;
86-
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
128+
writeFileSecure(filePath, JSON.stringify(data, null, 2) + "\n");
87129
}
88130

89131
// ── MCP settings helpers ─────────────────────────────────────────────────────
@@ -122,7 +164,7 @@ export function settingsSetMcp(filePath, servers, { scope = "user", projectPath
122164
if (!target.mcpServers) target.mcpServers = {};
123165
Object.assign(target.mcpServers, servers);
124166
fs.mkdirSync(path.dirname(filePath), { recursive: true });
125-
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
167+
writeFileSecure(filePath, JSON.stringify(data, null, 2) + "\n");
126168
}
127169

128170
/**
@@ -137,7 +179,7 @@ export function settingsRemoveMcp(filePath, names, { scope = "user", projectPath
137179
for (const n of names) delete servers[n];
138180
if (Object.keys(servers).length === 0) delete target.mcpServers;
139181
else target.mcpServers = servers;
140-
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
182+
writeFileSecure(filePath, JSON.stringify(data, null, 2) + "\n");
141183
}
142184

143185
/**
@@ -309,6 +351,9 @@ export function writeShellRc(filePath, block) {
309351
""
310352
);
311353
content += "\n" + block + "\n";
354+
// Preserve the user's existing permissions — never implicitly chmod a
355+
// user-managed shell rc. Callers warn via warnIfFileGroupOrWorldReadable
356+
// when the block contains a secret.
312357
fs.writeFileSync(filePath, content);
313358
}
314359

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 ───────────────────────────────────────────────────

0 commit comments

Comments
 (0)