@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/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AgentX
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
Contact: founders@agentx-core.com | https://agentx-core.com
|
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# `@agentx-core/security-sdk`
|
|
2
|
+
|
|
3
|
+
**See what your TypeScript AI agent actually did.**
|
|
4
|
+
|
|
5
|
+
Wrap a tool in one line. Every call it makes is written down locally, and one command prints
|
|
6
|
+
the report. It watches and records; it blocks nothing, so it cannot break an agent that works
|
|
7
|
+
today.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @agentx-core/security-sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { agentxWatchAll } from "@agentx-core/security-sdk";
|
|
15
|
+
|
|
16
|
+
const result = await generateText({
|
|
17
|
+
model,
|
|
18
|
+
tools: agentxWatchAll({ runSql, sendEmail, writeFile }), // was: tools: { runSql, ... }
|
|
19
|
+
prompt,
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then, from the folder your agent ran in:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx @agentx-core/security-sdk audit
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
🔎 WHAT YOUR AGENT DID (local to this ledger)
|
|
31
|
+
===========================================================================
|
|
32
|
+
412 calls across 3 tools since 2026-09-01 09:14
|
|
33
|
+
|
|
34
|
+
TOOL CALLS SURFACE ARGUMENTS
|
|
35
|
+
-----------------------------------------------------------------------
|
|
36
|
+
runSql 388 DB limit, query
|
|
37
|
+
writeFile 19 FS content, path
|
|
38
|
+
sendEmail 5 - body, subject, to
|
|
39
|
+
|
|
40
|
+
A dash under SURFACE means we could not tell what that tool touches.
|
|
41
|
+
We go by tool and argument names, and only match words we are sure of.
|
|
42
|
+
|
|
43
|
+
AgentX watches and writes down what your agent did, and blocks nothing,
|
|
44
|
+
so it cannot break a working agent.
|
|
45
|
+
▶ To stop the dangerous ones before they run, a gateway:
|
|
46
|
+
https://agentx-core.com/gateway?utm_source=ts-sdk
|
|
47
|
+
===========================================================================
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`audit --calls` lists every call, newest first. `audit --json` is the same data for a program,
|
|
51
|
+
always complete.
|
|
52
|
+
|
|
53
|
+
## What it writes down, and what it never does
|
|
54
|
+
|
|
55
|
+
Each call becomes one line in `.agentx-calls.jsonl` in the current folder:
|
|
56
|
+
|
|
57
|
+
- the tool's name,
|
|
58
|
+
- the **names** of the arguments it was called with,
|
|
59
|
+
- a size band for an argument the tool itself named as an `amount` (only when a `currency`
|
|
60
|
+
sits beside it) or a `count`: a power of ten, so 1,042.55 is recorded as 1,000,
|
|
61
|
+
- a coarse surface such as DB, FS, HTTP, SHELL or CLOUD, read from the names only,
|
|
62
|
+
- when, and which agent.
|
|
63
|
+
|
|
64
|
+
**Never an argument value, a query, a payload, or a result.** There is no code path from a
|
|
65
|
+
value to that file: the wrapper reduces a call to its shape before anything is written, and
|
|
66
|
+
`test/wrap.test.ts` asserts the file holds no value from a call carrying a SQL statement, an
|
|
67
|
+
email address and a dollar figure. This is exactly what the Python SDK's `agentx audit` keeps.
|
|
68
|
+
|
|
69
|
+
The file is yours. Add it to `.gitignore`; the wrapper reminds you once when it creates it. It
|
|
70
|
+
keeps the last 30 days or 10,000 rows, and when it has trimmed, the report says the counts
|
|
71
|
+
describe what was **kept**.
|
|
72
|
+
|
|
73
|
+
## Nothing is blocked
|
|
74
|
+
|
|
75
|
+
There are no rules in this package and it makes no decision about a call. Every wrapped call
|
|
76
|
+
runs exactly as it would have, with the same arguments and the same result, and a throw
|
|
77
|
+
propagates unchanged. The record answers "what did my agent do" from the calls themselves.
|
|
78
|
+
|
|
79
|
+
To stop the dangerous ones before they run, add the gateway:
|
|
80
|
+
https://agentx-core.com/gateway?utm_source=ts-sdk
|
|
81
|
+
|
|
82
|
+
## This package phones home. `@agentx-core/scan` never does.
|
|
83
|
+
|
|
84
|
+
Our two TypeScript packages make different promises:
|
|
85
|
+
|
|
86
|
+
- **`@agentx-core/scan`** reads your code and makes **no network calls, ever**. Its own test
|
|
87
|
+
suite enforces that.
|
|
88
|
+
- **This package** sends one anonymous usage pulse a day: an install id (a random token, not
|
|
89
|
+
derived from you or your machine), the package version, your OS, and **counts** of calls and
|
|
90
|
+
tools. Never tool names, arguments, paths, hostnames or keys. `test/pulse.test.ts` pins the
|
|
91
|
+
field list exactly, and a test on the Python side pins it to the Python SDK's list, so the
|
|
92
|
+
two SDKs cannot drift into sending different things.
|
|
93
|
+
|
|
94
|
+
It tells you this on first run. Turn it off with one line:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
AGENTX_TELEMETRY=off
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Test runners and CI are excluded automatically (`VITEST`, `JEST_WORKER_ID`, `CI`,
|
|
101
|
+
`GITHUB_ACTIONS` and the usual others), so your own test suite never counts as usage.
|
|
102
|
+
`test/network-boundary.test.ts` fails if any file other than the pulse reaches the network.
|
|
103
|
+
|
|
104
|
+
## Shapes it wraps
|
|
105
|
+
|
|
106
|
+
Anything with an `execute` function: a Vercel AI SDK `tool()`, or your own object.
|
|
107
|
+
`agentxWatch(tool, { name })` wraps one; `agentxWatchAll({ ... })` wraps a map and takes the
|
|
108
|
+
names from its keys. A Vercel `tool()` carries no name of its own (the name is the key in your
|
|
109
|
+
`tools` object), so prefer `agentxWatchAll` there. A tool with no `execute` (a client-side
|
|
110
|
+
tool) is returned unchanged. LangChain.js tools run through `invoke` and are not covered in
|
|
111
|
+
this version.
|
|
112
|
+
|
|
113
|
+
Options: `name`, `agentId` (shown when more than one agent is on the report), and `action`,
|
|
114
|
+
the closed vocabulary the rest of AgentX uses (`execute_database_query`, `fetch_url`,
|
|
115
|
+
`execute_shell`, `send_message`, `write_file`, `other`). `action` fills the SURFACE column only
|
|
116
|
+
when the tool and argument names leave it blank; it never overrides them.
|
|
117
|
+
|
|
118
|
+
Node 18 or newer. The wrapper does nothing on a runtime without a filesystem.
|
|
119
|
+
|
|
120
|
+
## Usage
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
npx @agentx-core/security-sdk audit # grouped by tool
|
|
124
|
+
npx @agentx-core/security-sdk audit --calls # one line per call, newest first
|
|
125
|
+
npx @agentx-core/security-sdk audit --json # machine-readable, always complete
|
|
126
|
+
npx @agentx-core/security-sdk audit --all # every row on the human screen
|
|
127
|
+
npx @agentx-core/security-sdk audit --limit 10
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Errors go to stderr, so `audit --json > out.json` is always a JSON file. An unreadable ledger
|
|
131
|
+
exits 2 and says so; it is never reported as "no calls".
|
|
132
|
+
|
|
133
|
+
## The Python SDK
|
|
134
|
+
|
|
135
|
+
Same product, other language: `pip install agentx-security-sdk`, `@agentx_protect` over a tool,
|
|
136
|
+
`agentx audit`. The Python one also carries a keyless local shield and can block; this one, in
|
|
137
|
+
this version, watches.
|
|
138
|
+
|
|
139
|
+
MIT licensed.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.main = main;
|
|
5
|
+
/**
|
|
6
|
+
* `npx @agentx-core/security-sdk audit [--calls] [--json] [--limit N | --all]`
|
|
7
|
+
*
|
|
8
|
+
* The reader for the local record `agentxWatch` writes. Flags, errors and stream discipline
|
|
9
|
+
* follow `agentx audit` in agentx_sdk/cli.py: errors go to STDERR (so `--json > out.json` is
|
|
10
|
+
* always a JSON file), `--json` returns before any human line reaches stdout, and an
|
|
11
|
+
* unreadable ledger exits 2 rather than printing "no calls".
|
|
12
|
+
*/
|
|
13
|
+
const ledger_1 = require("./ledger");
|
|
14
|
+
const report_1 = require("./report");
|
|
15
|
+
const pulse_1 = require("./pulse");
|
|
16
|
+
const USAGE = `
|
|
17
|
+
🔎 agentx audit — what your agent actually did, from the local record.
|
|
18
|
+
|
|
19
|
+
${report_1.AUDIT_COMMAND} grouped by tool
|
|
20
|
+
${report_1.AUDIT_COMMAND} --calls one line per call, newest first
|
|
21
|
+
--json machine-readable output (always complete)
|
|
22
|
+
--limit N | --all how many rows the human screen shows
|
|
23
|
+
--help this message
|
|
24
|
+
|
|
25
|
+
The record is written by agentxWatch() into .agentx-calls.jsonl in the folder your agent ran
|
|
26
|
+
in; run this from that folder. It holds tool and argument NAMES, never values.
|
|
27
|
+
`;
|
|
28
|
+
const defaultIo = {
|
|
29
|
+
out: (s) => console.log(s),
|
|
30
|
+
err: (s) => console.error(s),
|
|
31
|
+
};
|
|
32
|
+
function main(argv, io = defaultIo) {
|
|
33
|
+
const args = [...argv];
|
|
34
|
+
const wantsJson = args.includes("--json");
|
|
35
|
+
const usageTo = wantsJson ? io.err : io.out;
|
|
36
|
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
37
|
+
usageTo(USAGE.trim());
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
const sub = args.shift();
|
|
41
|
+
if (sub !== "audit") {
|
|
42
|
+
io.err(`\n❌ Unknown command '${sub}'.`);
|
|
43
|
+
io.err(USAGE.trim());
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
const opts = { calls: false, json: false };
|
|
47
|
+
const die = (message) => {
|
|
48
|
+
io.err(`\n❌ ${message}`);
|
|
49
|
+
io.err(` Usage: ${report_1.AUDIT_COMMAND} [--calls] [--json] [--limit N | --all]`);
|
|
50
|
+
return 1;
|
|
51
|
+
};
|
|
52
|
+
for (let i = 0; i < args.length; i++) {
|
|
53
|
+
const tok = args[i];
|
|
54
|
+
if (tok === "--calls")
|
|
55
|
+
opts.calls = true;
|
|
56
|
+
else if (tok === "--json")
|
|
57
|
+
opts.json = true;
|
|
58
|
+
else if (tok === "--all")
|
|
59
|
+
opts.all = true;
|
|
60
|
+
else if (tok === "--limit") {
|
|
61
|
+
const raw = args[i + 1];
|
|
62
|
+
if (raw === undefined)
|
|
63
|
+
return die("--limit needs a number.");
|
|
64
|
+
const value = Number(raw);
|
|
65
|
+
if (!Number.isInteger(value))
|
|
66
|
+
return die(`--limit needs a number, not '${raw}'.`);
|
|
67
|
+
if (value < 1)
|
|
68
|
+
return die("--limit must be at least 1.");
|
|
69
|
+
opts.limit = value;
|
|
70
|
+
i += 1;
|
|
71
|
+
}
|
|
72
|
+
else
|
|
73
|
+
return die(`Unknown option '${tok}' for \`${report_1.AUDIT_COMMAND}\`.`);
|
|
74
|
+
}
|
|
75
|
+
if (opts.all && opts.limit !== undefined)
|
|
76
|
+
return die("--all and --limit ask for different things; pass one.");
|
|
77
|
+
const env = io.env ?? process.env;
|
|
78
|
+
const file = (0, ledger_1.ledgerPath)(env);
|
|
79
|
+
(0, ledger_1.trimLedger)(file);
|
|
80
|
+
const read = (0, ledger_1.readLedger)(file);
|
|
81
|
+
if (opts.json) {
|
|
82
|
+
io.out(JSON.stringify((0, report_1.jsonPayload)(read, opts.calls ? "calls" : "tools", opts), null, 2));
|
|
83
|
+
return read.readable ? 0 : 2;
|
|
84
|
+
}
|
|
85
|
+
// A human looked. The rung this package exists to make visible, and the one thing `--json`
|
|
86
|
+
// must never climb: a cron job polling it is not a person.
|
|
87
|
+
(0, pulse_1.markAuditReportRun)(undefined, env);
|
|
88
|
+
if (!read.readable) {
|
|
89
|
+
io.err("\n❌ The ledger is on disk but could not be read, so this is not a statement that");
|
|
90
|
+
io.err(` your agent did nothing. ${file}`);
|
|
91
|
+
return 2;
|
|
92
|
+
}
|
|
93
|
+
io.out(opts.calls ? (0, report_1.renderCalls)(read, opts) : (0, report_1.renderTools)(read, opts));
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
// Only auto-run as the real entry point (the published `bin`), never when a test imports `main`.
|
|
97
|
+
if (require.main === module && !process.env.VITEST) {
|
|
98
|
+
process.exitCode = main(process.argv.slice(2));
|
|
99
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.agentxWatchAll = exports.agentxWatch = void 0;
|
|
4
|
+
var wrap_1 = require("./wrap");
|
|
5
|
+
Object.defineProperty(exports, "agentxWatch", { enumerable: true, get: function () { return wrap_1.agentxWatch; } });
|
|
6
|
+
Object.defineProperty(exports, "agentxWatchAll", { enumerable: true, get: function () { return wrap_1.agentxWatchAll; } });
|
package/dist/ledger.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { TargetClass } from "./shape";
|
|
2
|
+
export declare const LEDGER_FILE = ".agentx-calls.jsonl";
|
|
3
|
+
export declare const RETENTION_DAYS = 30;
|
|
4
|
+
export declare const RETENTION_MAX_ROWS = 10000;
|
|
5
|
+
export interface CallRow {
|
|
6
|
+
k: "call";
|
|
7
|
+
/** Epoch milliseconds. */
|
|
8
|
+
ts: number;
|
|
9
|
+
tool: string;
|
|
10
|
+
args: string[];
|
|
11
|
+
surface: TargetClass;
|
|
12
|
+
amount: number;
|
|
13
|
+
quantity: number;
|
|
14
|
+
agent: string;
|
|
15
|
+
}
|
|
16
|
+
/** Where the ledger lives. `AGENTX_TS_LEDGER_PATH` moves it (tests, and a shared folder). */
|
|
17
|
+
export declare function ledgerPath(env?: NodeJS.ProcessEnv): string;
|
|
18
|
+
/** Append one call. Returns whether this write CREATED the file (the first-run notice keys on it). */
|
|
19
|
+
export declare function appendCall(row: CallRow, file?: string): {
|
|
20
|
+
created: boolean;
|
|
21
|
+
};
|
|
22
|
+
export interface LedgerRead {
|
|
23
|
+
path: string;
|
|
24
|
+
exists: boolean;
|
|
25
|
+
/** False when the file is there and could not be read. Never confused with empty. */
|
|
26
|
+
readable: boolean;
|
|
27
|
+
/** Oldest first. */
|
|
28
|
+
rows: CallRow[];
|
|
29
|
+
/** Cumulative rows dropped by retention. 0 means the rows are everything ever recorded. */
|
|
30
|
+
dropped: number;
|
|
31
|
+
coversAll: boolean;
|
|
32
|
+
/** Epoch ms of the oldest kept row, or null. */
|
|
33
|
+
windowStart: number | null;
|
|
34
|
+
/** Bytes the read covered, and whether they ended on a line break. trimLedger's carry-over keys on both. */
|
|
35
|
+
bytes: number;
|
|
36
|
+
cleanEnd: boolean;
|
|
37
|
+
}
|
|
38
|
+
export declare function readLedger(file?: string): LedgerRead;
|
|
39
|
+
/**
|
|
40
|
+
* Apply retention: drop rows older than RETENTION_DAYS, then the oldest past RETENTION_MAX_ROWS.
|
|
41
|
+
* Rewrites atomically (temp file + rename) only when something was dropped, and records the
|
|
42
|
+
* cumulative drop count so `readLedger` can say the ledger was trimmed. Never throws.
|
|
43
|
+
*/
|
|
44
|
+
export declare function trimLedger(file?: string, now?: number, maxAgeDays?: number, maxRows?: number): {
|
|
45
|
+
dropped: number;
|
|
46
|
+
};
|
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
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.RETENTION_MAX_ROWS = exports.RETENTION_DAYS = exports.LEDGER_FILE = void 0;
|
|
37
|
+
exports.ledgerPath = ledgerPath;
|
|
38
|
+
exports.appendCall = appendCall;
|
|
39
|
+
exports.readLedger = readLedger;
|
|
40
|
+
exports.trimLedger = trimLedger;
|
|
41
|
+
/**
|
|
42
|
+
* The local record: one JSON line per wrapped call, in the current directory.
|
|
43
|
+
*
|
|
44
|
+
* Kept per FOLDER, like the Python SDK's `.agentx.db`, so an agent run in ~/proj and an
|
|
45
|
+
* `audit` run from ~ read two different files. The screens name the path for exactly that
|
|
46
|
+
* reason. Retention is the Python SDK's: the last 30 days or 10,000 rows, and a trim is
|
|
47
|
+
* written down (a `trim` line carrying the cumulative count) so a reader can say "what was
|
|
48
|
+
* KEPT" instead of "everything your agent has ever done".
|
|
49
|
+
*
|
|
50
|
+
* Rows hold what `shape.ts` produced and nothing else. There is no code path from a raw
|
|
51
|
+
* argument value to this file; `wrap.ts` reduces before it records.
|
|
52
|
+
*/
|
|
53
|
+
const fs = __importStar(require("fs"));
|
|
54
|
+
const path = __importStar(require("path"));
|
|
55
|
+
exports.LEDGER_FILE = ".agentx-calls.jsonl";
|
|
56
|
+
exports.RETENTION_DAYS = 30;
|
|
57
|
+
exports.RETENTION_MAX_ROWS = 10000;
|
|
58
|
+
/** Where the ledger lives. `AGENTX_TS_LEDGER_PATH` moves it (tests, and a shared folder). */
|
|
59
|
+
function ledgerPath(env = process.env) {
|
|
60
|
+
return path.resolve(env.AGENTX_TS_LEDGER_PATH || exports.LEDGER_FILE);
|
|
61
|
+
}
|
|
62
|
+
/** Append one call. Returns whether this write CREATED the file (the first-run notice keys on it). */
|
|
63
|
+
function appendCall(row, file = ledgerPath()) {
|
|
64
|
+
const created = !fs.existsSync(file);
|
|
65
|
+
fs.appendFileSync(file, JSON.stringify(row) + "\n", "utf8");
|
|
66
|
+
return { created };
|
|
67
|
+
}
|
|
68
|
+
function parseLines(text) {
|
|
69
|
+
const rows = [];
|
|
70
|
+
let dropped = 0;
|
|
71
|
+
for (const line of text.split("\n")) {
|
|
72
|
+
if (!line.trim())
|
|
73
|
+
continue;
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(line);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
continue; // a torn line from a crash mid-write is skipped, not fatal
|
|
80
|
+
}
|
|
81
|
+
const r = parsed;
|
|
82
|
+
if (r && r.k === "trim" && typeof r.dropped === "number") {
|
|
83
|
+
dropped = Math.max(dropped, r.dropped);
|
|
84
|
+
}
|
|
85
|
+
else if (r && r.k === "call" && typeof r.tool === "string" && typeof r.ts === "number") {
|
|
86
|
+
rows.push({
|
|
87
|
+
k: "call",
|
|
88
|
+
ts: r.ts,
|
|
89
|
+
tool: r.tool,
|
|
90
|
+
args: Array.isArray(r.args) ? r.args.map(String) : [],
|
|
91
|
+
surface: r.surface || "other",
|
|
92
|
+
amount: typeof r.amount === "number" ? r.amount : 0,
|
|
93
|
+
quantity: typeof r.quantity === "number" ? r.quantity : 0,
|
|
94
|
+
agent: typeof r.agent === "string" ? r.agent : "",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { rows, dropped };
|
|
99
|
+
}
|
|
100
|
+
function readLedger(file = ledgerPath()) {
|
|
101
|
+
const base = {
|
|
102
|
+
path: file, exists: false, readable: true, rows: [], dropped: 0, coversAll: true, windowStart: null,
|
|
103
|
+
bytes: 0, cleanEnd: true,
|
|
104
|
+
};
|
|
105
|
+
if (!fs.existsSync(file))
|
|
106
|
+
return base;
|
|
107
|
+
base.exists = true;
|
|
108
|
+
let buf;
|
|
109
|
+
try {
|
|
110
|
+
buf = fs.readFileSync(file);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// UNREADABLE IS NOT EMPTY. A reader that renders this as "no calls" makes a false
|
|
114
|
+
// statement about the developer's own data.
|
|
115
|
+
return { ...base, readable: false };
|
|
116
|
+
}
|
|
117
|
+
const { rows, dropped } = parseLines(buf.toString("utf8"));
|
|
118
|
+
rows.sort((a, b) => a.ts - b.ts);
|
|
119
|
+
return {
|
|
120
|
+
...base,
|
|
121
|
+
rows,
|
|
122
|
+
dropped,
|
|
123
|
+
coversAll: dropped === 0,
|
|
124
|
+
windowStart: rows.length ? rows[0].ts : null,
|
|
125
|
+
bytes: buf.length,
|
|
126
|
+
cleanEnd: buf.length === 0 || buf[buf.length - 1] === 0x0a,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Apply retention: drop rows older than RETENTION_DAYS, then the oldest past RETENTION_MAX_ROWS.
|
|
131
|
+
* Rewrites atomically (temp file + rename) only when something was dropped, and records the
|
|
132
|
+
* cumulative drop count so `readLedger` can say the ledger was trimmed. Never throws.
|
|
133
|
+
*/
|
|
134
|
+
function trimLedger(file = ledgerPath(), now = Date.now(), maxAgeDays = exports.RETENTION_DAYS, maxRows = exports.RETENTION_MAX_ROWS) {
|
|
135
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
136
|
+
try {
|
|
137
|
+
const read = readLedger(file);
|
|
138
|
+
if (!read.exists || !read.readable)
|
|
139
|
+
return { dropped: 0 };
|
|
140
|
+
const cutoff = now - maxAgeDays * 86400 * 1000;
|
|
141
|
+
let kept = read.rows.filter((r) => r.ts >= cutoff);
|
|
142
|
+
if (kept.length > maxRows)
|
|
143
|
+
kept = kept.slice(kept.length - maxRows);
|
|
144
|
+
const droppedNow = read.rows.length - kept.length;
|
|
145
|
+
if (droppedNow === 0)
|
|
146
|
+
return { dropped: 0 };
|
|
147
|
+
const trim = { k: "trim", ts: now, dropped: read.dropped + droppedNow };
|
|
148
|
+
const lines = [JSON.stringify(trim), ...kept.map((r) => JSON.stringify(r))].join("\n") + "\n";
|
|
149
|
+
fs.writeFileSync(tmp, lines, "utf8");
|
|
150
|
+
// Other processes write this file too: an agent appending in another terminal, or a second
|
|
151
|
+
// exiting agent running its own trim. Rows appended after the read (the file grew, and what
|
|
152
|
+
// was read ended on a line break) are copied onto the end of the rewrite. If the file
|
|
153
|
+
// shrank, someone else rewrote it and this pass no longer describes it; the rewrite is
|
|
154
|
+
// abandoned rather than renamed over a file this process did not read.
|
|
155
|
+
const nowBytes = fs.statSync(file).size;
|
|
156
|
+
if (nowBytes < read.bytes)
|
|
157
|
+
throw new Error("ledger rewritten during trim");
|
|
158
|
+
if (nowBytes > read.bytes) {
|
|
159
|
+
if (!read.cleanEnd)
|
|
160
|
+
throw new Error("ledger tail is mid-line; skip this trim");
|
|
161
|
+
const fd = fs.openSync(file, "r");
|
|
162
|
+
try {
|
|
163
|
+
const tail = Buffer.alloc(nowBytes - read.bytes);
|
|
164
|
+
const got = fs.readSync(fd, tail, 0, tail.length, read.bytes);
|
|
165
|
+
fs.appendFileSync(tmp, tail.subarray(0, got));
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
fs.closeSync(fd);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
fs.renameSync(tmp, file);
|
|
172
|
+
return { dropped: droppedNow };
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
try {
|
|
176
|
+
fs.unlinkSync(tmp);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
/* nothing to remove */
|
|
180
|
+
}
|
|
181
|
+
return { dropped: 0 };
|
|
182
|
+
}
|
|
183
|
+
}
|
package/dist/pulse.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export declare const INTEGRATION = "ts";
|
|
2
|
+
export declare const DEFAULT_ENDPOINT = "https://www.agentx-core.com/api/pulse";
|
|
3
|
+
export declare const DEBOUNCE_MS: number;
|
|
4
|
+
export declare const TIMEOUT_MS = 1000;
|
|
5
|
+
/** The complete set of top-level keys that may leave the machine. KEEP IN SYNC with agentx_sdk/pulse.py _ALLOWED_KEYS. */
|
|
6
|
+
export declare const ALLOWED_KEYS: readonly ["install_id", "sdk_version", "python", "os", "first_seen", "ts", "mode", "gateway_present", "reasoning_enabled", "contributed", "block_category", "integration", "session", "ran_audit_report"];
|
|
7
|
+
/** The session COUNT keys. KEEP IN SYNC with agentx_sdk/pulse.py _ALLOWED_SESSION_KEYS. */
|
|
8
|
+
export declare const ALLOWED_SESSION_KEYS: readonly ["tools_monitored", "intercepts", "critical_blocks", "human_escalations", "self_corrections", "would_blocks", "had_block", "first_block_ever", "shield_failopens", "own_agent_block", "audit_calls", "audit_tools"];
|
|
9
|
+
export interface SessionStats {
|
|
10
|
+
/** Every wrapped call this session. */
|
|
11
|
+
totalCalls: number;
|
|
12
|
+
/** Distinct wrapped tools this session. A count; the names stay on disk. */
|
|
13
|
+
distinctTools: number;
|
|
14
|
+
}
|
|
15
|
+
export interface PulseState {
|
|
16
|
+
install_id?: string;
|
|
17
|
+
first_seen?: string;
|
|
18
|
+
last_pulse?: number;
|
|
19
|
+
notice_shown?: boolean;
|
|
20
|
+
ran_audit_report?: boolean;
|
|
21
|
+
/** Counts from a run that ended through process.exit() before the pulse could be sent. */
|
|
22
|
+
pending?: SessionStats;
|
|
23
|
+
}
|
|
24
|
+
type Env = NodeJS.ProcessEnv;
|
|
25
|
+
export declare function stateFile(): string;
|
|
26
|
+
/** The machine-level "this machine is ours" marker the Python SDK writes. READ here, never written. */
|
|
27
|
+
export declare function internalMarker(): string;
|
|
28
|
+
export declare function loadState(file?: string): PulseState;
|
|
29
|
+
export declare function saveState(state: PulseState, file?: string): void;
|
|
30
|
+
/** True unless the developer explicitly opted out. `AGENTX_TELEMETRY` always wins when present. */
|
|
31
|
+
export declare function telemetryEnabled(env?: Env): boolean;
|
|
32
|
+
/** True when telemetry is on purely by default, i.e. nobody set AGENTX_TELEMETRY. Only these installs get the notice. */
|
|
33
|
+
export declare function isDefaultOn(env?: Env): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* True when this run is a test/CI invocation, an explicitly flagged dev environment
|
|
36
|
+
* (`AGENTX_ENV=development|dev|test`), or a machine the Python SDK has marked as ours. Such
|
|
37
|
+
* runs send nothing and climb no rung. Never throws.
|
|
38
|
+
*
|
|
39
|
+
* Unlike the Python side, this never WRITES the machine marker: a wrong write deletes a real
|
|
40
|
+
* user from the funnel permanently, and the Python SDK already owns that decision.
|
|
41
|
+
*/
|
|
42
|
+
export declare function isAutomationContext(env?: Env, markerExists?: () => boolean): boolean;
|
|
43
|
+
/** The coarse data-plane mode, resolved the way the Python SDK resolves it. */
|
|
44
|
+
export declare function mode(env?: Env): "local" | "linked" | "cloud";
|
|
45
|
+
/** Where the pulse goes: the configured control plane's /api/pulse, else the public default. */
|
|
46
|
+
export declare function endpoint(env?: Env): string;
|
|
47
|
+
/** This build's version, from package.json. "unknown" rather than a guess. */
|
|
48
|
+
export declare function sdkVersion(): string;
|
|
49
|
+
/** The Python SDK sends `platform.system().lower()`; map Node's names onto the same words. */
|
|
50
|
+
export declare function osName(platform?: string): string;
|
|
51
|
+
/**
|
|
52
|
+
* Assemble the pulse. ALLOWLIST ONLY: it reads two counters off `stats` and never a name.
|
|
53
|
+
* Every key in ALLOWED_KEYS is present, so the receiver sees one shape from every SDK; the
|
|
54
|
+
* fields this door has no way to observe are null or false, never omitted.
|
|
55
|
+
*/
|
|
56
|
+
export declare function buildPayload(stats: SessionStats, state: PulseState, opts?: {
|
|
57
|
+
now?: Date;
|
|
58
|
+
version?: string;
|
|
59
|
+
platform?: string;
|
|
60
|
+
env?: Env;
|
|
61
|
+
}): Record<string, unknown>;
|
|
62
|
+
/** Pure debounce decision. */
|
|
63
|
+
export declare function shouldSend(state: PulseState, nowMs: number): boolean;
|
|
64
|
+
/** Fire the pulse. Swallows every error and is bounded by TIMEOUT_MS. */
|
|
65
|
+
export declare function post(url: string, payload: Record<string, unknown>, timeoutMs?: number): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Send one pulse if telemetry is on and the day's window is open. Persists BEFORE dispatch so
|
|
68
|
+
* a crash mid-send cannot double-count tomorrow. Returns the payload sent, or null.
|
|
69
|
+
*/
|
|
70
|
+
export declare function maybeSend(stats: SessionStats, state: PulseState, file?: string, opts?: {
|
|
71
|
+
now?: number;
|
|
72
|
+
env?: Env;
|
|
73
|
+
url?: string;
|
|
74
|
+
}): Promise<Record<string, unknown> | null>;
|
|
75
|
+
/** The one-time transparency notice, printed BEFORE the first pulse leaves. To stderr: this runs inside someone else's program. */
|
|
76
|
+
export declare function showNotice(state: PulseState, file?: string, write?: (s: string) => void): void;
|
|
77
|
+
/**
|
|
78
|
+
* The session-end entry point. Fires on every run that wrapped a call. Merges in counts a
|
|
79
|
+
* previous run queued (see queuePending) so a script that always ends with process.exit()
|
|
80
|
+
* still reports, one run late rather than never.
|
|
81
|
+
*/
|
|
82
|
+
export interface SessionEndDeps {
|
|
83
|
+
env?: Env;
|
|
84
|
+
url?: string;
|
|
85
|
+
now?: number;
|
|
86
|
+
/** Injectable so a test can drive this path on a machine the Python SDK has marked as ours. */
|
|
87
|
+
markerExists?: () => boolean;
|
|
88
|
+
notice?: (s: string) => void;
|
|
89
|
+
}
|
|
90
|
+
export declare function onSessionEnd(stats: SessionStats, file?: string, deps?: SessionEndDeps): Promise<void>;
|
|
91
|
+
/**
|
|
92
|
+
* The synchronous fallback for `process.on("exit")`, where nothing async can run: write the
|
|
93
|
+
* counts down so the NEXT run sends them. Never throws.
|
|
94
|
+
*/
|
|
95
|
+
export declare function queuePending(stats: SessionStats, file?: string, deps?: SessionEndDeps): void;
|
|
96
|
+
/**
|
|
97
|
+
* A human ran `audit`. STICKY, and excluded from automation for the same reason the Python
|
|
98
|
+
* side is: this is the conversion event the funnel is built around and it cannot be
|
|
99
|
+
* un-climbed, so one CI job must not mark an install converted forever.
|
|
100
|
+
*/
|
|
101
|
+
export declare function markAuditReportRun(file?: string, env?: Env): void;
|
|
102
|
+
export {};
|