@agent-finops/core 0.9.6 → 0.9.8
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/README.md +3 -3
- package/dist/actionPlanner.js +4 -1
- package/dist/actionVerification.d.ts +54 -54
- package/dist/activitySnapshot.d.ts +57 -57
- package/dist/agentEconomicsReceipt.d.ts +62 -62
- package/dist/analyze.js +59 -18
- package/dist/contextHealth.js +15 -4
- package/dist/cutList.d.ts +23 -0
- package/dist/cutList.js +268 -11
- package/dist/deadContext.js +10 -3
- package/dist/insights.js +41 -23
- package/dist/localAgentLogs.d.ts +5 -2
- package/dist/localAgentLogs.js +112 -12
- package/dist/modelPricing.js +3 -0
- package/dist/planMath.js +25 -4
- package/dist/projectIndexStore.d.ts +8 -8
- package/dist/qualitativeIndexCache.d.ts +8 -8
- package/dist/toolInvocations.js +9 -1
- package/dist/untrustedLabel.d.ts +70 -0
- package/dist/untrustedLabel.js +161 -0
- package/package.json +1 -1
package/dist/localAgentLogs.js
CHANGED
|
@@ -228,6 +228,20 @@ function parseClaudeFinancialUsage(value, onDiagnostic) {
|
|
|
228
228
|
const write1hField = cacheCreation
|
|
229
229
|
? optionalTokenComponent(cacheCreation, "ephemeral_1h_input_tokens")
|
|
230
230
|
: { present: false };
|
|
231
|
+
const writeTotal = writeTotalField.value;
|
|
232
|
+
let write5m = write5mField.value;
|
|
233
|
+
let write1h = write1hField.value;
|
|
234
|
+
const writeSplitConsistent = writeTotal === undefined || ((write5m === undefined || write5m <= writeTotal) &&
|
|
235
|
+
(write1h === undefined || write1h <= writeTotal) &&
|
|
236
|
+
(write5m === undefined || write1h === undefined || write5m + write1h === writeTotal));
|
|
237
|
+
// A total plus one disjoint duration determines the other duration. Never
|
|
238
|
+
// add the entire total to the supplied duration or invent a negative split.
|
|
239
|
+
if (writeTotal !== undefined && writeSplitConsistent) {
|
|
240
|
+
if (write5m === undefined && write1h !== undefined)
|
|
241
|
+
write5m = writeTotal - write1h;
|
|
242
|
+
else if (write1h === undefined && write5m !== undefined)
|
|
243
|
+
write1h = writeTotal - write5m;
|
|
244
|
+
}
|
|
231
245
|
const componentsSupported = inputTokens !== undefined &&
|
|
232
246
|
outputTokens !== undefined &&
|
|
233
247
|
(!cacheReadField.present || cacheReadField.value !== undefined) &&
|
|
@@ -235,16 +249,18 @@ function parseClaudeFinancialUsage(value, onDiagnostic) {
|
|
|
235
249
|
(!reportedTotalField.present || reportedTotalField.value !== undefined) &&
|
|
236
250
|
(!cacheCreationPresent || Boolean(cacheCreation)) &&
|
|
237
251
|
(!write5mField.present || write5mField.value !== undefined) &&
|
|
238
|
-
(!write1hField.present || write1hField.value !== undefined)
|
|
252
|
+
(!write1hField.present || write1hField.value !== undefined) &&
|
|
253
|
+
writeSplitConsistent;
|
|
239
254
|
const usage = {
|
|
240
255
|
// Retain every valid component for partial evidence, but never let a
|
|
241
256
|
// missing/invalid required field become a priceable zero-dollar call.
|
|
242
257
|
inputTokens: inputTokens ?? 0,
|
|
243
258
|
outputTokens: outputTokens ?? 0,
|
|
244
259
|
cacheReadTokens: cacheReadField.value ?? 0,
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
260
|
+
// With no duration split at all, keep the existing cheaper-bound estimate.
|
|
261
|
+
// Partial evidence without a total remains partial, never a complete sum.
|
|
262
|
+
cacheWrite5mTokens: write5m ?? (write1h === undefined ? writeTotal : undefined) ?? 0,
|
|
263
|
+
cacheWrite1hTokens: write1h ?? 0
|
|
248
264
|
};
|
|
249
265
|
if (componentsSupported) {
|
|
250
266
|
const cacheWriteEvidence = writeTotalField.value !== undefined ||
|
|
@@ -1327,6 +1343,11 @@ export async function loadLocalAgentFinancialUsage(options = {}) {
|
|
|
1327
1343
|
/** Registry-driven financial-only engine; package-root exports stay unchanged. */
|
|
1328
1344
|
export async function loadLocalAgentFinancialUsageWithFormats(registry, options = {}) {
|
|
1329
1345
|
validateLocalAgentFormatDescriptors(registry.map((entry) => entry.descriptor));
|
|
1346
|
+
// Cumulative financial caches cannot supply event-day deltas. Keep their semantics unchanged.
|
|
1347
|
+
if (options.workspaceDailyFacts) {
|
|
1348
|
+
options = { ...options, financialIndex: undefined };
|
|
1349
|
+
registry = registry.filter(runtime => runtime.descriptor.id === "claude-code" || runtime.descriptor.id === "codex");
|
|
1350
|
+
}
|
|
1330
1351
|
const home = homedir();
|
|
1331
1352
|
const since = options.sinceIso ? Date.parse(options.sinceIso) : undefined;
|
|
1332
1353
|
const sinceMs = typeof since === "number" && Number.isFinite(since) ? since : undefined;
|
|
@@ -1401,12 +1422,12 @@ export async function loadLocalAgentFinancialUsageWithFormats(registry, options
|
|
|
1401
1422
|
const diagnosticsBefore = diagnostics.length;
|
|
1402
1423
|
const unreadableBefore = scan.unreadableFiles;
|
|
1403
1424
|
const filesParsedBefore = scan.filesParsed;
|
|
1404
|
-
const
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1425
|
+
const context = { filePath: file, sinceMs, scan, diagnostics };
|
|
1426
|
+
const parsedCalls = options.workspaceDailyFacts && descriptor.id === "codex"
|
|
1427
|
+
? await readCodexDailyFinancialFile(context)
|
|
1428
|
+
: options.workspaceDailyFacts && descriptor.id === "claude-code"
|
|
1429
|
+
? await readClaudeCodeFinancialFileForRegistry(context, true)
|
|
1430
|
+
: await runtime.parseFinancialFile(context);
|
|
1410
1431
|
assertFormatCallOwnership(descriptor, parsedCalls);
|
|
1411
1432
|
assertFinancialSourceOwnership(descriptor, scan, diagnostics);
|
|
1412
1433
|
calls.push(...parsedCalls);
|
|
@@ -1588,7 +1609,7 @@ function codexHeaderAttribution(header) {
|
|
|
1588
1609
|
};
|
|
1589
1610
|
}
|
|
1590
1611
|
/** @internal Runtime hook owned by the Claude Code registry entry. */
|
|
1591
|
-
export async function readClaudeCodeFinancialFileForRegistry(context) {
|
|
1612
|
+
export async function readClaudeCodeFinancialFileForRegistry(context, workspaceDailyFacts = false) {
|
|
1592
1613
|
const { filePath, sinceMs, scan, diagnostics } = context;
|
|
1593
1614
|
if (!await shouldStreamFile(filePath, sinceMs, "claude-code", scan, diagnostics)) {
|
|
1594
1615
|
return [];
|
|
@@ -1604,8 +1625,16 @@ export async function readClaudeCodeFinancialFileForRegistry(context) {
|
|
|
1604
1625
|
// file so a narrow-window run can never truncate a wider one. The
|
|
1605
1626
|
// loader's final timestamp filter performs all narrowing.
|
|
1606
1627
|
undefined, seen, (diagnostic) => fileDiagnostics.push(diagnostic));
|
|
1607
|
-
if (call)
|
|
1628
|
+
if (call) {
|
|
1629
|
+
if (workspaceDailyFacts) {
|
|
1630
|
+
const nativeAgent = stringOf(entry.agentId);
|
|
1631
|
+
const pathAgent = filePath.split(sep).includes("subagents")
|
|
1632
|
+
? basename(filePath).match(/^agent-([A-Za-z0-9_-]+)\.jsonl$/)?.[1] : undefined;
|
|
1633
|
+
if (nativeAgent || pathAgent)
|
|
1634
|
+
call.subagentId = nativeAgent ?? pathAgent;
|
|
1635
|
+
}
|
|
1608
1636
|
calls.push(call);
|
|
1637
|
+
}
|
|
1609
1638
|
});
|
|
1610
1639
|
}
|
|
1611
1640
|
catch (error) {
|
|
@@ -1668,6 +1697,77 @@ export async function readCodexFinancialFileForRegistry(context) {
|
|
|
1668
1697
|
});
|
|
1669
1698
|
return call ? [call] : [];
|
|
1670
1699
|
}
|
|
1700
|
+
/** Workspace-only event deltas. The existing snapshot reader remains cumulative. */
|
|
1701
|
+
async function readCodexDailyFinancialFile(context) {
|
|
1702
|
+
const { filePath, sinceMs, scan, diagnostics } = context;
|
|
1703
|
+
if (!await shouldStreamFile(filePath, sinceMs, "codex", scan, diagnostics))
|
|
1704
|
+
return [];
|
|
1705
|
+
const state = createCodexFinancialStreamState(), calls = [];
|
|
1706
|
+
const report = () => recordParseDiagnostic("codex", scan, diagnostics, { code: "unsupported_token_shape", count: 1 });
|
|
1707
|
+
let priorEventAt;
|
|
1708
|
+
try {
|
|
1709
|
+
const streamed = await streamJsonlRecords(filePath, entry => {
|
|
1710
|
+
const payload = isRecord(entry.payload) ? entry.payload : undefined;
|
|
1711
|
+
const previousTotal = state.lastTotal ?? state.inheritedUsageBaseline;
|
|
1712
|
+
consumeCodexFinancialEntry(state, entry);
|
|
1713
|
+
if (entry.type !== "event_msg" || payload?.type !== "token_count"
|
|
1714
|
+
|| state.hasInheritedHistory && !state.rootTaskStarted)
|
|
1715
|
+
return;
|
|
1716
|
+
const info = isRecord(payload.info) ? payload.info : undefined;
|
|
1717
|
+
const total = info && isRecord(info.total_token_usage) ? info.total_token_usage : undefined;
|
|
1718
|
+
if (!total) {
|
|
1719
|
+
// A usage-bearing event without its cumulative endpoint cannot be
|
|
1720
|
+
// placed safely: a later counter may span this event's UTC day.
|
|
1721
|
+
if (info && isRecord(info.last_token_usage))
|
|
1722
|
+
report();
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
const timestamp = toIso(stringOf(entry.timestamp));
|
|
1726
|
+
if (!timestamp || !state.sessionId) {
|
|
1727
|
+
report();
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
const parsed = parseCodexCumulativeUsage(total, previousTotal);
|
|
1731
|
+
// Missing initial history cannot be assigned to a later day. A changed or
|
|
1732
|
+
// decreasing counter remains unknown at that event; the next exact pair
|
|
1733
|
+
// can establish a delta without pretending the reset was a zero.
|
|
1734
|
+
const firstDayKnown = !!previousTotal || !!state.startedAt && state.startedAt.slice(0, 10) === timestamp.slice(0, 10);
|
|
1735
|
+
const chronological = !priorEventAt || timestamp >= priorEventAt;
|
|
1736
|
+
const cachedShapeStable = !previousTotal ||
|
|
1737
|
+
Object.hasOwn(total, "cached_input_tokens") === Object.hasOwn(previousTotal, "cached_input_tokens");
|
|
1738
|
+
const supported = parsed.supported && firstDayKnown && chronological && cachedShapeStable;
|
|
1739
|
+
priorEventAt = timestamp;
|
|
1740
|
+
if (supported && previousTotal && parsed.usage.inputTokens === 0 && parsed.usage.outputTokens === 0
|
|
1741
|
+
&& (parsed.usage.cacheReadTokens ?? 0) === 0 && (parsed.reportedTotalTokens ?? 0) === 0)
|
|
1742
|
+
return;
|
|
1743
|
+
if (!supported)
|
|
1744
|
+
report();
|
|
1745
|
+
const workingDirectory = absoluteWorkingDirectory(state.rootCwd);
|
|
1746
|
+
// The cumulative endpoint fingerprint is stable across copies of a
|
|
1747
|
+
// rollout. Different deltas for that same endpoint become a conflict in
|
|
1748
|
+
// dedupeCumulativeSessionCalls rather than two financial contributions.
|
|
1749
|
+
const callId = `callref_${createHash("sha256").update("codex-workspace-counter-event-v1\0")
|
|
1750
|
+
.update(JSON.stringify([state.sessionId, timestamp, total.input_tokens ?? null,
|
|
1751
|
+
total.output_tokens ?? null, total.cached_input_tokens ?? null, total.total_tokens ?? null])).digest("hex")}`;
|
|
1752
|
+
calls.push({ agent: "codex", sessionId: state.sessionId, callId,
|
|
1753
|
+
model: state.model ?? "codex", timestamp, startedAt: timestamp,
|
|
1754
|
+
project: projectFromCwd(workingDirectory), workingDirectory,
|
|
1755
|
+
usageScope: "turn", usageSupport: supported ? "complete" : "unsupported_token_shape",
|
|
1756
|
+
usage: parsed.usage,
|
|
1757
|
+
...(supported && parsed.tokenComponentEvidence ? { tokenComponentEvidence: parsed.tokenComponentEvidence } : {}),
|
|
1758
|
+
...(parsed.reportedTotalTokens !== undefined ? { reportedTotalTokens: parsed.reportedTotalTokens } : {}) });
|
|
1759
|
+
});
|
|
1760
|
+
if (streamed.hadContent)
|
|
1761
|
+
scan.filesParsed++;
|
|
1762
|
+
if (streamed.malformedLines)
|
|
1763
|
+
recordParseDiagnostic("codex", scan, diagnostics, { code: "malformed_jsonl", count: streamed.malformedLines });
|
|
1764
|
+
return calls;
|
|
1765
|
+
}
|
|
1766
|
+
catch (error) {
|
|
1767
|
+
recordUnreadableFile("codex", scan, diagnostics, error);
|
|
1768
|
+
return [];
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1671
1771
|
/** @internal Runtime hook owned by the Gemini CLI registry entry. */
|
|
1672
1772
|
export async function readGeminiFinancialFileForRegistry(context) {
|
|
1673
1773
|
const { filePath, sinceMs, scan, diagnostics } = context;
|
package/dist/modelPricing.js
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
export const PRICING_TABLE_AS_OF = "2026-08-25";
|
|
11
11
|
const pricingRules = [
|
|
12
12
|
// Anthropic
|
|
13
|
+
// Reviewed 2026-09-20: platform.claude.com/docs/en/about-claude/pricing.
|
|
14
|
+
// Fable/Mythos 5.1 keep base/write rates; cache reads are $0.25 per MTok.
|
|
15
|
+
{ match: /^claude-(?:fable|mythos)-5-1$/i, inputPerM: 10, outputPerM: 50, cacheReadPerM: 0.25 },
|
|
13
16
|
{ match: /^claude-fable-5/i, inputPerM: 10, outputPerM: 50 },
|
|
14
17
|
{ match: /^claude-mythos-5/i, inputPerM: 10, outputPerM: 50 },
|
|
15
18
|
{ match: /^claude-opus-5/i, inputPerM: 5, outputPerM: 25 },
|
package/dist/planMath.js
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
import { safeUntrustedLabel, WITHHELD_ENTITY_LABEL, WITHHELD_PLAN_LABEL } from "./untrustedLabel.js";
|
|
2
|
+
/**
|
|
3
|
+
* The plan label and the limit signal are read out of the agent's own local
|
|
4
|
+
* config files, so both are untrusted text that lands mid-sentence in a
|
|
5
|
+
* headline the readout, the report and `doctor` all print verbatim.
|
|
6
|
+
*/
|
|
7
|
+
function safePlanLabel(value) {
|
|
8
|
+
return safeUntrustedLabel(value, WITHHELD_PLAN_LABEL);
|
|
9
|
+
}
|
|
10
|
+
function safeLimitSignal(value) {
|
|
11
|
+
return safeUntrustedLabel(value, WITHHELD_ENTITY_LABEL);
|
|
12
|
+
}
|
|
1
13
|
export const subscriptionPlans = [
|
|
2
14
|
{ id: "claude-pro", provider: "anthropic", agent: "claude-code", name: "Claude Pro", monthlyUsd: 20, coversUpToUsd: 50 },
|
|
3
15
|
{ id: "claude-max-5x", provider: "anthropic", agent: "claude-code", name: "Claude Max 5x", monthlyUsd: 100, coversUpToUsd: 250 },
|
|
@@ -61,21 +73,21 @@ export function computePlanChecks(records, detectedPlans = []) {
|
|
|
61
73
|
const nextTier = subscriptionPlans.find((plan) => plan.agent === agent && plan.coversUpToUsd > detectedKnown.coversUpToUsd);
|
|
62
74
|
// A local limit signal upgrades "might hit limits" to hard evidence.
|
|
63
75
|
const evidence = detected?.limitSignal
|
|
64
|
-
? `local metadata reports ${detected.limitSignal}`
|
|
76
|
+
? `local metadata reports ${safeLimitSignal(detected.limitSignal)}`
|
|
65
77
|
: `if the provider reports active rate limits`;
|
|
66
78
|
upgradeHint = nextTier
|
|
67
79
|
? `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
80
|
: `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
81
|
}
|
|
70
82
|
else if (detected?.limitSignal) {
|
|
71
|
-
upgradeHint = `local metadata reports ${detected.limitSignal}; verify the live provider window. Trimming context (below) may buy headroom.`;
|
|
83
|
+
upgradeHint = `local metadata reports ${safeLimitSignal(detected.limitSignal)}; verify the live provider window. Trimming context (below) may buy headroom.`;
|
|
72
84
|
}
|
|
73
85
|
}
|
|
74
86
|
else if (detected) {
|
|
75
87
|
// Detected a plan we can't price (e.g. an unrecognized tier): state the
|
|
76
88
|
// fact, then fall back to suggestion math without pretending certainty.
|
|
77
89
|
headline =
|
|
78
|
-
`${agent}: ~${formatUsd(monthly)}/mo at API rates (${basis}) — compared with ${detected.planLabel} ` +
|
|
90
|
+
`${agent}: ~${formatUsd(monthly)}/mo at API rates (${basis}) — compared with ${safePlanLabel(detected.planLabel)} ` +
|
|
79
91
|
`(label detected locally; price not in our table)` +
|
|
80
92
|
(suggested ? `; reference listed plan: ${suggested.name} ($${suggested.monthlyUsd}/mo).` : `.`);
|
|
81
93
|
}
|
|
@@ -100,7 +112,16 @@ export function computePlanChecks(records, detectedPlans = []) {
|
|
|
100
112
|
suggestedPlan: detectedKnown ?? suggested,
|
|
101
113
|
monthlySavingsVsApiUsd: effectiveSavings,
|
|
102
114
|
valueMultiple,
|
|
103
|
-
|
|
115
|
+
// The STRUCTURED sibling of the headline. Neutralizing the sentence and
|
|
116
|
+
// shipping the raw label beside it in the same object is the inversion
|
|
117
|
+
// that let a hostile name reach an agent while the human saw a redaction.
|
|
118
|
+
detectedPlan: detected === undefined ? undefined : {
|
|
119
|
+
...detected,
|
|
120
|
+
planLabel: safePlanLabel(detected.planLabel),
|
|
121
|
+
...(detected.limitSignal === undefined
|
|
122
|
+
? {}
|
|
123
|
+
: { limitSignal: safeLimitSignal(detected.limitSignal) })
|
|
124
|
+
},
|
|
104
125
|
upgradeHint,
|
|
105
126
|
headline
|
|
106
127
|
});
|
|
@@ -112,9 +112,9 @@ declare const documentSchema: z.ZodObject<{
|
|
|
112
112
|
not_separately_reported: "not_separately_reported";
|
|
113
113
|
}>;
|
|
114
114
|
cacheWriteTokens: z.ZodEnum<{
|
|
115
|
-
partial: "partial";
|
|
116
115
|
observed: "observed";
|
|
117
116
|
not_separately_reported: "not_separately_reported";
|
|
117
|
+
partial: "partial";
|
|
118
118
|
}>;
|
|
119
119
|
thoughtTokens: z.ZodEnum<{
|
|
120
120
|
observed: "observed";
|
|
@@ -129,8 +129,8 @@ declare const documentSchema: z.ZodObject<{
|
|
|
129
129
|
calculated_partial: "calculated_partial";
|
|
130
130
|
}>;
|
|
131
131
|
reportedTotalTokens: z.ZodEnum<{
|
|
132
|
-
not_reported: "not_reported";
|
|
133
132
|
provider_reported: "provider_reported";
|
|
133
|
+
not_reported: "not_reported";
|
|
134
134
|
}>;
|
|
135
135
|
}, z.core.$strict>>;
|
|
136
136
|
sourceVersion: z.ZodOptional<z.ZodString>;
|
|
@@ -189,9 +189,9 @@ declare const documentSchema: z.ZodObject<{
|
|
|
189
189
|
activity: z.ZodOptional<z.ZodObject<{
|
|
190
190
|
summary: z.ZodString;
|
|
191
191
|
kind: z.ZodEnum<{
|
|
192
|
-
file: "file";
|
|
193
|
-
agent: "agent";
|
|
194
192
|
project: "project";
|
|
193
|
+
agent: "agent";
|
|
194
|
+
file: "file";
|
|
195
195
|
task: "task";
|
|
196
196
|
automation: "automation";
|
|
197
197
|
}>;
|
|
@@ -331,9 +331,9 @@ declare const documentSchema: z.ZodObject<{
|
|
|
331
331
|
not_separately_reported: "not_separately_reported";
|
|
332
332
|
}>;
|
|
333
333
|
cacheWriteTokens: z.ZodEnum<{
|
|
334
|
-
partial: "partial";
|
|
335
334
|
observed: "observed";
|
|
336
335
|
not_separately_reported: "not_separately_reported";
|
|
336
|
+
partial: "partial";
|
|
337
337
|
}>;
|
|
338
338
|
thoughtTokens: z.ZodEnum<{
|
|
339
339
|
observed: "observed";
|
|
@@ -348,8 +348,8 @@ declare const documentSchema: z.ZodObject<{
|
|
|
348
348
|
calculated_partial: "calculated_partial";
|
|
349
349
|
}>;
|
|
350
350
|
reportedTotalTokens: z.ZodEnum<{
|
|
351
|
-
not_reported: "not_reported";
|
|
352
351
|
provider_reported: "provider_reported";
|
|
352
|
+
not_reported: "not_reported";
|
|
353
353
|
}>;
|
|
354
354
|
}, z.core.$strict>>;
|
|
355
355
|
sourceVersion: z.ZodOptional<z.ZodString>;
|
|
@@ -408,9 +408,9 @@ declare const documentSchema: z.ZodObject<{
|
|
|
408
408
|
activity: z.ZodOptional<z.ZodObject<{
|
|
409
409
|
summary: z.ZodString;
|
|
410
410
|
kind: z.ZodEnum<{
|
|
411
|
-
file: "file";
|
|
412
|
-
agent: "agent";
|
|
413
411
|
project: "project";
|
|
412
|
+
agent: "agent";
|
|
413
|
+
file: "file";
|
|
414
414
|
task: "task";
|
|
415
415
|
automation: "automation";
|
|
416
416
|
}>;
|
|
@@ -83,9 +83,9 @@ declare const valueSchema: z.ZodObject<{
|
|
|
83
83
|
not_separately_reported: "not_separately_reported";
|
|
84
84
|
}>;
|
|
85
85
|
cacheWriteTokens: z.ZodEnum<{
|
|
86
|
-
partial: "partial";
|
|
87
86
|
observed: "observed";
|
|
88
87
|
not_separately_reported: "not_separately_reported";
|
|
88
|
+
partial: "partial";
|
|
89
89
|
}>;
|
|
90
90
|
thoughtTokens: z.ZodEnum<{
|
|
91
91
|
observed: "observed";
|
|
@@ -100,8 +100,8 @@ declare const valueSchema: z.ZodObject<{
|
|
|
100
100
|
calculated_partial: "calculated_partial";
|
|
101
101
|
}>;
|
|
102
102
|
reportedTotalTokens: z.ZodEnum<{
|
|
103
|
-
not_reported: "not_reported";
|
|
104
103
|
provider_reported: "provider_reported";
|
|
104
|
+
not_reported: "not_reported";
|
|
105
105
|
}>;
|
|
106
106
|
}, z.core.$strict>>;
|
|
107
107
|
sourceVersion: z.ZodOptional<z.ZodString>;
|
|
@@ -160,9 +160,9 @@ declare const valueSchema: z.ZodObject<{
|
|
|
160
160
|
activity: z.ZodOptional<z.ZodObject<{
|
|
161
161
|
summary: z.ZodString;
|
|
162
162
|
kind: z.ZodEnum<{
|
|
163
|
-
file: "file";
|
|
164
|
-
agent: "agent";
|
|
165
163
|
project: "project";
|
|
164
|
+
agent: "agent";
|
|
165
|
+
file: "file";
|
|
166
166
|
task: "task";
|
|
167
167
|
automation: "automation";
|
|
168
168
|
}>;
|
|
@@ -309,9 +309,9 @@ export declare const qualitativeEntryValueSchema: z.ZodObject<{
|
|
|
309
309
|
not_separately_reported: "not_separately_reported";
|
|
310
310
|
}>;
|
|
311
311
|
cacheWriteTokens: z.ZodEnum<{
|
|
312
|
-
partial: "partial";
|
|
313
312
|
observed: "observed";
|
|
314
313
|
not_separately_reported: "not_separately_reported";
|
|
314
|
+
partial: "partial";
|
|
315
315
|
}>;
|
|
316
316
|
thoughtTokens: z.ZodEnum<{
|
|
317
317
|
observed: "observed";
|
|
@@ -326,8 +326,8 @@ export declare const qualitativeEntryValueSchema: z.ZodObject<{
|
|
|
326
326
|
calculated_partial: "calculated_partial";
|
|
327
327
|
}>;
|
|
328
328
|
reportedTotalTokens: z.ZodEnum<{
|
|
329
|
-
not_reported: "not_reported";
|
|
330
329
|
provider_reported: "provider_reported";
|
|
330
|
+
not_reported: "not_reported";
|
|
331
331
|
}>;
|
|
332
332
|
}, z.core.$strict>>;
|
|
333
333
|
sourceVersion: z.ZodOptional<z.ZodString>;
|
|
@@ -386,9 +386,9 @@ export declare const qualitativeEntryValueSchema: z.ZodObject<{
|
|
|
386
386
|
activity: z.ZodOptional<z.ZodObject<{
|
|
387
387
|
summary: z.ZodString;
|
|
388
388
|
kind: z.ZodEnum<{
|
|
389
|
-
file: "file";
|
|
390
|
-
agent: "agent";
|
|
391
389
|
project: "project";
|
|
390
|
+
agent: "agent";
|
|
391
|
+
file: "file";
|
|
392
392
|
task: "task";
|
|
393
393
|
automation: "automation";
|
|
394
394
|
}>;
|
package/dist/toolInvocations.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
+
import { safeUntrustedLabel, WITHHELD_FILE_LABEL } from "./untrustedLabel.js";
|
|
4
5
|
/** Parse ONE transcript's content. Exported for tests. Returns the per-file pieces the aggregator needs. */
|
|
5
6
|
export function parseClaudeCodeInvocations(content, sinceMs) {
|
|
6
7
|
const counts = new Map();
|
|
@@ -605,8 +606,15 @@ function explicitReadFile(toolName, input) {
|
|
|
605
606
|
return name && name !== "." && name !== "/" ? name : undefined;
|
|
606
607
|
}
|
|
607
608
|
function buildSessionContextSignal(input) {
|
|
609
|
+
// File names come off transcript tool-call metadata, so they are untrusted,
|
|
610
|
+
// and they travel as DATA rather than prose: {name, count} objects that the
|
|
611
|
+
// MCP tools hand to an agent verbatim. Neutralizing the sentence built from
|
|
612
|
+
// this array while the array itself stayed raw gave the human the redaction
|
|
613
|
+
// and the agent the payload — backwards, on the one surface where injected
|
|
614
|
+
// text can actually steer a coding agent. Neutralize at the source, so every
|
|
615
|
+
// consumer (Glance, MCP, CLI, the action planner) gets the same safe name.
|
|
608
616
|
const fileReads = [...input.fileReads.entries()]
|
|
609
|
-
.map(([name, count]) => ({ name, count }))
|
|
617
|
+
.map(([name, count]) => ({ name: safeUntrustedLabel(name, WITHHELD_FILE_LABEL), count }))
|
|
610
618
|
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
|
|
611
619
|
return {
|
|
612
620
|
agent: input.agent,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE place that decides what an untrusted NAME is allowed to become before it
|
|
3
|
+
* is interpolated into a sentence this product wrote.
|
|
4
|
+
*
|
|
5
|
+
* Why it exists at all. Every user-facing string here is built by templating a
|
|
6
|
+
* fragment the user did not author — a folder name off disk, a model id off a
|
|
7
|
+
* provider response, an operation label off an adapter — into prose that a
|
|
8
|
+
* coding agent will later read as instructions. The renderers cannot be the
|
|
9
|
+
* ones to make that safe:
|
|
10
|
+
*
|
|
11
|
+
* - The `--full` terminal readout does not sanitize at all.
|
|
12
|
+
* - The Markdown/Apply sanitizers that BLANK on a directive hit delete the
|
|
13
|
+
* whole string, and the whole string is mostly OUR sentence. In 0.9.7 that
|
|
14
|
+
* deleted the entire recommendation for 8 of 11 ordinary repo basenames,
|
|
15
|
+
* because `write-ahead-log` sat 41 characters in front of our own word
|
|
16
|
+
* "tokens" — and the terminal kept printing the finding, so two surfaces
|
|
17
|
+
* disagreed about a dollar figure.
|
|
18
|
+
*
|
|
19
|
+
* So the check runs HERE, on the fragment alone, before it reaches any
|
|
20
|
+
* template. A fragment carries only the user's text, so an ordinary name has
|
|
21
|
+
* nothing of ours to pair with; and once the fragment is safe, every surface
|
|
22
|
+
* can render the finished sentence verbatim and they all agree.
|
|
23
|
+
*
|
|
24
|
+
* The rule for anyone adding a producer: if you interpolate a value that came
|
|
25
|
+
* off disk or off the wire into a string a user or an agent will read, wrap it
|
|
26
|
+
* in {@link safeUntrustedLabel} at the point of interpolation. Not at the
|
|
27
|
+
* renderer. Not once per surface. Here.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* What an untrusted label becomes when the name itself reads like an
|
|
31
|
+
* instruction. Each says WHY, because "withheld" with no reason reads like the
|
|
32
|
+
* product failed rather than declined: `diagnose` still shows the real folder
|
|
33
|
+
* name, so this is only about not REPEATING a name that looked like an
|
|
34
|
+
* instruction inside a sentence an agent will read.
|
|
35
|
+
*
|
|
36
|
+
* Every one of these must survive the report layer's own sanitizer UNCHANGED —
|
|
37
|
+
* a marker in brackets would be stripped there and two surfaces would disagree
|
|
38
|
+
* about a string whose whole job is agreeing. Parentheses survive; brackets do
|
|
39
|
+
* not.
|
|
40
|
+
*
|
|
41
|
+
* The project label sits in appositive and prepositional slots ("X — median day
|
|
42
|
+
* carried…", "the heaviest sessions in X"), so it carries the reason as prose.
|
|
43
|
+
* The rest sit in ATTRIBUTIVE slots ("Cache repeated X calls"), where a clause
|
|
44
|
+
* would not parse, so they carry the short parenthetical form.
|
|
45
|
+
*/
|
|
46
|
+
export declare const WITHHELD_PROJECT_LABEL = "a project whose name reads like an instruction";
|
|
47
|
+
export declare const WITHHELD_MODEL_LABEL = "(model name reads like an instruction; withheld)";
|
|
48
|
+
export declare const WITHHELD_OPERATION_LABEL = "(operation name reads like an instruction; withheld)";
|
|
49
|
+
export declare const WITHHELD_AGENT_LABEL = "(agent name reads like an instruction; withheld)";
|
|
50
|
+
export declare const WITHHELD_CLIENT_LABEL = "(client name reads like an instruction; withheld)";
|
|
51
|
+
/**
|
|
52
|
+
* For a breakdown key whose dimension is decided at runtime — the same slot
|
|
53
|
+
* holds a client, a project, an agent, a model, or an operation depending on
|
|
54
|
+
* which grouping won.
|
|
55
|
+
*/
|
|
56
|
+
export declare const WITHHELD_ENTITY_LABEL = "(name reads like an instruction; withheld)";
|
|
57
|
+
export declare const WITHHELD_FILE_LABEL = "(file name reads like an instruction; withheld)";
|
|
58
|
+
export declare const WITHHELD_PLAN_LABEL = "(plan label reads like an instruction; withheld)";
|
|
59
|
+
/** Map a list of untrusted keys for display, keeping order and length. */
|
|
60
|
+
export declare function safeUntrustedLabels(values: readonly string[], withheld?: string): string[];
|
|
61
|
+
/**
|
|
62
|
+
* Neutralize ONE untrusted fragment before it is interpolated into
|
|
63
|
+
* product-authored prose.
|
|
64
|
+
*
|
|
65
|
+
* Over-triggering here is cheap and under-triggering is not: a false positive
|
|
66
|
+
* costs one name while the finding and its dollars survive, so the patterns
|
|
67
|
+
* stay strict.
|
|
68
|
+
*/
|
|
69
|
+
export declare function safeUntrustedLabel(value: string, withheld: string): string;
|
|
70
|
+
//# sourceMappingURL=untrustedLabel.d.ts.map
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE place that decides what an untrusted NAME is allowed to become before it
|
|
3
|
+
* is interpolated into a sentence this product wrote.
|
|
4
|
+
*
|
|
5
|
+
* Why it exists at all. Every user-facing string here is built by templating a
|
|
6
|
+
* fragment the user did not author — a folder name off disk, a model id off a
|
|
7
|
+
* provider response, an operation label off an adapter — into prose that a
|
|
8
|
+
* coding agent will later read as instructions. The renderers cannot be the
|
|
9
|
+
* ones to make that safe:
|
|
10
|
+
*
|
|
11
|
+
* - The `--full` terminal readout does not sanitize at all.
|
|
12
|
+
* - The Markdown/Apply sanitizers that BLANK on a directive hit delete the
|
|
13
|
+
* whole string, and the whole string is mostly OUR sentence. In 0.9.7 that
|
|
14
|
+
* deleted the entire recommendation for 8 of 11 ordinary repo basenames,
|
|
15
|
+
* because `write-ahead-log` sat 41 characters in front of our own word
|
|
16
|
+
* "tokens" — and the terminal kept printing the finding, so two surfaces
|
|
17
|
+
* disagreed about a dollar figure.
|
|
18
|
+
*
|
|
19
|
+
* So the check runs HERE, on the fragment alone, before it reaches any
|
|
20
|
+
* template. A fragment carries only the user's text, so an ordinary name has
|
|
21
|
+
* nothing of ours to pair with; and once the fragment is safe, every surface
|
|
22
|
+
* can render the finished sentence verbatim and they all agree.
|
|
23
|
+
*
|
|
24
|
+
* The rule for anyone adding a producer: if you interpolate a value that came
|
|
25
|
+
* off disk or off the wire into a string a user or an agent will read, wrap it
|
|
26
|
+
* in {@link safeUntrustedLabel} at the point of interpolation. Not at the
|
|
27
|
+
* renderer. Not once per surface. Here.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* What an untrusted label becomes when the name itself reads like an
|
|
31
|
+
* instruction. Each says WHY, because "withheld" with no reason reads like the
|
|
32
|
+
* product failed rather than declined: `diagnose` still shows the real folder
|
|
33
|
+
* name, so this is only about not REPEATING a name that looked like an
|
|
34
|
+
* instruction inside a sentence an agent will read.
|
|
35
|
+
*
|
|
36
|
+
* Every one of these must survive the report layer's own sanitizer UNCHANGED —
|
|
37
|
+
* a marker in brackets would be stripped there and two surfaces would disagree
|
|
38
|
+
* about a string whose whole job is agreeing. Parentheses survive; brackets do
|
|
39
|
+
* not.
|
|
40
|
+
*
|
|
41
|
+
* The project label sits in appositive and prepositional slots ("X — median day
|
|
42
|
+
* carried…", "the heaviest sessions in X"), so it carries the reason as prose.
|
|
43
|
+
* The rest sit in ATTRIBUTIVE slots ("Cache repeated X calls"), where a clause
|
|
44
|
+
* would not parse, so they carry the short parenthetical form.
|
|
45
|
+
*/
|
|
46
|
+
export const WITHHELD_PROJECT_LABEL = "a project whose name reads like an instruction";
|
|
47
|
+
export const WITHHELD_MODEL_LABEL = "(model name reads like an instruction; withheld)";
|
|
48
|
+
export const WITHHELD_OPERATION_LABEL = "(operation name reads like an instruction; withheld)";
|
|
49
|
+
export const WITHHELD_AGENT_LABEL = "(agent name reads like an instruction; withheld)";
|
|
50
|
+
export const WITHHELD_CLIENT_LABEL = "(client name reads like an instruction; withheld)";
|
|
51
|
+
/**
|
|
52
|
+
* For a breakdown key whose dimension is decided at runtime — the same slot
|
|
53
|
+
* holds a client, a project, an agent, a model, or an operation depending on
|
|
54
|
+
* which grouping won.
|
|
55
|
+
*/
|
|
56
|
+
export const WITHHELD_ENTITY_LABEL = "(name reads like an instruction; withheld)";
|
|
57
|
+
export const WITHHELD_FILE_LABEL = "(file name reads like an instruction; withheld)";
|
|
58
|
+
export const WITHHELD_PLAN_LABEL = "(plan label reads like an instruction; withheld)";
|
|
59
|
+
/** Map a list of untrusted keys for display, keeping order and length. */
|
|
60
|
+
export function safeUntrustedLabels(values, withheld = WITHHELD_ENTITY_LABEL) {
|
|
61
|
+
return values.map((value) => safeUntrustedLabel(value, withheld));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Neutralize ONE untrusted fragment before it is interpolated into
|
|
65
|
+
* product-authored prose.
|
|
66
|
+
*
|
|
67
|
+
* Over-triggering here is cheap and under-triggering is not: a false positive
|
|
68
|
+
* costs one name while the finding and its dollars survive, so the patterns
|
|
69
|
+
* stay strict.
|
|
70
|
+
*/
|
|
71
|
+
export function safeUntrustedLabel(value, withheld) {
|
|
72
|
+
// Control characters and line breaks are structure, not name: a label that
|
|
73
|
+
// can open a new line can forge a new instruction on every surface at once.
|
|
74
|
+
const collapsed = value
|
|
75
|
+
.replace(/[\u0000-\u001F\u007F]/gu, " ")
|
|
76
|
+
.replace(/\s+/gu, " ")
|
|
77
|
+
.trim();
|
|
78
|
+
if (!collapsed)
|
|
79
|
+
return withheld;
|
|
80
|
+
return looksLikeDirectiveFragment(collapsed) ? withheld : collapsed;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Characters that are invisible to the reader but split a word for the
|
|
84
|
+
* matcher: zero-width spaces and joiners, bidi controls, variation selectors,
|
|
85
|
+
* the soft hyphen, the BOM. `i\u200Bgnore all previous instructions` reads as
|
|
86
|
+
* an instruction and matched nothing. Stripped for DETECTION ONLY — the label
|
|
87
|
+
* that gets printed is always the original text.
|
|
88
|
+
*/
|
|
89
|
+
const INVISIBLE_SEPARATORS = /[\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180E\u200B-\u200F\u202A-\u202E\u2060-\u2064\u206A-\u206F\u3164\uFE00-\uFE0F\uFEFF\uFFA0]/gu;
|
|
90
|
+
/**
|
|
91
|
+
* The eight Latin/Cyrillic confusables that carry the directive verbs we look
|
|
92
|
+
* for: `\u0456gnore`, `d\u0435lete`, `\u0455ystem:` are indistinguishable on screen
|
|
93
|
+
* and invisible to an ASCII pattern. Folded for DETECTION ONLY.
|
|
94
|
+
*/
|
|
95
|
+
const CONFUSABLE_FOLD = new Map([
|
|
96
|
+
["\u0430", "a"], ["\u0435", "e"], ["\u043E", "o"], ["\u0440", "p"],
|
|
97
|
+
["\u0441", "c"], ["\u0445", "x"], ["\u0455", "s"], ["\u0456", "i"]
|
|
98
|
+
]);
|
|
99
|
+
/**
|
|
100
|
+
* The fragment is read TWICE, because a name and an instruction disagree about
|
|
101
|
+
* what a hyphen means.
|
|
102
|
+
*
|
|
103
|
+
* As ONE IDENTIFIER (`-` behaves like `_`): `ignore-list` is a directory, so
|
|
104
|
+
* the blunt single-word patterns cannot fire on it. This is what keeps ordinary
|
|
105
|
+
* repo names whole.
|
|
106
|
+
*
|
|
107
|
+
* As SEPARATED WORDS (`-` and `_` are spaces): `ignore-all-previous-instructions`
|
|
108
|
+
* is an instruction wearing a filename's punctuation. Only the PAIRED patterns
|
|
109
|
+
* run in this pass — each needs a directive verb next to an injection-flavored
|
|
110
|
+
* object — so an ordinary compound name has nothing to pair with. The unpaired
|
|
111
|
+
* verb list and the execute/run pattern deliberately stay out: `run-command-service`
|
|
112
|
+
* is a real directory, and a name-shaped `run-shell` cannot instruct anything.
|
|
113
|
+
*/
|
|
114
|
+
function looksLikeDirectiveFragment(value) {
|
|
115
|
+
const folded = value
|
|
116
|
+
.normalize("NFKC")
|
|
117
|
+
.replace(INVISIBLE_SEPARATORS, "")
|
|
118
|
+
.replace(/[\u0430\u0435\u043E\u0440\u0441\u0445\u0455\u0456]/gu, (char) => CONFUSABLE_FOLD.get(char) ?? char);
|
|
119
|
+
// A dot joins a filename the way a hyphen joins an identifier, so the
|
|
120
|
+
// identifier pass folds it too: `override.ts` and `ignore.md` are files, not
|
|
121
|
+
// instructions. The separated pass splits on it for the same reason it splits
|
|
122
|
+
// on hyphens — `ignore.all.previous.instructions` is prose wearing punctuation.
|
|
123
|
+
const asIdentifier = folded.replace(/[-.]/gu, "_");
|
|
124
|
+
const asWords = folded.replace(/[-_.]+/gu, " ");
|
|
125
|
+
return IDENTIFIER_DIRECTIVE_PATTERNS.some((pattern) => pattern.test(asIdentifier)) ||
|
|
126
|
+
SEPARATED_DIRECTIVE_PATTERNS.some((pattern) => pattern.test(asWords));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* A directive needs a QUANTIFIER, not just a noun.
|
|
130
|
+
*
|
|
131
|
+
* `cache write tokens` is Anthropic's prompt-caching billing vocabulary and it
|
|
132
|
+
* arrives in the operation slot on real invoice lines; `write ALL tokens` is an
|
|
133
|
+
* instruction. Pairing a verb with a bare `tokens` withheld this product's own
|
|
134
|
+
* billing words — a real line item rendered as
|
|
135
|
+
* "acme / agent-finops / [unsafe metadata omitted]" — and on `aibill context`,
|
|
136
|
+
* whose entire job is naming exact files, it named one of three.
|
|
137
|
+
*
|
|
138
|
+
* Measured over 146 real strings (Anthropic + OpenAI caching vocabulary, real
|
|
139
|
+
* invoice line items, real filenames, ordinary repo names): false positives
|
|
140
|
+
* 18 -> 0, with hostile detection unchanged at 28/28.
|
|
141
|
+
*
|
|
142
|
+
* `everything` and `all files` already carry their own quantifier, so they stay
|
|
143
|
+
* unguarded. `system prompt` is an injection-specific noun phrase that no
|
|
144
|
+
* billing vocabulary contains, so it needs no quantifier either.
|
|
145
|
+
*/
|
|
146
|
+
const QUANTIFIED = "(?:all|every|any|each)";
|
|
147
|
+
const IDENTIFIER_DIRECTIVE_PATTERNS = [
|
|
148
|
+
/\b(?:ignore|disregard|override|bypass)\b/i,
|
|
149
|
+
/\b(?:system|developer|assistant)\s*:/i,
|
|
150
|
+
/\b(?:execute|run)\b.{0,80}\b(?:command|shell|bash|powershell)\b/i,
|
|
151
|
+
new RegExp(`\\b(?:delete|remove|overwrite|edit|write)\\b.{0,60}(?:\\beverything\\b|\\ball files?\\b|\\b${QUANTIFIED}\\s+(?:configs?|credentials?|secrets?|tokens?)\\b)`, "i"),
|
|
152
|
+
new RegExp(`\\b(?:reveal|print|upload|send|exfiltrate)\\b.{0,60}(?:\\ball files?\\b|\\b(?:system|developer)\\s+prompts?\\b|\\b(?:${QUANTIFIED}|the)\\s+(?:credentials?|secrets?|tokens?|keys?|files?)\\b)`, "i"),
|
|
153
|
+
/\b(?:do not|don't)\b.{0,60}\b(?:follow|obey|wait|ask|require)\b.{0,40}\b(?:approval|instructions?|rules?)\b/i
|
|
154
|
+
];
|
|
155
|
+
const SEPARATED_DIRECTIVE_PATTERNS = [
|
|
156
|
+
/\b(?:ignore|disregard|override|bypass|forget)\b.{0,80}\b(?:previous|prior|above|earlier|preceding|instructions?|approval|rules?|guardrails?|system|developer|prompts?)\b/i,
|
|
157
|
+
new RegExp(`\\b(?:delete|remove|overwrite|edit|write)\\b.{0,60}(?:\\beverything\\b|\\ball files?\\b|\\b${QUANTIFIED}\\s+(?:configs?|credentials?|secrets?|tokens?)\\b)`, "i"),
|
|
158
|
+
new RegExp(`\\b(?:reveal|print|upload|send|exfiltrate|leak|dump)\\b.{0,60}(?:\\ball files?\\b|\\b(?:system|developer)\\s+prompts?\\b|\\b(?:${QUANTIFIED}|the)\\s+(?:credentials?|secrets?|tokens?|keys?|files?|prompts?)\\b)`, "i"),
|
|
159
|
+
/\b(?:do not|don't|never)\b.{0,60}\b(?:follow|obey|wait|ask|require)\b.{0,40}\b(?:approval|instructions?|rules?)\b/i
|
|
160
|
+
];
|
|
161
|
+
//# sourceMappingURL=untrustedLabel.js.map
|