Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.

Commit d446431

Browse files
Andrew Eyeclaude
authored andcommitted
FEA-1436: harden admin-key secret handling + address pre-PR review
Independent pre-PR review (two reviewers) returned SHIP WITH MINOR FIXES. This commit resolves the actionable findings: - admin-billing: redact key-shaped tokens (sk-…) from vendor error bodies before they reach a thrown error, IPC reply, or the log file. OpenAI's 401 body echoes a copy of the key it received; the vendor response is outside our control, so requestAdminJson now scrubs it via redactKeyLikeTokens. Fixes the "key never in error message" invariant and corrects the now-accurate comments. - claude-code-analytics-client: tokenCount rejects negative values (matches its non-negative contract); assertUtcDayString round-trips the parsed date so an impossible calendar day (e.g. 2026-02-30, which JS silently overflows) is rejected instead of being silently shifted. - renderer: stop shadowing the browser `window` global in describeClaudeCodeResult; derive the usage-total label from the actual query window instead of hardcoding "7-day total". - reconciliation-worker: document why the dashboard.db handle is intentionally opened read-write (a SQLITE_OPEN_READONLY connection cannot attach to the sidecar's live WAL database without an existing writer) — declined the readOnly:true suggestion to avoid an intermittent open failure. Testing: - pnpm typecheck: clean - pnpm lint: clean - pnpm test: 2035 pass / 0 fail (added redaction, negative-token, impossible-date, and service key-absence regression tests asserting the exact reviewed invariants) Risks: - redactKeyLikeTokens over-redacts by design (any sk-… run → sk-[redacted]); diagnostic bodies stay readable, secrets cannot leak. - reconciliation read path unchanged (read-write open, SELECT-only) — no behavioral change to WAL coexistence. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 831b69b commit d446431

8 files changed

Lines changed: 151 additions & 19 deletions

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "desktop",
3-
"version": "0.15.101",
3+
"version": "0.15.102",
44
"description": "ClosedLoop Desktop",
55
"author": "ClosedLoop AI <support@closedloop.ai>",
66
"private": true,

apps/desktop/src/main/admin-billing.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@
1414
* 2. Every outbound request host is checked against a fixed allowlist
1515
* (api.anthropic.com / api.openai.com) via assertAllowedAdminHost, so a
1616
* misconfigured base URL can never ship the Admin key to another host.
17-
* Errors thrown here include the HTTP status and the vendor's own error body
18-
* (which never contains your key) but never the key itself.
17+
* Errors thrown here include the HTTP status and a truncated copy of the
18+
* vendor's error body, scrubbed of any key-shaped token via redactKeyLikeTokens.
19+
* We only ever put the key in request headers, but the vendor's *response* is
20+
* outside our control — OpenAI's 401 body, for instance, echoes a masked copy of
21+
* the key it received — so we redact before the body can reach an error message,
22+
* an IPC reply, or the log file. The key itself is never placed in an error.
1923
*/
2024
import {
2125
centsToMicroCents,
@@ -87,10 +91,29 @@ export function assertAllowedAdminHost(url: string, allowedHost: string): void {
8791
}
8892
}
8993

94+
/**
95+
* Redact anything that looks like an API key from a string before it is placed
96+
* in a thrown error or a log line. We send the Admin key only in request headers,
97+
* but a vendor's error *response* is outside our control and can echo a copy of
98+
* the key it received — e.g. OpenAI's 401 body: "Incorrect API key provided:
99+
* sk-admin-…". The token class matches the characters a real key is made of
100+
* (alphanumerics, `-`, `_`) plus `*` to also catch the asterisk-masked forms
101+
* vendors print; it deliberately excludes `.` so a trailing sentence period is
102+
* left intact. The full (unmasked) key is the only true secret and is always a
103+
* contiguous run of these characters, so it is always fully scrubbed. Over-
104+
* redaction is the intended posture: a secret leak is far worse than a slightly
105+
* noisier diagnostic.
106+
*/
107+
export function redactKeyLikeTokens(text: string): string {
108+
return text.replace(/sk-[A-Za-z0-9*_-]{4,}/g, "sk-[redacted]");
109+
}
110+
90111
/**
91112
* GET `url` with `headers`, returning the parsed JSON body. Throws on a non-2xx
92-
* with the status and a truncated copy of the vendor's error body (never the
93-
* Admin key, which is only ever in the request headers).
113+
* with the status and a truncated, key-scrubbed copy of the vendor's error body.
114+
* The Admin key is only ever in the request headers; redactKeyLikeTokens scrubs
115+
* any key-shaped token the vendor may echo back, so the key never lands in the
116+
* thrown error.
94117
*/
95118
export async function requestAdminJson(
96119
url: string,
@@ -103,7 +126,9 @@ export async function requestAdminJson(
103126
let bodyHint = "";
104127
try {
105128
const body = await response.text();
106-
bodyHint = body ? `: ${body.slice(0, 200)}` : "";
129+
// Redact before truncating so a key straddling the 200-char cut can't
130+
// survive in a half-scrubbed form.
131+
bodyHint = body ? `: ${redactKeyLikeTokens(body).slice(0, 200)}` : "";
107132
} catch {
108133
bodyHint = "";
109134
}

apps/desktop/src/main/claude-code-analytics-client.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ function assertUtcDayString(value: string, field: string): string {
106106
if (Number.isNaN(parsed.getTime())) {
107107
throw new Error(`Claude Code analytics: ${field} is not a real date`);
108108
}
109+
// JS Date silently overflows out-of-range days (e.g. 2024-02-30 → 2024-03-01),
110+
// so the NaN check above is not enough. Require the parsed date to round-trip
111+
// back to the same string, which rejects impossible calendar days.
112+
if (parsed.toISOString().slice(0, 10) !== value) {
113+
throw new Error(`Claude Code analytics: ${field} is not a real date`);
114+
}
109115
return value;
110116
}
111117

@@ -243,6 +249,9 @@ function tokenCount(raw: unknown): number {
243249
if (typeof raw !== "number" || !Number.isFinite(raw)) {
244250
throw new Error("Claude Code analytics: token count must be a number");
245251
}
252+
if (raw < 0) {
253+
throw new Error("Claude Code analytics: token count must be non-negative");
254+
}
246255
return raw;
247256
}
248257

703 Bytes
Binary file not shown.

apps/desktop/src/renderer/index.html

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5260,12 +5260,22 @@ <h3 class="settings-group-title">Labs</h3>
52605260
if (count === 0) {
52615261
return "No Claude Code usage reported for this window (requires a Team or Enterprise org).";
52625262
}
5263-
const window = result.window;
5264-
const range = window ? ` (${window.startDay}${window.endDay})` : "";
5263+
// Local name — NOT the browser `window` global (which we never need here).
5264+
const queryWindow = result.window;
5265+
const range = queryWindow ? ` (${queryWindow.startDay}${queryWindow.endDay})` : "";
52655266
return `Anthropic's estimate${range}.`;
52665267
}
52675268

5268-
function renderClaudeCodeTotal(rows) {
5269+
// Inclusive day count for a {startDay, endDay} window, or null if unparseable.
5270+
function claudeCodeWindowDayCount(queryWindow) {
5271+
if (!queryWindow || !queryWindow.startDay || !queryWindow.endDay) return null;
5272+
const start = Date.parse(`${queryWindow.startDay}T00:00:00Z`);
5273+
const end = Date.parse(`${queryWindow.endDay}T00:00:00Z`);
5274+
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null;
5275+
return Math.round((end - start) / 86400000) + 1;
5276+
}
5277+
5278+
function renderClaudeCodeTotal(rows, queryWindow) {
52695279
if (!claudeCodeUsageTotal) return;
52705280
if (!Array.isArray(rows) || rows.length === 0) {
52715281
claudeCodeUsageTotal.textContent = "";
@@ -5277,7 +5287,11 @@ <h3 class="settings-group-title">Labs</h3>
52775287
total += row.estimatedCostMicroCents;
52785288
}
52795289
}
5280-
claudeCodeUsageTotal.textContent = `7-day total: ${formatUsdFromMicroCents(total)}`;
5290+
// Derive the label from the actual window so it stays correct if the
5291+
// window ever becomes configurable, instead of hardcoding "7-day".
5292+
const days = claudeCodeWindowDayCount(queryWindow);
5293+
const label = days ? `${days}-day total` : "Total";
5294+
claudeCodeUsageTotal.textContent = `${label}: ${formatUsdFromMicroCents(total)}`;
52815295
}
52825296

52835297
async function refreshClaudeCodeUsage() {
@@ -5296,7 +5310,7 @@ <h3 class="settings-group-title">Labs</h3>
52965310
const records = result && Array.isArray(result.records) ? result.records : [];
52975311
const rows = aggregateClaudeCodeRecords(records);
52985312
renderClaudeCodeUsageTable(rows);
5299-
renderClaudeCodeTotal(rows);
5313+
renderClaudeCodeTotal(rows, result && result.window);
53005314
if (claudeCodeUsageMessage) {
53015315
claudeCodeUsageMessage.textContent = describeClaudeCodeResult(result);
53025316
}

apps/desktop/test/admin-cost-clients.test.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,19 @@
1515
* (4) pagination follows next_page and concatenates pages, and exceeding the
1616
* page cap throws rather than returning a partial (understated) bill;
1717
* (5) a non-2xx response throws with the status, and malformed money throws
18-
* rather than silently dropping a charge.
18+
* rather than silently dropping a charge;
19+
* (6) the thrown non-2xx error is scrubbed of any key-shaped token the vendor
20+
* echoes back in its error body, so the Admin key never lands in an error
21+
* message (and from there an IPC reply or the log file).
1922
*
2023
* The network is never touched: a recording fake fetch returns canned bodies.
2124
*/
2225
import assert from "node:assert/strict";
2326
import { test } from "node:test";
24-
import { assertAllowedAdminHost } from "../src/main/admin-billing.js";
27+
import {
28+
assertAllowedAdminHost,
29+
redactKeyLikeTokens,
30+
} from "../src/main/admin-billing.js";
2531
import { AnthropicAdminClient } from "../src/main/anthropic-admin-client.js";
2632
import { OpenAiAdminClient } from "../src/main/openai-admin-client.js";
2733
import { makeFetch } from "./helpers/admin-fetch.js";
@@ -154,15 +160,51 @@ test("Anthropic: follows next_page pagination and concatenates results", async (
154160
assert.ok(calls[1].url.includes("page=PAGE_2_TOKEN"));
155161
});
156162

157-
test("Anthropic: a non-2xx response throws with the status", async () => {
163+
test("Anthropic: a non-2xx response throws with the status, key scrubbed from the body", async () => {
164+
// Model a vendor 401 body that echoes the key it received (OpenAI does this;
165+
// we treat any vendor body as untrusted and scrub it).
158166
const { fetch } = makeFetch([{ error: "nope" }], {
159167
status: 401,
160-
bodyText: '{"error":"invalid x-api-key"}',
168+
bodyText:
169+
'{"error":"Incorrect API key provided: sk-ant-admin-TEST. Check your key."}',
161170
});
162171
const client = new AnthropicAdminClient({ apiKey: "sk-ant-admin-TEST", fetch });
163172
await assert.rejects(
164173
() => client.fetchCostReport({ startingAt: "2026-05-20T00:00:00Z" }),
165-
/Anthropic admin API HTTP 401/,
174+
(err: unknown) => {
175+
const message = (err as Error).message;
176+
assert.match(message, /Anthropic admin API HTTP 401/);
177+
// The exact key string must NOT appear; it is redacted instead.
178+
assert.ok(
179+
!message.includes("sk-ant-admin-TEST"),
180+
"error message must not contain the Admin key",
181+
);
182+
assert.match(message, /sk-\[redacted\]/);
183+
return true;
184+
},
185+
);
186+
});
187+
188+
test("redactKeyLikeTokens scrubs plain and masked keys but leaves prose intact", () => {
189+
// Plain key.
190+
assert.equal(
191+
redactKeyLikeTokens("Incorrect API key provided: sk-ant-admin-TEST."),
192+
"Incorrect API key provided: sk-[redacted].",
193+
);
194+
// OpenAI-style asterisk-masked key.
195+
assert.equal(
196+
redactKeyLikeTokens("provided: sk-admin-****************************tK8F."),
197+
"provided: sk-[redacted].",
198+
);
199+
// Both vendor prefixes in one body.
200+
assert.equal(
201+
redactKeyLikeTokens("sk-admin-AAAA and sk-ant-admin-BBBB"),
202+
"sk-[redacted] and sk-[redacted]",
203+
);
204+
// Non-key text is untouched (no false positives on ordinary words).
205+
assert.equal(
206+
redactKeyLikeTokens("rate limit exceeded for this organization"),
207+
"rate limit exceeded for this organization",
166208
);
167209
});
168210

apps/desktop/test/claude-code-analytics-client.test.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@
1212
* (3) the client loops ONE request per UTC day across the window;
1313
* (4) within a day it follows next_page and concatenates pages;
1414
* (5) a non-2xx throws with the status; malformed money throws rather than
15-
* silently dropping spend; exceeding the per-day page cap throws rather
16-
* than returning a partial usage picture;
15+
* silently dropping spend; a negative token count throws; exceeding the
16+
* per-day page cap throws rather than returning a partial usage picture;
1717
* (6) an unknown/future actor shape is surfaced (not dropped) under a stable
18-
* label, and an inverted/oversized window is rejected.
18+
* label, and an inverted/oversized/impossible-date window is rejected.
1919
*
2020
* The network is never touched: the shared recording fake fetch returns canned
2121
* bodies (see test/helpers/admin-fetch.ts).
@@ -236,6 +236,35 @@ test("malformed estimated cost throws rather than dropping spend", async () => {
236236
);
237237
});
238238

239+
test("a negative token count throws rather than recording a nonsensical value", async () => {
240+
const { fetch } = makeFetch([
241+
{
242+
data: [
243+
{
244+
actor: { type: "user_actor", email_address: "a@example.com" },
245+
model_breakdown: [
246+
{
247+
model: "m1",
248+
estimated_cost: { amount: 100, currency: "USD" },
249+
tokens: { input: -5, output: 10 },
250+
},
251+
],
252+
},
253+
],
254+
has_more: false,
255+
next_page: null,
256+
},
257+
]);
258+
const client = new ClaudeCodeAnalyticsClient({
259+
apiKey: "sk-ant-admin-TEST",
260+
fetch,
261+
});
262+
await assert.rejects(
263+
() => client.fetchUsage({ startDay: "2026-05-20", endDay: "2026-05-20" }),
264+
/token count must be non-negative/,
265+
);
266+
});
267+
239268
test("exceeding the per-day page cap throws instead of returning partial usage", async () => {
240269
const alwaysMore = {
241270
data: [
@@ -305,6 +334,12 @@ test("rejects an inverted window and a bad day string", async () => {
305334
() => client.fetchUsage({ startDay: "2026/05/20", endDay: "2026-05-20" }),
306335
/startDay must be YYYY-MM-DD/,
307336
);
337+
// A well-formed but impossible calendar day (Feb 30) must be rejected — JS
338+
// Date silently overflows it to March 1, so the shape check alone is not enough.
339+
await assert.rejects(
340+
() => client.fetchUsage({ startDay: "2026-02-30", endDay: "2026-02-30" }),
341+
/startDay is not a real date/,
342+
);
308343
});
309344

310345
test("rejects an empty Admin key at construction", () => {

apps/desktop/test/cost-reconciliation-service.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,13 @@ test("one vendor failing does not abort the other (per-vendor isolation)", async
230230
assert.equal(summary.errors.length, 1);
231231
assert.equal(summary.errors[0].vendor, "anthropic");
232232
assert.match(summary.errors[0].message, /HTTP 401/);
233+
// The Admin key the service holds must never surface in the IPC-visible
234+
// summary (the real client redacts upstream in requestAdminJson; the service
235+
// must not reintroduce it). Guards the renderer-visible error path.
236+
assert.ok(
237+
!summary.errors[0].message.includes("sk-ant-admin-bad"),
238+
"summary error must not contain the Admin key",
239+
);
233240
// The healthy vendor still produced a row.
234241
assert.equal(summary.rowsWritten, 1);
235242
});

0 commit comments

Comments
 (0)