signafaide 0.2.1 → 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 +32 -1
- package/dist/{chunk-MBXHRQ2J.js → chunk-6UDL5LZL.js} +96 -6
- package/dist/{chunk-S2RSID5F.js → chunk-DRRRZNIH.js} +1 -1
- package/dist/{chunk-4C2JEYAU.js → chunk-P3GJDXSQ.js} +291 -32
- package/dist/cli.js +403 -6
- package/dist/{core-BMfko_ex.d.ts → core-BTJGPYVW.d.cts} +64 -2
- package/dist/{core-BMfko_ex.d.cts → core-BTJGPYVW.d.ts} +64 -2
- package/dist/core.cjs +104 -6
- package/dist/core.d.cts +1 -1
- package/dist/core.d.ts +1 -1
- package/dist/core.js +20 -2
- package/dist/{http-YWG535HP.js → http-BZI6VZM3.js} +1 -1
- package/dist/index.cjs +391 -30
- package/dist/index.d.cts +98 -5
- package/dist/index.d.ts +98 -5
- package/dist/index.js +29 -3
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -6,19 +6,24 @@ import {
|
|
|
6
6
|
configure,
|
|
7
7
|
envOf,
|
|
8
8
|
findRoot,
|
|
9
|
+
recordingDisabled,
|
|
9
10
|
replay,
|
|
10
11
|
termsNotice,
|
|
12
|
+
toJsonable,
|
|
11
13
|
verify
|
|
12
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-P3GJDXSQ.js";
|
|
13
15
|
import {
|
|
14
16
|
KeysRequired,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
declaredName,
|
|
18
|
+
plural,
|
|
19
|
+
recordHash,
|
|
20
|
+
sha256Hex
|
|
21
|
+
} from "./chunk-6UDL5LZL.js";
|
|
17
22
|
import {
|
|
18
23
|
SignafaideError,
|
|
19
24
|
VERSION,
|
|
20
25
|
detectAgent
|
|
21
|
-
} from "./chunk-
|
|
26
|
+
} from "./chunk-DRRRZNIH.js";
|
|
22
27
|
|
|
23
28
|
// src/cli.ts
|
|
24
29
|
import * as fs2 from "fs";
|
|
@@ -65,7 +70,8 @@ Use it when the user asks for an audit trail, tamper-evident records or complian
|
|
|
65
70
|
A reason or choice that is not a tool call: \`signafaide.note("chose_escalation", {"reason": ...})\` (Python) or \`note(...)\` (TypeScript).
|
|
66
71
|
|
|
67
72
|
Tools that move money, freeze or close accounts, delete data or send messages: wrap them in strict mode, \`wrap_tools([...], agent="claims-bot", strict=True)\` or \`wrapTools({...}, { agent: "claims-bot", strict: true })\`, so the tool runs only once the witness has co-signed its decision record, and not at all when it cannot be reached. Strict mode needs an API key: until a person opts in, a strict call fails with \`no_key\` and its tool does not run, so say so in your final message.
|
|
68
|
-
3.
|
|
73
|
+
3. Record what you did not wrap. MCP servers the agent uses: start each one through the gateway in the agent's MCP configuration, \`{"command": "npx", "args": ["-y", "signafaide", "gateway", "--agent", "claims-bot", "--", <the server's command and arguments>]}\`; it records every tool call that passes through it and forwards none it cannot record. Then give the full list of the tools your code hands the agent, once at start-up: \`signafaide.declare_tools(["approve_payout", "escalate", "web_search"], agent="claims-bot")\` (Python) or \`declareTools([...], { agent: "claims-bot" })\` (TypeScript). A tool you did not wrap is declared as not recorded, and \`signafaide verify\` names it. The gateway records only the calls that go through it, so make it the only route to those tools.
|
|
74
|
+
4. Run the agent once, then \`signafaide verify\` (\`uv run signafaide verify\`, \`.venv/bin/signafaide verify\` or \`npx signafaide verify\`). It checks signatures, chain links and content hashes on this machine, and ends with a line such as \`PASS: 4 of 12 checks passed, 8 not checked (nothing co-signed yet)\`. The other checks need the witness.
|
|
69
75
|
|
|
70
76
|
## Sending records to the witness
|
|
71
77
|
|
|
@@ -354,6 +360,367 @@ function setup(opts) {
|
|
|
354
360
|
return chosen.map(({ agent, reason }) => ({ agent: agent.id, reason, changes: agent.install(ctx), ...agent.note ? { note: agent.note } : {} }));
|
|
355
361
|
}
|
|
356
362
|
|
|
363
|
+
// src/gateway.ts
|
|
364
|
+
import { spawn } from "child_process";
|
|
365
|
+
import * as os2 from "os";
|
|
366
|
+
var GATEWAY_ERROR = -32e3;
|
|
367
|
+
var FORWARD = { forward: true };
|
|
368
|
+
var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
369
|
+
function idKey(id) {
|
|
370
|
+
return typeof id === "string" ? `s:${id}` : typeof id === "number" ? `n:${id}` : void 0;
|
|
371
|
+
}
|
|
372
|
+
function parse(line) {
|
|
373
|
+
const text = line.toString("utf8").trim();
|
|
374
|
+
if (!text || text[0] !== "{" && text[0] !== "[") return void 0;
|
|
375
|
+
try {
|
|
376
|
+
return JSON.parse(text);
|
|
377
|
+
} catch {
|
|
378
|
+
return void 0;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function resultText(result) {
|
|
382
|
+
const parts = Array.isArray(result.content) ? result.content.filter((c) => isObject(c) && c.type === "text" && typeof c.text === "string").map((c) => c.text) : [];
|
|
383
|
+
return parts.join("\n").slice(0, 2e3) || "the tool reported an error";
|
|
384
|
+
}
|
|
385
|
+
function outcomeContent(answer) {
|
|
386
|
+
if (isObject(answer.error)) {
|
|
387
|
+
return { error: toJsonable({ type: "JsonRpcError", message: String(answer.error.message ?? "the server answered with an error"), code: answer.error.code ?? null }) };
|
|
388
|
+
}
|
|
389
|
+
const result = answer.result;
|
|
390
|
+
if (isObject(result) && result.isError === true) return { error: { type: "ToolError", message: resultText(result) }, output: toJsonable(result) };
|
|
391
|
+
return { output: toJsonable(result) ?? null };
|
|
392
|
+
}
|
|
393
|
+
function runGateway(opts) {
|
|
394
|
+
return new Gateway(opts).run();
|
|
395
|
+
}
|
|
396
|
+
var Gateway = class {
|
|
397
|
+
constructor(opts) {
|
|
398
|
+
this.opts = opts;
|
|
399
|
+
this.input = opts.stdin ?? process.stdin;
|
|
400
|
+
this.out = opts.stdout ?? process.stdout;
|
|
401
|
+
this.err = opts.stderr ?? process.stderr;
|
|
402
|
+
this.prefix = opts.namePrefix ?? "";
|
|
403
|
+
this.strictTools = new Set(opts.strictTools ?? []);
|
|
404
|
+
this.source = `gateway:${this.prefix || sha256Hex(new TextEncoder().encode([opts.command, ...opts.args ?? []].join("\0"))).slice(0, 8)}`;
|
|
405
|
+
this.rec = opts.recorder ?? configure({ via: "gateway", storeContent: opts.storeContent });
|
|
406
|
+
}
|
|
407
|
+
opts;
|
|
408
|
+
rec;
|
|
409
|
+
input;
|
|
410
|
+
out;
|
|
411
|
+
err;
|
|
412
|
+
prefix;
|
|
413
|
+
source;
|
|
414
|
+
strictTools;
|
|
415
|
+
off = recordingDisabled();
|
|
416
|
+
child;
|
|
417
|
+
/** Client-to-server `tools/call` requests waiting for their answer, by id. Server-to-client
|
|
418
|
+
* requests have ids of their own and are never looked up here. */
|
|
419
|
+
calls = /* @__PURE__ */ new Map();
|
|
420
|
+
/** Client-to-server `tools/list` requests waiting for their answer, by id. */
|
|
421
|
+
lists = /* @__PURE__ */ new Map();
|
|
422
|
+
/** The listing the client is paging through, and the tools of the last one it finished. */
|
|
423
|
+
listing = null;
|
|
424
|
+
shown = null;
|
|
425
|
+
/** Answers held for a client batch whose other entries went on to the server, by their ids. */
|
|
426
|
+
held = /* @__PURE__ */ new Map();
|
|
427
|
+
/** Strict calls waiting for the witness. */
|
|
428
|
+
waiting = /* @__PURE__ */ new Set();
|
|
429
|
+
/** The shown tools changed since they were last declared. */
|
|
430
|
+
undeclared = false;
|
|
431
|
+
toldDeclare = false;
|
|
432
|
+
done = false;
|
|
433
|
+
diag(msg) {
|
|
434
|
+
this.err.write(`signafaide gateway: ${msg}
|
|
435
|
+
`);
|
|
436
|
+
}
|
|
437
|
+
// --- relay --------------------------------------------------------------------------
|
|
438
|
+
toServer(data) {
|
|
439
|
+
const stdin = this.child.stdin;
|
|
440
|
+
if (!stdin || stdin.destroyed || stdin.writableEnded) return;
|
|
441
|
+
if (!stdin.write(data)) {
|
|
442
|
+
this.input.pause();
|
|
443
|
+
stdin.once("drain", () => this.input.resume());
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
toClient(data) {
|
|
447
|
+
if (!this.out.write(data)) {
|
|
448
|
+
this.child.stdout?.pause();
|
|
449
|
+
this.out.once("drain", () => this.child.stdout?.resume());
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
send(v, original) {
|
|
453
|
+
if ("forward" in v) this.toServer(Buffer.concat([original, NL]));
|
|
454
|
+
else if ("reply" in v) this.toClient(JSON.stringify(v.reply) + "\n");
|
|
455
|
+
}
|
|
456
|
+
error(id, message, code) {
|
|
457
|
+
return { jsonrpc: "2.0", id: id ?? null, error: { code: GATEWAY_ERROR, message, data: { signafaide: code } } };
|
|
458
|
+
}
|
|
459
|
+
// --- client to server -----------------------------------------------------------------
|
|
460
|
+
fromClient(line) {
|
|
461
|
+
const msg = this.off ? void 0 : parse(line);
|
|
462
|
+
if (msg === void 0) return this.toServer(Buffer.concat([line, NL]));
|
|
463
|
+
if (Array.isArray(msg)) return this.clientBatch(msg, line);
|
|
464
|
+
const v = this.clientMessage(msg);
|
|
465
|
+
if (v instanceof Promise) this.later(v.then((got) => this.send(got, line)));
|
|
466
|
+
else this.send(v, line);
|
|
467
|
+
}
|
|
468
|
+
/** Keep a strict call's wait, so the server's stdin is not closed under it. */
|
|
469
|
+
later(p) {
|
|
470
|
+
const tracked = p.catch((e) => this.diag(`${e.message}`)).finally(() => this.waiting.delete(tracked));
|
|
471
|
+
this.waiting.add(tracked);
|
|
472
|
+
}
|
|
473
|
+
/** A batch: each entry is judged like a single message. Entries that may not run are taken
|
|
474
|
+
* out and answered here; their answers travel with the server's answer to the rest. */
|
|
475
|
+
clientBatch(msgs, line) {
|
|
476
|
+
const verdicts = msgs.map((m) => this.clientMessage(m));
|
|
477
|
+
const finish = (vs) => {
|
|
478
|
+
const forward = msgs.filter((_, i) => "forward" in vs[i]);
|
|
479
|
+
const replies = vs.flatMap((v) => "reply" in v ? [v.reply] : []);
|
|
480
|
+
if (forward.length === msgs.length) return this.toServer(Buffer.concat([line, NL]));
|
|
481
|
+
if (forward.length) this.toServer(JSON.stringify(forward) + "\n");
|
|
482
|
+
if (!replies.length) return;
|
|
483
|
+
const answered = forward.flatMap((m) => isObject(m) && typeof m.method === "string" && idKey(m.id) !== void 0 ? [idKey(m.id)] : []);
|
|
484
|
+
if (!answered.length) return this.toClient(JSON.stringify(replies) + "\n");
|
|
485
|
+
const group = { replies };
|
|
486
|
+
for (const k of answered) this.held.set(k, group);
|
|
487
|
+
};
|
|
488
|
+
if (verdicts.some((v) => v instanceof Promise)) this.later(Promise.all(verdicts).then(finish));
|
|
489
|
+
else finish(verdicts);
|
|
490
|
+
}
|
|
491
|
+
clientMessage(msg) {
|
|
492
|
+
if (!isObject(msg) || typeof msg.method !== "string") return FORWARD;
|
|
493
|
+
const key = idKey(msg.id);
|
|
494
|
+
if (msg.method === "tools/list" && key !== void 0) {
|
|
495
|
+
const cursor = isObject(msg.params) ? msg.params.cursor : void 0;
|
|
496
|
+
if (typeof cursor !== "string" || !cursor || !this.listing) this.listing = { names: /* @__PURE__ */ new Set(), complete: false };
|
|
497
|
+
this.lists.set(key, this.listing);
|
|
498
|
+
} else if (msg.method === "notifications/cancelled") {
|
|
499
|
+
this.cancelled(isObject(msg.params) ? msg.params : {});
|
|
500
|
+
} else if (msg.method === "tools/call") {
|
|
501
|
+
return this.call(msg, key);
|
|
502
|
+
}
|
|
503
|
+
return FORWARD;
|
|
504
|
+
}
|
|
505
|
+
call(msg, key) {
|
|
506
|
+
const params = isObject(msg.params) ? msg.params : {};
|
|
507
|
+
const tool = params.name;
|
|
508
|
+
if (typeof tool !== "string" || !tool) return FORWARD;
|
|
509
|
+
const full = this.prefix + tool;
|
|
510
|
+
const name = declaredName(full);
|
|
511
|
+
this.declareShown();
|
|
512
|
+
const meta = { sdk: `signafaide-js/${VERSION}`, gateway: true };
|
|
513
|
+
if (name !== tool) meta.tool = tool;
|
|
514
|
+
let decision;
|
|
515
|
+
try {
|
|
516
|
+
const content = { input: toJsonable(params.arguments) ?? null, meta };
|
|
517
|
+
decision = this.rec.write("decision", name, content, { agent: this.opts.agent, storeContent: this.opts.storeContent });
|
|
518
|
+
} catch (e) {
|
|
519
|
+
const why2 = e.message;
|
|
520
|
+
this.diag(`${tool} was not run because the call could not be recorded: ${why2}`);
|
|
521
|
+
return key === void 0 ? { drop: true } : { reply: this.error(msg.id, `signafaide gateway: ${tool} was not run because the call could not be recorded: ${why2}`, "not_written") };
|
|
522
|
+
}
|
|
523
|
+
if (key === void 0) return FORWARD;
|
|
524
|
+
const old = this.calls.get(key);
|
|
525
|
+
if (old && old.state !== "cancelled") this.outcome(old, { error: { type: "NoAnswer", message: "the client used this request id again before the server answered" } });
|
|
526
|
+
const call = { key, name, decision, state: "forwarded" };
|
|
527
|
+
this.calls.set(key, call);
|
|
528
|
+
if (!(this.opts.strict || this.strictTools.has(tool)) || this.rec.test) return FORWARD;
|
|
529
|
+
call.state = "waiting";
|
|
530
|
+
const gone = () => {
|
|
531
|
+
if (this.calls.get(key) === call) this.calls.delete(key);
|
|
532
|
+
return { drop: true };
|
|
533
|
+
};
|
|
534
|
+
return this.rec.ensureReceipt(decision).then(
|
|
535
|
+
() => {
|
|
536
|
+
if (call.state === "cancelled") return gone();
|
|
537
|
+
call.state = "forwarded";
|
|
538
|
+
return FORWARD;
|
|
539
|
+
},
|
|
540
|
+
(e) => {
|
|
541
|
+
const err2 = e instanceof SignafaideError ? e : new SignafaideError(e.message);
|
|
542
|
+
this.rec.warnStrictRefused(name, err2);
|
|
543
|
+
if (call.state === "cancelled") return gone();
|
|
544
|
+
this.calls.delete(key);
|
|
545
|
+
return { reply: this.error(msg.id, `${err2.message} (strict mode: ${name} did not run)`, err2.code) };
|
|
546
|
+
}
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
cancelled(params) {
|
|
550
|
+
const key = idKey(params.requestId);
|
|
551
|
+
const call = key === void 0 ? void 0 : this.calls.get(key);
|
|
552
|
+
if (!call || call.state === "cancelled") return;
|
|
553
|
+
const reason = typeof params.reason === "string" && params.reason ? `: ${params.reason.slice(0, 500)}` : "";
|
|
554
|
+
const when = call.state === "waiting" ? "before the call was forwarded to the server" : "before the server answered";
|
|
555
|
+
this.outcome(call, { error: { type: "Cancelled", message: `the client cancelled the call ${when}${reason}` } });
|
|
556
|
+
if (call.state === "waiting") call.state = "cancelled";
|
|
557
|
+
else this.calls.delete(call.key);
|
|
558
|
+
}
|
|
559
|
+
// --- server to client -----------------------------------------------------------------
|
|
560
|
+
fromServer(line) {
|
|
561
|
+
const msg = this.off ? void 0 : parse(line);
|
|
562
|
+
if (msg === void 0) return this.toClient(Buffer.concat([line, NL]));
|
|
563
|
+
const answers = (Array.isArray(msg) ? msg : [msg]).filter((m) => isObject(m) && typeof m.method !== "string" && idKey(m.id) !== void 0);
|
|
564
|
+
for (const m of answers) this.answer(m);
|
|
565
|
+
const groups = new Set(answers.flatMap((m) => this.held.has(idKey(m.id)) ? [this.held.get(idKey(m.id))] : []));
|
|
566
|
+
for (const m of answers) this.held.delete(idKey(m.id));
|
|
567
|
+
const extra = [...groups].flatMap((g) => g.replies.splice(0));
|
|
568
|
+
if (!extra.length) return this.toClient(Buffer.concat([line, NL]));
|
|
569
|
+
if (Array.isArray(msg)) return this.toClient(JSON.stringify([...msg, ...extra]) + "\n");
|
|
570
|
+
this.toClient(Buffer.concat([line, NL]));
|
|
571
|
+
this.toClient(JSON.stringify(extra) + "\n");
|
|
572
|
+
}
|
|
573
|
+
/** An answer from the server to a request of the client's. */
|
|
574
|
+
answer(m) {
|
|
575
|
+
const key = idKey(m.id);
|
|
576
|
+
const listing = this.lists.get(key);
|
|
577
|
+
if (listing) {
|
|
578
|
+
this.lists.delete(key);
|
|
579
|
+
this.listed(listing, m);
|
|
580
|
+
}
|
|
581
|
+
const call = this.calls.get(key);
|
|
582
|
+
if (call && call.state === "forwarded") {
|
|
583
|
+
this.calls.delete(key);
|
|
584
|
+
this.outcome(call, outcomeContent(m));
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
listed(listing, m) {
|
|
588
|
+
const result = m.result;
|
|
589
|
+
if (!isObject(result) || !Array.isArray(result.tools)) return;
|
|
590
|
+
for (const t of result.tools) if (isObject(t) && typeof t.name === "string" && t.name) listing.names.add(t.name);
|
|
591
|
+
this.undeclared = true;
|
|
592
|
+
if (typeof result.nextCursor === "string" && result.nextCursor) return;
|
|
593
|
+
listing.complete = true;
|
|
594
|
+
this.shown = listing.names;
|
|
595
|
+
this.declareShown();
|
|
596
|
+
}
|
|
597
|
+
/** Declare the tools the client has been shown: the last finished listing, with the pages of
|
|
598
|
+
* the one it is still paging through. Written once per distinct list (the recorder sees to
|
|
599
|
+
* that). A failure is reported once and never stops the relay. */
|
|
600
|
+
declareShown() {
|
|
601
|
+
const paging = this.listing && !this.listing.complete ? this.listing.names : null;
|
|
602
|
+
if (!this.undeclared || !this.shown && !paging?.size) return;
|
|
603
|
+
const tools = {};
|
|
604
|
+
for (const n of [...this.shown ?? [], ...paging ?? []]) tools[this.prefix + n] = true;
|
|
605
|
+
try {
|
|
606
|
+
this.rec.declareTools(tools, { agent: this.opts.agent, source: this.source, origin: "gateway", storeContent: this.opts.storeContent });
|
|
607
|
+
this.undeclared = false;
|
|
608
|
+
} catch (e) {
|
|
609
|
+
if (!this.toldDeclare) this.diag(`could not write the tool declaration: ${e.message}`);
|
|
610
|
+
this.toldDeclare = true;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
outcome(call, content) {
|
|
614
|
+
try {
|
|
615
|
+
this.rec.write("outcome", call.name, content, { agent: call.decision.envelope.agent, ref: recordHash(call.decision), storeContent: this.opts.storeContent });
|
|
616
|
+
} catch (e) {
|
|
617
|
+
this.diag(`could not write the outcome of ${call.name}: ${e.message}`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
// --- life cycle -----------------------------------------------------------------------
|
|
621
|
+
run() {
|
|
622
|
+
return new Promise((resolve3) => {
|
|
623
|
+
const { command, args = [] } = this.opts;
|
|
624
|
+
if (this.off) this.diag("SIGNAFAIDE_DISABLED is set: relaying only, nothing is recorded");
|
|
625
|
+
const child = this.child = spawn(command, args, { stdio: ["pipe", "pipe", this.opts.stderr ? "pipe" : "inherit"], windowsHide: true, env: this.opts.env ?? process.env });
|
|
626
|
+
const signals = this.opts.forwardSignals === false ? [] : ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
627
|
+
let killer;
|
|
628
|
+
const onSignal = (sig) => {
|
|
629
|
+
child.kill(sig);
|
|
630
|
+
killer ??= setTimeout(() => child.kill("SIGKILL"), this.opts.killAfterMs ?? 5e3);
|
|
631
|
+
killer.unref?.();
|
|
632
|
+
};
|
|
633
|
+
for (const s of signals) process.on(s, onSignal);
|
|
634
|
+
const finish = async (result, upstream) => {
|
|
635
|
+
if (this.done) return;
|
|
636
|
+
this.done = true;
|
|
637
|
+
clearTimeout(killer);
|
|
638
|
+
for (const s of signals) process.off(s, onSignal);
|
|
639
|
+
this.input.off("data", onInput);
|
|
640
|
+
this.input.off("end", onEnd);
|
|
641
|
+
this.input.pause();
|
|
642
|
+
await Promise.allSettled([...this.waiting]);
|
|
643
|
+
for (const call of [...this.calls.values()]) {
|
|
644
|
+
if (call.state !== "cancelled") this.outcome(call, { error: { type: "UpstreamExited", message: `${upstream} before it answered` } });
|
|
645
|
+
}
|
|
646
|
+
this.calls.clear();
|
|
647
|
+
if (!this.off) this.declareShown();
|
|
648
|
+
try {
|
|
649
|
+
this.rec.releaseToolSources(this.source);
|
|
650
|
+
if (!this.opts.recorder) await this.rec.shutdown();
|
|
651
|
+
} catch (e) {
|
|
652
|
+
this.diag(e.message);
|
|
653
|
+
}
|
|
654
|
+
if (this.out.writableLength && !this.out.destroyed) await new Promise((r) => this.out.write("", () => r()));
|
|
655
|
+
resolve3(result);
|
|
656
|
+
};
|
|
657
|
+
child.once("error", (e) => {
|
|
658
|
+
this.diag(`could not start ${command}: ${e.message}`);
|
|
659
|
+
void finish({ code: e.code === "ENOENT" ? 127 : e.code === "EACCES" ? 126 : 1, signal: null }, "the MCP server could not be started");
|
|
660
|
+
});
|
|
661
|
+
const guard = (what, fn) => {
|
|
662
|
+
try {
|
|
663
|
+
fn();
|
|
664
|
+
} catch (e) {
|
|
665
|
+
this.diag(`${what} dropped: ${e.message}`);
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
const clientLines = new Lines((l) => guard("a client message was", () => this.fromClient(l)));
|
|
669
|
+
const serverLines = new Lines((l) => guard("a server message was", () => this.fromServer(l)));
|
|
670
|
+
const onInput = (chunk) => clientLines.push(chunk);
|
|
671
|
+
const onEnd = () => {
|
|
672
|
+
guard("a client message was", () => clientLines.end((rest) => this.off ? this.toServer(rest) : this.fromClientTail(rest)));
|
|
673
|
+
void Promise.allSettled([...this.waiting]).then(() => child.stdin?.end());
|
|
674
|
+
};
|
|
675
|
+
this.input.on("data", onInput);
|
|
676
|
+
this.input.once("end", onEnd);
|
|
677
|
+
child.stderr?.on("data", (chunk) => this.err.write(chunk));
|
|
678
|
+
child.stdin?.on("error", () => void 0);
|
|
679
|
+
this.out.on("error", () => child.kill("SIGTERM"));
|
|
680
|
+
child.stdout?.on("data", (chunk) => serverLines.push(chunk));
|
|
681
|
+
child.once("close", (code, signal) => {
|
|
682
|
+
guard("a server message was", () => serverLines.end((rest) => this.toClient(rest)));
|
|
683
|
+
const how = signal ? `the MCP server was ended by ${signal}` : `the MCP server exited (code ${code ?? 0})`;
|
|
684
|
+
void finish({ code: code ?? 128 + (os2.constants.signals[signal] ?? 0), signal }, how);
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
/** A last client line with no newline: judged like any other, and sent without one. */
|
|
689
|
+
fromClientTail(rest) {
|
|
690
|
+
const msg = parse(rest);
|
|
691
|
+
const v = msg === void 0 || Array.isArray(msg) ? FORWARD : this.clientMessage(msg);
|
|
692
|
+
const put = (got) => {
|
|
693
|
+
if ("forward" in got) this.toServer(rest);
|
|
694
|
+
else if ("reply" in got) this.toClient(JSON.stringify(got.reply) + "\n");
|
|
695
|
+
};
|
|
696
|
+
if (v instanceof Promise) this.later(v.then(put));
|
|
697
|
+
else put(v);
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
var NL = Buffer.from("\n");
|
|
701
|
+
var Lines = class {
|
|
702
|
+
constructor(onLine) {
|
|
703
|
+
this.onLine = onLine;
|
|
704
|
+
}
|
|
705
|
+
onLine;
|
|
706
|
+
buf = Buffer.alloc(0);
|
|
707
|
+
push(chunk) {
|
|
708
|
+
this.buf = this.buf.length ? Buffer.concat([this.buf, Buffer.from(chunk)]) : Buffer.from(chunk);
|
|
709
|
+
let start = 0;
|
|
710
|
+
for (let i = this.buf.indexOf(10, start); i !== -1; i = this.buf.indexOf(10, start)) {
|
|
711
|
+
const line = this.buf.subarray(start, i);
|
|
712
|
+
start = i + 1;
|
|
713
|
+
this.onLine(line);
|
|
714
|
+
}
|
|
715
|
+
this.buf = this.buf.subarray(start);
|
|
716
|
+
}
|
|
717
|
+
/** What is left when the stream ends: a last line without a newline. */
|
|
718
|
+
end(onRest) {
|
|
719
|
+
if (this.buf.length) onRest(this.buf);
|
|
720
|
+
this.buf = Buffer.alloc(0);
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
|
|
357
724
|
// src/cli.ts
|
|
358
725
|
var out = (s) => console.log(s);
|
|
359
726
|
var err = (s) => console.error(s);
|
|
@@ -716,6 +1083,33 @@ var COMMANDS = {
|
|
|
716
1083
|
return 0;
|
|
717
1084
|
}
|
|
718
1085
|
},
|
|
1086
|
+
gateway: {
|
|
1087
|
+
help: "run an MCP server behind a recording proxy: every tool call that passes through it is recorded (a decision before the call is forwarded, an outcome when the answer arrives), and the server's tools are declared as the agent's recorded tools",
|
|
1088
|
+
usage: 'gateway [--agent X] [--name-prefix P] [--strict | --strict-tools a,b] [--store-content] -- <command> [args...]\n In the agent\'s MCP configuration, start the gateway in place of the server: {"command": "npx", "args": ["-y", "signafaide", "gateway", "--agent", "support-bot", "--", "npx", "-y", "@modelcontextprotocol/server-filesystem", "/data"]}\n --agent: the agent\'s chain name (default: the project folder\'s name)\n --name-prefix: put before each recorded tool name, for an agent with several servers (--name-prefix github.)\n --strict: forward a call only once the witness has co-signed its decision; --strict-tools a,b: only for those tools\n --store-content: send inputs and outputs to the witness too (default: hash-only)\n A remote (HTTP) server: put a stdio bridge after the --, for example `signafaide gateway -- npx -y mcp-remote https://mcp.example.com/mcp`\n A call that cannot be recorded is not forwarded. The gateway records every call that goes through it; a call the application makes around it is still not recorded, so the deployment has to make the gateway the only route to those tools.\n Nothing is sent until the project has run `signafaide init` or has an API key. Only protocol messages go to stdout; diagnostics go to stderr.',
|
|
1089
|
+
options: { agent: { type: "string" }, "name-prefix": { type: "string" }, strict: { type: "boolean" }, "strict-tools": { type: "string" }, "store-content": { type: "boolean" } },
|
|
1090
|
+
async run(v, pos) {
|
|
1091
|
+
if (!pos.length) {
|
|
1092
|
+
err(`usage: signafaide ${(COMMANDS.gateway.usage ?? "").split("\n")[0]}`);
|
|
1093
|
+
return 2;
|
|
1094
|
+
}
|
|
1095
|
+
const r = await runGateway({
|
|
1096
|
+
command: pos[0],
|
|
1097
|
+
args: pos.slice(1),
|
|
1098
|
+
agent: v.agent,
|
|
1099
|
+
namePrefix: v["name-prefix"],
|
|
1100
|
+
strict: !!v.strict,
|
|
1101
|
+
strictTools: typeof v["strict-tools"] === "string" ? v["strict-tools"].split(",").map((t) => t.trim()).filter(Boolean) : void 0,
|
|
1102
|
+
storeContent: v["store-content"] ? true : void 0
|
|
1103
|
+
});
|
|
1104
|
+
if (r.signal) {
|
|
1105
|
+
try {
|
|
1106
|
+
process.kill(process.pid, r.signal);
|
|
1107
|
+
} catch {
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return r.code;
|
|
1111
|
+
}
|
|
1112
|
+
},
|
|
719
1113
|
flush: {
|
|
720
1114
|
help: "ship the outbox now",
|
|
721
1115
|
async run() {
|
|
@@ -753,6 +1147,7 @@ function help() {
|
|
|
753
1147
|
const width = Math.max(...Object.keys(COMMANDS).map((c) => c.length));
|
|
754
1148
|
return ["usage: signafaide <command> [options]", "", ...Object.entries(COMMANDS).map(([c, d]) => ` ${c.padEnd(width)} ${d.help}`)].join("\n");
|
|
755
1149
|
}
|
|
1150
|
+
var EXIT_WHEN_DONE = /* @__PURE__ */ new Set(["gateway"]);
|
|
756
1151
|
async function main(argv = process.argv.slice(2)) {
|
|
757
1152
|
const [name, ...rest] = argv;
|
|
758
1153
|
if (name === "--version" || name === "-v") {
|
|
@@ -764,7 +1159,8 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
764
1159
|
(name && name !== "--help" && name !== "-h" ? err : out)(help());
|
|
765
1160
|
return name && name !== "--help" && name !== "-h" ? 1 : 0;
|
|
766
1161
|
}
|
|
767
|
-
|
|
1162
|
+
const own = rest.includes("--") ? rest.slice(0, rest.indexOf("--")) : rest;
|
|
1163
|
+
if (own.includes("--help") || own.includes("-h")) {
|
|
768
1164
|
out(`${cmd.help}
|
|
769
1165
|
usage: signafaide ${cmd.usage ?? name}`);
|
|
770
1166
|
return 0;
|
|
@@ -790,4 +1186,5 @@ usage: signafaide ${cmd.usage ?? name}`);
|
|
|
790
1186
|
// src/bin.ts
|
|
791
1187
|
main().then((code) => {
|
|
792
1188
|
process.exitCode = code;
|
|
1189
|
+
if (EXIT_WHEN_DONE.has(process.argv[2])) process.exit(code);
|
|
793
1190
|
});
|
|
@@ -300,6 +300,62 @@ declare class Client {
|
|
|
300
300
|
/** GET a JSON document (used for `--keys <url>`). */
|
|
301
301
|
declare function fetchJson(url: string, timeoutMs?: number): Promise<any>;
|
|
302
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Tool declarations (SPEC §2.6) and the coverage they give (§7): which of an agent's tools are
|
|
305
|
+
* recorded, read from envelopes alone (kind, name, seq), so it holds for hash-only records.
|
|
306
|
+
* Pure: no I/O. The same names, rules and wording as the Rust core and the Python SDK.
|
|
307
|
+
*
|
|
308
|
+
* Coverage is a note, never a failure, and never a claim of completeness: a declaration is the
|
|
309
|
+
* client's own signed statement of the tools the agent has.
|
|
310
|
+
*/
|
|
311
|
+
/** The name of the note that starts a new tool declaration for the agent's chain. */
|
|
312
|
+
declare const TOOLS = "signafaide.tools";
|
|
313
|
+
/** `signafaide.tool/<tool name>`: the agent has this tool and calls to it are recorded. */
|
|
314
|
+
declare const TOOL_PREFIX = "signafaide.tool/";
|
|
315
|
+
/** `signafaide.tool_unrecorded/<tool name>`: the agent has this tool and calls to it are not recorded. */
|
|
316
|
+
declare const TOOL_UNRECORDED_PREFIX = "signafaide.tool_unrecorded/";
|
|
317
|
+
/** The longest tool name a declaration holds as it is, in characters (Unicode code points): an
|
|
318
|
+
* envelope name is at most 128 and the longer prefix takes 27. */
|
|
319
|
+
declare const MAX_DECLARED_NAME = 101;
|
|
320
|
+
/** A tool's name as a declaration holds it: the name itself when it has at most 101 characters,
|
|
321
|
+
* else its first 92 characters, `~`, and the first 8 hex characters of sha256(UTF-8 tool name).
|
|
322
|
+
* A name that is already short maps to itself. */
|
|
323
|
+
declare function declaredName(tool: string): string;
|
|
324
|
+
/** The envelope name of the note that declares `tool`. */
|
|
325
|
+
declare function toolNoteName(tool: string, recorded: boolean): string;
|
|
326
|
+
/** What a record says about the agent's tool list, from its kind and name alone: `"header"`,
|
|
327
|
+
* a tool, or null when it is not a tool-declaration note. */
|
|
328
|
+
declare function toolNote(kind: unknown, name: unknown): "header" | {
|
|
329
|
+
name: string;
|
|
330
|
+
recorded: boolean;
|
|
331
|
+
} | null;
|
|
332
|
+
interface DeclaredTool {
|
|
333
|
+
name: string;
|
|
334
|
+
recorded: boolean;
|
|
335
|
+
}
|
|
336
|
+
/** What one agent's records show about its tools (§7 tool coverage). */
|
|
337
|
+
interface Coverage {
|
|
338
|
+
/** The tools of the agent's current declaration, in name order; empty when there is none. */
|
|
339
|
+
declared: DeclaredTool[];
|
|
340
|
+
/** The names of decision records that are not in the current declaration, in name order. */
|
|
341
|
+
undeclared_recorded: string[];
|
|
342
|
+
/** The seq of the `signafaide.tools` note that starts the current declaration, or null. */
|
|
343
|
+
declared_at_seq: number | null;
|
|
344
|
+
}
|
|
345
|
+
/** The coverage of one agent's records, given in seq order. The current declaration is the one
|
|
346
|
+
* started by the last `signafaide.tools` note; tool notes before the first such note belong to a
|
|
347
|
+
* declaration the records do not hold and are ignored. Inside a declaration a later note for the
|
|
348
|
+
* same tool replaces an earlier one. A `decision` counts as a recorded tool unless its name
|
|
349
|
+
* starts with `signafaide.`; it is compared by its declared name. */
|
|
350
|
+
declare function coverage(records: Iterable<{
|
|
351
|
+
seq: number;
|
|
352
|
+
kind: unknown;
|
|
353
|
+
name: unknown;
|
|
354
|
+
}>): Coverage;
|
|
355
|
+
/** The coverage of `agent` in words (§7), one line each: what was declared, then the recorded
|
|
356
|
+
* tools the declaration does not list. */
|
|
357
|
+
declare function coverageLines(agent: string, c: Coverage): string[];
|
|
358
|
+
|
|
303
359
|
declare const MAX_PROOF_BYTES = 65536;
|
|
304
360
|
declare const MAX_NODES = 1024;
|
|
305
361
|
type Attestation = ({
|
|
@@ -460,6 +516,9 @@ declare class Report {
|
|
|
460
516
|
notSent: number;
|
|
461
517
|
/** The project's name from the signed export statement, when the pack has one. */
|
|
462
518
|
projectName: string | null;
|
|
519
|
+
/** Tool coverage per agent (§7; per chain for local state): the agent's current tool
|
|
520
|
+
* declaration and the recorded tools it does not list. A note, never a failure. */
|
|
521
|
+
coverage: Record<string, Coverage>;
|
|
463
522
|
constructor(checks: Check[], project?: string | null, env?: string | null, records?: number, warnings?: string[], notes?: string[], independent?: {
|
|
464
523
|
service_keys: boolean;
|
|
465
524
|
agent_keys: boolean;
|
|
@@ -471,6 +530,9 @@ declare class Report {
|
|
|
471
530
|
get result(): "PASS" | "FAIL" | "BLOCKED";
|
|
472
531
|
check(id: CheckId): Check;
|
|
473
532
|
firstFailed(): CheckId | null;
|
|
533
|
+
/** Tool coverage in words (§7), agent by agent: what each agent declared, then the recorded
|
|
534
|
+
* tools its declaration does not list. */
|
|
535
|
+
coverageLines(): string[];
|
|
474
536
|
lines(): string[];
|
|
475
537
|
toString(): string;
|
|
476
538
|
toJSON(): object;
|
|
@@ -585,6 +647,6 @@ declare function clientHeader(opts?: {
|
|
|
585
647
|
env?: Env;
|
|
586
648
|
}): string;
|
|
587
649
|
|
|
588
|
-
declare const VERSION = "0.
|
|
650
|
+
declare const VERSION = "0.3.0";
|
|
589
651
|
|
|
590
|
-
export {
|
|
652
|
+
export { checkHeader as $, type AgentKeyEntry as A, type BlockHeader as B, Client as C, DEFAULT_TIMESTAMP_URL as D, type Envelope as E, TOOL_UNRECORDED_PREFIX as F, agentName as G, HEADER as H, assertPortableNumbers as I, JsonNumber as J, type Kind as K, type LocalContent as L, MAX_DECLARED_NAME as M, NOT_INDEPENDENT as N, auditPath as O, type PackFilter as P, b64url as Q, type Receipt as R, SigningKey as S, TOOLS as T, b64urlDecode as U, VERSION as V, blockAttestations as W, blockHeader as X, canonicalBytes as Y, canonicalize as Z, checkContent as _, type SignedRecord as a, checkRoundTime as a0, clampTime as a1, clientHeader as a2, contentFromText as a3, contentHash as a4, countsText as a5, coverage as a6, coverageLines as a7, declaredName as a8, detectAgent as a9, recordHash as aA, recordsHash as aB, registration as aC, resultLine as aD, rootsFromVector as aE, scopeText as aF, serviceKeysHash as aG, sha256Hex as aH, signDraft as aI, signedMessage as aJ, target as aK, telemetryEnabled as aL, timestampHeights as aM, toolNote as aN, toolNoteName as aO, validProjectId as aP, validTime as aQ, verifyInclusion as aR, verifyLocalState as aS, verifyPack as aT, verifySig as aU, wireRecord as aV, withWallTime as aW, draftErrors as aa, envelopeErrors as ab, exportStatement as ac, fetchJson as ad, hashJson as ae, isLoopbackUrl as af, keyEntryErrors as ag, keyId as ah, leafHash as ai, loadDrandVerifier as aj, lookupTimestamps as ak, makeDraft as al, makeRecord as am, manifestEntry as an, manifestErrors as ao, merkleRoot as ap, newSalt as aq, now as ar, otsStatus as as, parseAgentKeys as at, parseHeadFile as au, parseJson as av, parseOts as aw, parseServiceKeys as ax, receiptErrors as ay, recordErrors as az, SignafaideError as b, Report as c, type KeyDoc as d, AGENT_RE as e, ANCHOR_WINDOW_MS as f, type BlockRoots as g, CHECKS as h, type Check as i, type CheckId as j, type Coverage as k, DEFAULT_URL as l, type DeclaredTool as m, type Domain as n, type DrandBeacon as o, type DrandVerifier as p, type KeySource as q, KeysRequired as r, MAX_NODES as s, MAX_PROOF_BYTES as t, MAX_ROUND as u, type PackOptions as v, type RecordFields as w, type SignedHeadDoc as x, type Status as y, TOOL_PREFIX as z };
|