@agent-finops/core 0.5.5 → 0.5.6

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/planMath.js CHANGED
@@ -11,10 +11,9 @@ const localLogCostType = "local_agent_logs";
11
11
  * from local agent logs participate (billing-API records already have real
12
12
  * prices and a real plan behind them).
13
13
  *
14
- * When `detectedPlans` carries a locally detected plan (or --plan override)
15
- * for an agent, the check speaks in facts ("you're on Claude Max 5x") instead
16
- * of guesses ("Max 20x likely covers this") — and warns when projected usage
17
- * exceeds what the detected tier typically covers.
14
+ * When `detectedPlans` carries a locally detected plan label (or --plan
15
+ * override), the result identifies that provenance and keeps comparison math
16
+ * separate from provider-reported limits.
18
17
  */
19
18
  export function computePlanChecks(records, detectedPlans = []) {
20
19
  const localRecords = records.filter((record) => record.providerCostType === localLogCostType &&
@@ -49,35 +48,36 @@ export function computePlanChecks(records, detectedPlans = []) {
49
48
  let upgradeHint;
50
49
  let effectiveSavings;
51
50
  if (detectedKnown) {
52
- // FACT mode: we know the user's actual plan from local agent config.
51
+ // The label comes from local metadata or an explicit override. It is not
52
+ // independently verified against the provider account.
53
53
  valueMultiple = Math.round((monthly / detectedKnown.monthlyUsd) * 10) / 10;
54
54
  const savingsVsApi = roundMoney(monthly - detectedKnown.monthlyUsd);
55
55
  effectiveSavings = savingsVsApi > 0 ? savingsVsApi : undefined;
56
56
  headline =
57
- `${agent}: ~$${monthly.toFixed(2)}/mo at API rates (${basis}) — you're on ${detectedKnown.name} ` +
58
- `($${detectedKnown.monthlyUsd}/mo, detected locally): ~${valueMultiple}× the plan price in usage` +
59
- (effectiveSavings ? `, ~$${effectiveSavings.toFixed(2)}/mo cheaper than paying per token.` : `.`);
57
+ `${agent}: ~$${monthly.toFixed(2)}/mo at API rates (${basis}) — compared with ${detectedKnown.name} ` +
58
+ `($${detectedKnown.monthlyUsd}/mo; label detected locally): ~${valueMultiple}× the plan price in API-equivalent usage` +
59
+ (effectiveSavings ? `, a ~$${effectiveSavings.toFixed(2)}/mo value difference to investigate.` : `.`);
60
60
  if (monthly > detectedKnown.coversUpToUsd) {
61
61
  const nextTier = subscriptionPlans.find((plan) => plan.agent === agent && plan.coversUpToUsd > detectedKnown.coversUpToUsd);
62
62
  // A local limit signal upgrades "might hit limits" to hard evidence.
63
63
  const evidence = detected?.limitSignal
64
- ? `your local config shows ${detected.limitSignal} — you ARE hitting your plan's limits`
65
- : `if you're hitting rate limits`;
64
+ ? `local metadata reports ${detected.limitSignal}`
65
+ : `if the provider reports active rate limits`;
66
66
  upgradeHint = nextTier
67
- ? `usage runs past what ${detectedKnown.name} typically covers (~$${detectedKnown.coversUpToUsd}/mo); ${evidence}: ${nextTier.name} ($${nextTier.monthlyUsd}/mo) is the next tier; trimming context (below) buys headroom without upgrading.`
68
- : `usage runs past what ${detectedKnown.name} typically covers (~$${detectedKnown.coversUpToUsd}/mo) — trimming context (below) is the main headroom lever.`;
67
+ ? `API-equivalent projection exceeds the rough ${detectedKnown.name} comparison threshold (~$${detectedKnown.coversUpToUsd}/mo); ${evidence}. ${nextTier.name} ($${nextTier.monthlyUsd}/mo) is the next listed tier, but verify account limits before changing plans; trimming context (below) may buy headroom.`
68
+ : `API-equivalent projection exceeds the rough ${detectedKnown.name} comparison threshold (~$${detectedKnown.coversUpToUsd}/mo); verify account limits before changing plans. Trimming context (below) may buy headroom.`;
69
69
  }
70
70
  else if (detected?.limitSignal) {
71
- upgradeHint = `your local config shows ${detected.limitSignal} — you're hitting your plan's limits; trimming context (below) buys headroom without upgrading.`;
71
+ upgradeHint = `local metadata reports ${detected.limitSignal}; verify the live provider window. Trimming context (below) may buy headroom.`;
72
72
  }
73
73
  }
74
74
  else if (detected) {
75
75
  // Detected a plan we can't price (e.g. an unrecognized tier): state the
76
76
  // fact, then fall back to suggestion math without pretending certainty.
77
77
  headline =
78
- `${agent}: ~$${monthly.toFixed(2)}/mo at API rates (${basis}) — you're on ${detected.planLabel} ` +
79
- `(detected locally; price not in our table)` +
80
- (suggested ? `; closest known plan: ${suggested.name} ($${suggested.monthlyUsd}/mo).` : `.`);
78
+ `${agent}: ~$${monthly.toFixed(2)}/mo at API rates (${basis}) — compared with ${detected.planLabel} ` +
79
+ `(label detected locally; price not in our table)` +
80
+ (suggested ? `; reference listed plan: ${suggested.name} ($${suggested.monthlyUsd}/mo).` : `.`);
81
81
  }
82
82
  else {
83
83
  const covered = suggested && typeof savings === "number" && savings > 0;
@@ -87,10 +87,10 @@ export function computePlanChecks(records, detectedPlans = []) {
87
87
  headline = `${agent}: ~$${monthly.toFixed(2)}/mo at API rates (${basis}).`;
88
88
  }
89
89
  else if (covered) {
90
- headline = `${agent}: ~$${monthly.toFixed(2)}/mo at API rates (${basis}) — ${suggested.name} ($${suggested.monthlyUsd}/mo) likely covers this. You're getting ~${valueMultiple}× the plan price in usage, ~$${savings.toFixed(2)}/mo cheaper than paying per token.`;
90
+ headline = `${agent}: ~$${monthly.toFixed(2)}/mo at API rates (${basis}) — ${suggested.name} is a $${suggested.monthlyUsd}/mo reference point. That is ~${valueMultiple}× the plan price in API-equivalent usage, a ~$${savings.toFixed(2)}/mo value difference to investigate; it does not prove plan coverage.`;
91
91
  }
92
92
  else {
93
- headline = `${agent}: ~$${monthly.toFixed(2)}/mo at API rates (${basis}) — within ${suggested.name} ($${suggested.monthlyUsd}/mo); pay-as-you-go API could be cheaper if you drop the subscription.`;
93
+ headline = `${agent}: ~$${monthly.toFixed(2)}/mo at API rates (${basis}) — below the $${suggested.monthlyUsd}/mo price of ${suggested.name}; compare account benefits and provider-reported charges before changing plans.`;
94
94
  }
95
95
  }
96
96
  checks.push({
@@ -12,12 +12,20 @@ type ProviderResponse = {
12
12
  export type ProviderQaPagination = {
13
13
  label: string;
14
14
  pagesFetched: number;
15
- stoppedBecause: "complete" | "missing_cursor" | "max_pages" | "fetch_error";
15
+ stoppedBecause: "complete" | "missing_cursor" | "max_pages" | "max_range_days" | "fetch_error" | "unsafe_next_link";
16
16
  maxPages: number;
17
17
  limitPerPage?: number;
18
18
  /** Present when stoppedBecause is "fetch_error": the sanitized reason the fetch stopped early. */
19
19
  note?: string;
20
20
  };
21
+ export type ProviderCoverageStatus = "complete" | "partial";
22
+ export type ProviderFinancialSummary = {
23
+ providerReportedBilledUsd: number | null;
24
+ apiEquivalentEstimatedUsd: number | null;
25
+ providerEstimatedUsd: number | null;
26
+ headlineUsd: number | null;
27
+ headlineBasis: "provider_reported_billed_cost" | "api_equivalent_estimate" | "provider_estimated_cost" | "unavailable";
28
+ };
21
29
  export type ProviderQaRateLimit = {
22
30
  label: string;
23
31
  remainingRequests?: number;
@@ -30,6 +38,7 @@ export type ProviderQaDriftIssue = {
30
38
  };
31
39
  export type ProviderQaSummary = {
32
40
  provider: string;
41
+ coverage?: ProviderCoverageStatus;
33
42
  requestedEndpoints: string[];
34
43
  pagination: ProviderQaPagination[];
35
44
  rateLimits: ProviderQaRateLimit[];
@@ -54,6 +63,8 @@ export type ProviderConnectorResult = {
54
63
  source: ApprovedSource;
55
64
  records: UsageRecord[];
56
65
  fetchedAt: string;
66
+ coverage: ProviderCoverageStatus;
67
+ financials: ProviderFinancialSummary;
57
68
  completeness: "verified" | "estimated" | "detected_unverified" | "missing";
58
69
  qa: ProviderQaSummary;
59
70
  };
@@ -86,6 +97,13 @@ export declare function normalizeAnthropicCostResponse(response: unknown, option
86
97
  export declare function normalizeGitHubCopilotMetricsResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
87
98
  export declare function normalizeCursorSpendResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
88
99
  export declare function fetchProviderUsageRecords(input: ProviderConnectorInput): Promise<ProviderConnectorResult>;
100
+ export declare function summarizeProviderFinancials(records: UsageRecord[]): ProviderFinancialSummary;
101
+ /**
102
+ * Keep evidence records available to callers, but never add estimates to a
103
+ * provider's official billed total. This selection is intended for aggregate
104
+ * spend headlines; callers should retain the original records for attribution.
105
+ */
106
+ export declare function selectProviderFinancialHeadlineRecords(records: UsageRecord[]): UsageRecord[];
89
107
  export declare function createProviderConnection(input: CreateProviderConnectionInput): ApprovedSource;
90
108
  export declare function resolveTokenReference(reference: string, env?: Record<string, string | undefined>): string;
91
109
  export {};
@@ -387,10 +387,20 @@ async function fetchPaginatedJson(fetcher, initialUrl, request, provider, label)
387
387
  rateLimits.push(response.rateLimit);
388
388
  responseDrift.push(...detectResponseDrift(page, provider, label));
389
389
  const nextPage = nextPageFromPayload(page);
390
- const nextLink = nextUrlFromHeaders(response.headers);
391
- const hasMore = isRecord(page) && (page.has_more === true || page.hasMore === true || Boolean(nextPage) || Boolean(nextLink));
392
- if (nextLink) {
393
- nextUrl = nextLink;
390
+ const rawNextLink = nextUrlFromHeaders(response.headers);
391
+ const safeNextLink = rawNextLink ? validatePaginationUrl(initialUrl, rawNextLink) : undefined;
392
+ const hasMore = isRecord(page) && (page.has_more === true || page.hasMore === true || Boolean(nextPage) || Boolean(rawNextLink));
393
+ if (rawNextLink && !safeNextLink) {
394
+ stoppedBecause = "unsafe_next_link";
395
+ responseDrift.push({
396
+ label,
397
+ field: "headers.link",
398
+ issue: "rejected pagination URL because it was not HTTPS and same-origin with the provider endpoint"
399
+ });
400
+ nextUrl = undefined;
401
+ }
402
+ else if (safeNextLink) {
403
+ nextUrl = safeNextLink;
394
404
  }
395
405
  else if (hasMore && nextPage) {
396
406
  nextUrl = appendPageCursor(initialUrl, nextPage);
@@ -412,11 +422,25 @@ async function fetchPaginatedJson(fetcher, initialUrl, request, provider, label)
412
422
  responseDrift
413
423
  };
414
424
  }
425
+ function validatePaginationUrl(initialUrl, candidate) {
426
+ try {
427
+ const initial = new URL(initialUrl);
428
+ const next = new URL(candidate, initial);
429
+ if (initial.protocol !== "https:" || next.protocol !== "https:" || next.origin !== initial.origin) {
430
+ return undefined;
431
+ }
432
+ return next.toString();
433
+ }
434
+ catch {
435
+ return undefined;
436
+ }
437
+ }
415
438
  async function fetchDateRangeJson(fetcher, buildUrl, startTime, endTime, request, provider, label) {
416
439
  const results = [];
417
440
  const daySeconds = 24 * 60 * 60;
418
441
  const finalTime = endTime ?? startTime;
419
- for (let cursor = startTime, count = 0; cursor <= finalTime && count < 370; cursor += daySeconds, count += 1) {
442
+ const maxRangeDays = 370;
443
+ for (let cursor = startTime, count = 0; cursor <= finalTime && count < maxRangeDays; cursor += daySeconds, count += 1) {
420
444
  try {
421
445
  results.push(await fetchPaginatedJson(fetcher, buildUrl(cursor), request, provider, label));
422
446
  }
@@ -441,6 +465,21 @@ async function fetchDateRangeJson(fetcher, buildUrl, startTime, endTime, request
441
465
  break;
442
466
  }
443
467
  }
468
+ const lastCoveredTime = startTime + (maxRangeDays - 1) * daySeconds;
469
+ if (finalTime > lastCoveredTime && results.every((result) => result.pagination.stoppedBecause !== "fetch_error")) {
470
+ results.push({
471
+ pages: [],
472
+ pagination: {
473
+ label,
474
+ pagesFetched: 0,
475
+ stoppedBecause: "max_range_days",
476
+ maxPages: 50,
477
+ note: `Requested range exceeds the ${maxRangeDays}-day connector limit; narrow the range or run multiple syncs.`
478
+ },
479
+ rateLimits: [],
480
+ responseDrift: []
481
+ });
482
+ }
444
483
  return results;
445
484
  }
446
485
  /** Retries per request on 429/5xx before giving up (initial try + retries). */
@@ -519,6 +558,8 @@ function headerNumber(headers, name) {
519
558
  if (!headers)
520
559
  return undefined;
521
560
  const value = hasHeaderGetter(headers) ? headers.get(name) : headers[name] ?? headers[name.toLowerCase()];
561
+ if (value === null || value === undefined || value.trim() === "")
562
+ return undefined;
522
563
  const numeric = Number(value);
523
564
  return Number.isFinite(numeric) ? numeric : undefined;
524
565
  }
@@ -554,13 +595,13 @@ function knownProviderFields(provider, label) {
554
595
  // as drift, burying real drift signals in thousands of false positives.
555
596
  const common = ["data", "data[]", "has_more", "hasMore", "next_page", "nextPage", "object", "links", "links.next"];
556
597
  if (provider === "openai" && label.includes("costs")) {
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"]);
598
+ return new Set([...common, "data[].object", "data[].start_time", "data[].start_time_iso", "data[].end_time", "data[].end_time_iso", "data[].results", "data[].results[]", "data[].results[].object", "data[].results[].amount", "data[].results[].amount.value", "data[].results[].amount.currency", "data[].results[].line_item", "data[].results[].organization_id", "data[].results[].organization_name", "data[].results[].project_id", "data[].results[].project_name", "data[].results[].user_id", "data[].results[].user_email", "data[].results[].api_key_id", "data[].results[].quantity"]);
558
599
  }
559
600
  if (provider === "openai" && label.includes("usage")) {
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"]);
601
+ return new Set([...common, "data[].object", "data[].start_time", "data[].start_time_iso", "data[].end_time", "data[].end_time_iso", "data[].results", "data[].results[]", "data[].results[].object", "data[].results[].input_tokens", "data[].results[].input_uncached_tokens", "data[].results[].input_cache_write_tokens", "data[].results[].input_cached_tokens", "data[].results[].input_text_tokens", "data[].results[].input_image_tokens", "data[].results[].input_audio_tokens", "data[].results[].input_cached_text_tokens", "data[].results[].input_cached_image_tokens", "data[].results[].input_cached_audio_tokens", "data[].results[].output_tokens", "data[].results[].output_text_tokens", "data[].results[].output_image_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", "data[].results[].batch", "data[].results[].service_tier"]);
561
602
  }
562
603
  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"]);
604
+ 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", "data[].results[].inference_geo"]);
564
605
  }
565
606
  if (provider === "anthropic" && label.toLowerCase().includes("claude code")) {
566
607
  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[]"]);
@@ -579,6 +620,7 @@ function knownProviderFields(provider, label) {
579
620
  function qaSummary(provider, fetches) {
580
621
  return {
581
622
  provider,
623
+ coverage: fetches.every((fetchResult) => fetchResult.pagination.stoppedBecause === "complete") ? "complete" : "partial",
582
624
  requestedEndpoints: Array.from(new Set(fetches.map((fetchResult) => fetchResult.pagination.label))),
583
625
  pagination: fetches.map((fetchResult) => fetchResult.pagination),
584
626
  rateLimits: fetches.flatMap((fetchResult) => fetchResult.rateLimits),
@@ -642,32 +684,68 @@ function sanitizeProviderMessage(message) {
642
684
  // One redaction implementation for the whole product (discovery.ts owns it).
643
685
  return redactSecrets(message).replace(/sk-[A-Za-z0-9_-]+/g, "[REDACTED]").replace(/gh[pousr]_[A-Za-z0-9_]+/g, "[REDACTED]");
644
686
  }
687
+ export function summarizeProviderFinancials(records) {
688
+ const providerReportedBilledUsd = sumAmounts(records.filter((record) => record.costConfidence === "verified"));
689
+ const apiEquivalentEstimatedUsd = sumAmounts(records.filter((record) => record.providerCostType === "anthropic_claude_code_usage" && record.costConfidence === "estimated"));
690
+ const providerEstimatedUsd = sumAmounts(records.filter((record) => record.costConfidence === "estimated" && record.providerCostType !== "anthropic_claude_code_usage"));
691
+ if (providerReportedBilledUsd !== null) {
692
+ return { providerReportedBilledUsd, apiEquivalentEstimatedUsd, providerEstimatedUsd, headlineUsd: providerReportedBilledUsd, headlineBasis: "provider_reported_billed_cost" };
693
+ }
694
+ if (apiEquivalentEstimatedUsd !== null) {
695
+ return { providerReportedBilledUsd, apiEquivalentEstimatedUsd, providerEstimatedUsd, headlineUsd: apiEquivalentEstimatedUsd, headlineBasis: "api_equivalent_estimate" };
696
+ }
697
+ if (providerEstimatedUsd !== null) {
698
+ return { providerReportedBilledUsd, apiEquivalentEstimatedUsd, providerEstimatedUsd, headlineUsd: providerEstimatedUsd, headlineBasis: "provider_estimated_cost" };
699
+ }
700
+ return { providerReportedBilledUsd, apiEquivalentEstimatedUsd, providerEstimatedUsd, headlineUsd: null, headlineBasis: "unavailable" };
701
+ }
645
702
  /**
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.
703
+ * Keep evidence records available to callers, but never add estimates to a
704
+ * provider's official billed total. This selection is intended for aggregate
705
+ * spend headlines; callers should retain the original records for attribution.
650
706
  */
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));
707
+ export function selectProviderFinancialHeadlineRecords(records) {
708
+ const byProvider = new Map();
709
+ for (const record of records) {
710
+ const providerRecords = byProvider.get(record.source.provider) ?? [];
711
+ providerRecords.push(record);
712
+ byProvider.set(record.source.provider, providerRecords);
713
+ }
714
+ return Array.from(byProvider.values()).flatMap((providerRecords) => {
715
+ const hasProviderBilledCost = providerRecords.some((record) => record.costConfidence === "verified" && typeof record.amountUsd === "number");
716
+ return hasProviderBilledCost
717
+ ? providerRecords.filter((record) => record.costConfidence === "verified" || record.amountUsd === null)
718
+ : providerRecords;
719
+ });
720
+ }
721
+ function sumAmounts(records) {
722
+ const amounts = records
723
+ .map((record) => record.amountUsd)
724
+ .filter((amount) => typeof amount === "number");
725
+ return amounts.length > 0 ? amounts.reduce((sum, amount) => sum + amount, 0) : null;
660
726
  }
661
727
  function providerResult(provider, sourceId, authReference, records, qa) {
662
- const totalUsd = records.reduce((sum, record) => sum + (record.amountUsd ?? 0), 0);
663
- const completeness = completenessFromRecords(records);
728
+ const resolvedQa = qa ?? qaSummary(provider, []);
729
+ const coverage = resolvedQa.coverage
730
+ ?? (resolvedQa.pagination.every((pagination) => pagination.stoppedBecause === "complete") ? "complete" : "partial");
731
+ const financials = summarizeProviderFinancials(records);
732
+ const headlineConfidence = financials.headlineBasis === "provider_reported_billed_cost"
733
+ ? "verified"
734
+ : financials.headlineBasis === "unavailable"
735
+ ? "missing"
736
+ : "estimated";
737
+ const completeness = coverage === "partial" && headlineConfidence !== "missing"
738
+ ? "detected_unverified"
739
+ : headlineConfidence;
664
740
  return {
665
741
  provider,
666
- source: createProviderConnection({ provider, sourceId, authReference, verifiedRecordCount: records.length, totalUsd, completeness }),
742
+ source: createProviderConnection({ provider, sourceId, authReference, verifiedRecordCount: records.length, totalUsd: financials.headlineUsd ?? 0, completeness }),
667
743
  records,
668
744
  fetchedAt: new Date().toISOString(),
745
+ coverage,
746
+ financials,
669
747
  completeness,
670
- qa: qa ?? qaSummary(provider, [])
748
+ qa: resolvedQa
671
749
  };
672
750
  }
673
751
  export function createProviderConnection(input) {
@@ -752,7 +830,9 @@ function defaultTokenResolver(reference) {
752
830
  return resolveTokenReference(reference);
753
831
  }
754
832
  async function defaultFetcher(url, init) {
755
- return fetch(url, init);
833
+ // Never let the runtime automatically replay provider credentials to a
834
+ // redirect target. Provider endpoint changes must be explicit code changes.
835
+ return fetch(url, { ...init, redirect: "manual" });
756
836
  }
757
837
  function parseMinorUsd(value) {
758
838
  const numeric = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
@@ -2,7 +2,42 @@ export declare class UnsafeScanRootError extends Error {
2
2
  readonly rootPath: string;
3
3
  constructor(rootPath: string, reason: string);
4
4
  }
5
+ export declare class UnsafeStateDirectoryError extends Error {
6
+ readonly statePath: string;
7
+ constructor(statePath: string, reason: string);
8
+ }
9
+ export declare class UnsafeStateFileError extends Error {
10
+ readonly filePath: string;
11
+ constructor(filePath: string, reason: string);
12
+ }
5
13
  export declare function unsafeScanRootReason(rootPath: string, home?: string): string | undefined;
6
14
  /** Throws a typed error when the root is unsafe; callers map it to their UX. */
7
15
  export declare function assertSafeScanRoot(rootPath: string, home?: string): void;
16
+ /**
17
+ * Resolves an approved root through the filesystem before applying the broad-
18
+ * root policy. This prevents a harmless-looking symlink such as
19
+ * `/tmp/project` from approving a scan of `$HOME` or a system directory.
20
+ * Callers should persist and use the returned canonical path.
21
+ */
22
+ export declare function resolveSafeScanRoot(rootPath: string, home?: string): Promise<string>;
23
+ /**
24
+ * Returns a validated local state directory, creating it only when requested.
25
+ * The state path must be a real directory directly under the canonical scan
26
+ * root; symbolic links are refused for both reads and writes.
27
+ */
28
+ export declare function resolveSafeStateDirectory(rootPath: string, options?: {
29
+ create?: boolean;
30
+ }): Promise<string>;
31
+ /**
32
+ * Read one regular child file without following a symbolic link. State can sit
33
+ * inside a cloned repository, so validating only the parent directory is not
34
+ * enough: a committed child symlink must never expose an arbitrary local file.
35
+ */
36
+ export declare function readSafeStateText(stateDir: string, fileName: string): Promise<string>;
37
+ /**
38
+ * Atomically replace one regular child file. The temporary file is created
39
+ * exclusively with mode 0600, and rename replaces a last-moment symlink
40
+ * itself rather than writing through to its target.
41
+ */
42
+ export declare function writeSafeStateText(stateDir: string, fileName: string, contents: string): Promise<void>;
8
43
  //# sourceMappingURL=scanGuard.d.ts.map
package/dist/scanGuard.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { homedir } from "node:os";
2
- import { resolve, sep } from "node:path";
2
+ import { constants, realpathSync } from "node:fs";
3
+ import { chmod, lstat, mkdir, open, realpath, rename, stat, unlink } from "node:fs/promises";
4
+ import { randomUUID } from "node:crypto";
5
+ import { basename, join, resolve, sep } from "node:path";
3
6
  /**
4
7
  * Shared unsafe-scan-root policy for EVERY scan entrypoint (CLI `scan`, MCP
5
8
  * `scan_ai_spend`, and any future surface). Scanning the home directory, the
@@ -34,19 +37,39 @@ export class UnsafeScanRootError extends Error {
34
37
  this.rootPath = rootPath;
35
38
  }
36
39
  }
40
+ export class UnsafeStateDirectoryError extends Error {
41
+ statePath;
42
+ constructor(statePath, reason) {
43
+ super(`Refusing to use ${statePath}: ${reason}. Remove the link or choose another approved folder.`);
44
+ this.name = "UnsafeStateDirectoryError";
45
+ this.statePath = statePath;
46
+ }
47
+ }
48
+ export class UnsafeStateFileError extends Error {
49
+ filePath;
50
+ constructor(filePath, reason) {
51
+ super(`Refusing to use ${filePath}: ${reason}. Recreate the local aibill state instead of following repository-provided links.`);
52
+ this.name = "UnsafeStateFileError";
53
+ this.filePath = filePath;
54
+ }
55
+ }
37
56
  export function unsafeScanRootReason(rootPath, home = homedir()) {
38
- const resolved = resolve(rootPath);
39
- const resolvedHome = resolve(home);
40
- if (resolved === "/" || /^[A-Za-z]:[\\/]?$/.test(resolved)) {
57
+ const requested = resolve(rootPath);
58
+ const resolved = bestEffortRealpathSync(requested);
59
+ const requestedHome = resolve(home);
60
+ const resolvedHome = bestEffortRealpathSync(requestedHome);
61
+ const rootCandidates = new Set([requested, resolved]);
62
+ const homeCandidates = new Set([requestedHome, resolvedHome]);
63
+ if (Array.from(rootCandidates).some((candidate) => candidate === "/" || /^[A-Za-z]:[\\/]?$/.test(candidate))) {
41
64
  return "the filesystem root is too broad for approved-source scanning";
42
65
  }
43
- if (resolved === resolvedHome) {
66
+ if (Array.from(rootCandidates).some((candidate) => homeCandidates.has(candidate))) {
44
67
  return "the home directory is too broad for approved-source scanning";
45
68
  }
46
- if (isAncestorPath(resolved, resolvedHome)) {
69
+ if (Array.from(rootCandidates).some((candidate) => Array.from(homeCandidates).some((candidateHome) => isAncestorPath(candidate, candidateHome)))) {
47
70
  return "this directory contains your home directory and is too broad for approved-source scanning";
48
71
  }
49
- if (systemRootDirectories.has(resolved)) {
72
+ if (Array.from(rootCandidates).some((candidate) => systemRootDirectories.has(candidate))) {
50
73
  return "system directories are not valid approved-source scan targets";
51
74
  }
52
75
  return undefined;
@@ -55,8 +78,173 @@ export function unsafeScanRootReason(rootPath, home = homedir()) {
55
78
  export function assertSafeScanRoot(rootPath, home = homedir()) {
56
79
  const reason = unsafeScanRootReason(rootPath, home);
57
80
  if (reason) {
58
- throw new UnsafeScanRootError(resolve(rootPath), reason);
81
+ throw new UnsafeScanRootError(bestEffortRealpathSync(resolve(rootPath)), reason);
82
+ }
83
+ }
84
+ /**
85
+ * Resolves an approved root through the filesystem before applying the broad-
86
+ * root policy. This prevents a harmless-looking symlink such as
87
+ * `/tmp/project` from approving a scan of `$HOME` or a system directory.
88
+ * Callers should persist and use the returned canonical path.
89
+ */
90
+ export async function resolveSafeScanRoot(rootPath, home = homedir()) {
91
+ const requested = resolve(rootPath);
92
+ let canonicalRoot;
93
+ try {
94
+ canonicalRoot = await realpath(requested);
95
+ }
96
+ catch {
97
+ throw new UnsafeScanRootError(requested, "the approved scan root does not resolve to an existing directory");
98
+ }
99
+ let canonicalHome = resolve(home);
100
+ try {
101
+ canonicalHome = await realpath(canonicalHome);
102
+ }
103
+ catch {
104
+ // A synthetic/nonexistent home is useful in tests. The lexical path still
105
+ // preserves the same policy when there is nothing to canonicalize.
106
+ }
107
+ const reason = unsafeScanRootReason(canonicalRoot, canonicalHome);
108
+ if (reason) {
109
+ throw new UnsafeScanRootError(canonicalRoot, reason);
110
+ }
111
+ const rootInfo = await stat(canonicalRoot);
112
+ if (!rootInfo.isDirectory()) {
113
+ throw new UnsafeScanRootError(canonicalRoot, "the approved scan root is not a directory");
114
+ }
115
+ return canonicalRoot;
116
+ }
117
+ /**
118
+ * Returns a validated local state directory, creating it only when requested.
119
+ * The state path must be a real directory directly under the canonical scan
120
+ * root; symbolic links are refused for both reads and writes.
121
+ */
122
+ export async function resolveSafeStateDirectory(rootPath, options = {}) {
123
+ const canonicalRoot = await resolveSafeScanRoot(rootPath);
124
+ const statePath = join(canonicalRoot, ".ai-spend-agent");
125
+ let stateInfo = await lstat(statePath).catch((error) => {
126
+ if (isNodeError(error, "ENOENT"))
127
+ return undefined;
128
+ throw error;
129
+ });
130
+ if (!stateInfo && options.create) {
131
+ try {
132
+ await mkdir(statePath, { mode: 0o700 });
133
+ }
134
+ catch (error) {
135
+ // A concurrent creator is safe only after the same lstat checks below.
136
+ if (!isNodeError(error, "EEXIST"))
137
+ throw error;
138
+ }
139
+ stateInfo = await lstat(statePath);
140
+ }
141
+ if (!stateInfo) {
142
+ const error = new Error(`State directory does not exist: ${statePath}`);
143
+ error.code = "ENOENT";
144
+ throw error;
145
+ }
146
+ if (stateInfo.isSymbolicLink()) {
147
+ throw new UnsafeStateDirectoryError(statePath, ".ai-spend-agent is a symbolic link");
148
+ }
149
+ if (!stateInfo.isDirectory()) {
150
+ throw new UnsafeStateDirectoryError(statePath, ".ai-spend-agent is not a directory");
151
+ }
152
+ const canonicalState = await realpath(statePath);
153
+ if (canonicalState !== statePath) {
154
+ throw new UnsafeStateDirectoryError(statePath, ".ai-spend-agent does not resolve directly inside the approved root");
59
155
  }
156
+ if (options.create) {
157
+ await chmod(statePath, 0o700);
158
+ }
159
+ return statePath;
160
+ }
161
+ /**
162
+ * Read one regular child file without following a symbolic link. State can sit
163
+ * inside a cloned repository, so validating only the parent directory is not
164
+ * enough: a committed child symlink must never expose an arbitrary local file.
165
+ */
166
+ export async function readSafeStateText(stateDir, fileName) {
167
+ const filePath = await resolveSafeStateChild(stateDir, fileName, false);
168
+ let handle;
169
+ try {
170
+ handle = await open(filePath, constants.O_RDONLY | noFollowFlag());
171
+ const info = await handle.stat();
172
+ if (!info.isFile()) {
173
+ throw new UnsafeStateFileError(filePath, "the state entry is not a regular file");
174
+ }
175
+ return await handle.readFile("utf8");
176
+ }
177
+ catch (error) {
178
+ if (isNodeError(error, "ELOOP")) {
179
+ throw new UnsafeStateFileError(filePath, "the state entry is a symbolic link");
180
+ }
181
+ throw error;
182
+ }
183
+ finally {
184
+ await handle?.close();
185
+ }
186
+ }
187
+ /**
188
+ * Atomically replace one regular child file. The temporary file is created
189
+ * exclusively with mode 0600, and rename replaces a last-moment symlink
190
+ * itself rather than writing through to its target.
191
+ */
192
+ export async function writeSafeStateText(stateDir, fileName, contents) {
193
+ const filePath = await resolveSafeStateChild(stateDir, fileName, true);
194
+ const temporaryName = `.${fileName}.${process.pid}.${randomUUID()}.tmp`;
195
+ const temporaryPath = join(stateDir, temporaryName);
196
+ let handle;
197
+ try {
198
+ handle = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), 0o600);
199
+ await handle.writeFile(contents, "utf8");
200
+ await handle.sync();
201
+ await handle.close();
202
+ handle = undefined;
203
+ await rename(temporaryPath, filePath);
204
+ await chmod(filePath, 0o600);
205
+ }
206
+ catch (error) {
207
+ await handle?.close().catch(() => undefined);
208
+ await unlink(temporaryPath).catch(() => undefined);
209
+ throw error;
210
+ }
211
+ }
212
+ async function resolveSafeStateChild(stateDir, fileName, allowMissing) {
213
+ if (!fileName || basename(fileName) !== fileName || fileName === "." || fileName === "..") {
214
+ throw new UnsafeStateFileError(join(stateDir, fileName), "state filenames must be one direct child name");
215
+ }
216
+ const requestedState = resolve(stateDir);
217
+ const stateInfo = await lstat(requestedState);
218
+ if (stateInfo.isSymbolicLink() || !stateInfo.isDirectory()) {
219
+ throw new UnsafeStateDirectoryError(requestedState, "the state path is not a real directory");
220
+ }
221
+ const filePath = join(requestedState, fileName);
222
+ const childInfo = await lstat(filePath).catch((error) => {
223
+ if (allowMissing && isNodeError(error, "ENOENT"))
224
+ return undefined;
225
+ throw error;
226
+ });
227
+ if (childInfo?.isSymbolicLink()) {
228
+ throw new UnsafeStateFileError(filePath, "the state entry is a symbolic link");
229
+ }
230
+ if (childInfo && !childInfo.isFile()) {
231
+ throw new UnsafeStateFileError(filePath, "the state entry is not a regular file");
232
+ }
233
+ return filePath;
234
+ }
235
+ function noFollowFlag() {
236
+ return typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
237
+ }
238
+ function bestEffortRealpathSync(path) {
239
+ try {
240
+ return realpathSync(path);
241
+ }
242
+ catch {
243
+ return path;
244
+ }
245
+ }
246
+ function isNodeError(error, code) {
247
+ return error instanceof Error && error.code === code;
60
248
  }
61
249
  function isAncestorPath(candidateAncestor, path) {
62
250
  const normalizedAncestor = candidateAncestor.endsWith(sep) ? candidateAncestor : candidateAncestor + sep;