@agent-finops/core 0.1.0 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agentInventory.d.ts +86 -0
- package/dist/agentInventory.js +324 -0
- package/dist/analyze.js +39 -8
- package/dist/cutList.d.ts +37 -0
- package/dist/cutList.js +49 -2
- package/dist/deadContext.d.ts +72 -0
- package/dist/deadContext.js +174 -0
- package/dist/discovery.d.ts +2 -0
- package/dist/discovery.js +56 -9
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/modelPricing.js +1 -0
- package/dist/providerConnectors.d.ts +5 -1
- package/dist/providerConnectors.js +128 -21
- package/dist/scanGuard.d.ts +8 -0
- package/dist/scanGuard.js +65 -0
- package/dist/schema.d.ts +29 -29
- package/dist/toolInvocations.d.ts +61 -0
- package/dist/toolInvocations.js +186 -0
- package/package.json +9 -4
- package/samples/anthropic-usage.csv +3 -3
- package/samples/openai-usage.csv +6 -6
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createProviderConnectorStub, slugifySourceId } from "./sourceRegistry.js";
|
|
2
|
+
import { redactSecrets } from "./discovery.js";
|
|
2
3
|
export function normalizeOpenAiCostResponse(response, options) {
|
|
3
4
|
const data = isObject(response) && Array.isArray(response.data) ? response.data : [];
|
|
4
5
|
const records = [];
|
|
@@ -257,12 +258,15 @@ export function normalizeCursorSpendResponse(response, options) {
|
|
|
257
258
|
return [{
|
|
258
259
|
id: slugifySourceId(["cursor-spend", options.accountId, userId].filter(Boolean).join("-")),
|
|
259
260
|
timestamp,
|
|
260
|
-
|
|
261
|
+
// The Cursor connector is spec-built and not yet live-verified (beta),
|
|
262
|
+
// so its dollars are labeled estimated until reconciled against a real
|
|
263
|
+
// team's invoice. Never stamp "verified" on data we haven't verified.
|
|
264
|
+
source: { id: options.sourceId, name: "Cursor Admin API", provider: "cursor", confidence: "estimated", observedFrom: options.observedFrom },
|
|
261
265
|
model: "cursor-team-usage",
|
|
262
266
|
inputTokens: 0,
|
|
263
267
|
outputTokens: 0,
|
|
264
268
|
amountUsd: cents / 100,
|
|
265
|
-
costConfidence: "
|
|
269
|
+
costConfidence: "estimated",
|
|
266
270
|
userId,
|
|
267
271
|
projectId: options.accountId,
|
|
268
272
|
providerCostType: "cursor_spend",
|
|
@@ -299,7 +303,7 @@ async function fetchOpenAi(input, token, fetcher, sourceId) {
|
|
|
299
303
|
...costFetch.pages.flatMap((page) => normalizeOpenAiCostResponse(page, { sourceId, observedFrom: "OpenAI organization costs API" })),
|
|
300
304
|
...usageFetch.pages.flatMap((page) => normalizeOpenAiUsageResponse(page, { sourceId, observedFrom: "OpenAI organization usage API" }))
|
|
301
305
|
];
|
|
302
|
-
return providerResult("openai", sourceId, input.authReference, records,
|
|
306
|
+
return providerResult("openai", sourceId, input.authReference, records, qaSummary("openai", [costFetch, usageFetch]));
|
|
303
307
|
}
|
|
304
308
|
async function fetchAnthropic(input, token, fetcher, sourceId) {
|
|
305
309
|
const costRequest = {
|
|
@@ -312,7 +316,7 @@ async function fetchAnthropic(input, token, fetcher, sourceId) {
|
|
|
312
316
|
...costFetch.pages.flatMap((page) => normalizeAnthropicCostResponse(page, { sourceId, observedFrom: "Anthropic Admin Cost Report" })),
|
|
313
317
|
...claudeCodeFetches.flatMap((fetchResult) => fetchResult.pages.flatMap((page) => normalizeAnthropicClaudeCodeUsageResponse(page, { sourceId, observedFrom: "Anthropic Claude Code Usage Report", accountId: input.accountId })))
|
|
314
318
|
];
|
|
315
|
-
return providerResult("anthropic", sourceId, input.authReference, records,
|
|
319
|
+
return providerResult("anthropic", sourceId, input.authReference, records, qaSummary("anthropic", [costFetch, ...claudeCodeFetches]));
|
|
316
320
|
}
|
|
317
321
|
async function fetchGitHubCopilot(input, token, fetcher, sourceId) {
|
|
318
322
|
const accountId = input.org ?? input.enterprise;
|
|
@@ -326,7 +330,7 @@ async function fetchGitHubCopilot(input, token, fetcher, sourceId) {
|
|
|
326
330
|
const seatFetch = input.org ? await fetchPaginatedJson(fetcher, buildGitHubCopilotSeatsUrl(input.org), request, "github-copilot", "GitHub Copilot seats") : undefined;
|
|
327
331
|
const metricsRecords = metricsFetch.pages.flatMap((page) => normalizeGitHubCopilotMetricsResponse(page, { sourceId, observedFrom: "GitHub Copilot metrics API", accountId }));
|
|
328
332
|
const seatRecords = seatFetch ? seatFetch.pages.flatMap((page) => normalizeGitHubCopilotSeatResponse(page, { sourceId, observedFrom: "GitHub Copilot billing seats API", accountId })) : [];
|
|
329
|
-
return providerResult("github-copilot", sourceId, input.authReference, [...metricsRecords, ...seatRecords],
|
|
333
|
+
return providerResult("github-copilot", sourceId, input.authReference, [...metricsRecords, ...seatRecords], qaSummary("github-copilot", [metricsFetch, ...(seatFetch ? [seatFetch] : [])]));
|
|
330
334
|
}
|
|
331
335
|
async function fetchCursor(input, token, fetcher, sourceId) {
|
|
332
336
|
const accountId = input.accountId ?? input.org ?? "cursor-team";
|
|
@@ -337,13 +341,21 @@ async function fetchCursor(input, token, fetcher, sourceId) {
|
|
|
337
341
|
}, "cursor", "Cursor Admin API spend");
|
|
338
342
|
const page = response.payload;
|
|
339
343
|
const records = normalizeCursorSpendResponse(page, { sourceId, observedFrom: "Cursor Admin API", accountId });
|
|
344
|
+
// The Cursor connector is matched to the published spec but not live-verified.
|
|
345
|
+
// If the API answered with content but no spend fields we recognize, say so
|
|
346
|
+
// loudly rather than silently report $0 (which reads as "you spent nothing").
|
|
347
|
+
if (records.length === 0 && isRecord(page) && Object.keys(page).length > 0) {
|
|
348
|
+
throw new Error("Cursor returned data but no spend fields this connector recognizes " +
|
|
349
|
+
`(saw: ${Object.keys(page).slice(0, 8).join(", ")}). The Cursor connector is beta — ` +
|
|
350
|
+
"please open an issue with this field list so we can map it: https://github.com/futurastudio/ai-spend-agent/issues");
|
|
351
|
+
}
|
|
340
352
|
const singleFetch = {
|
|
341
353
|
pages: [page],
|
|
342
354
|
pagination: { label: "Cursor Admin API spend", pagesFetched: 1, stoppedBecause: "complete", maxPages: 1 },
|
|
343
355
|
rateLimits: response.rateLimit ? [response.rateLimit] : [],
|
|
344
356
|
responseDrift: detectResponseDrift(page, "cursor", "Cursor Admin API spend")
|
|
345
357
|
};
|
|
346
|
-
return providerResult("cursor", sourceId, input.authReference, records,
|
|
358
|
+
return providerResult("cursor", sourceId, input.authReference, records, qaSummary("cursor", [singleFetch]));
|
|
347
359
|
}
|
|
348
360
|
async function fetchPaginatedJson(fetcher, initialUrl, request, provider, label) {
|
|
349
361
|
const pages = [];
|
|
@@ -351,9 +363,24 @@ async function fetchPaginatedJson(fetcher, initialUrl, request, provider, label)
|
|
|
351
363
|
const responseDrift = [];
|
|
352
364
|
let nextUrl = initialUrl;
|
|
353
365
|
let stoppedBecause = "complete";
|
|
366
|
+
let note;
|
|
354
367
|
const maxPages = 50;
|
|
355
368
|
for (let pageCount = 0; nextUrl && pageCount < maxPages; pageCount += 1) {
|
|
356
|
-
|
|
369
|
+
let response;
|
|
370
|
+
try {
|
|
371
|
+
response = await fetchJsonOrThrow(fetcher, nextUrl, request, provider, label);
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
// A mid-pagination failure (after retries) must not discard the pages
|
|
375
|
+
// already fetched — return partial results with an explicit QA note.
|
|
376
|
+
// A failure on the FIRST page (auth, bad scope) still throws.
|
|
377
|
+
if (pages.length === 0)
|
|
378
|
+
throw error;
|
|
379
|
+
stoppedBecause = "fetch_error";
|
|
380
|
+
note = `Stopped after ${pages.length} page(s): ${error instanceof Error ? error.message : String(error)}`;
|
|
381
|
+
nextUrl = undefined;
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
357
384
|
const page = response.payload;
|
|
358
385
|
pages.push(page);
|
|
359
386
|
if (response.rateLimit)
|
|
@@ -380,7 +407,7 @@ async function fetchPaginatedJson(fetcher, initialUrl, request, provider, label)
|
|
|
380
407
|
}
|
|
381
408
|
return {
|
|
382
409
|
pages,
|
|
383
|
-
pagination: { label, pagesFetched: pages.length, stoppedBecause, maxPages, limitPerPage: limitPerPageFromUrl(initialUrl) },
|
|
410
|
+
pagination: { label, pagesFetched: pages.length, stoppedBecause, maxPages, limitPerPage: limitPerPageFromUrl(initialUrl), ...(note ? { note } : {}) },
|
|
384
411
|
rateLimits,
|
|
385
412
|
responseDrift
|
|
386
413
|
};
|
|
@@ -390,17 +417,60 @@ async function fetchDateRangeJson(fetcher, buildUrl, startTime, endTime, request
|
|
|
390
417
|
const daySeconds = 24 * 60 * 60;
|
|
391
418
|
const finalTime = endTime ?? startTime;
|
|
392
419
|
for (let cursor = startTime, count = 0; cursor <= finalTime && count < 370; cursor += daySeconds, count += 1) {
|
|
393
|
-
|
|
420
|
+
try {
|
|
421
|
+
results.push(await fetchPaginatedJson(fetcher, buildUrl(cursor), request, provider, label));
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
// Persistent failure mid-range: keep the days already fetched and note
|
|
425
|
+
// where the sync stopped instead of discarding everything. First-day
|
|
426
|
+
// failures (bad auth/scope) still throw so the user sees the real error.
|
|
427
|
+
if (results.length === 0)
|
|
428
|
+
throw error;
|
|
429
|
+
results.push({
|
|
430
|
+
pages: [],
|
|
431
|
+
pagination: {
|
|
432
|
+
label,
|
|
433
|
+
pagesFetched: 0,
|
|
434
|
+
stoppedBecause: "fetch_error",
|
|
435
|
+
maxPages: 50,
|
|
436
|
+
note: `Day range stopped early after ${results.length} day(s): ${error instanceof Error ? error.message : String(error)}`
|
|
437
|
+
},
|
|
438
|
+
rateLimits: [],
|
|
439
|
+
responseDrift: []
|
|
440
|
+
});
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
394
443
|
}
|
|
395
444
|
return results;
|
|
396
445
|
}
|
|
446
|
+
/** Retries per request on 429/5xx before giving up (initial try + retries). */
|
|
447
|
+
const maxFetchRetries = 2;
|
|
448
|
+
/** Cap on how long a retry-after header can make us wait, per attempt. */
|
|
449
|
+
const maxRetryDelayMs = 30_000;
|
|
397
450
|
async function fetchJsonOrThrow(fetcher, url, request, provider, label) {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
451
|
+
let lastError;
|
|
452
|
+
for (let attempt = 0; attempt <= maxFetchRetries; attempt += 1) {
|
|
453
|
+
const response = await fetcher(url, request);
|
|
454
|
+
const payload = await response.json().catch(() => undefined);
|
|
455
|
+
if (response.ok) {
|
|
456
|
+
return { payload, rateLimit: rateLimitFromHeaders(label, response.headers), headers: response.headers };
|
|
457
|
+
}
|
|
458
|
+
lastError = new Error(providerPermissionPrompt(provider, label, response, payload));
|
|
459
|
+
// 429 and 5xx are transient: honor retry-after when present, otherwise
|
|
460
|
+
// back off briefly and try again. 4xx auth/scope errors fail immediately.
|
|
461
|
+
const retryable = response.status === 429 || response.status >= 500;
|
|
462
|
+
if (!retryable || attempt === maxFetchRetries) {
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
const retryAfterSeconds = headerNumber(response.headers, "retry-after");
|
|
466
|
+
const delayMs = typeof retryAfterSeconds === "number"
|
|
467
|
+
? Math.min(Math.max(retryAfterSeconds, 0) * 1000, maxRetryDelayMs)
|
|
468
|
+
: 500 * 2 ** attempt;
|
|
469
|
+
if (delayMs > 0) {
|
|
470
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs));
|
|
471
|
+
}
|
|
402
472
|
}
|
|
403
|
-
|
|
473
|
+
throw lastError ?? new Error(`${label} request failed.`);
|
|
404
474
|
}
|
|
405
475
|
function nextPageFromPayload(payload) {
|
|
406
476
|
if (!isRecord(payload))
|
|
@@ -479,13 +549,31 @@ function walkProviderFields(value, path, visit) {
|
|
|
479
549
|
}
|
|
480
550
|
}
|
|
481
551
|
function knownProviderFields(provider, label) {
|
|
482
|
-
|
|
552
|
+
// Every provider MUST enumerate the fields its normalizer consumes; a
|
|
553
|
+
// fall-through to `common` alone flags every legitimate field of every page
|
|
554
|
+
// as drift, burying real drift signals in thousands of false positives.
|
|
555
|
+
const common = ["data", "data[]", "has_more", "hasMore", "next_page", "nextPage", "object", "links", "links.next"];
|
|
483
556
|
if (provider === "openai" && label.includes("costs")) {
|
|
484
557
|
return new Set([...common, "data[].object", "data[].start_time", "data[].end_time", "data[].results", "data[].results[]", "data[].results[].object", "data[].results[].amount", "data[].results[].amount.value", "data[].results[].amount.currency", "data[].results[].line_item", "data[].results[].project_id", "data[].results[].api_key_id", "data[].results[].quantity"]);
|
|
485
558
|
}
|
|
486
559
|
if (provider === "openai" && label.includes("usage")) {
|
|
487
560
|
return new Set([...common, "data[].object", "data[].start_time", "data[].end_time", "data[].results", "data[].results[]", "data[].results[].object", "data[].results[].input_tokens", "data[].results[].output_tokens", "data[].results[].input_cached_tokens", "data[].results[].input_audio_tokens", "data[].results[].output_audio_tokens", "data[].results[].num_model_requests", "data[].results[].project_id", "data[].results[].user_id", "data[].results[].api_key_id", "data[].results[].model"]);
|
|
488
561
|
}
|
|
562
|
+
if (provider === "anthropic" && label.toLowerCase().includes("cost")) {
|
|
563
|
+
return new Set([...common, "data[].starting_at", "data[].ending_at", "data[].results", "data[].results[]", "data[].results[].amount", "data[].results[].currency", "data[].results[].cost_type", "data[].results[].description", "data[].results[].model", "data[].results[].workspace_id", "data[].results[].token_type", "data[].results[].service_tier", "data[].results[].context_window"]);
|
|
564
|
+
}
|
|
565
|
+
if (provider === "anthropic" && label.toLowerCase().includes("claude code")) {
|
|
566
|
+
return new Set([...common, "data[].date", "data[].actor", "data[].actor.email_address", "data[].actor.api_key_name", "data[].actor.id", "data[].actor.type", "data[].organization_id", "data[].customer_type", "data[].terminal_type", "data[].subscription_type", "data[].core_metrics", "data[].core_metrics.num_sessions", "data[].core_metrics.lines_of_code", "data[].core_metrics.lines_of_code.added", "data[].core_metrics.lines_of_code.removed", "data[].core_metrics.commits_by_claude_code", "data[].core_metrics.pull_requests_by_claude_code", "data[].model_breakdown", "data[].model_breakdown[]", "data[].model_breakdown[].model", "data[].model_breakdown[].tokens", "data[].model_breakdown[].tokens.input", "data[].model_breakdown[].tokens.output", "data[].model_breakdown[].tokens.cache_read", "data[].model_breakdown[].tokens.cache_creation", "data[].model_breakdown[].estimated_cost", "data[].model_breakdown[].estimated_cost.currency", "data[].model_breakdown[].estimated_cost.amount", "data[].tool_actions", "data[].tool_actions[]"]);
|
|
567
|
+
}
|
|
568
|
+
if (provider === "github-copilot" && label.toLowerCase().includes("metrics")) {
|
|
569
|
+
return new Set([...common, "day_totals", "day_totals[]", "day_totals[].day", "day_totals[].daily_active_users", "day_totals[].totals_by_model_feature", "day_totals[].totals_by_model_feature[]", "day_totals[].totals_by_model_feature[].model", "day_totals[].totals_by_model_feature[].feature", "day_totals[].totals_by_model_feature[].engaged_users", "day_totals[].totals_by_model_feature[].total_requests", "day_totals[].totals_by_model_feature[].user_initiated_interaction_count", "day_totals[].totals_by_cli", "day_totals[].totals_by_cli.request_count", "day_totals[].totals_by_cli.token_usage", "day_totals[].totals_by_cli.token_usage.prompt_tokens_sum", "day_totals[].totals_by_cli.token_usage.output_tokens_sum", "day_totals[].totals_by_cli.engaged_users", "day_totals[].totals_by_cli.total_requests", "report_start_day", "report_end_day", "generated_at"]);
|
|
570
|
+
}
|
|
571
|
+
if (provider === "github-copilot" && label.toLowerCase().includes("seats")) {
|
|
572
|
+
return new Set([...common, "total_seats", "plan_type", "seats", "seats[]", "seats[].created_at", "seats[].updated_at", "seats[].pending_cancellation_date", "seats[].last_activity_at", "seats[].last_activity_editor", "seats[].plan_type", "seats[].login", "seats[].id", "seats[].assignee", "seats[].assignee.login", "seats[].assignee.email", "seats[].assignee.id", "seats[].assignee.node_id", "seats[].assignee.avatar_url", "seats[].assignee.html_url", "seats[].assignee.type", "seats[].assignee.site_admin", "seats[].assigning_team", "seats[].organization"]);
|
|
573
|
+
}
|
|
574
|
+
if (provider === "cursor") {
|
|
575
|
+
return new Set([...common, "users", "users[]", "users[].email", "users[].emailAddress", "users[].userId", "users[].id", "users[].name", "users[].role", "users[].spendCents", "users[].usageBasedCents", "users[].chargedCents", "users[].fastPremiumRequests", "users[].hardLimitOverrideDollars", "data[].email", "data[].emailAddress", "data[].userId", "data[].id", "data[].name", "data[].role", "data[].spendCents", "data[].usageBasedCents", "data[].chargedCents", "subscriptionCycleStart", "totalMembers", "totalPages"]);
|
|
576
|
+
}
|
|
489
577
|
return new Set([...common]);
|
|
490
578
|
}
|
|
491
579
|
function qaSummary(provider, fetches) {
|
|
@@ -551,29 +639,48 @@ function extractProviderMessage(payload) {
|
|
|
551
639
|
return stringValue(error?.message) ?? stringValue(payload.message) ?? "";
|
|
552
640
|
}
|
|
553
641
|
function sanitizeProviderMessage(message) {
|
|
554
|
-
|
|
642
|
+
// One redaction implementation for the whole product (discovery.ts owns it).
|
|
643
|
+
return redactSecrets(message).replace(/sk-[A-Za-z0-9_-]+/g, "[REDACTED]").replace(/gh[pousr]_[A-Za-z0-9_]+/g, "[REDACTED]");
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Result-level completeness is DERIVED from record-level confidence, never
|
|
647
|
+
* hardcoded: the label on the whole pull is the weakest confidence among the
|
|
648
|
+
* records that actually carry dollars (worst-wins, matching analyze.ts).
|
|
649
|
+
* Copilot seats and Cursor spend are estimated, so their results say so.
|
|
650
|
+
*/
|
|
651
|
+
function completenessFromRecords(records) {
|
|
652
|
+
const rank = { verified: 0, estimated: 1, detected_unverified: 2, missing: 3 };
|
|
653
|
+
const costBearing = records.filter((record) => typeof record.amountUsd === "number");
|
|
654
|
+
if (costBearing.length === 0) {
|
|
655
|
+
return "missing";
|
|
656
|
+
}
|
|
657
|
+
return costBearing
|
|
658
|
+
.map((record) => record.costConfidence)
|
|
659
|
+
.reduce((worst, current) => (rank[current] > rank[worst] ? current : worst));
|
|
555
660
|
}
|
|
556
|
-
function providerResult(provider, sourceId, authReference, records,
|
|
661
|
+
function providerResult(provider, sourceId, authReference, records, qa) {
|
|
557
662
|
const totalUsd = records.reduce((sum, record) => sum + (record.amountUsd ?? 0), 0);
|
|
663
|
+
const completeness = completenessFromRecords(records);
|
|
558
664
|
return {
|
|
559
665
|
provider,
|
|
560
|
-
source: createProviderConnection({ provider, sourceId, authReference, verifiedRecordCount: records.length, totalUsd }),
|
|
666
|
+
source: createProviderConnection({ provider, sourceId, authReference, verifiedRecordCount: records.length, totalUsd, completeness }),
|
|
561
667
|
records,
|
|
562
668
|
fetchedAt: new Date().toISOString(),
|
|
563
|
-
completeness
|
|
669
|
+
completeness,
|
|
564
670
|
qa: qa ?? qaSummary(provider, [])
|
|
565
671
|
};
|
|
566
672
|
}
|
|
567
673
|
export function createProviderConnection(input) {
|
|
568
674
|
const source = createProviderConnectorStub(input.provider, "provider_api", input.fetchedAt);
|
|
569
675
|
const total = `$${input.totalUsd.toFixed(2)}`;
|
|
676
|
+
const verification = input.completeness ?? "verified";
|
|
570
677
|
return {
|
|
571
678
|
...source,
|
|
572
679
|
id: input.sourceId ?? source.id,
|
|
573
|
-
verification
|
|
680
|
+
verification,
|
|
574
681
|
authReference: input.authReference,
|
|
575
682
|
fieldsMissing: [],
|
|
576
|
-
scope: `${source.scope} Last successful pull produced ${input.verifiedRecordCount}
|
|
683
|
+
scope: `${source.scope} Last successful pull produced ${input.verifiedRecordCount} ${verification} records totaling ${total}.`
|
|
577
684
|
};
|
|
578
685
|
}
|
|
579
686
|
export function resolveTokenReference(reference, env = process.env) {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare class UnsafeScanRootError extends Error {
|
|
2
|
+
readonly rootPath: string;
|
|
3
|
+
constructor(rootPath: string, reason: string);
|
|
4
|
+
}
|
|
5
|
+
export declare function unsafeScanRootReason(rootPath: string, home?: string): string | undefined;
|
|
6
|
+
/** Throws a typed error when the root is unsafe; callers map it to their UX. */
|
|
7
|
+
export declare function assertSafeScanRoot(rootPath: string, home?: string): void;
|
|
8
|
+
//# sourceMappingURL=scanGuard.d.ts.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { resolve, sep } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Shared unsafe-scan-root policy for EVERY scan entrypoint (CLI `scan`, MCP
|
|
5
|
+
* `scan_ai_spend`, and any future surface). Scanning the home directory, the
|
|
6
|
+
* filesystem root, or a system directory is refused: the product's consent
|
|
7
|
+
* model is "one explicitly approved project folder", and anything broader can
|
|
8
|
+
* pull unrelated personal files into evidence output.
|
|
9
|
+
*
|
|
10
|
+
* Keep this the ONLY implementation — a CLI/MCP divergence here is exactly the
|
|
11
|
+
* class of bug that let MCP scan `~` while the CLI refused.
|
|
12
|
+
*/
|
|
13
|
+
const systemRootDirectories = new Set([
|
|
14
|
+
"/etc",
|
|
15
|
+
"/usr",
|
|
16
|
+
"/bin",
|
|
17
|
+
"/sbin",
|
|
18
|
+
"/var",
|
|
19
|
+
"/opt",
|
|
20
|
+
"/private",
|
|
21
|
+
"/Library",
|
|
22
|
+
"/System",
|
|
23
|
+
"/Applications",
|
|
24
|
+
"/Volumes",
|
|
25
|
+
"/proc",
|
|
26
|
+
"/sys",
|
|
27
|
+
"/dev"
|
|
28
|
+
]);
|
|
29
|
+
export class UnsafeScanRootError extends Error {
|
|
30
|
+
rootPath;
|
|
31
|
+
constructor(rootPath, reason) {
|
|
32
|
+
super(`Refusing to scan ${rootPath}: ${reason}. Choose a narrower approved folder.`);
|
|
33
|
+
this.name = "UnsafeScanRootError";
|
|
34
|
+
this.rootPath = rootPath;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function unsafeScanRootReason(rootPath, home = homedir()) {
|
|
38
|
+
const resolved = resolve(rootPath);
|
|
39
|
+
const resolvedHome = resolve(home);
|
|
40
|
+
if (resolved === "/" || /^[A-Za-z]:[\\/]?$/.test(resolved)) {
|
|
41
|
+
return "the filesystem root is too broad for approved-source scanning";
|
|
42
|
+
}
|
|
43
|
+
if (resolved === resolvedHome) {
|
|
44
|
+
return "the home directory is too broad for approved-source scanning";
|
|
45
|
+
}
|
|
46
|
+
if (isAncestorPath(resolved, resolvedHome)) {
|
|
47
|
+
return "this directory contains your home directory and is too broad for approved-source scanning";
|
|
48
|
+
}
|
|
49
|
+
if (systemRootDirectories.has(resolved)) {
|
|
50
|
+
return "system directories are not valid approved-source scan targets";
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
/** Throws a typed error when the root is unsafe; callers map it to their UX. */
|
|
55
|
+
export function assertSafeScanRoot(rootPath, home = homedir()) {
|
|
56
|
+
const reason = unsafeScanRootReason(rootPath, home);
|
|
57
|
+
if (reason) {
|
|
58
|
+
throw new UnsafeScanRootError(resolve(rootPath), reason);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function isAncestorPath(candidateAncestor, path) {
|
|
62
|
+
const normalizedAncestor = candidateAncestor.endsWith(sep) ? candidateAncestor : candidateAncestor + sep;
|
|
63
|
+
return path.startsWith(normalizedAncestor);
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=scanGuard.js.map
|
package/dist/schema.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export declare const costConfidenceValues: readonly ["verified", "estimated", "detected_unverified", "missing"];
|
|
3
3
|
export declare const costConfidenceSchema: z.ZodEnum<{
|
|
4
|
-
verified: "verified";
|
|
5
4
|
estimated: "estimated";
|
|
5
|
+
verified: "verified";
|
|
6
6
|
detected_unverified: "detected_unverified";
|
|
7
7
|
missing: "missing";
|
|
8
8
|
}>;
|
|
@@ -12,8 +12,8 @@ export declare const spendSourceSchema: z.ZodObject<{
|
|
|
12
12
|
name: z.ZodString;
|
|
13
13
|
provider: z.ZodString;
|
|
14
14
|
confidence: z.ZodEnum<{
|
|
15
|
-
verified: "verified";
|
|
16
15
|
estimated: "estimated";
|
|
16
|
+
verified: "verified";
|
|
17
17
|
detected_unverified: "detected_unverified";
|
|
18
18
|
missing: "missing";
|
|
19
19
|
}>;
|
|
@@ -28,8 +28,8 @@ export declare const usageRecordSchema: z.ZodObject<{
|
|
|
28
28
|
name: z.ZodString;
|
|
29
29
|
provider: z.ZodString;
|
|
30
30
|
confidence: z.ZodEnum<{
|
|
31
|
-
verified: "verified";
|
|
32
31
|
estimated: "estimated";
|
|
32
|
+
verified: "verified";
|
|
33
33
|
detected_unverified: "detected_unverified";
|
|
34
34
|
missing: "missing";
|
|
35
35
|
}>;
|
|
@@ -40,8 +40,8 @@ export declare const usageRecordSchema: z.ZodObject<{
|
|
|
40
40
|
outputTokens: z.ZodNumber;
|
|
41
41
|
amountUsd: z.ZodNullable<z.ZodNumber>;
|
|
42
42
|
costConfidence: z.ZodEnum<{
|
|
43
|
-
verified: "verified";
|
|
44
43
|
estimated: "estimated";
|
|
44
|
+
verified: "verified";
|
|
45
45
|
detected_unverified: "detected_unverified";
|
|
46
46
|
missing: "missing";
|
|
47
47
|
}>;
|
|
@@ -58,10 +58,10 @@ export declare const usageRecordSchema: z.ZodObject<{
|
|
|
58
58
|
export type UsageRecord = z.infer<typeof usageRecordSchema>;
|
|
59
59
|
export declare const attributionCandidateSchema: z.ZodObject<{
|
|
60
60
|
entityType: z.ZodEnum<{
|
|
61
|
-
|
|
61
|
+
user: "user";
|
|
62
62
|
project: "project";
|
|
63
|
+
client: "client";
|
|
63
64
|
agent: "agent";
|
|
64
|
-
user: "user";
|
|
65
65
|
workspace: "workspace";
|
|
66
66
|
api_key: "api_key";
|
|
67
67
|
}>;
|
|
@@ -74,10 +74,10 @@ export declare const attributionMappingSchema: z.ZodObject<{
|
|
|
74
74
|
usageRecordId: z.ZodString;
|
|
75
75
|
candidates: z.ZodArray<z.ZodObject<{
|
|
76
76
|
entityType: z.ZodEnum<{
|
|
77
|
-
|
|
77
|
+
user: "user";
|
|
78
78
|
project: "project";
|
|
79
|
+
client: "client";
|
|
79
80
|
agent: "agent";
|
|
80
|
-
user: "user";
|
|
81
81
|
workspace: "workspace";
|
|
82
82
|
api_key: "api_key";
|
|
83
83
|
}>;
|
|
@@ -87,10 +87,10 @@ export declare const attributionMappingSchema: z.ZodObject<{
|
|
|
87
87
|
}, z.core.$strip>>;
|
|
88
88
|
selected: z.ZodOptional<z.ZodObject<{
|
|
89
89
|
entityType: z.ZodEnum<{
|
|
90
|
-
|
|
90
|
+
user: "user";
|
|
91
91
|
project: "project";
|
|
92
|
+
client: "client";
|
|
92
93
|
agent: "agent";
|
|
93
|
-
user: "user";
|
|
94
94
|
workspace: "workspace";
|
|
95
95
|
api_key: "api_key";
|
|
96
96
|
}>;
|
|
@@ -112,8 +112,8 @@ export declare const spendBreakdownEntrySchema: z.ZodObject<{
|
|
|
112
112
|
amountUsd: z.ZodNumber;
|
|
113
113
|
recordCount: z.ZodNumber;
|
|
114
114
|
confidence: z.ZodEnum<{
|
|
115
|
-
verified: "verified";
|
|
116
115
|
estimated: "estimated";
|
|
116
|
+
verified: "verified";
|
|
117
117
|
detected_unverified: "detected_unverified";
|
|
118
118
|
missing: "missing";
|
|
119
119
|
}>;
|
|
@@ -129,8 +129,8 @@ export declare const spendAnomalySchema: z.ZodObject<{
|
|
|
129
129
|
currentAmountUsd: z.ZodNumber;
|
|
130
130
|
multiplier: z.ZodNumber;
|
|
131
131
|
confidence: z.ZodEnum<{
|
|
132
|
-
verified: "verified";
|
|
133
132
|
estimated: "estimated";
|
|
133
|
+
verified: "verified";
|
|
134
134
|
detected_unverified: "detected_unverified";
|
|
135
135
|
missing: "missing";
|
|
136
136
|
}>;
|
|
@@ -146,8 +146,8 @@ export declare const workflowWatchEntrySchema: z.ZodObject<{
|
|
|
146
146
|
shareOfSpend: z.ZodNumber;
|
|
147
147
|
recordCount: z.ZodNumber;
|
|
148
148
|
confidence: z.ZodEnum<{
|
|
149
|
-
verified: "verified";
|
|
150
149
|
estimated: "estimated";
|
|
150
|
+
verified: "verified";
|
|
151
151
|
detected_unverified: "detected_unverified";
|
|
152
152
|
missing: "missing";
|
|
153
153
|
}>;
|
|
@@ -171,8 +171,8 @@ export declare const recommendationSchema: z.ZodObject<{
|
|
|
171
171
|
}>;
|
|
172
172
|
estimatedImpactUsd: z.ZodNumber;
|
|
173
173
|
confidence: z.ZodEnum<{
|
|
174
|
-
verified: "verified";
|
|
175
174
|
estimated: "estimated";
|
|
175
|
+
verified: "verified";
|
|
176
176
|
detected_unverified: "detected_unverified";
|
|
177
177
|
missing: "missing";
|
|
178
178
|
}>;
|
|
@@ -216,8 +216,8 @@ export declare const spendInsightSchema: z.ZodObject<{
|
|
|
216
216
|
affectedModels: z.ZodArray<z.ZodString>;
|
|
217
217
|
estimatedImpactUsd: z.ZodNumber;
|
|
218
218
|
confidence: z.ZodEnum<{
|
|
219
|
-
verified: "verified";
|
|
220
219
|
estimated: "estimated";
|
|
220
|
+
verified: "verified";
|
|
221
221
|
detected_unverified: "detected_unverified";
|
|
222
222
|
missing: "missing";
|
|
223
223
|
}>;
|
|
@@ -229,14 +229,14 @@ export declare const spendSummarySchema: z.ZodObject<{
|
|
|
229
229
|
totalUsd: z.ZodNumber;
|
|
230
230
|
recordCount: z.ZodNumber;
|
|
231
231
|
confidence: z.ZodEnum<{
|
|
232
|
-
verified: "verified";
|
|
233
232
|
estimated: "estimated";
|
|
233
|
+
verified: "verified";
|
|
234
234
|
detected_unverified: "detected_unverified";
|
|
235
235
|
missing: "missing";
|
|
236
236
|
}>;
|
|
237
237
|
confidenceBreakdown: z.ZodRecord<z.ZodEnum<{
|
|
238
|
-
verified: "verified";
|
|
239
238
|
estimated: "estimated";
|
|
239
|
+
verified: "verified";
|
|
240
240
|
detected_unverified: "detected_unverified";
|
|
241
241
|
missing: "missing";
|
|
242
242
|
}>, z.ZodNumber>;
|
|
@@ -245,8 +245,8 @@ export declare const spendSummarySchema: z.ZodObject<{
|
|
|
245
245
|
amountUsd: z.ZodNumber;
|
|
246
246
|
recordCount: z.ZodNumber;
|
|
247
247
|
confidence: z.ZodEnum<{
|
|
248
|
-
verified: "verified";
|
|
249
248
|
estimated: "estimated";
|
|
249
|
+
verified: "verified";
|
|
250
250
|
detected_unverified: "detected_unverified";
|
|
251
251
|
missing: "missing";
|
|
252
252
|
}>;
|
|
@@ -256,8 +256,8 @@ export declare const spendSummarySchema: z.ZodObject<{
|
|
|
256
256
|
amountUsd: z.ZodNumber;
|
|
257
257
|
recordCount: z.ZodNumber;
|
|
258
258
|
confidence: z.ZodEnum<{
|
|
259
|
-
verified: "verified";
|
|
260
259
|
estimated: "estimated";
|
|
260
|
+
verified: "verified";
|
|
261
261
|
detected_unverified: "detected_unverified";
|
|
262
262
|
missing: "missing";
|
|
263
263
|
}>;
|
|
@@ -267,8 +267,8 @@ export declare const spendSummarySchema: z.ZodObject<{
|
|
|
267
267
|
amountUsd: z.ZodNumber;
|
|
268
268
|
recordCount: z.ZodNumber;
|
|
269
269
|
confidence: z.ZodEnum<{
|
|
270
|
-
verified: "verified";
|
|
271
270
|
estimated: "estimated";
|
|
271
|
+
verified: "verified";
|
|
272
272
|
detected_unverified: "detected_unverified";
|
|
273
273
|
missing: "missing";
|
|
274
274
|
}>;
|
|
@@ -278,8 +278,8 @@ export declare const spendSummarySchema: z.ZodObject<{
|
|
|
278
278
|
amountUsd: z.ZodNumber;
|
|
279
279
|
recordCount: z.ZodNumber;
|
|
280
280
|
confidence: z.ZodEnum<{
|
|
281
|
-
verified: "verified";
|
|
282
281
|
estimated: "estimated";
|
|
282
|
+
verified: "verified";
|
|
283
283
|
detected_unverified: "detected_unverified";
|
|
284
284
|
missing: "missing";
|
|
285
285
|
}>;
|
|
@@ -289,8 +289,8 @@ export declare const spendSummarySchema: z.ZodObject<{
|
|
|
289
289
|
amountUsd: z.ZodNumber;
|
|
290
290
|
recordCount: z.ZodNumber;
|
|
291
291
|
confidence: z.ZodEnum<{
|
|
292
|
-
verified: "verified";
|
|
293
292
|
estimated: "estimated";
|
|
293
|
+
verified: "verified";
|
|
294
294
|
detected_unverified: "detected_unverified";
|
|
295
295
|
missing: "missing";
|
|
296
296
|
}>;
|
|
@@ -300,8 +300,8 @@ export declare const spendSummarySchema: z.ZodObject<{
|
|
|
300
300
|
amountUsd: z.ZodNumber;
|
|
301
301
|
recordCount: z.ZodNumber;
|
|
302
302
|
confidence: z.ZodEnum<{
|
|
303
|
-
verified: "verified";
|
|
304
303
|
estimated: "estimated";
|
|
304
|
+
verified: "verified";
|
|
305
305
|
detected_unverified: "detected_unverified";
|
|
306
306
|
missing: "missing";
|
|
307
307
|
}>;
|
|
@@ -311,8 +311,8 @@ export declare const spendSummarySchema: z.ZodObject<{
|
|
|
311
311
|
amountUsd: z.ZodNumber;
|
|
312
312
|
recordCount: z.ZodNumber;
|
|
313
313
|
confidence: z.ZodEnum<{
|
|
314
|
-
verified: "verified";
|
|
315
314
|
estimated: "estimated";
|
|
315
|
+
verified: "verified";
|
|
316
316
|
detected_unverified: "detected_unverified";
|
|
317
317
|
missing: "missing";
|
|
318
318
|
}>;
|
|
@@ -322,8 +322,8 @@ export declare const spendSummarySchema: z.ZodObject<{
|
|
|
322
322
|
amountUsd: z.ZodNumber;
|
|
323
323
|
recordCount: z.ZodNumber;
|
|
324
324
|
confidence: z.ZodEnum<{
|
|
325
|
-
verified: "verified";
|
|
326
325
|
estimated: "estimated";
|
|
326
|
+
verified: "verified";
|
|
327
327
|
detected_unverified: "detected_unverified";
|
|
328
328
|
missing: "missing";
|
|
329
329
|
}>;
|
|
@@ -338,8 +338,8 @@ export declare const spendSummarySchema: z.ZodObject<{
|
|
|
338
338
|
shareOfSpend: z.ZodNumber;
|
|
339
339
|
recordCount: z.ZodNumber;
|
|
340
340
|
confidence: z.ZodEnum<{
|
|
341
|
-
verified: "verified";
|
|
342
341
|
estimated: "estimated";
|
|
342
|
+
verified: "verified";
|
|
343
343
|
detected_unverified: "detected_unverified";
|
|
344
344
|
missing: "missing";
|
|
345
345
|
}>;
|
|
@@ -359,8 +359,8 @@ export declare const spendSummarySchema: z.ZodObject<{
|
|
|
359
359
|
currentAmountUsd: z.ZodNumber;
|
|
360
360
|
multiplier: z.ZodNumber;
|
|
361
361
|
confidence: z.ZodEnum<{
|
|
362
|
-
verified: "verified";
|
|
363
362
|
estimated: "estimated";
|
|
363
|
+
verified: "verified";
|
|
364
364
|
detected_unverified: "detected_unverified";
|
|
365
365
|
missing: "missing";
|
|
366
366
|
}>;
|
|
@@ -378,8 +378,8 @@ export declare const spendSummarySchema: z.ZodObject<{
|
|
|
378
378
|
}>;
|
|
379
379
|
estimatedImpactUsd: z.ZodNumber;
|
|
380
380
|
confidence: z.ZodEnum<{
|
|
381
|
-
verified: "verified";
|
|
382
381
|
estimated: "estimated";
|
|
382
|
+
verified: "verified";
|
|
383
383
|
detected_unverified: "detected_unverified";
|
|
384
384
|
missing: "missing";
|
|
385
385
|
}>;
|
|
@@ -416,8 +416,8 @@ export declare const spendSummarySchema: z.ZodObject<{
|
|
|
416
416
|
affectedModels: z.ZodArray<z.ZodString>;
|
|
417
417
|
estimatedImpactUsd: z.ZodNumber;
|
|
418
418
|
confidence: z.ZodEnum<{
|
|
419
|
-
verified: "verified";
|
|
420
419
|
estimated: "estimated";
|
|
420
|
+
verified: "verified";
|
|
421
421
|
detected_unverified: "detected_unverified";
|
|
422
422
|
missing: "missing";
|
|
423
423
|
}>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only ingestion of which tools were actually INVOKED in Claude Code
|
|
3
|
+
* sessions, plus session/turn counts for cache-aware pricing.
|
|
4
|
+
*
|
|
5
|
+
* This is the counterpart to agentInventory.ts (what's LOADED into context):
|
|
6
|
+
* comparing loaded-but-never-invoked tools against this "actually invoked" set
|
|
7
|
+
* is the "dead-context cost" signal.
|
|
8
|
+
*
|
|
9
|
+
* Transcript format (~/.claude/projects/** /*.jsonl, one JSON object per line):
|
|
10
|
+
* - type:"assistant" lines carry message.content = array of blocks; blocks
|
|
11
|
+
* with type:"tool_use" have `name` (the invoked tool) and `input`.
|
|
12
|
+
* - Built-ins: Read/Edit/Bash/Glob/Grep/...; MCP: "mcp__<server>__<tool>".
|
|
13
|
+
* - The `Skill` tool's input.skill names the invoked skill.
|
|
14
|
+
* - The `Agent` (or `Task`) tool's input.subagent_type names the subagent.
|
|
15
|
+
* - Slash commands surface in type:"user" lines as
|
|
16
|
+
* "<command-name>/foo</command-name>".
|
|
17
|
+
*
|
|
18
|
+
* A "turn" = one assistant message that produced an API call, deduped by
|
|
19
|
+
* message.id + requestId (streaming/retries write the same response on
|
|
20
|
+
* multiple lines), matching parseClaudeCodeTranscript in localAgentLogs.ts.
|
|
21
|
+
*/
|
|
22
|
+
export type ToolInvocationCount = {
|
|
23
|
+
name: string;
|
|
24
|
+
count: number;
|
|
25
|
+
};
|
|
26
|
+
export type InvocationSummary = {
|
|
27
|
+
/** aggregated counts by raw tool name across all parsed transcripts */
|
|
28
|
+
invocations: ToolInvocationCount[];
|
|
29
|
+
/** distinct mcp tool names invoked, formatted "mcp__<server>__<tool>" */
|
|
30
|
+
invokedMcpTools: string[];
|
|
31
|
+
/** distinct skill names invoked (resolved from the Skill tool input) */
|
|
32
|
+
invokedSkills: string[];
|
|
33
|
+
/** distinct subagent types invoked (resolved from Task/Agent input) */
|
|
34
|
+
invokedSubagents: string[];
|
|
35
|
+
/** distinct slash-command names invoked, if detectable; else [] */
|
|
36
|
+
invokedCommands: string[];
|
|
37
|
+
/** number of transcript files parsed (≈ sessions) */
|
|
38
|
+
sessions: number;
|
|
39
|
+
/** total assistant turns across all sessions (post-dedupe) */
|
|
40
|
+
totalAssistantTurns: number;
|
|
41
|
+
/** assistant-turn count per session, for cache-read pricing */
|
|
42
|
+
sessionTurnCounts: number[];
|
|
43
|
+
};
|
|
44
|
+
export type ToolInvocationOptions = {
|
|
45
|
+
/** default: join(homedir(), ".claude", "projects") */
|
|
46
|
+
claudeProjectsDir?: string;
|
|
47
|
+
/** optional: only count turns at/after this time */
|
|
48
|
+
sinceIso?: string;
|
|
49
|
+
};
|
|
50
|
+
/** Parse ONE transcript's content. Exported for tests. Returns the per-file pieces the aggregator needs. */
|
|
51
|
+
export declare function parseClaudeCodeInvocations(content: string, sinceMs?: number): {
|
|
52
|
+
invocations: ToolInvocationCount[];
|
|
53
|
+
invokedMcpTools: string[];
|
|
54
|
+
invokedSkills: string[];
|
|
55
|
+
invokedSubagents: string[];
|
|
56
|
+
invokedCommands: string[];
|
|
57
|
+
assistantTurns: number;
|
|
58
|
+
};
|
|
59
|
+
/** Scan this machine's Claude Code transcripts and aggregate tool invocations. */
|
|
60
|
+
export declare function loadToolInvocations(options?: ToolInvocationOptions): Promise<InvocationSummary>;
|
|
61
|
+
//# sourceMappingURL=toolInvocations.d.ts.map
|