@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/LICENSE +23 -0
- package/README.md +139 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +99 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/dist/ledger.d.ts +46 -0
- package/dist/ledger.js +183 -0
- package/dist/pulse.d.ts +102 -0
- package/dist/pulse.js +401 -0
- package/dist/report.d.ts +76 -0
- package/dist/report.js +359 -0
- package/dist/shape.d.ts +79 -0
- package/dist/shape.js +197 -0
- package/dist/wrap.d.ts +52 -0
- package/dist/wrap.js +200 -0
- package/package.json +47 -0
package/dist/pulse.js
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.ALLOWED_SESSION_KEYS = exports.ALLOWED_KEYS = exports.TIMEOUT_MS = exports.DEBOUNCE_MS = exports.DEFAULT_ENDPOINT = exports.INTEGRATION = void 0;
|
|
37
|
+
exports.stateFile = stateFile;
|
|
38
|
+
exports.internalMarker = internalMarker;
|
|
39
|
+
exports.loadState = loadState;
|
|
40
|
+
exports.saveState = saveState;
|
|
41
|
+
exports.telemetryEnabled = telemetryEnabled;
|
|
42
|
+
exports.isDefaultOn = isDefaultOn;
|
|
43
|
+
exports.isAutomationContext = isAutomationContext;
|
|
44
|
+
exports.mode = mode;
|
|
45
|
+
exports.endpoint = endpoint;
|
|
46
|
+
exports.sdkVersion = sdkVersion;
|
|
47
|
+
exports.osName = osName;
|
|
48
|
+
exports.buildPayload = buildPayload;
|
|
49
|
+
exports.shouldSend = shouldSend;
|
|
50
|
+
exports.post = post;
|
|
51
|
+
exports.maybeSend = maybeSend;
|
|
52
|
+
exports.showNotice = showNotice;
|
|
53
|
+
exports.onSessionEnd = onSessionEnd;
|
|
54
|
+
exports.queuePending = queuePending;
|
|
55
|
+
exports.markAuditReportRun = markAuditReportRun;
|
|
56
|
+
/**
|
|
57
|
+
* Anonymous usage pulse. ON by default, one-line opt-out (`AGENTX_TELEMETRY=off`).
|
|
58
|
+
*
|
|
59
|
+
* The same pulse the Python SDK sends, to the same receiver (`/api/pulse` on the control
|
|
60
|
+
* plane), with the same field list. `integration` is `"ts"`, which is how the funnel tells
|
|
61
|
+
* this package's installs from the Python decorator's and the MCP proxy's.
|
|
62
|
+
*
|
|
63
|
+
* Hard rules, asserted by test/pulse.test.ts and pinned against the Python allowlist by
|
|
64
|
+
* sdk_tests/test_ts_sdk_matches_python.py:
|
|
65
|
+
* * Never sends arguments, tool names, paths, hostnames, usernames or keys. COUNTS only.
|
|
66
|
+
* * Only the keys in ALLOWED_KEYS / ALLOWED_SESSION_KEYS ever leave, and exactly those.
|
|
67
|
+
* * Fire-and-forget: never throws, bounded by a 1s timeout, fails open offline.
|
|
68
|
+
* * Test and CI runs are excluded entirely, even when on: automation is not adoption.
|
|
69
|
+
*
|
|
70
|
+
* One difference from the Python pulse, stated so the two doors' columns are read right: the
|
|
71
|
+
* session counts here are the SUM of every session since the last pulse (a run that ends
|
|
72
|
+
* through process.exit() queues its counts; a run inside the day's debounce keeps them
|
|
73
|
+
* queued), so one pulse can carry a day's worth of sessions. The Python SDK sends one
|
|
74
|
+
* session's counts and drops a debounced session's.
|
|
75
|
+
*
|
|
76
|
+
* THIS IS THE ONLY FILE IN THE PACKAGE THAT REACHES THE NETWORK, and test/network-boundary.test.ts
|
|
77
|
+
* fails if a second one appears. `@agentx-core/scan` never phones home at all; this package
|
|
78
|
+
* does, once a day, with counts. Two packages, two promises, and each README says which.
|
|
79
|
+
*/
|
|
80
|
+
const fs = __importStar(require("fs"));
|
|
81
|
+
const os = __importStar(require("os"));
|
|
82
|
+
const path = __importStar(require("path"));
|
|
83
|
+
const crypto_1 = require("crypto");
|
|
84
|
+
exports.INTEGRATION = "ts";
|
|
85
|
+
// The receiver: the control plane's POST /api/pulse. MUST be the canonical www host: the
|
|
86
|
+
// bare apex answers a 307 to www, and a POST that is not re-sent on redirect is a pulse
|
|
87
|
+
// silently dropped.
|
|
88
|
+
exports.DEFAULT_ENDPOINT = "https://www.agentx-core.com/api/pulse";
|
|
89
|
+
exports.DEBOUNCE_MS = 24 * 60 * 60 * 1000; // at most one pulse per install per day
|
|
90
|
+
exports.TIMEOUT_MS = 1000;
|
|
91
|
+
/** The complete set of top-level keys that may leave the machine. KEEP IN SYNC with agentx_sdk/pulse.py _ALLOWED_KEYS. */
|
|
92
|
+
exports.ALLOWED_KEYS = [
|
|
93
|
+
"install_id", "sdk_version", "python", "os", "first_seen", "ts",
|
|
94
|
+
"mode", "gateway_present", "reasoning_enabled", "contributed",
|
|
95
|
+
"block_category", "integration", "session", "ran_audit_report",
|
|
96
|
+
];
|
|
97
|
+
/** The session COUNT keys. KEEP IN SYNC with agentx_sdk/pulse.py _ALLOWED_SESSION_KEYS. */
|
|
98
|
+
exports.ALLOWED_SESSION_KEYS = [
|
|
99
|
+
"tools_monitored", "intercepts", "critical_blocks",
|
|
100
|
+
"human_escalations", "self_corrections", "would_blocks",
|
|
101
|
+
"had_block", "first_block_ever", "shield_failopens",
|
|
102
|
+
"own_agent_block", "audit_calls", "audit_tools",
|
|
103
|
+
];
|
|
104
|
+
// A separate file from the Python SDK's pulse.json: two SDKs on one machine are two installs,
|
|
105
|
+
// and sharing a file would couple this writer to that one's atomic-write format.
|
|
106
|
+
function stateFile() {
|
|
107
|
+
return path.join(os.homedir(), ".agentx", "ts-pulse.json");
|
|
108
|
+
}
|
|
109
|
+
/** The machine-level "this machine is ours" marker the Python SDK writes. READ here, never written. */
|
|
110
|
+
function internalMarker() {
|
|
111
|
+
return path.join(os.homedir(), ".agentx", "internal");
|
|
112
|
+
}
|
|
113
|
+
function loadState(file = stateFile()) {
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
116
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function saveState(state, file = stateFile()) {
|
|
123
|
+
try {
|
|
124
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
125
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
126
|
+
fs.writeFileSync(tmp, JSON.stringify(state), "utf8");
|
|
127
|
+
fs.renameSync(tmp, file);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* telemetry must never break a run */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function ensureIdentity(state) {
|
|
134
|
+
if (!state.install_id)
|
|
135
|
+
state.install_id = (0, crypto_1.randomUUID)();
|
|
136
|
+
if (!state.first_seen)
|
|
137
|
+
state.first_seen = new Date().toISOString().slice(0, 10);
|
|
138
|
+
return state;
|
|
139
|
+
}
|
|
140
|
+
function truthy(v) {
|
|
141
|
+
return ["1", "true", "on", "yes"].includes(String(v ?? "").trim().toLowerCase());
|
|
142
|
+
}
|
|
143
|
+
/** True unless the developer explicitly opted out. `AGENTX_TELEMETRY` always wins when present. */
|
|
144
|
+
function telemetryEnabled(env = process.env) {
|
|
145
|
+
const explicit = env.AGENTX_TELEMETRY;
|
|
146
|
+
if (explicit !== undefined && explicit !== "")
|
|
147
|
+
return truthy(explicit);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
/** True when telemetry is on purely by default, i.e. nobody set AGENTX_TELEMETRY. Only these installs get the notice. */
|
|
151
|
+
function isDefaultOn(env = process.env) {
|
|
152
|
+
return env.AGENTX_TELEMETRY === undefined || env.AGENTX_TELEMETRY === "";
|
|
153
|
+
}
|
|
154
|
+
const CI_ENV_VARS = [
|
|
155
|
+
"CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL",
|
|
156
|
+
"CIRCLECI", "TRAVIS", "BUILDKITE", "TF_BUILD", "TEAMCITY_VERSION",
|
|
157
|
+
"BITBUCKET_BUILD_NUMBER", "APPVEYOR", "DRONE", "CODEBUILD_BUILD_ID",
|
|
158
|
+
];
|
|
159
|
+
// Test runners set these on every worker. A developer running THEIR suite over an agent that
|
|
160
|
+
// imports us is exactly the run that must not count as adoption.
|
|
161
|
+
const TEST_RUNNER_VARS = ["VITEST", "JEST_WORKER_ID", "NODE_TEST_CONTEXT"];
|
|
162
|
+
/**
|
|
163
|
+
* True when this run is a test/CI invocation, an explicitly flagged dev environment
|
|
164
|
+
* (`AGENTX_ENV=development|dev|test`), or a machine the Python SDK has marked as ours. Such
|
|
165
|
+
* runs send nothing and climb no rung. Never throws.
|
|
166
|
+
*
|
|
167
|
+
* Unlike the Python side, this never WRITES the machine marker: a wrong write deletes a real
|
|
168
|
+
* user from the funnel permanently, and the Python SDK already owns that decision.
|
|
169
|
+
*/
|
|
170
|
+
function isAutomationContext(env = process.env, markerExists = () => fs.existsSync(internalMarker())) {
|
|
171
|
+
try {
|
|
172
|
+
if (["development", "dev", "test"].includes(String(env.AGENTX_ENV ?? "").trim().toLowerCase()))
|
|
173
|
+
return true;
|
|
174
|
+
if (CI_ENV_VARS.some((v) => env[v]))
|
|
175
|
+
return true;
|
|
176
|
+
if (TEST_RUNNER_VARS.some((v) => env[v]))
|
|
177
|
+
return true;
|
|
178
|
+
return markerExists();
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** The coarse data-plane mode, resolved the way the Python SDK resolves it. */
|
|
185
|
+
function mode(env = process.env) {
|
|
186
|
+
const m = String(env.AGENTX_MODE ?? "").trim().toLowerCase();
|
|
187
|
+
if (m === "local" || m === "linked" || m === "cloud")
|
|
188
|
+
return m;
|
|
189
|
+
if (truthy(env.AGENTX_ALLOW_PAYLOAD_SYNC))
|
|
190
|
+
return "cloud";
|
|
191
|
+
return String(env.CONTROL_PLANE_URL ?? "").trim() ? "linked" : "local";
|
|
192
|
+
}
|
|
193
|
+
function isHttp(url) {
|
|
194
|
+
return /^https?:\/\//i.test(url);
|
|
195
|
+
}
|
|
196
|
+
/** Where the pulse goes: the configured control plane's /api/pulse, else the public default. */
|
|
197
|
+
function endpoint(env = process.env) {
|
|
198
|
+
const plane = String(env.CONTROL_PLANE_URL ?? "").trim();
|
|
199
|
+
if (isHttp(plane)) {
|
|
200
|
+
const base = plane.replace(/\/+$/, "");
|
|
201
|
+
return base.endsWith("/api/pulse") ? base : base + "/api/pulse";
|
|
202
|
+
}
|
|
203
|
+
return exports.DEFAULT_ENDPOINT;
|
|
204
|
+
}
|
|
205
|
+
/** This build's version, from package.json. "unknown" rather than a guess. */
|
|
206
|
+
function sdkVersion() {
|
|
207
|
+
try {
|
|
208
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
|
|
209
|
+
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return "unknown";
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** The Python SDK sends `platform.system().lower()`; map Node's names onto the same words. */
|
|
216
|
+
function osName(platform = process.platform) {
|
|
217
|
+
if (platform === "win32")
|
|
218
|
+
return "windows";
|
|
219
|
+
return platform || "unknown";
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Assemble the pulse. ALLOWLIST ONLY: it reads two counters off `stats` and never a name.
|
|
223
|
+
* Every key in ALLOWED_KEYS is present, so the receiver sees one shape from every SDK; the
|
|
224
|
+
* fields this door has no way to observe are null or false, never omitted.
|
|
225
|
+
*/
|
|
226
|
+
function buildPayload(stats, state, opts = {}) {
|
|
227
|
+
const env = opts.env ?? process.env;
|
|
228
|
+
const n = (v) => (typeof v === "number" && Number.isFinite(v) ? Math.max(0, Math.trunc(v)) : 0);
|
|
229
|
+
return {
|
|
230
|
+
install_id: state.install_id,
|
|
231
|
+
// The bare package version. The receiver stores every SDK's version in one column beside
|
|
232
|
+
// `integration`, and scripts/funnel.py keys its version bar on the pair, so "0.1.0" here
|
|
233
|
+
// and "0.1.0" from the Python SDK never share a bucket.
|
|
234
|
+
sdk_version: opts.version ?? sdkVersion(),
|
|
235
|
+
// A Node runtime has no Python version. Null, not a Node version in a column named
|
|
236
|
+
// python: the receiver's column carries a meaning and this door cannot supply it.
|
|
237
|
+
python: null,
|
|
238
|
+
os: osName(opts.platform),
|
|
239
|
+
first_seen: state.first_seen,
|
|
240
|
+
ts: (opts.now ?? new Date()).toISOString(),
|
|
241
|
+
mode: mode(env),
|
|
242
|
+
// No gateway leg on this door, so these are the honest constants. Not omitted: absent
|
|
243
|
+
// reads as "pre-signal" on the receiver, and this install is not pre-anything.
|
|
244
|
+
gateway_present: false,
|
|
245
|
+
reasoning_enabled: null,
|
|
246
|
+
contributed: false,
|
|
247
|
+
block_category: null,
|
|
248
|
+
integration: exports.INTEGRATION,
|
|
249
|
+
session: {
|
|
250
|
+
tools_monitored: n(stats.totalCalls),
|
|
251
|
+
intercepts: 0,
|
|
252
|
+
critical_blocks: 0,
|
|
253
|
+
human_escalations: 0,
|
|
254
|
+
self_corrections: 0,
|
|
255
|
+
would_blocks: 0,
|
|
256
|
+
had_block: false,
|
|
257
|
+
first_block_ever: false,
|
|
258
|
+
shield_failopens: 0,
|
|
259
|
+
own_agent_block: false,
|
|
260
|
+
// The audit rung, same meaning as the Python counters: calls the record saw and
|
|
261
|
+
// DISTINCT tools it saw. Counts; the names never leave.
|
|
262
|
+
audit_calls: n(stats.totalCalls),
|
|
263
|
+
audit_tools: n(stats.distinctTools),
|
|
264
|
+
},
|
|
265
|
+
ran_audit_report: Boolean(state.ran_audit_report),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/** Pure debounce decision. */
|
|
269
|
+
function shouldSend(state, nowMs) {
|
|
270
|
+
const last = state.last_pulse ?? 0;
|
|
271
|
+
return nowMs - last >= exports.DEBOUNCE_MS;
|
|
272
|
+
}
|
|
273
|
+
/** Fire the pulse. Swallows every error and is bounded by TIMEOUT_MS. */
|
|
274
|
+
async function post(url, payload, timeoutMs = exports.TIMEOUT_MS) {
|
|
275
|
+
const controller = new AbortController();
|
|
276
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
277
|
+
try {
|
|
278
|
+
await fetch(url, {
|
|
279
|
+
method: "POST",
|
|
280
|
+
headers: {
|
|
281
|
+
"Content-Type": "application/json",
|
|
282
|
+
"User-Agent": `agentx-ts-sdk-pulse/${String(payload.sdk_version ?? "?")}`,
|
|
283
|
+
},
|
|
284
|
+
body: JSON.stringify(payload),
|
|
285
|
+
signal: controller.signal,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
/* offline, blocked, timed out: all fine */
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
clearTimeout(timer);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Send one pulse if telemetry is on and the day's window is open. Persists BEFORE dispatch so
|
|
297
|
+
* a crash mid-send cannot double-count tomorrow. Returns the payload sent, or null.
|
|
298
|
+
*/
|
|
299
|
+
async function maybeSend(stats, state, file = stateFile(), opts = {}) {
|
|
300
|
+
try {
|
|
301
|
+
const env = opts.env ?? process.env;
|
|
302
|
+
if (!telemetryEnabled(env))
|
|
303
|
+
return null;
|
|
304
|
+
ensureIdentity(state);
|
|
305
|
+
const now = opts.now ?? Date.now();
|
|
306
|
+
if (!shouldSend(state, now)) {
|
|
307
|
+
saveState(state, file); // persist identity even when we skip
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
state.last_pulse = now;
|
|
311
|
+
// A send is being attempted, so the queued counts are spent here whether or not the
|
|
312
|
+
// network answers: post() swallows failure and the day's debounce stands either way.
|
|
313
|
+
delete state.pending;
|
|
314
|
+
saveState(state, file);
|
|
315
|
+
const payload = buildPayload(stats, state, { now: new Date(now), env });
|
|
316
|
+
await post(opts.url ?? endpoint(env), payload);
|
|
317
|
+
return payload;
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/** The one-time transparency notice, printed BEFORE the first pulse leaves. To stderr: this runs inside someone else's program. */
|
|
324
|
+
function showNotice(state, file = stateFile(), write = (s) => process.stderr.write(s)) {
|
|
325
|
+
try {
|
|
326
|
+
const rule = "─".repeat(60);
|
|
327
|
+
write(`\n${rule}\n` +
|
|
328
|
+
" AgentX shares ANONYMOUS usage by default: SDK version, OS, and call\n" +
|
|
329
|
+
" COUNTS only. Never your code, your arguments, or your data.\n" +
|
|
330
|
+
" Opt out anytime: AGENTX_TELEMETRY=off\n" +
|
|
331
|
+
" Questions or feedback: founders@agentx-core.com\n" +
|
|
332
|
+
`${rule}\n`);
|
|
333
|
+
state.notice_shown = true;
|
|
334
|
+
ensureIdentity(state);
|
|
335
|
+
saveState(state, file);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
/* never break a run */
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function sum(a, b) {
|
|
342
|
+
if (!a)
|
|
343
|
+
return { ...b };
|
|
344
|
+
return { totalCalls: a.totalCalls + b.totalCalls, distinctTools: Math.max(a.distinctTools, b.distinctTools) };
|
|
345
|
+
}
|
|
346
|
+
async function onSessionEnd(stats, file = stateFile(), deps = {}) {
|
|
347
|
+
try {
|
|
348
|
+
const env = deps.env ?? process.env;
|
|
349
|
+
if (!telemetryEnabled(env))
|
|
350
|
+
return;
|
|
351
|
+
if (isAutomationContext(env, deps.markerExists))
|
|
352
|
+
return;
|
|
353
|
+
const state = loadState(file);
|
|
354
|
+
// Everything not yet carried by a pulse is `pending`, this session included. maybeSend
|
|
355
|
+
// clears it on its send path only, right before its own write, so each path writes the
|
|
356
|
+
// state file once and the file never holds a version with the counts missing.
|
|
357
|
+
state.pending = sum(state.pending, stats);
|
|
358
|
+
if (isDefaultOn(env) && !state.notice_shown)
|
|
359
|
+
showNotice(state, file, deps.notice);
|
|
360
|
+
await maybeSend(state.pending, state, file, { env, url: deps.url, now: deps.now });
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
/* never break a run */
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* The synchronous fallback for `process.on("exit")`, where nothing async can run: write the
|
|
368
|
+
* counts down so the NEXT run sends them. Never throws.
|
|
369
|
+
*/
|
|
370
|
+
function queuePending(stats, file = stateFile(), deps = {}) {
|
|
371
|
+
try {
|
|
372
|
+
const env = deps.env ?? process.env;
|
|
373
|
+
if (!telemetryEnabled(env))
|
|
374
|
+
return;
|
|
375
|
+
if (isAutomationContext(env, deps.markerExists))
|
|
376
|
+
return;
|
|
377
|
+
const state = loadState(file);
|
|
378
|
+
state.pending = sum(state.pending, stats);
|
|
379
|
+
saveState(state, file);
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
/* never break a run */
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* A human ran `audit`. STICKY, and excluded from automation for the same reason the Python
|
|
387
|
+
* side is: this is the conversion event the funnel is built around and it cannot be
|
|
388
|
+
* un-climbed, so one CI job must not mark an install converted forever.
|
|
389
|
+
*/
|
|
390
|
+
function markAuditReportRun(file = stateFile(), env = process.env) {
|
|
391
|
+
try {
|
|
392
|
+
if (isAutomationContext(env))
|
|
393
|
+
return;
|
|
394
|
+
const state = ensureIdentity(loadState(file));
|
|
395
|
+
state.ran_audit_report = true;
|
|
396
|
+
saveState(state, file);
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
/* a reader command must never fail on bookkeeping */
|
|
400
|
+
}
|
|
401
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `audit` screens. The design is `agentx audit` in agentx_sdk/cli.py, copied rather than
|
|
3
|
+
* reinvented: the same header, the same TOOL / CALLS / SURFACE / ARGUMENTS table, the same
|
|
4
|
+
* "what was KEPT" disclosure when retention has trimmed the file, and the same rule about
|
|
5
|
+
* naming the ledger path on an empty screen (the likeliest reason a screen is empty is the
|
|
6
|
+
* reader standing in a different folder).
|
|
7
|
+
*
|
|
8
|
+
* Every line keeps to 75 columns, with one documented exception carried over from the Python
|
|
9
|
+
* screen: a single argument name longer than the ARGUMENTS budget is kept whole up to
|
|
10
|
+
* MAX_WHOLE_NAME (40) characters, because a reader greps for a name and cannot grep half of
|
|
11
|
+
* one. So a --calls row can reach 48 + 40 columns plus a " +N more" suffix (96 with one name
|
|
12
|
+
* dropped), and a grouped row 46 + 40 plus the same suffix.
|
|
13
|
+
*/
|
|
14
|
+
import { type TargetClass } from "./shape";
|
|
15
|
+
import { type CallRow, type LedgerRead } from "./ledger";
|
|
16
|
+
/** The wire-format version of `--json`. Its own namespace: this package versions independently of the Python `agentx.audit/1`. */
|
|
17
|
+
export declare const AUDIT_SCHEMA = "agentx.ts-audit/1";
|
|
18
|
+
/**
|
|
19
|
+
* The call to action, with its OWN tag. The two TypeScript calls to action we can already
|
|
20
|
+
* measure (scan's, and the /docs handoff) both read about one click each, so this one carries
|
|
21
|
+
* `utm_source=ts-sdk` rather than borrowing either, and the /gateway view beacon can finally
|
|
22
|
+
* say which of the three, if any, moves anyone.
|
|
23
|
+
*/
|
|
24
|
+
export declare const GATEWAY_URL = "https://agentx-core.com/gateway?utm_source=ts-sdk";
|
|
25
|
+
export declare const PACKAGE = "@agentx-core/security-sdk";
|
|
26
|
+
export declare const AUDIT_COMMAND = "npx @agentx-core/security-sdk audit";
|
|
27
|
+
export declare const DOCS_URL = "https://agentx-core.com/docs";
|
|
28
|
+
/** The one snippet every empty screen shows. Written once so two screens cannot teach two spellings. */
|
|
29
|
+
export declare const WRAP_SNIPPET: readonly string[];
|
|
30
|
+
export interface ViewOptions {
|
|
31
|
+
/** Explicit --limit. */
|
|
32
|
+
limit?: number;
|
|
33
|
+
/** --all. */
|
|
34
|
+
all?: boolean;
|
|
35
|
+
}
|
|
36
|
+
export declare function plural(n: number, singular: string, pluralForm?: string): string;
|
|
37
|
+
/** Clip with a visible ellipsis, never silently: a truncated name that still looks like a name is worse. */
|
|
38
|
+
export declare function fit(text: string, width: number): string;
|
|
39
|
+
/**
|
|
40
|
+
* Join argument names to fit `width`, dropping WHOLE names rather than cutting one, and saying
|
|
41
|
+
* "+N more" for what was dropped. A half name matches nothing when the reader greps for it.
|
|
42
|
+
* Port of cli.py `_fit_names`, including its one deliberate overrun: a single name longer than
|
|
43
|
+
* the budget is kept whole up to MAX_WHOLE_NAME.
|
|
44
|
+
*/
|
|
45
|
+
export declare function fitNames(namesIn: readonly unknown[], width: number): string;
|
|
46
|
+
/** A magnitude BUCKET rendered as the ">= floor" it means, never as a figure the ledger does not hold. */
|
|
47
|
+
export declare function formatBucket(amount: number): string;
|
|
48
|
+
/** " since <local date time>" for a window, or "". */
|
|
49
|
+
export declare function sincePhrase(windowStart: number | null): string;
|
|
50
|
+
export interface ToolSummary {
|
|
51
|
+
tool: string;
|
|
52
|
+
calls: number;
|
|
53
|
+
classes: TargetClass[];
|
|
54
|
+
argNames: string[];
|
|
55
|
+
maxAmount: number;
|
|
56
|
+
maxQuantity: number;
|
|
57
|
+
agents: string[];
|
|
58
|
+
firstTs: number;
|
|
59
|
+
lastTs: number;
|
|
60
|
+
}
|
|
61
|
+
/** Group the rows by tool, busiest first. */
|
|
62
|
+
export declare function summariseTools(rows: readonly CallRow[]): ToolSummary[];
|
|
63
|
+
/** The grouped screen. Returns the text; the caller prints it. */
|
|
64
|
+
export declare function renderTools(read: LedgerRead, opts?: ViewOptions): string;
|
|
65
|
+
/** The `--calls` screen: one line per call, newest first. The ORDER is the point. */
|
|
66
|
+
export declare function renderCalls(read: LedgerRead, opts?: ViewOptions): string;
|
|
67
|
+
/**
|
|
68
|
+
* The `--json` document. ALWAYS COMPLETE unless the caller passed `--limit`: a program reading
|
|
69
|
+
* a silently truncated list would report a smaller agent than the one that ran. Carries a
|
|
70
|
+
* `schema` and a `view` so a file read in isolation says which flag produced it, and a
|
|
71
|
+
* `produced_by` block so a reader who has never heard of us knows what made it, which build,
|
|
72
|
+
* and where to look. Field names follow the Python `agentx audit --json` wherever the two
|
|
73
|
+
* describe the same fact (`ledger.*`, `time`, `status_label`, `arg_names`, `amount_bucket`,
|
|
74
|
+
* `surface`), so one consumer can read both files.
|
|
75
|
+
*/
|
|
76
|
+
export declare function jsonPayload(read: LedgerRead, view: "tools" | "calls", opts?: ViewOptions): Record<string, unknown>;
|