@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/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
- /** The grouped screen. Returns the text; the caller prints it. */
194
- function renderTools(read, opts = {}) {
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
- stderr(`[agentx] recording wrapped tool calls to ${file} (tool and argument NAMES, never values). Add it to .gitignore if this folder is a repository.`);
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.1.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)",