@agent-finops/core 0.9.5 → 0.9.7

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.
@@ -20,21 +20,38 @@ const pricingRules = [
20
20
  { match: /^claude-haiku-4/i, inputPerM: 1, outputPerM: 5 },
21
21
  { match: /^claude-3-7-sonnet|^claude-3-5-sonnet/i, inputPerM: 3, outputPerM: 15 },
22
22
  { match: /^claude-3-5-haiku/i, inputPerM: 0.8, outputPerM: 4 },
23
- // OpenAI (newer and more specific families must precede the GPT-5 fallback)
23
+ // OpenAI (newer and more specific families must precede the GPT-5 fallback).
24
+ // Rates from developers.openai.com/api/docs/pricing cross-checked against each
25
+ // model's own doc page, both fetched 2026-08-25.
26
+ //
27
+ // GPT-5.6 ships exactly three API models — sol, terra, luna
28
+ // (developers.openai.com/api/docs/models, 2026-08-25). Each 5.6/5.5/5.4 rule
29
+ // below is END-ANCHORED on purpose: an undocumented or future sibling
30
+ // (gpt-5.6-cyber, gpt-5.5-pro, gpt-5.7-sol) must fall through to
31
+ // honest-unpriced rather than inherit a neighbour's rate. That is the 0.9.4
32
+ // `^kimi-k2` mistake one family up, and it is the expensive direction here —
33
+ // the pre-0.9.6 `^gpt-5.6(?:-sol)?$` rule carried GPT-5.5's numbers, so every
34
+ // gpt-5.6-sol record was overstated by 25% on input and 50% on output.
35
+ //
36
+ // Long context, published identically on all three 5.6 pages plus 5.5/5.4:
37
+ // "Prompts with >272K input tokens are priced at 2x input and 1.5x output for
38
+ // the full request." Cached input scales with the 2x input leg.
24
39
  {
25
- match: /^gpt-5\.6(?:-sol)?$/i,
26
- inputPerM: 5,
27
- outputPerM: 30,
28
- cacheReadPerM: 0.5,
40
+ // developers.openai.com/api/docs/models/gpt-5.6-sol, 2026-08-25
41
+ match: /^gpt-5\.6-sol$/i,
42
+ inputPerM: 4,
43
+ outputPerM: 20,
44
+ cacheReadPerM: 0.4,
29
45
  abovePromptTokens: {
30
46
  threshold: 272_000,
31
- inputPerM: 10,
32
- outputPerM: 45,
33
- cacheReadPerM: 1
47
+ inputPerM: 8,
48
+ outputPerM: 30,
49
+ cacheReadPerM: 0.8
34
50
  }
35
51
  },
36
52
  {
37
- match: /^gpt-5\.6-terra/i,
53
+ // developers.openai.com/api/docs/models/gpt-5.6-terra, 2026-08-25
54
+ match: /^gpt-5\.6-terra$/i,
38
55
  inputPerM: 2,
39
56
  outputPerM: 12,
40
57
  cacheReadPerM: 0.2,
@@ -46,7 +63,8 @@ const pricingRules = [
46
63
  }
47
64
  },
48
65
  {
49
- match: /^gpt-5\.6-luna/i,
66
+ // developers.openai.com/api/docs/models/gpt-5.6-luna, 2026-08-25
67
+ match: /^gpt-5\.6-luna$/i,
50
68
  inputPerM: 0.2,
51
69
  outputPerM: 1.2,
52
70
  cacheReadPerM: 0.02,
@@ -57,15 +75,50 @@ const pricingRules = [
57
75
  cacheReadPerM: 0.04
58
76
  }
59
77
  },
60
- { match: /^gpt-5\.5(?:-codex)?/i, inputPerM: 5, outputPerM: 30, cacheReadPerM: 0.5 },
78
+ {
79
+ // developers.openai.com/api/docs/models/gpt-5.5, 2026-08-25
80
+ match: /^gpt-5\.5$/i,
81
+ inputPerM: 5,
82
+ outputPerM: 30,
83
+ cacheReadPerM: 0.5,
84
+ abovePromptTokens: {
85
+ threshold: 272_000,
86
+ inputPerM: 10,
87
+ outputPerM: 45,
88
+ cacheReadPerM: 1
89
+ }
90
+ },
91
+ // gpt-5.5-codex bills at the 5.5 base rate but is absent from the published
92
+ // long-context list, so it deliberately carries no >272K tier.
93
+ { match: /^gpt-5\.5-codex$/i, inputPerM: 5, outputPerM: 30, cacheReadPerM: 0.5 },
61
94
  { match: /^gpt-5\.4-mini/i, inputPerM: 0.75, outputPerM: 4.5, cacheReadPerM: 0.075 },
62
95
  { match: /^gpt-5\.4-nano/i, inputPerM: 0.2, outputPerM: 1.25, cacheReadPerM: 0.02 },
63
- { match: /^gpt-5\.4/i, inputPerM: 2.5, outputPerM: 15, cacheReadPerM: 0.25 },
96
+ {
97
+ // developers.openai.com/api/docs/models/gpt-5.4, 2026-08-25
98
+ match: /^gpt-5\.4$/i,
99
+ inputPerM: 2.5,
100
+ outputPerM: 15,
101
+ cacheReadPerM: 0.25,
102
+ abovePromptTokens: {
103
+ threshold: 272_000,
104
+ inputPerM: 5,
105
+ outputPerM: 22.5,
106
+ cacheReadPerM: 0.5
107
+ }
108
+ },
64
109
  { match: /^gpt-5\.3-codex/i, inputPerM: 1.75, outputPerM: 14, cacheReadPerM: 0.175 },
65
110
  { match: /^gpt-5\.2(?:-codex)?/i, inputPerM: 1.75, outputPerM: 14, cacheReadPerM: 0.175 },
66
111
  { match: /^gpt-5(?:\.1)?-codex/i, inputPerM: 1.25, outputPerM: 10, cacheReadPerM: 0.125 },
67
112
  { match: /^gpt-5(?:\.1)?-mini/i, inputPerM: 0.25, outputPerM: 2, cacheReadPerM: 0.025 },
68
- { match: /^gpt-5/i, inputPerM: 1.25, outputPerM: 10, cacheReadPerM: 0.125 },
113
+ // developers.openai.com/api/docs/models/gpt-5-nano, 2026-08-25. Before 0.9.6
114
+ // this fell through to the ^gpt-5 fallback and billed at $1.25/$10 — 25x the
115
+ // real rate in both directions. No published long-context tier.
116
+ { match: /^gpt-5-nano/i, inputPerM: 0.05, outputPerM: 0.4, cacheReadPerM: 0.005 },
117
+ // GPT-5 base and its dash-suffixed snapshots only. The `-|$` boundary stops
118
+ // this fallback from swallowing dot-minor families it knows nothing about:
119
+ // gpt-5.7-*, gpt-5.6-cyber and any future gpt-5.6-<variant> now return
120
+ // undefined -> "missing" instead of silently billing at GPT-5's $1.25/$10.
121
+ { match: /^gpt-5(?:-|$)/i, inputPerM: 1.25, outputPerM: 10, cacheReadPerM: 0.125 },
69
122
  { match: /^gpt-4\.1-nano/i, inputPerM: 0.1, outputPerM: 0.4 },
70
123
  { match: /^gpt-4\.1-mini/i, inputPerM: 0.4, outputPerM: 1.6 },
71
124
  { match: /^gpt-4\.1/i, inputPerM: 2, outputPerM: 8, cacheReadPerM: 0.5 },
@@ -122,6 +175,23 @@ const pricingRules = [
122
175
  // - deepseek-v4-* (api-docs.deepseek.com/quick_start/pricing): published
123
176
  // rates are time-of-day (off-peak = half price, up to 2x swing), so any
124
177
  // flat number here would be dishonest; needs timestamp-aware pricing.
178
+ //
179
+ // Deliberate deferrals (2026-08-25 OpenAI review), same honest path:
180
+ // - gpt-5.6-cyber / gpt-5.5-cyber ($12.50/$1.25/$75 on the pricing page):
181
+ // the two canonical sources disagree on whether the >272K tier applies —
182
+ // the pricing page omits cyber from its long-context list while
183
+ // developers.openai.com/api/docs/models/gpt-5.6-cyber states the 2x/1.5x
184
+ // rule does apply. Access is gated behind the Daybreak program, so the
185
+ // cost of leaving it unpriced is near zero and a coin-flip on the tier
186
+ // would be a real number that is wrong on long requests.
187
+ // - gpt-5.5-pro / gpt-5.4-pro: listed as long-context-capable but no
188
+ // per-model rate is published on either canonical source.
189
+ // - bare gpt-5.1 / gpt-5.3: the pricing page quotes the 5/5.1/5.2 group as a
190
+ // RANGE ($1.25-$1.75) and neither has a resolved per-model figure. Their
191
+ // -codex and -mini variants keep their own verified rules above.
192
+ // - gpt-5.6-codex / gpt-5.5-mini: NOT OpenAI model ids (both 404 on the model
193
+ // docs and are absent from developers.openai.com/api/docs/models). They
194
+ // appear only in this repo's fixtures and sample CSVs.
125
195
  ];
126
196
  export function findPricingRule(model) {
127
197
  return pricingRules.find((rule) => rule.match.test(model));
@@ -139,11 +209,20 @@ export function estimateTokenCostUsd(model, usage) {
139
209
  * models whose entire request moves to a higher rate above a prompt-size
140
210
  * threshold; pricing a daily token sum would incorrectly treat many small
141
211
  * requests as one large request.
212
+ *
213
+ * `tierPromptTokens[i]`, when provided, fixes the tier of `usages[i]` from
214
+ * request-level evidence instead of the slice's own prompt total. A
215
+ * session-cumulative slice is a sum of many requests whose prompt total
216
+ * routinely clears a per-request threshold on cache reads alone, even though no
217
+ * single request did; supplying the largest single request's prompt keeps such
218
+ * a slice on the base tier (and pricing it there is exact, since the base rate
219
+ * distributes over the sum). Omitting the array preserves single-request
220
+ * behaviour: each slice's own prompt selects its tier.
142
221
  */
143
- export function estimateTokenCostsUsd(model, usages) {
222
+ export function estimateTokenCostsUsd(model, usages, tierPromptTokens) {
144
223
  let total = 0;
145
- for (const usage of usages) {
146
- const usd = rawTokenCostUsd(model, usage);
224
+ for (let index = 0; index < usages.length; index += 1) {
225
+ const usd = rawTokenCostUsd(model, usages[index], tierPromptTokens?.[index]);
147
226
  if (usd === undefined)
148
227
  return undefined;
149
228
  total += usd;
@@ -162,21 +241,34 @@ export function promptTierThreshold(model) {
162
241
  * Tiered prices are selected per request, never from a multi-request sum.
163
242
  * An aggregate is still unambiguous when its entire non-negative prompt-side
164
243
  * total is at or below the threshold; then no constituent request can have
165
- * crossed it. Larger aggregates fail closed until request-level evidence is
166
- * available.
244
+ * crossed it. It is also unambiguous when request-level evidence
245
+ * (`maxRequestPromptTokens`, the largest single request the aggregate contains)
246
+ * proves that no constituent request crossed the threshold: every request was
247
+ * base-tier, so the whole sum is base-tier and prices exactly at the base rate.
248
+ * Larger aggregates without such evidence fail closed to keep an unpriceable
249
+ * total honestly "missing" rather than guessing a tier.
167
250
  */
168
- export function canPriceTokenUsageAtScope(model, usage, scope) {
251
+ export function canPriceTokenUsageAtScope(model, usage, scope, maxRequestPromptTokens) {
169
252
  const threshold = promptTierThreshold(model);
170
253
  if (threshold === undefined || scope === "request")
171
254
  return true;
172
- return effectivePromptTokens(usage) <= threshold;
255
+ if (effectivePromptTokens(usage) <= threshold)
256
+ return true;
257
+ return maxRequestPromptTokens !== undefined && maxRequestPromptTokens <= threshold;
173
258
  }
174
- function rawTokenCostUsd(model, usage) {
259
+ /**
260
+ * @param tierPromptTokens Prompt size used ONLY to select the request tier,
261
+ * when it differs from the priced slice's own prompt total (e.g. a
262
+ * session-cumulative slice whose tier is fixed by its largest single
263
+ * request). Component pricing always uses `usage`; defaults to the slice's
264
+ * own effective prompt so single-request callers are unchanged.
265
+ */
266
+ function rawTokenCostUsd(model, usage, tierPromptTokens) {
175
267
  const rule = findPricingRule(model);
176
268
  if (!rule) {
177
269
  return undefined;
178
270
  }
179
- const promptTokens = effectivePromptTokens(usage);
271
+ const promptTokens = tierPromptTokens ?? effectivePromptTokens(usage);
180
272
  const rates = rule.abovePromptTokens &&
181
273
  promptTokens > rule.abovePromptTokens.threshold
182
274
  ? rule.abovePromptTokens
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
  });
@@ -7,7 +7,7 @@ export declare const projectIndexStoreLockFileName = ".project-index-v2.lock";
7
7
  export declare const projectIndexMaxDocumentBytes: number;
8
8
  /** Null-window variant plus the newest bounded windows (BLOCKER-2 option i). */
9
9
  export declare const projectIndexMaxWindowedVariants = 4;
10
- export declare const projectIndexFinancialParserVersion = 1;
10
+ export declare const projectIndexFinancialParserVersion = 3;
11
11
  declare const financialKeySchema: z.ZodObject<{
12
12
  schemaVersion: z.ZodLiteral<2>;
13
13
  section: z.ZodLiteral<"financial">;
@@ -18,7 +18,7 @@ declare const financialKeySchema: z.ZodObject<{
18
18
  }>;
19
19
  pathHash: z.ZodString;
20
20
  fileIdentity: z.ZodString;
21
- financialParserVersion: z.ZodLiteral<1>;
21
+ financialParserVersion: z.ZodLiteral<3>;
22
22
  }, z.core.$strict>;
23
23
  /**
24
24
  * Header-pass ownership evidence (A4a consumer). "unknown" is a first-class
@@ -53,7 +53,7 @@ declare const documentSchema: z.ZodObject<{
53
53
  qualitative: z.ZodArray<z.ZodObject<{
54
54
  key: z.ZodObject<{
55
55
  schemaVersion: z.ZodLiteral<1>;
56
- parserVersion: z.ZodLiteral<4>;
56
+ parserVersion: z.ZodLiteral<6>;
57
57
  agent: z.ZodEnum<{
58
58
  "claude-code": "claude-code";
59
59
  codex: "codex";
@@ -102,6 +102,7 @@ declare const documentSchema: z.ZodObject<{
102
102
  complete: "complete";
103
103
  unsupported_token_shape: "unsupported_token_shape";
104
104
  }>>;
105
+ maxRequestPromptTokens: z.ZodOptional<z.ZodNumber>;
105
106
  reportedTotalTokens: z.ZodOptional<z.ZodNumber>;
106
107
  tokenComponentEvidence: z.ZodOptional<z.ZodObject<{
107
108
  inputTokens: z.ZodLiteral<"observed">;
@@ -280,7 +281,7 @@ declare const documentSchema: z.ZodObject<{
280
281
  }>;
281
282
  pathHash: z.ZodString;
282
283
  fileIdentity: z.ZodString;
283
- financialParserVersion: z.ZodLiteral<1>;
284
+ financialParserVersion: z.ZodLiteral<3>;
284
285
  }, z.core.$strict>;
285
286
  storedAt: z.ZodString;
286
287
  value: z.ZodObject<{
@@ -320,6 +321,7 @@ declare const documentSchema: z.ZodObject<{
320
321
  complete: "complete";
321
322
  unsupported_token_shape: "unsupported_token_shape";
322
323
  }>>;
324
+ maxRequestPromptTokens: z.ZodOptional<z.ZodNumber>;
323
325
  reportedTotalTokens: z.ZodOptional<z.ZodNumber>;
324
326
  tokenComponentEvidence: z.ZodOptional<z.ZodObject<{
325
327
  inputTokens: z.ZodLiteral<"observed">;
@@ -25,7 +25,7 @@ export declare class QualitativeIndexCacheError extends Error {
25
25
  }
26
26
  declare const keySchema: z.ZodObject<{
27
27
  schemaVersion: z.ZodLiteral<1>;
28
- parserVersion: z.ZodLiteral<4>;
28
+ parserVersion: z.ZodLiteral<6>;
29
29
  agent: z.ZodEnum<{
30
30
  "claude-code": "claude-code";
31
31
  codex: "codex";
@@ -73,6 +73,7 @@ declare const valueSchema: z.ZodObject<{
73
73
  complete: "complete";
74
74
  unsupported_token_shape: "unsupported_token_shape";
75
75
  }>>;
76
+ maxRequestPromptTokens: z.ZodOptional<z.ZodNumber>;
76
77
  reportedTotalTokens: z.ZodOptional<z.ZodNumber>;
77
78
  tokenComponentEvidence: z.ZodOptional<z.ZodObject<{
78
79
  inputTokens: z.ZodLiteral<"observed">;
@@ -250,7 +251,7 @@ type PersistedValue = PersistedQualitativeValue;
250
251
  */
251
252
  export declare const qualitativeEntryKeySchema: z.ZodObject<{
252
253
  schemaVersion: z.ZodLiteral<1>;
253
- parserVersion: z.ZodLiteral<4>;
254
+ parserVersion: z.ZodLiteral<6>;
254
255
  agent: z.ZodEnum<{
255
256
  "claude-code": "claude-code";
256
257
  codex: "codex";
@@ -298,6 +299,7 @@ export declare const qualitativeEntryValueSchema: z.ZodObject<{
298
299
  complete: "complete";
299
300
  unsupported_token_shape: "unsupported_token_shape";
300
301
  }>>;
302
+ maxRequestPromptTokens: z.ZodOptional<z.ZodNumber>;
301
303
  reportedTotalTokens: z.ZodOptional<z.ZodNumber>;
302
304
  tokenComponentEvidence: z.ZodOptional<z.ZodObject<{
303
305
  inputTokens: z.ZodLiteral<"observed">;
@@ -129,6 +129,11 @@ const callSchema = z.object({
129
129
  latestTurnUsage: turnUsageSchema.optional(),
130
130
  usageScope: z.enum(["turn", "session_cumulative"]).optional(),
131
131
  usageSupport: z.enum(["complete", "unsupported_token_shape"]).optional(),
132
+ // Per-request tier evidence for session-cumulative slices. The strict schema
133
+ // must carry it: without this key the entry fails validation on write, the
134
+ // failure is swallowed by the caller, and every run re-parses the whole
135
+ // corpus instead of reusing the cache.
136
+ maxRequestPromptTokens: finiteNonnegativeInteger.optional(),
132
137
  reportedTotalTokens: finiteNonnegativeInteger.optional(),
133
138
  tokenComponentEvidence: tokenComponentEvidenceSchema.optional(),
134
139
  sourceVersion: z.string().min(1).max(64).optional(),
@@ -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.5",
3
+ "version": "0.9.7",
4
4
  "funding": "https://asktilden.com",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",