@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/LICENSE +21 -0
- package/README.md +16 -0
- package/dist/agentInventory.d.ts +35 -9
- package/dist/agentInventory.js +309 -11
- package/dist/analyze.js +6 -2
- package/dist/contextHealth.d.ts +101 -0
- package/dist/contextHealth.js +371 -0
- package/dist/deadContext.js +10 -1
- package/dist/discovery.d.ts +14 -1
- package/dist/discovery.js +54 -28
- package/dist/glance.d.ts +161 -0
- package/dist/glance.js +586 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/localAgentLogs.d.ts +42 -2
- package/dist/localAgentLogs.js +460 -7
- package/dist/modelPricing.d.ts +2 -1
- package/dist/modelPricing.js +22 -3
- package/dist/planMath.d.ts +12 -14
- package/dist/planMath.js +18 -18
- package/dist/providerConnectors.d.ts +19 -1
- package/dist/providerConnectors.js +106 -26
- package/dist/scanGuard.d.ts +35 -0
- package/dist/scanGuard.js +196 -8
- package/dist/schema.d.ts +24 -24
- package/dist/sourceRegistry.js +1 -1
- package/dist/toolInvocations.d.ts +42 -1
- package/dist/toolInvocations.js +244 -4
- package/package.json +15 -2
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
//
|
|
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}) —
|
|
58
|
-
`($${detectedKnown.monthlyUsd}/mo
|
|
59
|
-
(effectiveSavings ? `, ~$${effectiveSavings.toFixed(2)}/mo
|
|
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
|
-
? `
|
|
65
|
-
: `if
|
|
64
|
+
? `local metadata reports ${detected.limitSignal}`
|
|
65
|
+
: `if the provider reports active rate limits`;
|
|
66
66
|
upgradeHint = nextTier
|
|
67
|
-
? `
|
|
68
|
-
: `
|
|
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 = `
|
|
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}) —
|
|
79
|
-
`(detected locally; price not in our table)` +
|
|
80
|
-
(suggested ? `;
|
|
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}
|
|
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}) —
|
|
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
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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[].
|
|
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
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
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
|
|
652
|
-
const
|
|
653
|
-
const
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
.
|
|
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
|
|
663
|
-
const
|
|
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:
|
|
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
|
-
|
|
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;
|
package/dist/scanGuard.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
39
|
-
const
|
|
40
|
-
|
|
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 (
|
|
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(
|
|
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(
|
|
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;
|