@agentx-core/security-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/report.js ADDED
@@ -0,0 +1,359 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WRAP_SNIPPET = exports.DOCS_URL = exports.AUDIT_COMMAND = exports.PACKAGE = exports.GATEWAY_URL = exports.AUDIT_SCHEMA = void 0;
4
+ exports.plural = plural;
5
+ exports.fit = fit;
6
+ exports.fitNames = fitNames;
7
+ exports.formatBucket = formatBucket;
8
+ exports.sincePhrase = sincePhrase;
9
+ exports.summariseTools = summariseTools;
10
+ exports.renderTools = renderTools;
11
+ exports.renderCalls = renderCalls;
12
+ exports.jsonPayload = jsonPayload;
13
+ /**
14
+ * The `audit` screens. The design is `agentx audit` in agentx_sdk/cli.py, copied rather than
15
+ * reinvented: the same header, the same TOOL / CALLS / SURFACE / ARGUMENTS table, the same
16
+ * "what was KEPT" disclosure when retention has trimmed the file, and the same rule about
17
+ * naming the ledger path on an empty screen (the likeliest reason a screen is empty is the
18
+ * reader standing in a different folder).
19
+ *
20
+ * Every line keeps to 75 columns, with one documented exception carried over from the Python
21
+ * screen: a single argument name longer than the ARGUMENTS budget is kept whole up to
22
+ * MAX_WHOLE_NAME (40) characters, because a reader greps for a name and cannot grep half of
23
+ * one. So a --calls row can reach 48 + 40 columns plus a " +N more" suffix (96 with one name
24
+ * dropped), and a grouped row 46 + 40 plus the same suffix.
25
+ */
26
+ const shape_1 = require("./shape");
27
+ const ledger_1 = require("./ledger");
28
+ const pulse_1 = require("./pulse");
29
+ /** The wire-format version of `--json`. Its own namespace: this package versions independently of the Python `agentx.audit/1`. */
30
+ exports.AUDIT_SCHEMA = "agentx.ts-audit/1";
31
+ /**
32
+ * The call to action, with its OWN tag. The two TypeScript calls to action we can already
33
+ * measure (scan's, and the /docs handoff) both read about one click each, so this one carries
34
+ * `utm_source=ts-sdk` rather than borrowing either, and the /gateway view beacon can finally
35
+ * say which of the three, if any, moves anyone.
36
+ */
37
+ exports.GATEWAY_URL = "https://agentx-core.com/gateway?utm_source=ts-sdk";
38
+ exports.PACKAGE = "@agentx-core/security-sdk";
39
+ exports.AUDIT_COMMAND = `npx ${exports.PACKAGE} audit`;
40
+ exports.DOCS_URL = "https://agentx-core.com/docs";
41
+ /** The one snippet every empty screen shows. Written once so two screens cannot teach two spellings. */
42
+ exports.WRAP_SNIPPET = [
43
+ `import { agentxWatch } from "${exports.PACKAGE}";`,
44
+ 'const guardedTool = agentxWatch(tool, { name: "runSql" }); // around any tool',
45
+ ];
46
+ const RULE = "=".repeat(75);
47
+ const DEFAULT_TOOL_ROWS = 25;
48
+ const DEFAULT_CALL_ROWS = 50;
49
+ const MAX_WHOLE_NAME = 40;
50
+ const MAGNITUDE_WORTH_SHOWING = 100;
51
+ function plural(n, singular, pluralForm) {
52
+ const word = n === 1 ? singular : pluralForm ?? singular + "s";
53
+ return `${n.toLocaleString("en-US")} ${word}`;
54
+ }
55
+ function pad(text, width) {
56
+ return text.length >= width ? text : text + " ".repeat(width - text.length);
57
+ }
58
+ function padLeft(text, width) {
59
+ return text.length >= width ? text : " ".repeat(width - text.length) + text;
60
+ }
61
+ /** Clip with a visible ellipsis, never silently: a truncated name that still looks like a name is worse. */
62
+ function fit(text, width) {
63
+ text = String(text ?? "");
64
+ return text.length <= width ? text : text.slice(0, Math.max(0, width - 3)) + "...";
65
+ }
66
+ /**
67
+ * Join argument names to fit `width`, dropping WHOLE names rather than cutting one, and saying
68
+ * "+N more" for what was dropped. A half name matches nothing when the reader greps for it.
69
+ * Port of cli.py `_fit_names`, including its one deliberate overrun: a single name longer than
70
+ * the budget is kept whole up to MAX_WHOLE_NAME.
71
+ */
72
+ function fitNames(namesIn, width) {
73
+ const names = namesIn.map((n) => String(n ?? "")).filter(Boolean);
74
+ if (!names.length)
75
+ return "(none)";
76
+ const joined = names.join(", ");
77
+ if (joined.length <= width)
78
+ return joined;
79
+ const reserve = ` +${names.length} more`.length;
80
+ const kept = [];
81
+ for (let name of names) {
82
+ // The first name is kept whole up to MAX_WHOLE_NAME and clipped past it; later names
83
+ // must fit the remaining budget or are dropped and counted.
84
+ name = kept.length ? name : fit(name, MAX_WHOLE_NAME);
85
+ const candidate = [...kept, name].join(", ");
86
+ if (kept.length && candidate.length > width - reserve)
87
+ continue;
88
+ kept.push(name);
89
+ }
90
+ const dropped = names.length - kept.length;
91
+ return dropped ? `${kept.join(", ")} +${dropped} more` : kept.join(", ");
92
+ }
93
+ /** A magnitude BUCKET rendered as the ">= floor" it means, never as a figure the ledger does not hold. */
94
+ function formatBucket(amount) {
95
+ if (!amount || amount < 1)
96
+ return "";
97
+ return `≥${Math.trunc(amount).toLocaleString("en-US")}`;
98
+ }
99
+ function localDay(ts) {
100
+ const d = new Date(ts);
101
+ const p = (n) => String(n).padStart(2, "0");
102
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
103
+ }
104
+ function localClock(ts) {
105
+ const d = new Date(ts);
106
+ const p = (n) => String(n).padStart(2, "0");
107
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
108
+ }
109
+ /** " since <local date time>" for a window, or "". */
110
+ function sincePhrase(windowStart) {
111
+ if (!windowStart)
112
+ return "";
113
+ const d = new Date(windowStart);
114
+ const p = (n) => String(n).padStart(2, "0");
115
+ return ` since ${localDay(windowStart)} ${p(d.getHours())}:${p(d.getMinutes())}`;
116
+ }
117
+ /** Group the rows by tool, busiest first. */
118
+ function summariseTools(rows) {
119
+ const byTool = new Map();
120
+ for (const r of rows) {
121
+ let s = byTool.get(r.tool);
122
+ if (!s) {
123
+ s = {
124
+ tool: r.tool, calls: 0, classes: [], argNames: [], maxAmount: 0, maxQuantity: 0, agents: [],
125
+ firstTs: r.ts, lastTs: r.ts, _classes: new Set(), _args: new Set(), _agents: new Set(),
126
+ };
127
+ byTool.set(r.tool, s);
128
+ }
129
+ s.calls += 1;
130
+ if (r.surface && r.surface !== shape_1.CLASS_OTHER)
131
+ s._classes.add(r.surface);
132
+ for (const a of r.args)
133
+ s._args.add(a);
134
+ if (r.agent)
135
+ s._agents.add(r.agent);
136
+ s.maxAmount = Math.max(s.maxAmount, r.amount || 0);
137
+ s.maxQuantity = Math.max(s.maxQuantity, r.quantity || 0);
138
+ s.firstTs = Math.min(s.firstTs, r.ts);
139
+ s.lastTs = Math.max(s.lastTs, r.ts);
140
+ }
141
+ return [...byTool.values()]
142
+ .map(({ _classes, _args, _agents, ...s }) => ({
143
+ ...s, classes: [..._classes].sort(), argNames: [..._args].sort(), agents: [..._agents].sort(),
144
+ }))
145
+ .sort((a, b) => b.calls - a.calls || a.tool.localeCompare(b.tool));
146
+ }
147
+ function effectiveLimit(opts, fallback) {
148
+ if (opts.limit !== undefined)
149
+ return opts.limit;
150
+ if (opts.all)
151
+ return null;
152
+ return fallback;
153
+ }
154
+ function ledgerPathLine(read, indent) {
155
+ return `${indent}${read.path}${read.exists ? "" : " (not created yet)"}`;
156
+ }
157
+ function emptyScreen(read) {
158
+ return [
159
+ " No calls recorded yet.",
160
+ ledgerPathLine(read, " "),
161
+ " Calls are recorded per ledger, so running this from another folder",
162
+ " reads a different one.",
163
+ "",
164
+ " AgentX records every wrapped tool call. If you have not wrapped a tool yet:",
165
+ "",
166
+ ...exports.WRAP_SNIPPET.map((l) => " " + l),
167
+ "",
168
+ RULE,
169
+ ];
170
+ }
171
+ function trimmedNote(read, indent = " ") {
172
+ if (read.coversAll)
173
+ return [];
174
+ return [
175
+ `${indent}⚠️ This ledger has been trimmed, so the counts below describe what was`,
176
+ `${indent} KEPT, not everything your agent has ever done. It keeps the last`,
177
+ `${indent} ${ledger_1.RETENTION_DAYS} days or ${ledger_1.RETENTION_MAX_ROWS.toLocaleString("en-US")} records.`,
178
+ "",
179
+ ];
180
+ }
181
+ function footer() {
182
+ return [
183
+ " AgentX watches and writes down what your agent did, and blocks nothing,",
184
+ " so it cannot break a working agent.",
185
+ // The URL on its own line, as the Python session summary prints its gateway link: on one
186
+ // line with the sentence this ran to 101 columns and broke the 75-column fence every
187
+ // screen keeps to. Found by the fence test written for a different row.
188
+ " ▶ To stop the dangerous ones before they run, a gateway:",
189
+ ` ${exports.GATEWAY_URL}`,
190
+ RULE,
191
+ ];
192
+ }
193
+ /** The grouped screen. Returns the text; the caller prints it. */
194
+ function renderTools(read, opts = {}) {
195
+ const out = [
196
+ "",
197
+ "🔎 WHAT YOUR AGENT DID (local to this ledger)",
198
+ RULE,
199
+ ];
200
+ if (!read.rows.length)
201
+ return [...out, ...emptyScreen(read)].join("\n");
202
+ out.push(...trimmedNote(read));
203
+ const tools = summariseTools(read.rows);
204
+ const limit = effectiveLimit(opts, DEFAULT_TOOL_ROWS);
205
+ const shown = limit === null ? tools : tools.slice(0, limit);
206
+ out.push(` ${plural(read.rows.length, "call")} across ${plural(tools.length, "tool")}${sincePhrase(read.windowStart)}`);
207
+ if (shown.length < tools.length) {
208
+ out.push(` (showing the busiest ${shown.length}; ${tools.length - shown.length} more not listed -- use --all)`);
209
+ }
210
+ out.push("");
211
+ // The same widths as the Python screen: " %-20s %6s %-13s %s", ARGUMENTS gets 75 - 46.
212
+ const row = (a, b, c, d) => ` ${pad(a, 20)} ${padLeft(b, 6)} ${pad(c, 13)} ${d}`;
213
+ out.push(row("TOOL", "CALLS", "SURFACE", "ARGUMENTS"));
214
+ out.push(" " + "-".repeat(71));
215
+ const agentsOnScreen = new Set(shown.flatMap((s) => s.agents));
216
+ const showAgents = agentsOnScreen.size > 1;
217
+ let unknownSurface = false;
218
+ for (const s of shown) {
219
+ const classes = s.classes.map((c) => shape_1.SURFACE_LABELS[c] ?? c).join("/") || "-";
220
+ if (classes === "-")
221
+ unknownSurface = true;
222
+ out.push(row(fit(s.tool, 20), s.calls.toLocaleString("en-US"), fit(classes, 13), fitNames(s.argNames, 75 - 46)));
223
+ const detail = [];
224
+ if (s.maxAmount >= MAGNITUDE_WORTH_SHOWING)
225
+ detail.push(`largest amount passed: ${formatBucket(s.maxAmount)}`);
226
+ if (s.maxQuantity >= MAGNITUDE_WORTH_SHOWING)
227
+ detail.push(`largest count passed: ${formatBucket(s.maxQuantity)}`);
228
+ if (showAgents && s.agents.length)
229
+ detail.push(`agent: ${fitNames(s.agents, 39)}`);
230
+ if (detail.length)
231
+ out.push(` ${pad("", 20)} ${detail.join("; ")}`);
232
+ }
233
+ out.push("");
234
+ if (unknownSurface) {
235
+ out.push(" A dash under SURFACE means we could not tell what that tool touches.");
236
+ out.push(" We go by tool and argument names, and only match words we are sure of.");
237
+ out.push("");
238
+ }
239
+ out.push(...footer());
240
+ return out.join("\n");
241
+ }
242
+ /** The `--calls` screen: one line per call, newest first. The ORDER is the point. */
243
+ function renderCalls(read, opts = {}) {
244
+ const out = [
245
+ "",
246
+ "🔎 WHAT YOUR AGENT DID, CALL BY CALL (local to this ledger)",
247
+ RULE,
248
+ ];
249
+ if (!read.rows.length)
250
+ return [...out, ...emptyScreen(read)].join("\n");
251
+ if (!read.coversAll) {
252
+ out.push(" This ledger has been trimmed, so the rows below are what was KEPT, not");
253
+ out.push(" everything your agent has ever done.");
254
+ }
255
+ const newestFirst = [...read.rows].sort((a, b) => b.ts - a.ts);
256
+ const tools = new Set(newestFirst.map((r) => r.tool)).size;
257
+ const limit = effectiveLimit(opts, DEFAULT_CALL_ROWS);
258
+ const shown = limit === null ? newestFirst : newestFirst.slice(0, limit);
259
+ out.push(` ${plural(read.rows.length, "call")} across ${plural(tools, "tool")}${sincePhrase(read.windowStart)}`);
260
+ if (shown.length < newestFirst.length) {
261
+ out.push(` (showing the most recent ${shown.length}; ${(newestFirst.length - shown.length).toLocaleString("en-US")} more not listed -- use --all)`);
262
+ }
263
+ out.push("");
264
+ // " %-8s %-20s %-6s %-7s %s" -> 2 + 8 + 2 + 20 + 1 + 6 + 1 + 7 + 1 = 48, ARGUMENTS gets 27.
265
+ // The Python screen gives STATUS 12 because its widest value is "ran, flagged"; on this
266
+ // door every row is "ran", so the column is its header's width and the nine characters go
267
+ // to ARGUMENTS, the column a reader opens this screen for. Seen on the founder's first walk:
268
+ // at 19 a three-name call rendered "amount +2 more". TOOL is 20, as on the grouped screen.
269
+ const row = (a, b, c, d, e) => ` ${pad(a, 8)} ${pad(b, 20)} ${pad(c, 6)} ${pad(d, 7)} ${e}`;
270
+ out.push(row("TIME", "TOOL", "STATUS", "SURFACE", "ARGUMENTS"));
271
+ out.push(" " + "-".repeat(71));
272
+ let shownDay = null;
273
+ for (const r of shown) {
274
+ const day = localDay(r.ts);
275
+ if (day !== shownDay) {
276
+ out.push(` ${day}`);
277
+ shownDay = day;
278
+ }
279
+ // Every row is "ran": this package has no opinion about a call, so there is no other status.
280
+ out.push(row(localClock(r.ts), fit(r.tool || "(unnamed)", 20), "ran", shape_1.SURFACE_LABELS[r.surface] ?? "-", fitNames(r.args, 75 - 48)));
281
+ }
282
+ out.push("");
283
+ // The Python `--calls` screen ends here too: the pointer to the grouped view, and no gateway
284
+ // link. One next step per screen; the grouped screen carries the CTA.
285
+ out.push(` Grouped by tool: ${exports.AUDIT_COMMAND}`);
286
+ out.push(RULE);
287
+ return out.join("\n");
288
+ }
289
+ /**
290
+ * The `--json` document. ALWAYS COMPLETE unless the caller passed `--limit`: a program reading
291
+ * a silently truncated list would report a smaller agent than the one that ran. Carries a
292
+ * `schema` and a `view` so a file read in isolation says which flag produced it, and a
293
+ * `produced_by` block so a reader who has never heard of us knows what made it, which build,
294
+ * and where to look. Field names follow the Python `agentx audit --json` wherever the two
295
+ * describe the same fact (`ledger.*`, `time`, `status_label`, `arg_names`, `amount_bucket`,
296
+ * `surface`), so one consumer can read both files.
297
+ */
298
+ function jsonPayload(read, view, opts = {}) {
299
+ const surfaceLabel = (c) => (c && c !== shape_1.CLASS_OTHER ? shape_1.SURFACE_LABELS[c] ?? c : null);
300
+ const base = {
301
+ schema: exports.AUDIT_SCHEMA,
302
+ view,
303
+ produced_by: {
304
+ tool: "agentx-security-sdk audit",
305
+ version: (0, pulse_1.sdkVersion)(),
306
+ docs: exports.DOCS_URL,
307
+ },
308
+ generated_at: new Date().toISOString(),
309
+ ledger: {
310
+ readable: read.readable,
311
+ path: read.path,
312
+ exists: read.exists,
313
+ window_start: read.windowStart ? read.windowStart / 1000 : null,
314
+ window_start_iso: read.windowStart ? new Date(read.windowStart).toISOString() : null,
315
+ covers_all: read.readable ? read.coversAll : null,
316
+ dropped: read.readable ? read.dropped : null,
317
+ retention_days: ledger_1.RETENTION_DAYS,
318
+ max_rows: ledger_1.RETENTION_MAX_ROWS,
319
+ },
320
+ cta: { gateway: exports.GATEWAY_URL, command: exports.AUDIT_COMMAND },
321
+ };
322
+ if (!read.readable)
323
+ return base;
324
+ const limit = opts.limit ?? null;
325
+ const tools = summariseTools(read.rows);
326
+ if (view === "tools") {
327
+ const shown = limit === null ? tools : tools.slice(0, limit);
328
+ base.totals = { calls: read.rows.length, tools: tools.length, shown: shown.length, listed_total: tools.length };
329
+ base.tools = shown.map((s) => ({
330
+ tool: s.tool,
331
+ calls: s.calls,
332
+ surfaces: s.classes.map((c) => shape_1.SURFACE_LABELS[c] ?? c),
333
+ arg_names: s.argNames,
334
+ max_amount_bucket: s.maxAmount,
335
+ max_count_bucket: s.maxQuantity,
336
+ agents: s.agents,
337
+ first_time: new Date(s.firstTs).toISOString(),
338
+ last_time: new Date(s.lastTs).toISOString(),
339
+ }));
340
+ }
341
+ else {
342
+ const newestFirst = [...read.rows].sort((a, b) => b.ts - a.ts);
343
+ const shown = limit === null ? newestFirst : newestFirst.slice(0, limit);
344
+ base.totals = { calls: read.rows.length, tools: tools.length, shown: shown.length, rows: read.rows.length };
345
+ base.calls = shown.map((r) => ({
346
+ ts: r.ts / 1000,
347
+ time: new Date(r.ts).toISOString(),
348
+ tool: r.tool,
349
+ status: "ALLOWED",
350
+ status_label: "ran",
351
+ arg_names: r.args,
352
+ amount_bucket: r.amount,
353
+ count_bucket: r.quantity,
354
+ surface: surfaceLabel(r.surface),
355
+ agent: r.agent || null,
356
+ }));
357
+ }
358
+ return base;
359
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The SHAPE of a call: what gets written down about it, and the rule deciding what does not.
3
+ *
4
+ * A port of `agentx_sdk/db.py::_call_shape` and the helpers around it. Only the KEYS of the
5
+ * arguments are read, plus the power-of-ten magnitude of a value the developer themselves
6
+ * named as an amount (with a currency beside it) or a count. No argument VALUE is ever
7
+ * returned from this module, so nothing downstream can write one.
8
+ *
9
+ * The numbers and the hint table below are pinned to the Python ones by
10
+ * `sdk_tests/test_ts_sdk_matches_python.py`. "Same limits as the Python SDK" is a test, not a
11
+ * sentence in a README.
12
+ */
13
+ export type TargetClass = "filesystem" | "http" | "db" | "shell" | "cloud" | "other";
14
+ export declare const CLASS_OTHER: TargetClass;
15
+ /** What the SURFACE column prints. The stored value stays long (`filesystem`); the screen is short. */
16
+ export declare const SURFACE_LABELS: Record<TargetClass, string>;
17
+ /**
18
+ * Token -> class. Matched against the TOOL NAME and the ARGUMENT NAMES only, never against
19
+ * argument values, so nothing a user's data says can influence the result. First match wins
20
+ * in THIS order.
21
+ *
22
+ * Deliberately under-inclusive, and it should stay that way: `query`, `table`, `cursor`,
23
+ * `request`, `bucket`, `cluster` and `command` were all removed on the Python side after each
24
+ * mislabelled a real tool. A confident wrong class on a screen the developer reads as a
25
+ * statement about their own agent is worse than `other`, which says we do not know.
26
+ *
27
+ * ONE HINT PER LINE. The Python-side tripwire reads this table line by line.
28
+ */
29
+ export declare const CLASS_HINTS: ReadonlyArray<readonly [readonly string[], TargetClass]>;
30
+ /**
31
+ * The closed action vocabulary the whole product uses (the gateway's `action` field, and the
32
+ * `action` option on the TypeScript guard in examples/ts-guard). Reused here EXACTLY, so a call
33
+ * is described the same way on every surface. A developer's own label fills the SURFACE
34
+ * column only when the tool and argument names left it blank; it never overrides them.
35
+ */
36
+ export type Action = "execute_database_query" | "fetch_url" | "execute_shell" | "send_message" | "write_file" | "other";
37
+ export declare const ACTION_SURFACE: Record<Action, TargetClass>;
38
+ /** Bounds on the argument-name list, so one 400-parameter tool cannot bloat a row. */
39
+ export declare const MAX_ARG_NAMES = 24;
40
+ export declare const MAX_ARG_NAMES_CHARS = 512;
41
+ export interface CallShape {
42
+ /** Sorted, comma-free, budgeted argument NAMES. */
43
+ argNames: string[];
44
+ /** Power-of-ten floor of the largest labelled amount, or 0. */
45
+ amount: number;
46
+ /** Power-of-ten floor of the largest labelled count, or 0. */
47
+ quantity: number;
48
+ targetClass: TargetClass;
49
+ }
50
+ export declare function normaliseKey(key: unknown): string;
51
+ export declare function isAmountKey(name: string): boolean;
52
+ export declare function isCurrencyKey(name: string): boolean;
53
+ export declare function isCountKey(name: string): boolean;
54
+ /** A currency key must CARRY a currency: `null` and booleans are not one. */
55
+ export declare function isRealCurrencyValue(value: unknown): boolean;
56
+ /**
57
+ * The bucket FLOOR for a numeric magnitude, as a power of ten. 5 -> 1, 42 -> 10, 1500 -> 1000,
58
+ * 0 -> 0. Anything non-numeric returns 0: a string is never coerced, because parsing one is
59
+ * how a value sneaks into a numeric column.
60
+ */
61
+ export declare function magnitudeBucket(value: unknown): number;
62
+ /**
63
+ * Whole lowercase tokens from identifier-ish text. camelCase is a boundary too (`sendHttpRequest`
64
+ * is three tokens), and the match below is on WHOLE tokens, never substrings: `send_feedback`
65
+ * must not classify as DB because "fee(db)ack" contains "db".
66
+ */
67
+ export declare function nameTokens(raw: unknown): Set<string>;
68
+ /** The bounded target class from the tool name and argument NAMES. Reads no values. */
69
+ export declare function classifyTarget(toolName: string, names: readonly string[]): TargetClass;
70
+ /** The same closed set, from a tool's own advertised description. Display-only, by rule. */
71
+ export declare function classifyText(text: unknown): TargetClass;
72
+ /**
73
+ * Derive the shape of one call. Pure, never throws.
74
+ *
75
+ * The caller's own words decide first (tool and argument names). The `action` label and then
76
+ * the description are consulted only when those leave the class blank: they can fill a `-`,
77
+ * never override what the developer actually called.
78
+ */
79
+ export declare function callShape(toolName: string, args: unknown, description?: string, action?: Action): CallShape;
package/dist/shape.js ADDED
@@ -0,0 +1,197 @@
1
+ "use strict";
2
+ /**
3
+ * The SHAPE of a call: what gets written down about it, and the rule deciding what does not.
4
+ *
5
+ * A port of `agentx_sdk/db.py::_call_shape` and the helpers around it. Only the KEYS of the
6
+ * arguments are read, plus the power-of-ten magnitude of a value the developer themselves
7
+ * named as an amount (with a currency beside it) or a count. No argument VALUE is ever
8
+ * returned from this module, so nothing downstream can write one.
9
+ *
10
+ * The numbers and the hint table below are pinned to the Python ones by
11
+ * `sdk_tests/test_ts_sdk_matches_python.py`. "Same limits as the Python SDK" is a test, not a
12
+ * sentence in a README.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.MAX_ARG_NAMES_CHARS = exports.MAX_ARG_NAMES = exports.ACTION_SURFACE = exports.CLASS_HINTS = exports.SURFACE_LABELS = exports.CLASS_OTHER = void 0;
16
+ exports.normaliseKey = normaliseKey;
17
+ exports.isAmountKey = isAmountKey;
18
+ exports.isCurrencyKey = isCurrencyKey;
19
+ exports.isCountKey = isCountKey;
20
+ exports.isRealCurrencyValue = isRealCurrencyValue;
21
+ exports.magnitudeBucket = magnitudeBucket;
22
+ exports.nameTokens = nameTokens;
23
+ exports.classifyTarget = classifyTarget;
24
+ exports.classifyText = classifyText;
25
+ exports.callShape = callShape;
26
+ exports.CLASS_OTHER = "other";
27
+ /** What the SURFACE column prints. The stored value stays long (`filesystem`); the screen is short. */
28
+ exports.SURFACE_LABELS = {
29
+ db: "DB",
30
+ filesystem: "FS",
31
+ http: "HTTP",
32
+ shell: "SHELL",
33
+ cloud: "CLOUD",
34
+ other: "-",
35
+ };
36
+ /**
37
+ * Token -> class. Matched against the TOOL NAME and the ARGUMENT NAMES only, never against
38
+ * argument values, so nothing a user's data says can influence the result. First match wins
39
+ * in THIS order.
40
+ *
41
+ * Deliberately under-inclusive, and it should stay that way: `query`, `table`, `cursor`,
42
+ * `request`, `bucket`, `cluster` and `command` were all removed on the Python side after each
43
+ * mislabelled a real tool. A confident wrong class on a screen the developer reads as a
44
+ * statement about their own agent is worse than `other`, which says we do not know.
45
+ *
46
+ * ONE HINT PER LINE. The Python-side tripwire reads this table line by line.
47
+ */
48
+ exports.CLASS_HINTS = [
49
+ [["sql", "db", "database", "postgres", "mysql", "mongo", "sqlite"], "db"],
50
+ [["aws", "gcp", "azure", "cloud", "terraform", "s3"], "cloud"],
51
+ [["http", "https", "url", "webhook"], "http"],
52
+ [["shell", "exec", "cmd", "bash", "subprocess"], "shell"],
53
+ [["file", "path", "dir", "directory", "unlink", "chmod"], "filesystem"],
54
+ ];
55
+ exports.ACTION_SURFACE = {
56
+ execute_database_query: "db",
57
+ fetch_url: "http",
58
+ execute_shell: "shell",
59
+ send_message: "other",
60
+ write_file: "filesystem",
61
+ other: "other",
62
+ };
63
+ // Which argument is money: the developer tells us. Named `amount` (or `<prefix>_amount`) AND
64
+ // the same call carries a `currency` key holding a real value. A range bound (`min_amount`) is
65
+ // a read, not money moved. Both rules ported from the Python writer, which ported them from
66
+ // the gateway, so all three answer "which number is money" the same way.
67
+ const AMOUNT_KEY = "amount";
68
+ const CURRENCY_KEY = "currency";
69
+ const COUNT_KEY = "count";
70
+ const RANGE_BOUND_PREFIXES = new Set(["min", "max", "lower", "upper"]);
71
+ /** Bounds on the argument-name list, so one 400-parameter tool cannot bloat a row. */
72
+ exports.MAX_ARG_NAMES = 24;
73
+ exports.MAX_ARG_NAMES_CHARS = 512;
74
+ function normaliseKey(key) {
75
+ return String(key).trim().toLowerCase().replace(/-/g, "_").replace(/\./g, "_");
76
+ }
77
+ function isAmountKey(name) {
78
+ return name === AMOUNT_KEY || name.endsWith("_" + AMOUNT_KEY);
79
+ }
80
+ function isCurrencyKey(name) {
81
+ return name === CURRENCY_KEY || name.endsWith("_" + CURRENCY_KEY);
82
+ }
83
+ function isCountKey(name) {
84
+ return name === COUNT_KEY || name.endsWith("_" + COUNT_KEY);
85
+ }
86
+ /** `total_amount` -> `total`, `amount` -> ``. For the range-bound check. */
87
+ function amountPrefix(name) {
88
+ return name.slice(0, name.length - AMOUNT_KEY.length).replace(/_+$/, "");
89
+ }
90
+ /** A currency key must CARRY a currency: `null` and booleans are not one. */
91
+ function isRealCurrencyValue(value) {
92
+ if (value === null || value === undefined || typeof value === "boolean")
93
+ return false;
94
+ return String(value).trim().length > 0;
95
+ }
96
+ /**
97
+ * The bucket FLOOR for a numeric magnitude, as a power of ten. 5 -> 1, 42 -> 10, 1500 -> 1000,
98
+ * 0 -> 0. Anything non-numeric returns 0: a string is never coerced, because parsing one is
99
+ * how a value sneaks into a numeric column.
100
+ */
101
+ function magnitudeBucket(value) {
102
+ if (typeof value !== "number" || !Number.isFinite(value))
103
+ return 0;
104
+ const size = Math.abs(value);
105
+ if (size < 1)
106
+ return 0;
107
+ let bucket = 1;
108
+ while (bucket * 10 <= size) {
109
+ bucket *= 10;
110
+ if (bucket >= 1e15)
111
+ break;
112
+ }
113
+ return bucket;
114
+ }
115
+ /**
116
+ * Whole lowercase tokens from identifier-ish text. camelCase is a boundary too (`sendHttpRequest`
117
+ * is three tokens), and the match below is on WHOLE tokens, never substrings: `send_feedback`
118
+ * must not classify as DB because "fee(db)ack" contains "db".
119
+ */
120
+ function nameTokens(raw) {
121
+ const spaced = String(raw ?? "").replace(/(?<=[a-z0-9])(?=[A-Z])/g, " ");
122
+ return new Set(spaced.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
123
+ }
124
+ function classifyTokens(tokens) {
125
+ for (const [needles, klass] of exports.CLASS_HINTS) {
126
+ if (needles.some((n) => tokens.has(n)))
127
+ return klass;
128
+ }
129
+ return exports.CLASS_OTHER;
130
+ }
131
+ /** The bounded target class from the tool name and argument NAMES. Reads no values. */
132
+ function classifyTarget(toolName, names) {
133
+ return classifyTokens(nameTokens([toolName, ...names].join(" ")));
134
+ }
135
+ /** The same closed set, from a tool's own advertised description. Display-only, by rule. */
136
+ function classifyText(text) {
137
+ return classifyTokens(nameTokens(text));
138
+ }
139
+ /**
140
+ * Derive the shape of one call. Pure, never throws.
141
+ *
142
+ * The caller's own words decide first (tool and argument names). The `action` label and then
143
+ * the description are consulted only when those leave the class blank: they can fill a `-`,
144
+ * never override what the developer actually called.
145
+ */
146
+ function callShape(toolName, args, description, action) {
147
+ const record = args && typeof args === "object" && !Array.isArray(args) ? args : {};
148
+ // The comma is the record separator on the Python side and in the screens, so a key carrying
149
+ // one would split into two names the tool never had. Stripped, not escaped.
150
+ const names = Object.keys(record).map((k) => k.replace(/,/g, " ")).sort();
151
+ // Budget the LIST, then join. `continue`, not `break`: names arrive sorted, and one oversized
152
+ // name must not discard every name after it.
153
+ const kept = [];
154
+ let used = 0;
155
+ for (const name of names.slice(0, exports.MAX_ARG_NAMES)) {
156
+ const cost = name.length + (kept.length ? 1 : 0);
157
+ if (used + cost > exports.MAX_ARG_NAMES_CHARS)
158
+ continue;
159
+ kept.push(name);
160
+ used += cost;
161
+ }
162
+ let amount = 0;
163
+ try {
164
+ const entries = Object.entries(record);
165
+ const hasCurrency = entries.some(([k, v]) => isCurrencyKey(normaliseKey(k)) && isRealCurrencyValue(v));
166
+ if (hasCurrency) {
167
+ for (const [k, v] of entries) {
168
+ const norm = normaliseKey(k);
169
+ if (!isAmountKey(norm) || RANGE_BOUND_PREFIXES.has(amountPrefix(norm)))
170
+ continue;
171
+ amount = Math.max(amount, magnitudeBucket(v));
172
+ }
173
+ }
174
+ }
175
+ catch {
176
+ amount = 0;
177
+ }
178
+ let quantity = 0;
179
+ try {
180
+ for (const [k, v] of Object.entries(record)) {
181
+ if (!isCountKey(normaliseKey(k)))
182
+ continue;
183
+ quantity = Math.max(quantity, magnitudeBucket(v));
184
+ }
185
+ }
186
+ catch {
187
+ quantity = 0;
188
+ }
189
+ let targetClass = classifyTarget(toolName, names);
190
+ if (targetClass === exports.CLASS_OTHER && action && action in exports.ACTION_SURFACE) {
191
+ targetClass = exports.ACTION_SURFACE[action];
192
+ }
193
+ if (targetClass === exports.CLASS_OTHER && description) {
194
+ targetClass = classifyText(description);
195
+ }
196
+ return { argNames: kept, amount, quantity, targetClass };
197
+ }