@agent-finops/core 0.9.6 → 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.
@@ -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.7",
4
4
  "funding": "https://asktilden.com",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",