tersign 0.2.0 → 0.3.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 +21 -0
- package/dist/cli.js +12 -1
- package/dist/intercept/capture.d.ts +51 -0
- package/dist/intercept/capture.js +134 -0
- package/dist/intercept/flags.d.ts +18 -0
- package/dist/intercept/flags.js +38 -0
- package/dist/intercept/frames.d.ts +19 -0
- package/dist/intercept/frames.js +50 -0
- package/dist/intercept/proxy.d.ts +29 -0
- package/dist/intercept/proxy.js +60 -0
- package/dist/intercept/sink.d.ts +40 -0
- package/dist/intercept/sink.js +102 -0
- package/dist/intercept-bin.d.ts +2 -0
- package/dist/intercept-bin.js +123 -0
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -79,6 +79,27 @@ npm i tersign
|
|
|
79
79
|
| Venue envelopes | Internet Court (5,000-char slot) · Kleros ERC-1497 · UMA · generic |
|
|
80
80
|
| Evidence packs | `format=art50` · `format=safr` (beta) |
|
|
81
81
|
| Idempotency | In-memory + Cloudflare D1 stores |
|
|
82
|
+
| `tersign intercept` | Audit capture at the MCP boundary — a signed, digest-only action record per tool call (experimental) |
|
|
83
|
+
|
|
84
|
+
## Capture at the MCP Boundary — `tersign intercept`
|
|
85
|
+
|
|
86
|
+
An agent's tool calls are usually recorded, if at all, by the party running the agent. Put a
|
|
87
|
+
recording clamp on the wire instead:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
npx tersign intercept -- npx your-mcp-server
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The proxy is a **pure observer**: bytes reach the server and the client exactly as sent, in
|
|
94
|
+
order, unmodified. Every `tools/call` it sees becomes an `ActionRecordV1` signed by *your* key
|
|
95
|
+
and counter-signed into a hash chain — **digests only**, so the record proves what happened
|
|
96
|
+
without carrying arguments or results anywhere. Records go to a configured ledger, and fall
|
|
97
|
+
back to a local `~/.tersign/intercepts-<date>.jsonl` so evidence is never silently dropped.
|
|
98
|
+
|
|
99
|
+
Experimental, and deliberately unopinionated about where the protocol lands: it implements the
|
|
100
|
+
observation semantics of the audit-mode validator described in MCP **SEP-2624** (Draft) as a
|
|
101
|
+
transport-level proxy today, and is structured to move onto the interceptor primitive if and
|
|
102
|
+
when that stabilizes. It makes no conformance claim to that draft.
|
|
82
103
|
|
|
83
104
|
## For Agents — the MCP Server
|
|
84
105
|
|
package/dist/cli.js
CHANGED
|
@@ -23,6 +23,10 @@ else if (sub === 'disclose') {
|
|
|
23
23
|
process.argv.splice(2, 1);
|
|
24
24
|
await import('./disclose-bin.js');
|
|
25
25
|
}
|
|
26
|
+
else if (sub === 'intercept') {
|
|
27
|
+
process.argv.splice(2, 1);
|
|
28
|
+
await import('./intercept-bin.js');
|
|
29
|
+
}
|
|
26
30
|
else if (sub === 'help' || sub === '--help' || sub === '-h') {
|
|
27
31
|
console.log('tersign — evidence layer for the agent economy\n\n' +
|
|
28
32
|
' tersign start the MCP server (stdio)\n' +
|
|
@@ -31,7 +35,14 @@ else if (sub === 'help' || sub === '--help' || sub === '-h') {
|
|
|
31
35
|
' verify a receipt: local signature recovery + public chain check\n' +
|
|
32
36
|
' tersign disclose "<text>" [--medium chat] [--agent-id id] [--url resourceUrl]\n' +
|
|
33
37
|
' counter-signed disclosure evidence — text digested locally,\n' +
|
|
34
|
-
' only the digest travels; key created on first use\n'
|
|
38
|
+
' only the digest travels; key created on first use\n' +
|
|
39
|
+
' tersign intercept [--events m1,m2] [--agent-id id] [--ledger url] -- <server cmd…>\n' +
|
|
40
|
+
' byte-faithful MCP stdio proxy — signed, digest-only\n' +
|
|
41
|
+
' tool-call evidence for the wrapped server (experimental).\n' +
|
|
42
|
+
' Hosted ledger mode requires the signer key registered for\n' +
|
|
43
|
+
' the sellerId (TERSIGN_SELLER_KEY); rejected records fall\n' +
|
|
44
|
+
" back to ~/.tersign. Exits with the child's code; 1 if\n" +
|
|
45
|
+
' evidence was lost while the child exited 0\n');
|
|
35
46
|
}
|
|
36
47
|
else {
|
|
37
48
|
console.error(`unknown subcommand '${sub}' — did you mean: tersign verify ${sub}\nrun 'tersign help' for usage`);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Account } from 'viem/accounts';
|
|
2
|
+
import { type SignedActionRecord } from '../evidence/action.js';
|
|
3
|
+
/** Direction-aware JSON-RPC pairing → signed, digest-only ActionRecordV1 evidence.
|
|
4
|
+
*
|
|
5
|
+
* JSON-RPC ids are independent per direction in MCP v1 — client→server requests and
|
|
6
|
+
* server→client requests (sampling, elicitation) coexist, so the same id can be in flight
|
|
7
|
+
* both ways at once. Pending requests key on (request direction, id); a response observed
|
|
8
|
+
* in direction D answers the request that travelled in the OPPOSITE direction.
|
|
9
|
+
*
|
|
10
|
+
* The prototype captures client→server requests only, and notifications (no id) are not
|
|
11
|
+
* captured. DIGESTS ONLY: a record carries digestOf(arguments) and digestOf(result ?? error),
|
|
12
|
+
* never the content itself — the data-minimized posture of evidence/action.ts. */
|
|
13
|
+
export type Direction = 'client' | 'server';
|
|
14
|
+
export declare const DEFAULT_EVENTS: readonly string[];
|
|
15
|
+
export interface McpCaptureOptions {
|
|
16
|
+
/** agent identity stamped on every record */
|
|
17
|
+
agentId: string;
|
|
18
|
+
/** deployer signing account (keystore-resolved on the CLI path) */
|
|
19
|
+
account: Account;
|
|
20
|
+
/** captured client→server request methods; '*' captures every method */
|
|
21
|
+
events?: readonly string[];
|
|
22
|
+
clock?: () => number;
|
|
23
|
+
/** receives each signed record — wire this to the sink */
|
|
24
|
+
onRecord: (signed: SignedActionRecord) => void;
|
|
25
|
+
/** signing failures land here; the observer never throws into the proxy path */
|
|
26
|
+
onError?: (err: unknown) => void;
|
|
27
|
+
}
|
|
28
|
+
export declare class McpCapture {
|
|
29
|
+
private readonly opts;
|
|
30
|
+
private readonly events;
|
|
31
|
+
private readonly clock;
|
|
32
|
+
private readonly pending;
|
|
33
|
+
private tail;
|
|
34
|
+
private stopped;
|
|
35
|
+
private emitted;
|
|
36
|
+
private evicted;
|
|
37
|
+
private collided;
|
|
38
|
+
constructor(opts: McpCaptureOptions);
|
|
39
|
+
/** signed records emitted so far (for the exit summary) */
|
|
40
|
+
get recordCount(): number;
|
|
41
|
+
/** pending entries evicted at the MAX_PENDING cap — each is a request that can no longer pair */
|
|
42
|
+
get evictedCount(): number;
|
|
43
|
+
/** same-direction id collisions — each dropped a stale pending request without emitting */
|
|
44
|
+
get collisionCount(): number;
|
|
45
|
+
/** Observe one complete frame (a COPY — the proxied bytes are already on their way). */
|
|
46
|
+
onFrame(direction: Direction, line: Buffer): void;
|
|
47
|
+
/** Stop observing new frames (shutdown began); already-queued signings still complete. */
|
|
48
|
+
stop(): void;
|
|
49
|
+
/** Resolves once every queued record has been signed and delivered to onRecord. */
|
|
50
|
+
settle(): Promise<void>;
|
|
51
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { digestOf } from '../canonical.js';
|
|
2
|
+
import { signActionRecord } from '../evidence/action.js';
|
|
3
|
+
import { tryParseFrame } from './frames.js';
|
|
4
|
+
export const DEFAULT_EVENTS = ['tools/call'];
|
|
5
|
+
/** pending-map ceiling — cancelled-and-lost or never-answered requests must not grow memory
|
|
6
|
+
* forever; on overflow the oldest entry is evicted (counted, warned once via onError) */
|
|
7
|
+
const MAX_PENDING = 4096;
|
|
8
|
+
export class McpCapture {
|
|
9
|
+
opts;
|
|
10
|
+
events;
|
|
11
|
+
clock;
|
|
12
|
+
pending = new Map();
|
|
13
|
+
tail = Promise.resolve();
|
|
14
|
+
stopped = false;
|
|
15
|
+
emitted = 0;
|
|
16
|
+
evicted = 0;
|
|
17
|
+
collided = 0;
|
|
18
|
+
constructor(opts) {
|
|
19
|
+
this.opts = opts;
|
|
20
|
+
this.events = new Set(opts.events ?? DEFAULT_EVENTS);
|
|
21
|
+
this.clock = opts.clock ?? (() => Math.floor(Date.now() / 1000));
|
|
22
|
+
}
|
|
23
|
+
/** signed records emitted so far (for the exit summary) */
|
|
24
|
+
get recordCount() {
|
|
25
|
+
return this.emitted;
|
|
26
|
+
}
|
|
27
|
+
/** pending entries evicted at the MAX_PENDING cap — each is a request that can no longer pair */
|
|
28
|
+
get evictedCount() {
|
|
29
|
+
return this.evicted;
|
|
30
|
+
}
|
|
31
|
+
/** same-direction id collisions — each dropped a stale pending request without emitting */
|
|
32
|
+
get collisionCount() {
|
|
33
|
+
return this.collided;
|
|
34
|
+
}
|
|
35
|
+
/** Observe one complete frame (a COPY — the proxied bytes are already on their way). */
|
|
36
|
+
onFrame(direction, line) {
|
|
37
|
+
if (this.stopped)
|
|
38
|
+
return;
|
|
39
|
+
const msg = tryParseFrame(line);
|
|
40
|
+
if (msg === undefined || msg === null || typeof msg !== 'object' || Array.isArray(msg))
|
|
41
|
+
return;
|
|
42
|
+
const frame = msg;
|
|
43
|
+
// MCP cancellation: the request this direction sent will not be answered — drop its
|
|
44
|
+
// pending entry so cancelled calls cannot accumulate forever.
|
|
45
|
+
if (frame.method === 'notifications/cancelled') {
|
|
46
|
+
const params = typeof frame.params === 'object' && frame.params !== null && !Array.isArray(frame.params)
|
|
47
|
+
? frame.params
|
|
48
|
+
: {};
|
|
49
|
+
const requestId = params.requestId;
|
|
50
|
+
if (typeof requestId === 'string' || typeof requestId === 'number') {
|
|
51
|
+
this.pending.delete(pendingKey(direction, requestId));
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const id = frame.id;
|
|
56
|
+
if (typeof id !== 'string' && typeof id !== 'number')
|
|
57
|
+
return; // notifications / null-id errors
|
|
58
|
+
if (typeof frame.method === 'string') {
|
|
59
|
+
if (direction !== 'client')
|
|
60
|
+
return; // prototype: client→server requests only
|
|
61
|
+
if (!this.events.has('*') && !this.events.has(frame.method))
|
|
62
|
+
return;
|
|
63
|
+
const params = typeof frame.params === 'object' && frame.params !== null && !Array.isArray(frame.params)
|
|
64
|
+
? frame.params
|
|
65
|
+
: {};
|
|
66
|
+
const key = pendingKey('client', id);
|
|
67
|
+
if (this.pending.has(key)) {
|
|
68
|
+
// Same-direction id reuse while the first request is still pending (a JSON-RPC
|
|
69
|
+
// violation): the stale entry can no longer be paired truthfully — drop it WITHOUT
|
|
70
|
+
// emitting a record (never fabricate a pairing). The delete keeps insertion order
|
|
71
|
+
// honest for cap eviction.
|
|
72
|
+
this.pending.delete(key);
|
|
73
|
+
this.collided += 1;
|
|
74
|
+
this.opts.onError?.(new Error(`duplicate in-flight JSON-RPC id ${String(id)} (client) — dropped the stale pending request without emitting a record`));
|
|
75
|
+
}
|
|
76
|
+
else if (this.pending.size >= MAX_PENDING) {
|
|
77
|
+
const oldest = this.pending.keys().next().value;
|
|
78
|
+
if (oldest !== undefined)
|
|
79
|
+
this.pending.delete(oldest);
|
|
80
|
+
this.evicted += 1;
|
|
81
|
+
if (this.evicted === 1) {
|
|
82
|
+
this.opts.onError?.(new Error(`pending-request map hit its ${MAX_PENDING}-entry cap — evicting oldest unanswered requests (counted, warned once)`));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
this.pending.set(key, {
|
|
86
|
+
method: frame.method,
|
|
87
|
+
...(typeof params.name === 'string' ? { toolName: params.name } : {}),
|
|
88
|
+
inputDigest: digestOf(params.arguments ?? null),
|
|
89
|
+
at: this.clock(),
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (!('result' in frame) && !('error' in frame))
|
|
94
|
+
return;
|
|
95
|
+
// A response in direction D answers the request that travelled the opposite way.
|
|
96
|
+
const key = pendingKey(direction === 'server' ? 'client' : 'server', id);
|
|
97
|
+
const entry = this.pending.get(key);
|
|
98
|
+
if (entry === undefined)
|
|
99
|
+
return;
|
|
100
|
+
this.pending.delete(key);
|
|
101
|
+
// JSON-RPC error responses still produce a record — the digest covers the error object.
|
|
102
|
+
const outputDigest = digestOf(frame.result ?? frame.error ?? null);
|
|
103
|
+
this.tail = this.tail
|
|
104
|
+
.then(async () => {
|
|
105
|
+
const record = {
|
|
106
|
+
version: 1,
|
|
107
|
+
agent: { id: this.opts.agentId },
|
|
108
|
+
action: {
|
|
109
|
+
kind: 'tool-call',
|
|
110
|
+
...(entry.toolName !== undefined ? { name: entry.toolName } : {}),
|
|
111
|
+
inputDigest: entry.inputDigest,
|
|
112
|
+
outputDigest,
|
|
113
|
+
},
|
|
114
|
+
occurredAt: entry.at,
|
|
115
|
+
};
|
|
116
|
+
const signed = await signActionRecord(record, this.opts.account);
|
|
117
|
+
this.emitted += 1;
|
|
118
|
+
this.opts.onRecord(signed);
|
|
119
|
+
})
|
|
120
|
+
.catch((err) => this.opts.onError?.(err));
|
|
121
|
+
}
|
|
122
|
+
/** Stop observing new frames (shutdown began); already-queued signings still complete. */
|
|
123
|
+
stop() {
|
|
124
|
+
this.stopped = true;
|
|
125
|
+
}
|
|
126
|
+
/** Resolves once every queued record has been signed and delivered to onRecord. */
|
|
127
|
+
async settle() {
|
|
128
|
+
await this.tail;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** ids are string-or-number and independent per direction — 1 and '1' must not collide. */
|
|
132
|
+
function pendingKey(direction, id) {
|
|
133
|
+
return `${direction}:${typeof id}:${String(id)}`;
|
|
134
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Flag parsing for `tersign intercept` — the tokens strictly BEFORE '--' (everything after
|
|
2
|
+
* belongs to the child verbatim). Supports both '--flag value' and '--flag=value'; unknown
|
|
3
|
+
* flags and flags missing their value are errors, never silent misreads (a bare '--agent-id'
|
|
4
|
+
* must not consume a following '--ledger' as its value — that once signed records with
|
|
5
|
+
* agent.id '--ledger'). Split out of intercept-bin so tests can drive it directly. */
|
|
6
|
+
export interface InterceptFlags {
|
|
7
|
+
events?: string;
|
|
8
|
+
agentId?: string;
|
|
9
|
+
ledger?: string;
|
|
10
|
+
}
|
|
11
|
+
export type ParsedInterceptFlags = {
|
|
12
|
+
ok: true;
|
|
13
|
+
flags: InterceptFlags;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
error: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function parseInterceptFlags(tokens: readonly string[]): ParsedInterceptFlags;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Flag parsing for `tersign intercept` — the tokens strictly BEFORE '--' (everything after
|
|
2
|
+
* belongs to the child verbatim). Supports both '--flag value' and '--flag=value'; unknown
|
|
3
|
+
* flags and flags missing their value are errors, never silent misreads (a bare '--agent-id'
|
|
4
|
+
* must not consume a following '--ledger' as its value — that once signed records with
|
|
5
|
+
* agent.id '--ledger'). Split out of intercept-bin so tests can drive it directly. */
|
|
6
|
+
const FLAG_KEYS = new Map([
|
|
7
|
+
['--events', 'events'],
|
|
8
|
+
['--agent-id', 'agentId'],
|
|
9
|
+
['--ledger', 'ledger'],
|
|
10
|
+
]);
|
|
11
|
+
export function parseInterceptFlags(tokens) {
|
|
12
|
+
const flags = {};
|
|
13
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
14
|
+
const token = tokens[i];
|
|
15
|
+
const eq = token.indexOf('=');
|
|
16
|
+
const name = eq === -1 ? token : token.slice(0, eq);
|
|
17
|
+
const key = FLAG_KEYS.get(name);
|
|
18
|
+
if (key === undefined) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
error: name.startsWith('--')
|
|
22
|
+
? `unknown flag '${name}'`
|
|
23
|
+
: `unexpected argument '${token}' — flags go before '--', the server command after`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (eq !== -1) {
|
|
27
|
+
flags[key] = token.slice(eq + 1);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const next = tokens[i + 1];
|
|
31
|
+
if (next === undefined || next.startsWith('--')) {
|
|
32
|
+
return { ok: false, error: `flag '${name}' requires a value` };
|
|
33
|
+
}
|
|
34
|
+
flags[key] = next;
|
|
35
|
+
i += 1;
|
|
36
|
+
}
|
|
37
|
+
return { ok: true, flags };
|
|
38
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Newline-delimited JSON-RPC framing for the MCP stdio transport.
|
|
2
|
+
*
|
|
3
|
+
* Observation only: the proxied byte path never flows through this module — the proxy pipes
|
|
4
|
+
* raw bytes straight through and feeds COPIES here, so the child receives exactly the bytes
|
|
5
|
+
* the client sent and vice versa (parse a copy, never a re-serialization). Frames may arrive
|
|
6
|
+
* split across reads or coalesced several-per-chunk, and can be arbitrarily large (buffered
|
|
7
|
+
* until the newline arrives). */
|
|
8
|
+
/** Splits a byte stream into newline-delimited frames. Yielded lines exclude the trailing
|
|
9
|
+
* `\n` (a trailing `\r` is left in place — `tryParseFrame` tolerates it). Partial pieces
|
|
10
|
+
* accumulate as an array and concatenate once per completed line, so a large frame fed in
|
|
11
|
+
* many chunks costs O(bytes), not O(bytes²). */
|
|
12
|
+
export declare class LineSplitter {
|
|
13
|
+
private pending;
|
|
14
|
+
feed(chunk: Buffer): Buffer[];
|
|
15
|
+
}
|
|
16
|
+
/** Parse one frame for observation. Returns the parsed JSON value, or undefined for empty
|
|
17
|
+
* and non-JSON lines — those pass through the proxy untouched and are never captured.
|
|
18
|
+
* Never throws into the proxy path. */
|
|
19
|
+
export declare function tryParseFrame(line: Buffer): unknown;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Newline-delimited JSON-RPC framing for the MCP stdio transport.
|
|
2
|
+
*
|
|
3
|
+
* Observation only: the proxied byte path never flows through this module — the proxy pipes
|
|
4
|
+
* raw bytes straight through and feeds COPIES here, so the child receives exactly the bytes
|
|
5
|
+
* the client sent and vice versa (parse a copy, never a re-serialization). Frames may arrive
|
|
6
|
+
* split across reads or coalesced several-per-chunk, and can be arbitrarily large (buffered
|
|
7
|
+
* until the newline arrives). */
|
|
8
|
+
/** Splits a byte stream into newline-delimited frames. Yielded lines exclude the trailing
|
|
9
|
+
* `\n` (a trailing `\r` is left in place — `tryParseFrame` tolerates it). Partial pieces
|
|
10
|
+
* accumulate as an array and concatenate once per completed line, so a large frame fed in
|
|
11
|
+
* many chunks costs O(bytes), not O(bytes²). */
|
|
12
|
+
export class LineSplitter {
|
|
13
|
+
pending = [];
|
|
14
|
+
feed(chunk) {
|
|
15
|
+
const lines = [];
|
|
16
|
+
let start = 0;
|
|
17
|
+
let nl;
|
|
18
|
+
while ((nl = chunk.indexOf(0x0a, start)) !== -1) {
|
|
19
|
+
const piece = chunk.subarray(start, nl);
|
|
20
|
+
if (this.pending.length > 0) {
|
|
21
|
+
this.pending.push(piece);
|
|
22
|
+
lines.push(Buffer.concat(this.pending));
|
|
23
|
+
this.pending = [];
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
lines.push(piece);
|
|
27
|
+
}
|
|
28
|
+
start = nl + 1;
|
|
29
|
+
}
|
|
30
|
+
if (start < chunk.length)
|
|
31
|
+
this.pending.push(chunk.subarray(start));
|
|
32
|
+
return lines;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Parse one frame for observation. Returns the parsed JSON value, or undefined for empty
|
|
36
|
+
* and non-JSON lines — those pass through the proxy untouched and are never captured.
|
|
37
|
+
* Never throws into the proxy path. */
|
|
38
|
+
export function tryParseFrame(line) {
|
|
39
|
+
let end = line.length;
|
|
40
|
+
if (end > 0 && line[end - 1] === 0x0d)
|
|
41
|
+
end -= 1; // CRLF tolerance
|
|
42
|
+
if (end === 0)
|
|
43
|
+
return undefined;
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(line.subarray(0, end).toString('utf8'));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ChildProcess } from 'node:child_process';
|
|
2
|
+
import type { Readable, Writable } from 'node:stream';
|
|
3
|
+
import type { McpCapture } from './capture.js';
|
|
4
|
+
import type { EvidenceSink } from './sink.js';
|
|
5
|
+
/** The byte-faithful proxy core: spawn the MCP server, pipe raw bytes straight through in
|
|
6
|
+
* both directions, and tee COPIES into the frame splitter → capture. The proxy is a pure
|
|
7
|
+
* observer — it never injects, reorders, delays, or modifies frames; the child receives
|
|
8
|
+
* exactly the bytes the client sent and vice versa. Split out of intercept-bin so tests can
|
|
9
|
+
* drive it with in-memory streams while spawning a real child. */
|
|
10
|
+
export interface StartInterceptOptions {
|
|
11
|
+
command: string;
|
|
12
|
+
args: readonly string[];
|
|
13
|
+
capture: McpCapture;
|
|
14
|
+
sink: EvidenceSink;
|
|
15
|
+
/** client→server bytes (process.stdin on the CLI path) */
|
|
16
|
+
stdin: Readable;
|
|
17
|
+
/** server→client bytes (process.stdout on the CLI path) */
|
|
18
|
+
stdout: Writable;
|
|
19
|
+
/** child stderr disposition: 'inherit' passes it through to our stderr */
|
|
20
|
+
stderr: 'inherit' | 'ignore';
|
|
21
|
+
}
|
|
22
|
+
export interface RunningIntercept {
|
|
23
|
+
child: ChildProcess;
|
|
24
|
+
/** resolves with the child's exit code after the outbound writable has drained, capture
|
|
25
|
+
* settles and the sink is flushed; rejects if the child could not be spawned (the sink is
|
|
26
|
+
* still flushed first) */
|
|
27
|
+
done: Promise<number>;
|
|
28
|
+
}
|
|
29
|
+
export declare function startIntercept(opts: StartInterceptOptions): RunningIntercept;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { LineSplitter } from './frames.js';
|
|
3
|
+
export function startIntercept(opts) {
|
|
4
|
+
const child = spawn(opts.command, [...opts.args], { stdio: ['pipe', 'pipe', opts.stderr] });
|
|
5
|
+
const clientFrames = new LineSplitter();
|
|
6
|
+
const serverFrames = new LineSplitter();
|
|
7
|
+
// Byte path: raw pipes. Observer path: 'data' taps feeding frame COPIES to capture —
|
|
8
|
+
// parsing never touches the proxied bytes, and a malformed frame changes nothing.
|
|
9
|
+
opts.stdin.on('data', (chunk) => {
|
|
10
|
+
for (const line of clientFrames.feed(chunk))
|
|
11
|
+
opts.capture.onFrame('client', line);
|
|
12
|
+
});
|
|
13
|
+
opts.stdin.pipe(child.stdin);
|
|
14
|
+
child.stdout.on('data', (chunk) => {
|
|
15
|
+
for (const line of serverFrames.feed(chunk))
|
|
16
|
+
opts.capture.onFrame('server', line);
|
|
17
|
+
});
|
|
18
|
+
// end: false — process.stdout cannot be closed; the exit path decides when we are done
|
|
19
|
+
child.stdout.pipe(opts.stdout, { end: false });
|
|
20
|
+
// EPIPE when the child exits mid-write — the close path below reports the outcome
|
|
21
|
+
child.stdin.on('error', () => { });
|
|
22
|
+
// Exit-path drain: the child's 'close' means its output has been READ into Node userspace —
|
|
23
|
+
// not that the outbound writable has flushed it. For a slow reader the tail of a large final
|
|
24
|
+
// response is still queued here, and resolving 'done' before it drains lets the CLI exit and
|
|
25
|
+
// discard it mid-frame. Wait until the writable holds nothing before settling.
|
|
26
|
+
const drainOutbound = async () => {
|
|
27
|
+
while (!opts.stdout.destroyed && opts.stdout.writableLength > 0) {
|
|
28
|
+
await new Promise((resolve) => {
|
|
29
|
+
const settle = () => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
opts.stdout.off('drain', settle);
|
|
32
|
+
opts.stdout.off('close', settle);
|
|
33
|
+
opts.stdout.off('error', settle);
|
|
34
|
+
resolve();
|
|
35
|
+
};
|
|
36
|
+
// 'drain' only fires after a write returned false; the timer covers bytes queued
|
|
37
|
+
// below the high-water mark, and 'close'/'error' cover a reader that went away.
|
|
38
|
+
const timer = setTimeout(settle, 20);
|
|
39
|
+
opts.stdout.once('drain', settle);
|
|
40
|
+
opts.stdout.once('close', settle);
|
|
41
|
+
opts.stdout.once('error', settle);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const done = new Promise((resolve, reject) => {
|
|
46
|
+
const finish = async () => {
|
|
47
|
+
opts.capture.stop();
|
|
48
|
+
await drainOutbound();
|
|
49
|
+
await opts.capture.settle();
|
|
50
|
+
await opts.sink.close();
|
|
51
|
+
};
|
|
52
|
+
child.once('error', (err) => {
|
|
53
|
+
void finish().then(() => reject(err));
|
|
54
|
+
});
|
|
55
|
+
child.once('close', (code, signal) => {
|
|
56
|
+
void finish().then(() => resolve(code ?? (signal !== null ? 1 : 0)));
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
return { child, done };
|
|
60
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { SignedActionRecord } from '../evidence/action.js';
|
|
2
|
+
export interface EvidenceSinkOptions {
|
|
3
|
+
/** ledger base URL (used only when apiKey + sellerId are both set) */
|
|
4
|
+
ledgerUrl: string;
|
|
5
|
+
apiKey?: string;
|
|
6
|
+
sellerId?: string;
|
|
7
|
+
/** local sink directory — defaults to ~/.tersign; injectable for tests */
|
|
8
|
+
dir?: string;
|
|
9
|
+
fetchImpl?: typeof fetch;
|
|
10
|
+
now?: () => Date;
|
|
11
|
+
/** per-POST cap in ms before falling back to the local file (default 10s) */
|
|
12
|
+
ledgerTimeoutMs?: number;
|
|
13
|
+
/** diagnostic line sink — defaults to stderr */
|
|
14
|
+
warn?: (line: string) => void;
|
|
15
|
+
}
|
|
16
|
+
export declare class EvidenceSink {
|
|
17
|
+
private readonly opts;
|
|
18
|
+
private readonly ledgerUrl;
|
|
19
|
+
private readonly dir;
|
|
20
|
+
private readonly warn;
|
|
21
|
+
private tail;
|
|
22
|
+
private closed;
|
|
23
|
+
/** records the ledger accepted */
|
|
24
|
+
ledgerCount: number;
|
|
25
|
+
/** records written to the local file (including ledger fallbacks) */
|
|
26
|
+
localCount: number;
|
|
27
|
+
/** records lost to both paths (local append failed) */
|
|
28
|
+
failedCount: number;
|
|
29
|
+
/** non-ok ledger responses — the first is warned with status + body excerpt, the rest count silently */
|
|
30
|
+
ledgerRejectCount: number;
|
|
31
|
+
/** the local file, once anything was written there (for the exit summary) */
|
|
32
|
+
localPath: string | null;
|
|
33
|
+
constructor(opts: EvidenceSinkOptions);
|
|
34
|
+
/** Queue one signed record. Returns immediately; never throws. */
|
|
35
|
+
push(signed: SignedActionRecord): void;
|
|
36
|
+
/** Stop accepting and flush everything queued. */
|
|
37
|
+
close(): Promise<void>;
|
|
38
|
+
private write;
|
|
39
|
+
private appendLocal;
|
|
40
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { appendFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/** Non-blocking evidence sink — records queue here and flush sequentially in the
|
|
5
|
+
* background, so evidence I/O never delays the proxied traffic.
|
|
6
|
+
*
|
|
7
|
+
* Ledger mode (apiKey + sellerId configured): POST each signed record to
|
|
8
|
+
* {ledger}/v1/evidence — bearer-key authed, the ledgerClient.ts convention; an HTTP or
|
|
9
|
+
* network failure (each POST capped at 10s) falls back to the local file so no record is
|
|
10
|
+
* dropped. The ledger's /v1/evidence gate only accepts the seller's REGISTERED signing key,
|
|
11
|
+
* so a mismatched signer is warned on the first rejection. Local mode: one
|
|
12
|
+
* JSON line per record appended to <dir>/intercepts-YYYY-MM-DD.jsonl (0600, dir 0700 —
|
|
13
|
+
* the keyfile conventions from keystore.ts). close() drains the queue fully. */
|
|
14
|
+
/** default cap on a single ledger POST — a hung request must never wedge close() */
|
|
15
|
+
const LEDGER_TIMEOUT_MS = 10_000;
|
|
16
|
+
export class EvidenceSink {
|
|
17
|
+
opts;
|
|
18
|
+
ledgerUrl;
|
|
19
|
+
dir;
|
|
20
|
+
warn;
|
|
21
|
+
tail = Promise.resolve();
|
|
22
|
+
closed = false;
|
|
23
|
+
/** records the ledger accepted */
|
|
24
|
+
ledgerCount = 0;
|
|
25
|
+
/** records written to the local file (including ledger fallbacks) */
|
|
26
|
+
localCount = 0;
|
|
27
|
+
/** records lost to both paths (local append failed) */
|
|
28
|
+
failedCount = 0;
|
|
29
|
+
/** non-ok ledger responses — the first is warned with status + body excerpt, the rest count silently */
|
|
30
|
+
ledgerRejectCount = 0;
|
|
31
|
+
/** the local file, once anything was written there (for the exit summary) */
|
|
32
|
+
localPath = null;
|
|
33
|
+
constructor(opts) {
|
|
34
|
+
this.opts = opts;
|
|
35
|
+
this.ledgerUrl = opts.ledgerUrl.replace(/\/$/, '');
|
|
36
|
+
this.dir = opts.dir ?? join(homedir(), '.tersign');
|
|
37
|
+
this.warn = opts.warn ?? ((line) => console.error(line));
|
|
38
|
+
}
|
|
39
|
+
/** Queue one signed record. Returns immediately; never throws. */
|
|
40
|
+
push(signed) {
|
|
41
|
+
if (this.closed)
|
|
42
|
+
return;
|
|
43
|
+
this.tail = this.tail.then(() => this.write(signed));
|
|
44
|
+
}
|
|
45
|
+
/** Stop accepting and flush everything queued. */
|
|
46
|
+
async close() {
|
|
47
|
+
this.closed = true;
|
|
48
|
+
await this.tail;
|
|
49
|
+
}
|
|
50
|
+
async write(signed) {
|
|
51
|
+
if (this.opts.apiKey !== undefined && this.opts.sellerId !== undefined) {
|
|
52
|
+
try {
|
|
53
|
+
const f = this.opts.fetchImpl ?? fetch;
|
|
54
|
+
const signal = AbortSignal.timeout(this.opts.ledgerTimeoutMs ?? LEDGER_TIMEOUT_MS);
|
|
55
|
+
// Raced explicitly against the abort: even a fetch impl that ignores the signal must
|
|
56
|
+
// not wedge close() — the record falls back to the local file instead.
|
|
57
|
+
const res = await Promise.race([
|
|
58
|
+
f(`${this.ledgerUrl}/v1/evidence`, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${this.opts.apiKey}` },
|
|
61
|
+
body: JSON.stringify({ sellerId: this.opts.sellerId, artifact: signed }),
|
|
62
|
+
signal,
|
|
63
|
+
}),
|
|
64
|
+
new Promise((_, reject) => {
|
|
65
|
+
signal.addEventListener('abort', () => reject(new Error('ledger request timed out')), { once: true });
|
|
66
|
+
}),
|
|
67
|
+
]);
|
|
68
|
+
// Always consume the body — an unread body pins the connection in undici's pool.
|
|
69
|
+
const body = await res.text().catch(() => '');
|
|
70
|
+
if (res.ok) {
|
|
71
|
+
this.ledgerCount += 1;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
this.ledgerRejectCount += 1;
|
|
75
|
+
if (this.ledgerRejectCount === 1) {
|
|
76
|
+
const excerpt = body.replace(/\s+/g, ' ').trim().slice(0, 160);
|
|
77
|
+
this.warn(`tersign intercept: ledger rejected the record (HTTP ${res.status}${excerpt !== '' ? ` — ${excerpt}` : ''}); ` +
|
|
78
|
+
'writing to the local file instead. Hosted mode requires your registered signer key — ' +
|
|
79
|
+
'set TERSIGN_SELLER_KEY to the key registered for this sellerId.');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// network failure or timeout — fall through to the local file
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
await this.appendLocal(signed);
|
|
88
|
+
this.localCount += 1;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
this.failedCount += 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async appendLocal(signed) {
|
|
95
|
+
if (this.localPath === null) {
|
|
96
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
97
|
+
const day = (this.opts.now ?? (() => new Date()))().toISOString().slice(0, 10);
|
|
98
|
+
this.localPath = join(this.dir, `intercepts-${day}.jsonl`);
|
|
99
|
+
}
|
|
100
|
+
await appendFile(this.localPath, `${JSON.stringify(signed)}\n`, { mode: 0o600 });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** tersign intercept — byte-faithful MCP stdio proxy with signed, digest-only evidence.
|
|
3
|
+
*
|
|
4
|
+
* tersign intercept [--events tools/call,prompts/get] [--agent-id id] [--ledger url] \
|
|
5
|
+
* -- npx some-mcp-server --its-flags
|
|
6
|
+
*
|
|
7
|
+
* Sits between an MCP client and a stdio MCP server, passes every byte through untouched,
|
|
8
|
+
* and emits one signed ActionRecordV1 per captured tool call — digests of arguments and
|
|
9
|
+
* results only, never content (data-minimization by construction). Records go to the hosted
|
|
10
|
+
* ledger when TERSIGN_LEDGER_API_KEY + TERSIGN_LEDGER_SELLER_ID are set, else to
|
|
11
|
+
* ~/.tersign/intercepts-YYYY-MM-DD.jsonl. The hosted /v1/evidence gate only accepts the
|
|
12
|
+
* seller's REGISTERED signing key: set TERSIGN_SELLER_KEY to that key, or every POST is
|
|
13
|
+
* rejected (warned once) and records fall back to the local file.
|
|
14
|
+
*
|
|
15
|
+
* Exit code: the child's, except 1 when evidence was lost (failed both sinks) while the
|
|
16
|
+
* child itself exited 0 — a child failure code is never masked.
|
|
17
|
+
*
|
|
18
|
+
* SEP-2624 ("Interceptors for the Model Context Protocol" — Draft, Bloomberg/Saxo-led)
|
|
19
|
+
* defines audit-mode validators that observe traffic without mutating it; this prototype
|
|
20
|
+
* implements that capture semantics as a transport-level proxy today and is structured to
|
|
21
|
+
* migrate to the interceptor primitive when the SEP stabilizes. It makes no conformance
|
|
22
|
+
* claim against the SEP — the SEP is a draft proposal, not a released spec. */
|
|
23
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
24
|
+
import { DEFAULT_EVENTS, McpCapture } from './intercept/capture.js';
|
|
25
|
+
import { parseInterceptFlags } from './intercept/flags.js';
|
|
26
|
+
import { startIntercept } from './intercept/proxy.js';
|
|
27
|
+
import { EvidenceSink } from './intercept/sink.js';
|
|
28
|
+
import { resolveSignerKey } from './keystore.js';
|
|
29
|
+
const USAGE = 'usage: tersign intercept [--events tools/call,prompts/get] [--agent-id id] [--ledger url] -- <mcp server command> [args…]\n' +
|
|
30
|
+
'hosted ledger mode (TERSIGN_LEDGER_API_KEY + TERSIGN_LEDGER_SELLER_ID) requires the signer key\n' +
|
|
31
|
+
'registered for that sellerId — set TERSIGN_SELLER_KEY; rejected records fall back to ~/.tersign.\n' +
|
|
32
|
+
"exits with the child's code; 1 if evidence was lost while the child exited 0";
|
|
33
|
+
const sep = process.argv.indexOf('--');
|
|
34
|
+
const command = sep === -1 ? [] : process.argv.slice(sep + 1);
|
|
35
|
+
if (sep === -1 || command.length === 0) {
|
|
36
|
+
console.error(USAGE);
|
|
37
|
+
process.exit(2);
|
|
38
|
+
}
|
|
39
|
+
// Flags live strictly BEFORE '--'; everything after belongs to the child verbatim.
|
|
40
|
+
const parsed = parseInterceptFlags(process.argv.slice(2, sep));
|
|
41
|
+
if (!parsed.ok) {
|
|
42
|
+
console.error(`tersign intercept: ${parsed.error}\n${USAGE}`);
|
|
43
|
+
process.exit(2);
|
|
44
|
+
}
|
|
45
|
+
const flags = parsed.flags;
|
|
46
|
+
// --events ADDS to the default set; '*' captures every client→server request method.
|
|
47
|
+
const events = [...DEFAULT_EVENTS];
|
|
48
|
+
for (const e of (flags.events ?? '').split(',')) {
|
|
49
|
+
const method = e.trim();
|
|
50
|
+
if (method !== '' && !events.includes(method))
|
|
51
|
+
events.push(method);
|
|
52
|
+
}
|
|
53
|
+
const envAgentId = process.env.TERSIGN_AGENT_ID;
|
|
54
|
+
const agentId = flags.agentId ?? (envAgentId !== undefined && envAgentId !== '' ? envAgentId : 'mcp-intercept');
|
|
55
|
+
const ledgerUrl = flags.ledger ?? process.env.TERSIGN_LEDGER_URL ?? 'https://tersign.ai';
|
|
56
|
+
const apiKey = process.env.TERSIGN_LEDGER_API_KEY;
|
|
57
|
+
const sellerId = process.env.TERSIGN_LEDGER_SELLER_ID;
|
|
58
|
+
try {
|
|
59
|
+
const { key, source } = resolveSignerKey({ create: true });
|
|
60
|
+
const account = privateKeyToAccount(key);
|
|
61
|
+
console.error(`signing key: ${account.address} (${source})`);
|
|
62
|
+
const sink = new EvidenceSink({
|
|
63
|
+
ledgerUrl,
|
|
64
|
+
...(apiKey !== undefined && apiKey !== '' ? { apiKey } : {}),
|
|
65
|
+
...(sellerId !== undefined && sellerId !== '' ? { sellerId } : {}),
|
|
66
|
+
});
|
|
67
|
+
// Capture warnings (dropped stale pairings, cap evictions, signing failures) are
|
|
68
|
+
// diagnostics, not traffic — rate-limited so a pathological stream cannot flood stderr.
|
|
69
|
+
let captureWarnings = 0;
|
|
70
|
+
const onError = (err) => {
|
|
71
|
+
captureWarnings += 1;
|
|
72
|
+
if (captureWarnings <= 5)
|
|
73
|
+
console.error(`tersign intercept: ${err instanceof Error ? err.message : String(err)}`);
|
|
74
|
+
if (captureWarnings === 6)
|
|
75
|
+
console.error('tersign intercept: further capture warnings suppressed');
|
|
76
|
+
};
|
|
77
|
+
const capture = new McpCapture({ agentId, account, events, onRecord: (r) => sink.push(r), onError });
|
|
78
|
+
const { child, done } = startIntercept({
|
|
79
|
+
command: command[0],
|
|
80
|
+
args: command.slice(1),
|
|
81
|
+
capture,
|
|
82
|
+
sink,
|
|
83
|
+
stdin: process.stdin,
|
|
84
|
+
stdout: process.stdout,
|
|
85
|
+
stderr: 'inherit',
|
|
86
|
+
});
|
|
87
|
+
let childClosed = false;
|
|
88
|
+
child.once('close', () => {
|
|
89
|
+
childClosed = true;
|
|
90
|
+
});
|
|
91
|
+
let signalled = false;
|
|
92
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
93
|
+
const code = sig === 'SIGINT' ? 130 : 143; // 128 + signal number
|
|
94
|
+
process.on(sig, () => {
|
|
95
|
+
if (signalled)
|
|
96
|
+
process.exit(code); // a second signal always force-exits immediately
|
|
97
|
+
signalled = true;
|
|
98
|
+
if (childClosed) {
|
|
99
|
+
// the child is already gone, so there is nothing to forward to — flush best-effort
|
|
100
|
+
// (every sink write is time-capped), then force-exit
|
|
101
|
+
void sink.close().finally(() => process.exit(code));
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
// forward to the child; its close event drives the flush + summary below
|
|
105
|
+
child.kill(sig);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const code = await done;
|
|
110
|
+
console.error(`tersign intercept: ${capture.recordCount} tool-call records (${sink.ledgerCount} ledger, ${sink.localCount} local` +
|
|
111
|
+
(sink.failedCount > 0 ? `, ${sink.failedCount} FAILED — evidence lost` : '') +
|
|
112
|
+
')' +
|
|
113
|
+
(sink.localPath !== null ? ` — ${sink.localPath}` : ''));
|
|
114
|
+
// Natural exit (exitCode + stdin teardown, never process.exit()) so Node flushes any stdout
|
|
115
|
+
// still queued for a slow reader; evidence loss becomes exit 1 only when the child exited 0.
|
|
116
|
+
process.stdin.pause();
|
|
117
|
+
process.exitCode = sink.failedCount > 0 && code === 0 ? 1 : code;
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
121
|
+
process.stdin.pause();
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
}
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -8,6 +8,6 @@ export declare function envDeps(env?: Record<string, string | undefined>): McpDe
|
|
|
8
8
|
* every client; mcp.test.ts pins it against package.json so a release bump can't drift it. */
|
|
9
9
|
export declare const MCP_SERVER_IDENTITY: {
|
|
10
10
|
readonly name: "tersign";
|
|
11
|
-
readonly version: "0.
|
|
11
|
+
readonly version: "0.3.0";
|
|
12
12
|
};
|
|
13
13
|
export declare function buildServer(deps: McpDeps): McpServer;
|
package/dist/mcp/server.js
CHANGED
|
@@ -45,7 +45,7 @@ function json(value) {
|
|
|
45
45
|
}
|
|
46
46
|
/** MUST match package.json name/version — the MCP handshake self-reports this identity to
|
|
47
47
|
* every client; mcp.test.ts pins it against package.json so a release bump can't drift it. */
|
|
48
|
-
export const MCP_SERVER_IDENTITY = { name: 'tersign', version: '0.
|
|
48
|
+
export const MCP_SERVER_IDENTITY = { name: 'tersign', version: '0.3.0' };
|
|
49
49
|
export function buildServer(deps) {
|
|
50
50
|
const server = new McpServer(MCP_SERVER_IDENTITY);
|
|
51
51
|
server.registerTool('issue_receipt', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tersign",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Tersign \u2014 the evidence layer for the agent economy. Counter-signed receipts, agent action records, idempotency enforcement, refunds, disputes, and jury-ready evidence envelopes for x402/agent-commerce sellers.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|