@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.
@@ -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
- // Prefer the 5m/1h breakdown; fall back to the total as 5m (cheaper bound).
246
- cacheWrite5mTokens: write5mField.value ?? writeTotalField.value ?? 0,
247
- cacheWrite1hTokens: write1hField.value ?? 0
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 parsedCalls = await runtime.parseFinancialFile({
1405
- filePath: file,
1406
- sinceMs,
1407
- scan,
1408
- diagnostics
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;
@@ -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
- detectedPlan: detected,
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
  }>;
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-finops/core",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "funding": "https://asktilden.com",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",