@ganju/utils 0.0.10 → 0.0.12

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.
@@ -0,0 +1,291 @@
1
+ // When an organization turns on "confirm sensitive actions", a channel bot that
2
+ // wants to run a sensitive tool asks first, and the call runs only if the
3
+ // participant's next message is a plain yes. These are the pieces of that rule
4
+ // that aren't the runner's own bookkeeping.
5
+
6
+ // A call waiting on the participant's answer, stored on the conversation. The
7
+ // exact calls the model made are kept, so what runs after a yes is what the
8
+ // question described — the model never gets to re-derive the arguments.
9
+ export interface PendingToolConfirmation {
10
+ // Claimed by id, so a re-sent batch can't run the same calls twice.
11
+ id: string;
12
+ // Only the participant who was asked can answer — in a group, anyone else's
13
+ // message leaves the question open.
14
+ participantId: string;
15
+ createdAt: string;
16
+ calls: Array<{
17
+ id: string;
18
+ name: string;
19
+ arguments: Record<string, unknown>;
20
+ }>;
21
+ }
22
+
23
+ // What a conversation holds: one open question per participant, keyed by
24
+ // participant id, so in a group one person's question never replaces
25
+ // another's.
26
+ export type PendingToolConfirmations = Record<string, PendingToolConfirmation>;
27
+
28
+ // What an owner says a tool they wrote does, in the words the dashboard asks
29
+ // it in, and the MCP annotations each answer stands for: `read` only looks
30
+ // things up, `write` changes things that can be undone, `sensitive` sends,
31
+ // deletes, charges or can't be undone — the kind a confirming channel asks
32
+ // about.
33
+ export const TOOL_EFFECTS = ['read', 'write', 'sensitive'] as const;
34
+
35
+ export type ToolEffect = (typeof TOOL_EFFECTS)[number];
36
+
37
+ export const annotationsForEffect = (
38
+ effect: ToolEffect
39
+ ): { readOnlyHint: boolean; destructiveHint: boolean } =>
40
+ effect === 'read'
41
+ ? { readOnlyHint: true, destructiveHint: false }
42
+ : effect === 'write'
43
+ ? { readOnlyHint: false, destructiveHint: false }
44
+ : { readOnlyHint: false, destructiveHint: true };
45
+
46
+ // Whole replies that mean yes, lowercased with accents and punctuation
47
+ // removed. English, Spanish and Portuguese, the languages bots here answer in.
48
+ const AFFIRMATIVE = new Set([
49
+ 'y',
50
+ 'yes',
51
+ 'yeah',
52
+ 'yep',
53
+ 'yup',
54
+ 'sure',
55
+ 'ok',
56
+ 'okay',
57
+ 'confirm',
58
+ 'confirmed',
59
+ 'approve',
60
+ 'approved',
61
+ 'go',
62
+ 'go ahead',
63
+ 'do it',
64
+ 'send',
65
+ 'send it',
66
+ 'proceed',
67
+ 'yes please',
68
+ 'yes send it',
69
+ 'yes do it',
70
+ 'yes go ahead',
71
+ 'yes confirm',
72
+ 'sure thing',
73
+ 'go for it',
74
+ 'sounds good',
75
+ 'looks good',
76
+ 'all good',
77
+ 'correct',
78
+ 'si hazlo',
79
+ 'si adelante',
80
+ 'si envialo ya',
81
+ 'perfecto',
82
+ 'correcto',
83
+ 'esta bien',
84
+ 'ok dale',
85
+ 'ok envialo',
86
+ 'si',
87
+ 'sip',
88
+ 'claro',
89
+ 'dale',
90
+ 'va',
91
+ 'vale',
92
+ 'listo',
93
+ 'confirmo',
94
+ 'confirmado',
95
+ 'confirmar',
96
+ 'adelante',
97
+ 'hazlo',
98
+ 'envialo',
99
+ 'enviar',
100
+ 'de acuerdo',
101
+ 'si por favor',
102
+ 'si claro',
103
+ 'si dale',
104
+ 'si envialo',
105
+ 'si confirmo',
106
+ 'sim',
107
+ 'pode',
108
+ 'pode enviar'
109
+ ]);
110
+
111
+ const AFFIRMATIVE_EMOJI = new Set(['👍', '✅', '👌', '🆗']);
112
+
113
+ // The Yes/No buttons offered under a confirmation question, in the
114
+ // participant's language when the platform reports it. The labels are also
115
+ // what a tap sends where a button is just a canned reply (Telegram's reply
116
+ // keyboard), so "yes" must read as a yes to isConfirmationReply — and it does,
117
+ // in every language here.
118
+ const BUTTON_LABELS: Record<string, { yes: string; no: string }> = {
119
+ en: { yes: '✅ Yes', no: '❌ No' },
120
+ es: { yes: '✅ Sí', no: '❌ No' },
121
+ pt: { yes: '✅ Sim', no: '❌ Não' }
122
+ };
123
+
124
+ // WhatsApp reports no locale, so a button's language comes from the calling
125
+ // code of the participant's number: the Spanish- and Portuguese-speaking
126
+ // countries, English otherwise. Longest prefix wins.
127
+ const CALLING_CODE_LANGUAGE: Record<string, string> = {
128
+ '34': 'es',
129
+ '51': 'es',
130
+ '52': 'es',
131
+ '53': 'es',
132
+ '54': 'es',
133
+ '56': 'es',
134
+ '57': 'es',
135
+ '58': 'es',
136
+ '240': 'es',
137
+ '502': 'es',
138
+ '503': 'es',
139
+ '504': 'es',
140
+ '505': 'es',
141
+ '506': 'es',
142
+ '507': 'es',
143
+ '591': 'es',
144
+ '593': 'es',
145
+ '595': 'es',
146
+ '598': 'es',
147
+ '1787': 'es',
148
+ '1809': 'es',
149
+ '1829': 'es',
150
+ '1849': 'es',
151
+ '55': 'pt',
152
+ '238': 'pt',
153
+ '244': 'pt',
154
+ '245': 'pt',
155
+ '258': 'pt',
156
+ '351': 'pt'
157
+ };
158
+
159
+ export const languageFromPhoneNumber = (phone: string): string | null => {
160
+ const digits = phone.replace(/\D/g, '');
161
+ for (let length = 4; length >= 2; length--) {
162
+ const language = CALLING_CODE_LANGUAGE[digits.slice(0, length)];
163
+ if (language) return language;
164
+ }
165
+ return null;
166
+ };
167
+
168
+ export const confirmationButtonLabels = (
169
+ locale?: string | null
170
+ ): { yes: string; no: string } => {
171
+ const language = (locale || '').toLowerCase().split(/[-_]/)[0];
172
+ return BUTTON_LABELS[language] ?? BUTTON_LABELS.en;
173
+ };
174
+
175
+ // Where the platform sends back a button's id rather than its label (Discord,
176
+ // WhatsApp), the id says which answer it is and who was asked: in a shared
177
+ // channel everyone sees the buttons, and only that participant's tap counts.
178
+ // The runner never sees the id — a tap reaches it as the text "yes" or "no".
179
+ const CONFIRMATION_BUTTON_PREFIX = 'ganju-confirm';
180
+
181
+ export const confirmationButtonId = (
182
+ answer: 'yes' | 'no',
183
+ externalParticipantId: string
184
+ ): string => `${CONFIRMATION_BUTTON_PREFIX}:${answer}:${externalParticipantId}`;
185
+
186
+ export const parseConfirmationButton = (
187
+ id: string
188
+ ): { text: 'yes' | 'no'; externalParticipantId: string } | null => {
189
+ const [prefix, answer, ...rest] = id.split(':');
190
+ const externalParticipantId = rest.join(':');
191
+ if (prefix !== CONFIRMATION_BUTTON_PREFIX || !externalParticipantId) {
192
+ return null;
193
+ }
194
+ if (answer !== 'yes' && answer !== 'no') return null;
195
+ return { text: answer, externalParticipantId };
196
+ };
197
+
198
+ const normalize = (text: string): string =>
199
+ text
200
+ .toLowerCase()
201
+ .normalize('NFKD')
202
+ .replace(/[̀-ͯ]/g, '')
203
+ .replace(/[^a-z0-9\s]+/g, ' ')
204
+ .replace(/\s+/g, ' ')
205
+ .trim();
206
+
207
+ /**
208
+ * Whether a reply confirms the pending action. Deliberately narrow: the whole
209
+ * message has to be a yes. "Yes, but send it to Ana instead" is not one — it
210
+ * asks for a different action, and running the original would do exactly
211
+ * what the person just said not to. Anything that isn't a clear yes cancels,
212
+ * so the worst a misread costs is asking again.
213
+ */
214
+ export const isConfirmationReply = (text: string): boolean => {
215
+ const trimmed = text.trim();
216
+ if (AFFIRMATIVE_EMOJI.has(trimmed)) return true;
217
+ return AFFIRMATIVE.has(normalize(trimmed));
218
+ };
219
+
220
+ // How much of each argument the summary shows, and how many arguments.
221
+ const SUMMARY_VALUE_MAX = 200;
222
+ const SUMMARY_ARGUMENTS_MAX = 10;
223
+
224
+ const summaryValue = (value: unknown): string => {
225
+ const text =
226
+ typeof value === 'string'
227
+ ? value
228
+ : (JSON.stringify(value) ?? String(value));
229
+ // One line, and no backticks, so a value can neither fake another line of
230
+ // the summary nor break out of its code span into formatting or links.
231
+ const flat = text.replace(/\s+/g, ' ').replace(/`/g, "'").trim();
232
+ return flat.length > SUMMARY_VALUE_MAX
233
+ ? `${flat.slice(0, SUMMARY_VALUE_MAX)}…`
234
+ : flat;
235
+ };
236
+
237
+ /**
238
+ * What the held calls will do, written from the calls themselves and appended
239
+ * under the model's question. The model words the question, but a yes runs the
240
+ * stored calls — so the participant has to see those, not only the model's
241
+ * account of them, which a prompt injection earlier in the turn could have
242
+ * bent.
243
+ */
244
+ export const formatConfirmationSummary = (
245
+ calls: Array<{ name: string; arguments: Record<string, unknown> }>,
246
+ titleOf: (name: string) => string
247
+ ): string =>
248
+ calls
249
+ .map(call => {
250
+ const entries = Object.entries(call.arguments ?? {}).filter(
251
+ ([, value]) => value !== undefined && value !== null && value !== ''
252
+ );
253
+ const lines = entries
254
+ .slice(0, SUMMARY_ARGUMENTS_MAX)
255
+ .map(([key, value]) => `- ${key}: \`${summaryValue(value)}\``);
256
+ if (entries.length > SUMMARY_ARGUMENTS_MAX) lines.push('- …');
257
+ // The hourglass says "not done yet" in every language, whatever the
258
+ // model's own words above it claim.
259
+ return [`⏳ **${titleOf(call.name)}**`, ...lines].join('\n');
260
+ })
261
+ .join('\n\n');
262
+
263
+ /**
264
+ * Whether a tool needs a yes before it runs, by the MCP defaults: read-only
265
+ * only if it says so, and a tool that changes things is destructive unless it
266
+ * says it isn't. So an unannotated custom or proxied tool is confirmed — the
267
+ * safe reading for an organization that asked to be asked.
268
+ */
269
+ export const isSensitiveTool = (
270
+ annotations:
271
+ | { readOnlyHint?: boolean; destructiveHint?: boolean }
272
+ | null
273
+ | undefined
274
+ ): boolean =>
275
+ annotations?.readOnlyHint !== true && annotations?.destructiveHint !== false;
276
+
277
+ // JSON with keys sorted at every depth, so two argument objects that say the
278
+ // same thing serialize the same whatever order the model emitted them in.
279
+ export const stableJson = (value: unknown): string => {
280
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
281
+ if (value && typeof value === 'object') {
282
+ return `{${Object.keys(value)
283
+ .sort()
284
+ .map(
285
+ key =>
286
+ `${JSON.stringify(key)}:${stableJson((value as Record<string, unknown>)[key])}`
287
+ )
288
+ .join(',')}}`;
289
+ }
290
+ return JSON.stringify(value) ?? 'null';
291
+ };
@@ -0,0 +1,277 @@
1
+ // Checks on how a tool presents itself to a model: the things that make a
2
+ // model pick the wrong tool, pass the wrong arguments, or pay for tools it never
3
+ // uses. Every finding is a warning — a tool that fails one still works, it is
4
+ // just harder for a model to use well.
5
+ //
6
+ // One rule set, run in three places: `ganju build` (with `--strict` to fail on
7
+ // any finding), the API when a custom-code version is created (returned beside
8
+ // the version, with the artifact's other tools as context), and the dashboard's
9
+ // function dialog while it is being filled in.
10
+ //
11
+ // The rule for this file: it imports nothing. The published CLI bundles it, and
12
+ // anything it imported would ship inside the CLI with it (see cliConstants.ts).
13
+
14
+ // The shape all three callers can produce: a manifest entry, an http-endpoint's
15
+ // config, a proxied tool from discovery, a catalog tool.
16
+ export interface LintableTool {
17
+ name: string;
18
+ title?: string;
19
+ description?: string;
20
+ inputSchema?: unknown;
21
+ annotations?: {
22
+ readOnlyHint?: boolean;
23
+ destructiveHint?: boolean;
24
+ idempotentHint?: boolean;
25
+ openWorldHint?: boolean;
26
+ };
27
+ }
28
+
29
+ export const TOOL_LINT_RULES = [
30
+ 'missing-description',
31
+ 'short-description',
32
+ 'no-usage-guidance',
33
+ 'missing-annotations',
34
+ 'undescribed-input',
35
+ 'overlapping-tools',
36
+ 'too-many-tools'
37
+ ] as const;
38
+
39
+ export type ToolLintRule = (typeof TOOL_LINT_RULES)[number];
40
+
41
+ export interface ToolLintFinding {
42
+ rule: ToolLintRule;
43
+ // The tool the finding is about. Absent only for `too-many-tools`, which is
44
+ // about the server as a whole.
45
+ tool?: string;
46
+ // `overlapping-tools`: the tool it is confused with.
47
+ other?: string;
48
+ // `undescribed-input`: the properties with no description, as dotted paths.
49
+ paths?: string[];
50
+ // `too-many-tools`: how many are enabled, and the number past which it warns.
51
+ count?: number;
52
+ limit?: number;
53
+ // The same finding in English, for the CLI and the API. The dashboard builds
54
+ // its own from the fields above, in the reader's language.
55
+ message: string;
56
+ }
57
+
58
+ export interface ToolLintOptions {
59
+ // The other tools on the same server — natives, http endpoints, proxied
60
+ // tools. They are checked against for overlap and counted, but never
61
+ // reported on themselves: the author of these tools can't change them.
62
+ others?: LintableTool[];
63
+ // How many tools the server exposes beside `tools`, when `others` doesn't
64
+ // list them all. Defaults to `others.length`.
65
+ otherCount?: number;
66
+ }
67
+
68
+ // Shorter than this and a description rarely says both what a tool does and
69
+ // when to call it.
70
+ export const TOOL_LINT_MIN_DESCRIPTION_LENGTH = 40;
71
+
72
+ // Past this many tools every call carries a long list of schemas, and models
73
+ // start choosing among them badly. A channel turn hard-caps its list at 40
74
+ // (CHANNEL_MAX_TOOLS); this warns well before that.
75
+ export const TOOL_LINT_MAX_TOOLS = 25;
76
+
77
+ // Two descriptions sharing at least this much of their vocabulary (Jaccard,
78
+ // over content words) read as the same tool to a model.
79
+ const DESCRIPTION_OVERLAP = 0.6;
80
+ // Too few content words and two unrelated descriptions can share most of them.
81
+ const MIN_OVERLAP_WORDS = 5;
82
+
83
+ // Lowercased, accents off, so "Úsala cuando" and "usala cuando" are one text.
84
+ const fold = (text: string): string =>
85
+ text.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '');
86
+
87
+ // Wording that tells a model WHEN to call a tool rather than only what it does
88
+ // — English, Spanish and Portuguese, the languages descriptions here are
89
+ // written in. Loose on purpose: a false "says when" costs nothing, a false
90
+ // "doesn't" nags someone whose description is fine.
91
+ const USAGE_GUIDANCE =
92
+ /\b(use|uses|using|call|calls|when|whenever|before|after|instead|only if|if the user|if you|for questions|usa|usala|usalo|usar|llama|llamala|llamar|cuando|antes de|despues de|en lugar de|si el usuario|quando|use-a|utilize|chame)\b/;
93
+
94
+ const STOPWORDS = new Set([
95
+ 'the',
96
+ 'and',
97
+ 'for',
98
+ 'with',
99
+ 'from',
100
+ 'this',
101
+ 'that',
102
+ 'into',
103
+ 'your',
104
+ 'you',
105
+ 'use',
106
+ 'when',
107
+ 'call',
108
+ 'tool',
109
+ 'returns',
110
+ 'return',
111
+ 'given',
112
+ 'user',
113
+ 'about',
114
+ 'una',
115
+ 'uno',
116
+ 'los',
117
+ 'las',
118
+ 'del',
119
+ 'con',
120
+ 'por',
121
+ 'para',
122
+ 'que',
123
+ 'este',
124
+ 'esta',
125
+ 'como',
126
+ 'cuando',
127
+ 'usa',
128
+ 'herramienta',
129
+ 'devuelve',
130
+ 'usuario'
131
+ ]);
132
+
133
+ const contentWords = (text: string): Set<string> =>
134
+ new Set(
135
+ fold(text)
136
+ .split(/[^a-z0-9]+/)
137
+ .filter(word => word.length >= 3 && !STOPWORDS.has(word))
138
+ );
139
+
140
+ // `lookupOrder`, `lookup-order` and `order_lookup` name the same thing.
141
+ const nameTokens = (name: string): string =>
142
+ name
143
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
144
+ .toLowerCase()
145
+ .split(/[^a-z0-9]+/)
146
+ .filter(Boolean)
147
+ .sort()
148
+ .join(' ');
149
+
150
+ const jaccard = (a: Set<string>, b: Set<string>): number => {
151
+ let shared = 0;
152
+ for (const word of a) if (b.has(word)) shared++;
153
+ const union = a.size + b.size - shared;
154
+ return union === 0 ? 0 : shared / union;
155
+ };
156
+
157
+ const overlaps = (a: LintableTool, b: LintableTool): boolean => {
158
+ if (nameTokens(a.name) === nameTokens(b.name)) return true;
159
+ const aWords = contentWords(a.description ?? '');
160
+ const bWords = contentWords(b.description ?? '');
161
+ if (aWords.size < MIN_OVERLAP_WORDS || bWords.size < MIN_OVERLAP_WORDS) {
162
+ return false;
163
+ }
164
+ return jaccard(aWords, bWords) >= DESCRIPTION_OVERLAP;
165
+ };
166
+
167
+ const isObject = (value: unknown): value is Record<string, unknown> =>
168
+ typeof value === 'object' && value !== null && !Array.isArray(value);
169
+
170
+ // Every property the model may fill in that doesn't say what it is for, down
171
+ // through nested objects and arrays of objects.
172
+ const undescribedPaths = (schema: unknown, prefix = ''): string[] => {
173
+ if (!isObject(schema)) return [];
174
+ const paths: string[] = [];
175
+ if (isObject(schema.properties)) {
176
+ for (const [key, property] of Object.entries(schema.properties)) {
177
+ const path = prefix ? `${prefix}.${key}` : key;
178
+ if (!isObject(property)) continue;
179
+ const description = property.description;
180
+ if (typeof description !== 'string' || !description.trim()) {
181
+ paths.push(path);
182
+ }
183
+ paths.push(...undescribedPaths(property, path));
184
+ }
185
+ }
186
+ if (isObject(schema.items)) {
187
+ paths.push(...undescribedPaths(schema.items, `${prefix}[]`));
188
+ }
189
+ return paths;
190
+ };
191
+
192
+ const quoted = (name: string) => `"${name}"`;
193
+
194
+ /**
195
+ * Check a set of tools for what makes them hard for a model to use. Findings
196
+ * come back in the order of `tools`, then the server-wide one.
197
+ */
198
+ export const lintTools = (
199
+ tools: LintableTool[],
200
+ options: ToolLintOptions = {}
201
+ ): ToolLintFinding[] => {
202
+ const others = options.others ?? [];
203
+ const findings: ToolLintFinding[] = [];
204
+
205
+ tools.forEach((tool, index) => {
206
+ const description = (tool.description ?? '').trim();
207
+
208
+ if (!description) {
209
+ findings.push({
210
+ rule: 'missing-description',
211
+ tool: tool.name,
212
+ message: `${quoted(tool.name)} has no description, so a model sees only its name when deciding whether to call it.`
213
+ });
214
+ } else if (description.length < TOOL_LINT_MIN_DESCRIPTION_LENGTH) {
215
+ findings.push({
216
+ rule: 'short-description',
217
+ tool: tool.name,
218
+ message: `${quoted(tool.name)} has a very short description. Say what it does and when to call it, in at least ${TOOL_LINT_MIN_DESCRIPTION_LENGTH} characters.`
219
+ });
220
+ } else if (!USAGE_GUIDANCE.test(fold(description))) {
221
+ findings.push({
222
+ rule: 'no-usage-guidance',
223
+ tool: tool.name,
224
+ message: `${quoted(tool.name)}'s description says what it does but not when to use it (e.g. "Use when the user asks about…").`
225
+ });
226
+ }
227
+
228
+ // Undeclared means "may change things" by the MCP defaults, so with the
229
+ // organization confirming sensitive actions every call asks first — a
230
+ // lookup included.
231
+ const hints = tool.annotations;
232
+ if (
233
+ hints?.readOnlyHint === undefined &&
234
+ hints?.destructiveHint === undefined
235
+ ) {
236
+ findings.push({
237
+ rule: 'missing-annotations',
238
+ tool: tool.name,
239
+ message: `${quoted(tool.name)} declares no annotations, so it is treated as one that may change things and is confirmed before every call when the organization confirms sensitive actions. Set readOnlyHint: true if it only reads, or destructiveHint: false if its changes can be undone.`
240
+ });
241
+ }
242
+
243
+ const paths = undescribedPaths(tool.inputSchema);
244
+ if (paths.length > 0) {
245
+ findings.push({
246
+ rule: 'undescribed-input',
247
+ tool: tool.name,
248
+ paths,
249
+ message: `${quoted(tool.name)} has input properties with no description: ${paths.join(', ')}. A model has to guess what to pass.`
250
+ });
251
+ }
252
+
253
+ // Each pair once: against the tools after this one, then the rest of the
254
+ // server.
255
+ for (const other of [...tools.slice(index + 1), ...others]) {
256
+ if (other.name === tool.name || !overlaps(tool, other)) continue;
257
+ findings.push({
258
+ rule: 'overlapping-tools',
259
+ tool: tool.name,
260
+ other: other.name,
261
+ message: `${quoted(tool.name)} and ${quoted(other.name)} look alike in name or description, so a model may call one for the other. Make each description say what sets it apart.`
262
+ });
263
+ }
264
+ });
265
+
266
+ const count = tools.length + (options.otherCount ?? others.length);
267
+ if (count > TOOL_LINT_MAX_TOOLS) {
268
+ findings.push({
269
+ rule: 'too-many-tools',
270
+ count,
271
+ limit: TOOL_LINT_MAX_TOOLS,
272
+ message: `${count} tools are enabled on this server. Every one's schema is sent to the model on every call, and models choose worse past a few dozen — turn off the ones it doesn't need (more than ${TOOL_LINT_MAX_TOOLS} is where this warns).`
273
+ });
274
+ }
275
+
276
+ return findings;
277
+ };