@agentx-core/security-sdk 0.1.0 → 0.2.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/README.md +70 -3
- package/dist/cli.js +69 -4
- package/dist/ledger.d.ts +25 -0
- package/dist/ledger.js +52 -5
- package/dist/novelty.d.ts +138 -0
- package/dist/novelty.js +581 -0
- package/dist/report.d.ts +8 -2
- package/dist/report.js +50 -2
- package/dist/wrap.js +115 -1
- package/package.json +1 -1
package/dist/report.js
CHANGED
|
@@ -178,6 +178,35 @@ function trimmedNote(read, indent = " ") {
|
|
|
178
178
|
"",
|
|
179
179
|
];
|
|
180
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* The denominator: how many tools were WRAPPED, against how many actually ran.
|
|
183
|
+
*
|
|
184
|
+
* Silent on a ledger with no roster row, which is every ledger written before this shipped and
|
|
185
|
+
* every one written by an older version of the package. An absent roster means we do not know
|
|
186
|
+
* the total, and printing "0 never ran" from that would be a claim, not a gap.
|
|
187
|
+
*
|
|
188
|
+
* 🔴 IT COUNTS AGAINST THE ROSTER, AND CANNOT SEE THE SCREEN. `--limit` shortens the table
|
|
189
|
+
* above; a remainder computed from the visible rows would report a smaller roster every time
|
|
190
|
+
* someone narrowed the view. This function is not passed the limit, or the shown rows, or any
|
|
191
|
+
* count derived from them, so that mistake has nowhere to enter. An earlier version took a
|
|
192
|
+
* `calledCount` fallback parameter: fault injection showed nothing could redden it, because
|
|
193
|
+
* `renderTools` returns the empty screen before reaching here, so the fallback was unreachable
|
|
194
|
+
* and the test guarding it was guarding a risk the code did not have.
|
|
195
|
+
*/
|
|
196
|
+
function rosterLine(read, indent = " ") {
|
|
197
|
+
if (!read.roster.length)
|
|
198
|
+
return [];
|
|
199
|
+
// 🔴 COUNT THE INTERSECTION, NOT THE LEDGER. `roster` is the newest run's; `rows` is every run
|
|
200
|
+
// this folder has kept. Counting all distinct tools in the ledger against a roster from one
|
|
201
|
+
// run produced a self-contradicting line: yesterday's script wrapped and ran A, B and C, today's
|
|
202
|
+
// wraps D and E and runs only D, and the screen said "2 tools wrapped. 4 called. 0 never ran."
|
|
203
|
+
// It also disagreed with `--json`, whose `never_ran` was already a set difference. Same basis
|
|
204
|
+
// for both now.
|
|
205
|
+
const ran = new Set(read.rows.map((r) => r.tool || "(unnamed)"));
|
|
206
|
+
const called = read.roster.filter((t) => ran.has(t)).length;
|
|
207
|
+
const never = read.roster.length - called;
|
|
208
|
+
return [` ${plural(read.roster.length, "tool")} wrapped. ${called} called. ${never} never ran.`.replace(/^ {2}/, indent)];
|
|
209
|
+
}
|
|
181
210
|
function footer() {
|
|
182
211
|
return [
|
|
183
212
|
" AgentX watches and writes down what your agent did, and blocks nothing,",
|
|
@@ -190,8 +219,14 @@ function footer() {
|
|
|
190
219
|
RULE,
|
|
191
220
|
];
|
|
192
221
|
}
|
|
193
|
-
/**
|
|
194
|
-
|
|
222
|
+
/**
|
|
223
|
+
* The grouped screen. Returns the text; the caller prints it.
|
|
224
|
+
*
|
|
225
|
+
* `novelty` is the already-rendered "what changed" block, passed IN rather than computed here.
|
|
226
|
+
* This module stays a pure formatter: reading the stored mark and moving it are side effects,
|
|
227
|
+
* and they belong with the caller that also decides whether a human is looking.
|
|
228
|
+
*/
|
|
229
|
+
function renderTools(read, opts = {}, novelty = []) {
|
|
195
230
|
const out = [
|
|
196
231
|
"",
|
|
197
232
|
"🔎 WHAT YOUR AGENT DID (local to this ledger)",
|
|
@@ -204,6 +239,7 @@ function renderTools(read, opts = {}) {
|
|
|
204
239
|
const limit = effectiveLimit(opts, DEFAULT_TOOL_ROWS);
|
|
205
240
|
const shown = limit === null ? tools : tools.slice(0, limit);
|
|
206
241
|
out.push(` ${plural(read.rows.length, "call")} across ${plural(tools.length, "tool")}${sincePhrase(read.windowStart)}`);
|
|
242
|
+
out.push(...rosterLine(read));
|
|
207
243
|
if (shown.length < tools.length) {
|
|
208
244
|
out.push(` (showing the busiest ${shown.length}; ${tools.length - shown.length} more not listed -- use --all)`);
|
|
209
245
|
}
|
|
@@ -236,6 +272,11 @@ function renderTools(read, opts = {}) {
|
|
|
236
272
|
out.push(" We go by tool and argument names, and only match words we are sure of.");
|
|
237
273
|
out.push("");
|
|
238
274
|
}
|
|
275
|
+
// After the table, before the footer: the table is what the agent did, this is what is
|
|
276
|
+
// different about it, and the footer is what to do next. An empty block prints nothing at
|
|
277
|
+
// all, so a quiet run does not grow a bare header over a blank space.
|
|
278
|
+
if (novelty.length)
|
|
279
|
+
out.push(...novelty, "");
|
|
239
280
|
out.push(...footer());
|
|
240
281
|
return out.join("\n");
|
|
241
282
|
}
|
|
@@ -326,6 +367,13 @@ function jsonPayload(read, view, opts = {}) {
|
|
|
326
367
|
if (view === "tools") {
|
|
327
368
|
const shown = limit === null ? tools : tools.slice(0, limit);
|
|
328
369
|
base.totals = { calls: read.rows.length, tools: tools.length, shown: shown.length, listed_total: tools.length };
|
|
370
|
+
// The roster, as a first-class field and NULL rather than [] when the ledger has none. A
|
|
371
|
+
// consumer must be able to tell "this run wrapped nothing" from "this ledger predates the
|
|
372
|
+
// roster and cannot answer", which an empty array collapses into one.
|
|
373
|
+
base.roster = read.roster.length ? read.roster : null;
|
|
374
|
+
base.never_ran = read.roster.length
|
|
375
|
+
? read.roster.filter((t) => !tools.some((s) => s.tool === t))
|
|
376
|
+
: null;
|
|
329
377
|
base.tools = shown.map((s) => ({
|
|
330
378
|
tool: s.tool,
|
|
331
379
|
calls: s.calls,
|
package/dist/wrap.js
CHANGED
|
@@ -54,13 +54,19 @@ exports.agentxWatchAll = agentxWatchAll;
|
|
|
54
54
|
*/
|
|
55
55
|
const shape_1 = require("./shape");
|
|
56
56
|
const ledger_1 = require("./ledger");
|
|
57
|
+
const novelty_1 = require("./novelty");
|
|
57
58
|
const pulse = __importStar(require("./pulse"));
|
|
58
59
|
const report_1 = require("./report");
|
|
59
60
|
const session = {
|
|
60
61
|
totalCalls: 0,
|
|
61
62
|
toolNames: new Set(),
|
|
63
|
+
/** Every tool WRAPPED this process, called or not. The roster's whole source. */
|
|
64
|
+
wrapped: new Set(),
|
|
65
|
+
/** Groups this run's roster rows together. See RosterRow in ledger.ts. */
|
|
66
|
+
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
|
|
62
67
|
hooked: false,
|
|
63
68
|
reported: false,
|
|
69
|
+
rosterWritten: false,
|
|
64
70
|
ending: false,
|
|
65
71
|
ledgerNoticed: false,
|
|
66
72
|
};
|
|
@@ -68,10 +74,28 @@ const session = {
|
|
|
68
74
|
function __resetSessionForTests() {
|
|
69
75
|
session.totalCalls = 0;
|
|
70
76
|
session.toolNames.clear();
|
|
77
|
+
session.wrapped.clear();
|
|
78
|
+
session.id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
71
79
|
session.reported = false;
|
|
80
|
+
session.rosterWritten = false;
|
|
72
81
|
session.ending = false;
|
|
73
82
|
session.ledgerNoticed = false;
|
|
74
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Write down what this run WRAPPED, so the screen has a denominator: 23 wrapped, 4 called, 19
|
|
86
|
+
* that never ran. The set is already in hand at wrap time and was previously thrown away.
|
|
87
|
+
*
|
|
88
|
+
* ⚠️ ONLY WHEN A CALL WAS RECORDED, which is a deliberate limit and not an oversight.
|
|
89
|
+
* `appendRoster` refuses to create the ledger, so a process that wraps tools and calls none
|
|
90
|
+
* leaves no file behind, exactly as before this shipped. The denominator is only meaningful
|
|
91
|
+
* beside a numerator, and `audit` already has an honest empty screen for the other case.
|
|
92
|
+
*/
|
|
93
|
+
function writeRoster() {
|
|
94
|
+
if (session.rosterWritten || !session.wrapped.size || session.totalCalls === 0)
|
|
95
|
+
return;
|
|
96
|
+
session.rosterWritten = true;
|
|
97
|
+
(0, ledger_1.appendRoster)({ k: "roster", ts: Date.now(), session: session.id, tools: [...session.wrapped] });
|
|
98
|
+
}
|
|
75
99
|
/** TEST ONLY. The counters as the pulse will see them. */
|
|
76
100
|
function __sessionStatsForTests() {
|
|
77
101
|
return stats();
|
|
@@ -101,19 +125,59 @@ function printReport() {
|
|
|
101
125
|
stderr(` AgentX watched ${(0, report_1.plural)(session.totalCalls, "tool call")} across ${(0, report_1.plural)(session.toolNames.size, "tool")} this run.`);
|
|
102
126
|
stderr(" Watching records and blocks nothing. What your agent did:");
|
|
103
127
|
stderr(` ${report_1.AUDIT_COMMAND}`);
|
|
128
|
+
printSessionNovelty();
|
|
104
129
|
stderr(rule);
|
|
105
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* ONE LINE saying how much is new, and NOT the block itself.
|
|
133
|
+
*
|
|
134
|
+
* 🔴 IT USED TO PRINT THE WHOLE BLOCK, AND A FOUNDER WALK IS WHAT KILLED THAT. The common path
|
|
135
|
+
* is run the agent, then read the report. On that path the exit line printed four novelty lines
|
|
136
|
+
* and `audit` printed the same four again, immediately below. Correct by design and tiresome to
|
|
137
|
+
* read.
|
|
138
|
+
*
|
|
139
|
+
* A COUNT AND A POINTER KEEPS BOTH PROPERTIES THE TWO MARKS EXIST FOR. Nothing is eaten: this
|
|
140
|
+
* screen advances only its OWN mark, so `audit` still has the whole thing to say. And nothing is
|
|
141
|
+
* repeated, because the detail only ever lives in one place. Collapsing to a single shared mark
|
|
142
|
+
* would have removed the repetition too, by reintroducing the bug the two marks prevent.
|
|
143
|
+
*
|
|
144
|
+
* Wrapped whole in a try/catch and bounded to the same cheap read `audit` does. This runs while
|
|
145
|
+
* someone else's program is exiting: a novelty line is a nicety, and it must never be the reason
|
|
146
|
+
* a run ends badly.
|
|
147
|
+
*/
|
|
148
|
+
function printSessionNovelty() {
|
|
149
|
+
try {
|
|
150
|
+
const read = (0, ledger_1.readLedger)();
|
|
151
|
+
if (!read.readable || !read.rows.length)
|
|
152
|
+
return;
|
|
153
|
+
const marks = (0, novelty_1.noveltyPath)();
|
|
154
|
+
const novelty = (0, novelty_1.readNovelty)("session", read.rows, marks);
|
|
155
|
+
// The wording lives in novelty.ts as a pure function, because this call site is inside an
|
|
156
|
+
// exit hook where no test can read what it printed.
|
|
157
|
+
const line = (0, novelty_1.sessionNoveltyLine)(novelty);
|
|
158
|
+
if (!line)
|
|
159
|
+
return;
|
|
160
|
+
stderr(line);
|
|
161
|
+
// Only after the line is actually out. See the note in novelty.ts on not consuming a read.
|
|
162
|
+
(0, novelty_1.advanceMark)("session", novelty.current, marks);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
/* a novelty line is never worth a broken exit */
|
|
166
|
+
}
|
|
167
|
+
}
|
|
106
168
|
async function onBeforeExit() {
|
|
107
169
|
// `beforeExit` fires again once the awaited send below drains the loop; the flag stops the
|
|
108
170
|
// second pass from sending a second pulse.
|
|
109
171
|
if (session.ending)
|
|
110
172
|
return;
|
|
111
173
|
session.ending = true;
|
|
174
|
+
writeRoster();
|
|
112
175
|
printReport();
|
|
113
176
|
(0, ledger_1.trimLedger)();
|
|
114
177
|
await pulse.onSessionEnd(stats());
|
|
115
178
|
}
|
|
116
179
|
function onExit() {
|
|
180
|
+
writeRoster();
|
|
117
181
|
printReport();
|
|
118
182
|
if (!session.ending) {
|
|
119
183
|
// process.exit() skipped beforeExit, so nothing async can run now: queue the counts and
|
|
@@ -149,9 +213,55 @@ function record(name, args, description, opts) {
|
|
|
149
213
|
};
|
|
150
214
|
const file = (0, ledger_1.ledgerPath)();
|
|
151
215
|
const { created } = (0, ledger_1.appendCall)(row, file);
|
|
216
|
+
// 🔴 KEYED ON LEDGER CREATION, AND AN ATTEMPT TO WIDEN THAT WAS REVERTED. Someone upgrading
|
|
217
|
+
// from a version that wrote no mark file already has a ledger, so `created` is false for them
|
|
218
|
+
// forever and they are never told about `.agentx-novelty.json`. The fix tried was to also fire
|
|
219
|
+
// when the MARK file is absent. That is worse: the mark is written only by `advanceMark` at
|
|
220
|
+
// session exit, only when a novelty line was non-empty, and `saveMarkFile` swallows write
|
|
221
|
+
// errors. A process killed with SIGKILL, a read-only directory, or several processes sharing a
|
|
222
|
+
// directory then print this notice on EVERY run, turning a first-run line into a nag. Inferring
|
|
223
|
+
// "already told" from a file a different code path may never write is the defect, not the
|
|
224
|
+
// wording. Creation of the ledger is the one event that happens exactly once, so it stays.
|
|
225
|
+
//
|
|
226
|
+
// The upgrading reader is covered in prose instead: both READMEs name this file and say to
|
|
227
|
+
// ignore it, and `published-surface.test.ts` reddens if either stops. Founder call 2026-09-09.
|
|
152
228
|
if (created && !session.ledgerNoticed) {
|
|
153
229
|
session.ledgerNoticed = true;
|
|
154
|
-
|
|
230
|
+
// NAMES BOTH FILES. The novelty mark sits beside the ledger, so a developer who followed this
|
|
231
|
+
// notice exactly still found an untracked `.agentx-novelty.json` in `git status`, or committed
|
|
232
|
+
// one. A notice that lists some of what we write is worse than none, because it reads as all.
|
|
233
|
+
//
|
|
234
|
+
// 🔴 A GITIGNORE LINE IS A PATTERN, NOT A PATH, AND THIS WENT WRONG IN BOTH DIRECTIONS BEFORE
|
|
235
|
+
// SETTLING HERE. First it printed the two default constants, which ignored a moved ledger and
|
|
236
|
+
// named a file that was not there. The fix for that printed the RESOLVED ABSOLUTE paths, which
|
|
237
|
+
// is worse: git anchors any pattern containing a slash to the .gitignore's own directory, so
|
|
238
|
+
// an absolute path matches nothing and both files stay tracked, and on Windows the separator
|
|
239
|
+
// is a backslash, which git reads as an escape. The common case was broken to serve a rare one.
|
|
240
|
+
//
|
|
241
|
+
// The basename of each real path is right in both cases: `.agentx-calls.jsonl` by default, and
|
|
242
|
+
// the moved ledger's own name when someone moved it. It is DERIVED rather than retyped from
|
|
243
|
+
// LEDGER_FILE / NOVELTY_FILE on purpose -- a constant repeated here would be a second copy of
|
|
244
|
+
// the same fact, free to drift, which is the defect class this whole file keeps paying for.
|
|
245
|
+
// 🔴 BOTH FILES ON THE UNCONDITIONAL LINE. Naming the novelty file only on the gitignore
|
|
246
|
+
// line meant that once that line became conditional, a developer whose ledger sits outside
|
|
247
|
+
// the folder was told about one of the two files we write. "A notice that lists some of what
|
|
248
|
+
// we write is worse than none" was already the rule three fixes ago; making another line
|
|
249
|
+
// conditional quietly broke it, and the guard that should have caught it had been weakened.
|
|
250
|
+
stderr(`[agentx] recording wrapped tool calls to ${file} (tool and argument NAMES, never values).`);
|
|
251
|
+
stderr(`[agentx] it also keeps ${(0, novelty_1.noveltyPath)()}, which remembers what you have already seen.`);
|
|
252
|
+
// 🔴 NO .gitignore ADVICE HERE, AND THE ABSENCE IS THE DECISION. Four attempts at that one
|
|
253
|
+
// line were each wrong in a different way: the default constants ignored a moved ledger;
|
|
254
|
+
// absolute paths are not valid gitignore patterns at all, so both files stayed tracked; a
|
|
255
|
+
// bare basename ignores nothing when the file sits outside the folder AND matches at every
|
|
256
|
+
// depth, so it can untrack an unrelated `calls.jsonl` someone wants committed; and making
|
|
257
|
+
// the line conditional left the novelty file named nowhere in the suppressed case.
|
|
258
|
+
//
|
|
259
|
+
// Four wrong answers to "what pattern should we print" is not a run of bad luck, it is the
|
|
260
|
+
// question being wrong. We do not know the reader's repository root, whether this folder is
|
|
261
|
+
// even in a repository, or which of several `.gitignore` files would apply. The two lines
|
|
262
|
+
// above say exactly what we do know: which files we write and where. What to do about them
|
|
263
|
+
// is the developer's call, and the READMEs cover the ordinary case in prose, where a wrong
|
|
264
|
+
// guess costs a sentence rather than a silently untracked file. Founder call 2026-09-08.
|
|
155
265
|
}
|
|
156
266
|
}
|
|
157
267
|
/**
|
|
@@ -166,6 +276,10 @@ function agentxWatch(tool, opts = {}) {
|
|
|
166
276
|
return tool;
|
|
167
277
|
const original = tool.execute;
|
|
168
278
|
const name = opts.name ?? (typeof tool.name === "string" && tool.name ? tool.name : "(unnamed)");
|
|
279
|
+
try {
|
|
280
|
+
session.wrapped.add(name);
|
|
281
|
+
}
|
|
282
|
+
catch { /* the roster is never worth a failed wrap */ }
|
|
169
283
|
const description = typeof tool.description === "string" ? tool.description : undefined;
|
|
170
284
|
const execute = function (...callArgs) {
|
|
171
285
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentx-core/security-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "See what your TypeScript AI agent actually did. Wrap a tool in one line; every call is recorded locally (tool name, argument names, never values) and `audit` prints the report. Watches and records; blocks nothing.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "AgentX <founders@agentx-core.com> (https://agentx-core.com)",
|