signafaide 0.0.0-stage → 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 +270 -2
- package/dist/chunk-6UDL5LZL.js +2009 -0
- package/dist/chunk-DRRRZNIH.js +189 -0
- package/dist/chunk-P3GJDXSQ.js +3025 -0
- package/dist/cli.js +1190 -0
- package/dist/core-BTJGPYVW.d.cts +652 -0
- package/dist/core-BTJGPYVW.d.ts +652 -0
- package/dist/core.cjs +2711 -0
- package/dist/core.d.cts +1 -0
- package/dist/core.d.ts +1 -0
- package/dist/core.js +182 -0
- package/dist/http-BZI6VZM3.js +16 -0
- package/dist/index.cjs +5811 -0
- package/dist/index.d.cts +991 -0
- package/dist/index.d.ts +991 -0
- package/dist/index.js +256 -0
- package/package.json +84 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
StateError,
|
|
4
|
+
VerificationFailed,
|
|
5
|
+
compare,
|
|
6
|
+
configure,
|
|
7
|
+
envOf,
|
|
8
|
+
findRoot,
|
|
9
|
+
recordingDisabled,
|
|
10
|
+
replay,
|
|
11
|
+
termsNotice,
|
|
12
|
+
toJsonable,
|
|
13
|
+
verify
|
|
14
|
+
} from "./chunk-P3GJDXSQ.js";
|
|
15
|
+
import {
|
|
16
|
+
KeysRequired,
|
|
17
|
+
declaredName,
|
|
18
|
+
plural,
|
|
19
|
+
recordHash,
|
|
20
|
+
sha256Hex
|
|
21
|
+
} from "./chunk-6UDL5LZL.js";
|
|
22
|
+
import {
|
|
23
|
+
SignafaideError,
|
|
24
|
+
VERSION,
|
|
25
|
+
detectAgent
|
|
26
|
+
} from "./chunk-DRRRZNIH.js";
|
|
27
|
+
|
|
28
|
+
// src/cli.ts
|
|
29
|
+
import * as fs2 from "fs";
|
|
30
|
+
import * as path2 from "path";
|
|
31
|
+
import { pathToFileURL } from "url";
|
|
32
|
+
import { parseArgs } from "util";
|
|
33
|
+
|
|
34
|
+
// src/agents.ts
|
|
35
|
+
import * as fs from "fs";
|
|
36
|
+
import * as os from "os";
|
|
37
|
+
import * as path from "path";
|
|
38
|
+
|
|
39
|
+
// src/skill.ts
|
|
40
|
+
var SKILL_NAME = "signafaide";
|
|
41
|
+
var SKILL_DESCRIPTION = "Signed, tamper-evident records of what an AI agent decided and did, kept on this machine; nothing is sent until a person opts in. Use when the user asks to audit, log or trace AI or agent decisions, needs an audit trail, tamper-evidence or compliance evidence, or builds an agent whose tools pay, send, approve, delete or change real things.";
|
|
42
|
+
var PRODUCTION_DOC = "https://signafaide.co.uk/docs/production";
|
|
43
|
+
function skillBody(url) {
|
|
44
|
+
const witness = url ? `Once a person opts in, records go to the witness at \`${url}\` (set as \`SIGNAFAIDE_URL\` for the MCP server; the SDK reads it from the environment or \`.env\`).` : `Once a person opts in, records go to the hosted witness (https://api.signafaide.co.uk), or to a witness named by \`SIGNAFAIDE_URL\`.`;
|
|
45
|
+
return `# signafAIde
|
|
46
|
+
|
|
47
|
+
signafAIde writes a signed, chained record of each tool call an AI agent makes: a \`decision\` before the tool runs and an \`outcome\` after it. Records are kept in \`.signafaide/\` on this machine. Nothing is sent until a person runs \`signafaide init\` or sets \`SIGNAFAIDE_API_KEY\`. ${witness} Only hashes, tool names, agent names and times are sent, never inputs or outputs (unless stored content is turned on), so agent and tool names must not hold personal data.
|
|
48
|
+
|
|
49
|
+
Use it when the user asks for an audit trail, tamper-evident records or compliance evidence of what an agent decided, or the agent's tools pay, send, approve, delete or change real things. For plain application logs, do what the user asked instead.
|
|
50
|
+
|
|
51
|
+
## Steps
|
|
52
|
+
|
|
53
|
+
1. Install it as a project dependency.
|
|
54
|
+
- Python: \`uv add signafaide\` (uv), \`poetry add signafaide\` (Poetry), or \`python3 -m venv .venv && .venv/bin/python -m pip install signafaide\` and add it to requirements.txt. Not a bare \`pip install\` outside a virtual environment.
|
|
55
|
+
- TypeScript or JavaScript: \`npm install signafaide\` (or pnpm, yarn, bun).
|
|
56
|
+
2. Wrap the tools that act, and give the agent an explicit name (lowercase, for example \`claims-bot\`). Without one, the project folder's name is used, which is rarely what the user wants in production.
|
|
57
|
+
|
|
58
|
+
\`\`\`python
|
|
59
|
+
import signafaide
|
|
60
|
+
|
|
61
|
+
tools = signafaide.wrap_tools([approve_payout, escalate], agent="claims-bot") # functions, LangChain or OpenAI Agents tools
|
|
62
|
+
\`\`\`
|
|
63
|
+
|
|
64
|
+
\`\`\`ts
|
|
65
|
+
import { wrapTools } from "signafaide";
|
|
66
|
+
|
|
67
|
+
const tools = wrapTools({ approvePayout, escalate }, { agent: "claims-bot" }); // functions, Vercel AI SDK, OpenAI Agents JS or LangChain.js tools
|
|
68
|
+
\`\`\`
|
|
69
|
+
|
|
70
|
+
A reason or choice that is not a tool call: \`signafaide.note("chose_escalation", {"reason": ...})\` (Python) or \`note(...)\` (TypeScript).
|
|
71
|
+
|
|
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.
|
|
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.
|
|
75
|
+
|
|
76
|
+
## Sending records to the witness
|
|
77
|
+
|
|
78
|
+
This is the user's decision. Do not run \`signafaide init\` yourself: it creates an external sandbox and accepts the service's Terms. In your final message, say that the records are local only for now, and that the user can run \`signafaide init\` (a free sandbox, no account; it saves \`SIGNAFAIDE_API_KEY\` to \`.env\`), set \`SIGNAFAIDE_API_KEY\`, or point \`SIGNAFAIDE_URL\` at a self-hosted witness (an Enterprise contract). Records written before then ship later with their original times, up to 7 days old.
|
|
79
|
+
|
|
80
|
+
\`signafaide init\` adds \`.signafaide/\` and \`.env\` to \`.gitignore\`. Do not commit them. Tell the user to back up \`.signafaide/agents/\` (the agent keys); in production, keys can come from a secret store (\`SIGNAFAIDE_AGENT_KEY_<NAME>\`, made with \`signafaide keys create --agent <name> --env live --print\`), see ${PRODUCTION_DOC}.
|
|
81
|
+
|
|
82
|
+
When the user needs evidence for an auditor, a bank or a regulator, end your final message with that path: a live API key and agent keys in the secret store; an auditor link from the console with the agent keys and \`head.json\` attached, which gives the auditor the records and a pack with the exact verify command; and the verification report for the period (console, Reports). For containers without a persistent disk, point to the checklist in ${PRODUCTION_DOC}.
|
|
83
|
+
|
|
84
|
+
## Containers without a persistent disk
|
|
85
|
+
|
|
86
|
+
- Set \`SIGNAFAIDE_HEAD_FILE\` to a path on a persistent volume or a mounted bucket, so the newest signed heads outlive the container (the auditor's \`head.json\`). Agent keys come from the secret store, as above.
|
|
87
|
+
- Stored content: by default only hashes leave the machine and the inputs and outputs stay in \`.signafaide/chain/\`, which such a container loses with its disk. If the user will need them later (replay, an auditor reading inputs and outputs), turn on stored content (\`store_content=True\` in Python, \`storeContent: true\` in TypeScript; the witness keeps it encrypted, and live records need a plan that includes it) or keep \`.signafaide/chain/\` on a persistent volume. Say which one you chose.
|
|
88
|
+
- A new container continues the chain with a signed start note. Call \`signafaide.stop()\` (Python) or \`await stop()\` (TypeScript) in the shutdown handler: it sends what is left and writes a signed stop note, so a routine restart is recorded as clean. A start with no stop note before it is shown as a warning, because records the earlier instance had not sent are not known.
|
|
89
|
+
|
|
90
|
+
## MCP tools
|
|
91
|
+
|
|
92
|
+
\`signafaide_setup\` previews the install command and code edit and changes nothing. \`signafaide_verify\` runs the checks, \`signafaide_status\` shows chains and keys, \`signafaide_explain_error\` explains an error code. \`signafaide_init\` creates the external sandbox: call it only after the user has agreed, with \`confirm_user_agreed: true\` and \`user_words\` set to what the user wrote when they agreed. Without both it creates nothing.
|
|
93
|
+
`;
|
|
94
|
+
}
|
|
95
|
+
var SKILL_BODY = skillBody();
|
|
96
|
+
function claudeSkill(url) {
|
|
97
|
+
return `---
|
|
98
|
+
name: ${SKILL_NAME}
|
|
99
|
+
description: ${SKILL_DESCRIPTION}
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
${skillBody(url)}`;
|
|
103
|
+
}
|
|
104
|
+
function cursorRule(url) {
|
|
105
|
+
return `---
|
|
106
|
+
description: ${SKILL_DESCRIPTION}
|
|
107
|
+
alwaysApply: false
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
${skillBody(url)}`;
|
|
111
|
+
}
|
|
112
|
+
var SECTION_START = "<!-- signafaide:start -->";
|
|
113
|
+
var SECTION_END = "<!-- signafaide:end -->";
|
|
114
|
+
function instructionsSection(url) {
|
|
115
|
+
return `${SECTION_START}
|
|
116
|
+
${skillBody(url).replace(/^(#+) /gm, "#$1 ").trim()}
|
|
117
|
+
${SECTION_END}
|
|
118
|
+
`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/agents.ts
|
|
122
|
+
var SERVER = "signafaide";
|
|
123
|
+
var COMMAND = "npx";
|
|
124
|
+
var MCP_PACKAGE = "signafaide-mcp";
|
|
125
|
+
var ARGS = ["-y", `${MCP_PACKAGE}@${VERSION}`];
|
|
126
|
+
function SETUP_NOTES(url) {
|
|
127
|
+
return [
|
|
128
|
+
"what is sent: nothing yet. Records are signed and chained locally in .signafaide/ (verify with `signafaide verify`) until a person runs `signafaide init` or sets SIGNAFAIDE_API_KEY.",
|
|
129
|
+
"what leaves the machine after that: only hashes (hash-only by default). Full inputs and outputs stay in .signafaide/chain/<project>/<env>/<agent>.jsonl; storeContent (TypeScript) or store_content=True (Python) sends the content too, encrypted at rest (live records need the Team plan).",
|
|
130
|
+
url ? `witness: ${url} (SIGNAFAIDE_URL in each MCP entry); it needs an API key from that instance, or \`signafaide init\` against it` : "witness: https://api.signafaide.co.uk by default; a self-hosted witness (Enterprise contract) is set with --url <witness url> (https://signafaide.co.uk/docs/self-host)",
|
|
131
|
+
"strict mode: wrap tools that move money, freeze, delete or send with strict: true (strict=True in Python), so they run only once the witness has co-signed the decision",
|
|
132
|
+
"for an auditor: a live key, agent keys in a secret store (signafaide keys create --agent <name> --env live --print), an auditor link with the keys and head.json attached, and the verification report for the period",
|
|
133
|
+
'agent names: give each agent an explicit name, e.g. wrapTools(tools, { agent: "claims-bot" }) or wrap_tools(tools, agent="claims-bot"); otherwise the folder name is used. Production set-up: https://signafaide.co.uk/docs/production'
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
var mcpEnv = (c) => c.url ? { SIGNAFAIDE_URL: c.url } : {};
|
|
137
|
+
var entry = (c, extra = {}, alwaysEnv = false) => {
|
|
138
|
+
const env = mcpEnv(c);
|
|
139
|
+
return { ...extra, command: COMMAND, args: ARGS, ...alwaysEnv || Object.keys(env).length ? { env } : {} };
|
|
140
|
+
};
|
|
141
|
+
function throughLink(ctx, file) {
|
|
142
|
+
const base = [ctx.root, ctx.home].find((b) => file === b || file.startsWith(b + path.sep));
|
|
143
|
+
const parts = base ? path.relative(base, file).split(path.sep) : [path.basename(file)];
|
|
144
|
+
let p = base ?? path.dirname(file);
|
|
145
|
+
for (const part of parts) {
|
|
146
|
+
p = path.join(p, part);
|
|
147
|
+
try {
|
|
148
|
+
if (fs.lstatSync(p).isSymbolicLink()) return p;
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
function write(ctx, file, next) {
|
|
156
|
+
const shown = shortPath(ctx, file);
|
|
157
|
+
const link = throughLink(ctx, file);
|
|
158
|
+
if (link) return { file: shown, result: `skipped (${shortPath(ctx, link)} is a symbolic link; write it by hand)` };
|
|
159
|
+
const prev = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
|
|
160
|
+
if (prev === next) return { file: shown, result: "unchanged" };
|
|
161
|
+
if (!ctx.dryRun) {
|
|
162
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
163
|
+
fs.writeFileSync(file, next);
|
|
164
|
+
}
|
|
165
|
+
return { file: shown, result: prev === null ? "created" : "updated" };
|
|
166
|
+
}
|
|
167
|
+
function shortPath(ctx, file) {
|
|
168
|
+
const rel = path.relative(ctx.root, file);
|
|
169
|
+
if (!rel.startsWith("..") && !path.isAbsolute(rel)) return rel;
|
|
170
|
+
return file.startsWith(ctx.home + path.sep) ? "~" + file.slice(ctx.home.length) : file;
|
|
171
|
+
}
|
|
172
|
+
function mergeJson(ctx, file, key, entry2) {
|
|
173
|
+
const link = throughLink(ctx, file);
|
|
174
|
+
if (link) return { file: shortPath(ctx, file), result: `skipped (${shortPath(ctx, link)} is a symbolic link; write it by hand)` };
|
|
175
|
+
let doc = {};
|
|
176
|
+
if (fs.existsSync(file)) {
|
|
177
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
178
|
+
if (raw.trim()) {
|
|
179
|
+
try {
|
|
180
|
+
doc = JSON.parse(raw);
|
|
181
|
+
} catch {
|
|
182
|
+
return { file: shortPath(ctx, file), result: `skipped (not plain JSON; add "${SERVER}": ${JSON.stringify(entry2)} under "${key}" by hand)` };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return { file: shortPath(ctx, file), result: "skipped (not a JSON object)" };
|
|
186
|
+
}
|
|
187
|
+
const servers = typeof doc[key] === "object" && doc[key] !== null ? doc[key] : {};
|
|
188
|
+
if (JSON.stringify(servers[SERVER]) === JSON.stringify(entry2)) return { file: shortPath(ctx, file), result: "unchanged" };
|
|
189
|
+
doc[key] = { ...servers, [SERVER]: entry2 };
|
|
190
|
+
return write(ctx, file, JSON.stringify(doc, null, 2) + "\n");
|
|
191
|
+
}
|
|
192
|
+
function mergeSection(ctx, file) {
|
|
193
|
+
const section = instructionsSection(ctx.url);
|
|
194
|
+
const prev = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
195
|
+
const a = prev.indexOf(SECTION_START);
|
|
196
|
+
const b = prev.indexOf(SECTION_END, a);
|
|
197
|
+
let next;
|
|
198
|
+
if (a >= 0 && b > a) {
|
|
199
|
+
let end = b + SECTION_END.length;
|
|
200
|
+
if (prev[end] === "\n") end++;
|
|
201
|
+
next = prev.slice(0, a) + section + prev.slice(end);
|
|
202
|
+
} else {
|
|
203
|
+
next = prev ? prev.replace(/\n*$/, "\n\n") + section : section;
|
|
204
|
+
}
|
|
205
|
+
return write(ctx, file, next);
|
|
206
|
+
}
|
|
207
|
+
var tomlString = (s) => JSON.stringify(s);
|
|
208
|
+
function mergeToml(ctx, file) {
|
|
209
|
+
const want = [`command = ${tomlString(COMMAND)}`, `args = [${ARGS.map(tomlString).join(", ")}]`];
|
|
210
|
+
const urlKey = ctx.url ? `SIGNAFAIDE_URL = ${tomlString(ctx.url)}` : null;
|
|
211
|
+
const prev = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
212
|
+
const lines = prev.split("\n");
|
|
213
|
+
const header = new RegExp(`^\\s*\\[\\s*mcp_servers\\.(${SERVER}|"${SERVER}")\\s*\\]\\s*(#.*)?$`);
|
|
214
|
+
const envHeader = new RegExp(`^\\s*\\[\\s*mcp_servers\\.(${SERVER}|"${SERVER}")\\.env\\s*\\]\\s*(#.*)?$`);
|
|
215
|
+
const start = lines.findIndex((l) => header.test(l));
|
|
216
|
+
if (start < 0) {
|
|
217
|
+
const env = urlKey ? [`env = { ${urlKey} }`] : [];
|
|
218
|
+
return write(ctx, file, (prev.trim() ? prev.replace(/\n*$/, "\n\n") : "") + `[mcp_servers.${SERVER}]
|
|
219
|
+
${[...want, ...env].join("\n")}
|
|
220
|
+
`);
|
|
221
|
+
}
|
|
222
|
+
let end = start + 1;
|
|
223
|
+
while (end < lines.length && !/^\s*\[/.test(lines[end])) end++;
|
|
224
|
+
const body = [];
|
|
225
|
+
let inlineEnv = false;
|
|
226
|
+
for (let i = start + 1; i < end; i++) {
|
|
227
|
+
if (/^\s*command\s*=/.test(lines[i])) continue;
|
|
228
|
+
if (/^\s*args\s*=/.test(lines[i])) {
|
|
229
|
+
while (i < end - 1 && !lines[i].includes("]")) i++;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const m = urlKey && lines[i].match(/^(\s*env\s*=\s*\{)([^}]*)(\}.*)$/);
|
|
233
|
+
if (m) {
|
|
234
|
+
const vars = m[2].split(",").map((v) => v.trim()).filter((v) => v && !/^"?SIGNAFAIDE_URL"?\s*=/.test(v));
|
|
235
|
+
body.push(`${m[1]} ${[...vars, urlKey].join(", ")} ${m[3]}`);
|
|
236
|
+
inlineEnv = true;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
body.push(lines[i]);
|
|
240
|
+
}
|
|
241
|
+
let out2 = [...lines.slice(0, start + 1), ...want, ...body, ...lines.slice(end)];
|
|
242
|
+
if (urlKey && !inlineEnv) {
|
|
243
|
+
const e = out2.findIndex((l) => envHeader.test(l));
|
|
244
|
+
if (e >= 0) {
|
|
245
|
+
let f = e + 1;
|
|
246
|
+
while (f < out2.length && !/^\s*\[/.test(out2[f])) f++;
|
|
247
|
+
const kept = out2.slice(e + 1, f).filter((l) => !/^\s*"?SIGNAFAIDE_URL"?\s*=/.test(l));
|
|
248
|
+
out2 = [...out2.slice(0, e + 1), urlKey, ...kept, ...out2.slice(f)];
|
|
249
|
+
} else out2.splice(start + 1 + want.length, 0, `env = { ${urlKey} }`);
|
|
250
|
+
}
|
|
251
|
+
return write(ctx, file, out2.join("\n"));
|
|
252
|
+
}
|
|
253
|
+
var onPath = (ctx, bin) => (ctx.env.PATH ?? "").split(path.delimiter).filter(Boolean).some((d) => [bin, `${bin}.cmd`, `${bin}.exe`].some((b) => fs.existsSync(path.join(d, b))));
|
|
254
|
+
var why = (checks) => checks.find(([, ok]) => ok)?.[0] ?? null;
|
|
255
|
+
var inProject = (ctx, f) => fs.existsSync(path.join(ctx.root, f));
|
|
256
|
+
var inHome = (ctx, f) => fs.existsSync(path.join(ctx.home, f));
|
|
257
|
+
var running = (ctx, id) => detectAgent(ctx.env) === id;
|
|
258
|
+
var AGENTS = [
|
|
259
|
+
{
|
|
260
|
+
id: "claude-code",
|
|
261
|
+
detect: (c) => why([
|
|
262
|
+
["running in it", running(c, "claude-code")],
|
|
263
|
+
["`claude` on PATH", onPath(c, "claude")],
|
|
264
|
+
[".claude/ in the project", inProject(c, ".claude")],
|
|
265
|
+
["~/.claude", inHome(c, ".claude")]
|
|
266
|
+
]),
|
|
267
|
+
note: "Claude Code asks once to approve the project's MCP servers when it next starts here",
|
|
268
|
+
install: (c) => [
|
|
269
|
+
mergeJson(c, path.join(c.root, ".mcp.json"), "mcpServers", entry(c, { type: "stdio" }, true)),
|
|
270
|
+
write(c, path.join(c.root, ".claude", "skills", "signafaide", "SKILL.md"), claudeSkill(c.url)),
|
|
271
|
+
// Claude Code reads CLAUDE.md as the project's instructions; the skill alone is read as a file.
|
|
272
|
+
mergeSection(c, path.join(c.root, "CLAUDE.md"))
|
|
273
|
+
]
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
id: "codex",
|
|
277
|
+
detect: (c) => why([
|
|
278
|
+
["running in it", running(c, "codex")],
|
|
279
|
+
["`codex` on PATH", onPath(c, "codex")],
|
|
280
|
+
["CODEX_HOME", !!c.env.CODEX_HOME],
|
|
281
|
+
["~/.codex", inHome(c, ".codex")]
|
|
282
|
+
]),
|
|
283
|
+
install: (c) => [mergeToml(c, path.join(c.env.CODEX_HOME || path.join(c.home, ".codex"), "config.toml")), mergeSection(c, path.join(c.root, "AGENTS.md"))]
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
id: "cursor",
|
|
287
|
+
detect: (c) => why([
|
|
288
|
+
["running in it", running(c, "cursor")],
|
|
289
|
+
["`cursor` on PATH", onPath(c, "cursor") || onPath(c, "cursor-agent")],
|
|
290
|
+
[".cursor/ in the project", inProject(c, ".cursor")],
|
|
291
|
+
["~/.cursor", inHome(c, ".cursor")]
|
|
292
|
+
]),
|
|
293
|
+
install: (c) => [
|
|
294
|
+
mergeJson(c, path.join(c.root, ".cursor", "mcp.json"), "mcpServers", entry(c, { type: "stdio" })),
|
|
295
|
+
write(c, path.join(c.root, ".cursor", "rules", "signafaide.mdc"), cursorRule(c.url))
|
|
296
|
+
]
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
id: "gemini-cli",
|
|
300
|
+
detect: (c) => why([
|
|
301
|
+
["running in it", running(c, "gemini-cli")],
|
|
302
|
+
["`gemini` on PATH", onPath(c, "gemini")],
|
|
303
|
+
[".gemini/ in the project", inProject(c, ".gemini")],
|
|
304
|
+
["~/.gemini", inHome(c, ".gemini")]
|
|
305
|
+
]),
|
|
306
|
+
note: "Gemini CLI reads .gemini/settings.json only in folders you trust (it asks when it first starts in a folder)",
|
|
307
|
+
install: (c) => [mergeJson(c, path.join(c.root, ".gemini", "settings.json"), "mcpServers", entry(c)), mergeSection(c, path.join(c.root, "GEMINI.md"))]
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
id: "copilot",
|
|
311
|
+
detect: (c) => why([
|
|
312
|
+
["running in it", running(c, "copilot")],
|
|
313
|
+
[".github/copilot-instructions.md in the project", inProject(c, ".github/copilot-instructions.md")],
|
|
314
|
+
[".vscode/mcp.json in the project", inProject(c, ".vscode/mcp.json")],
|
|
315
|
+
["`copilot` on PATH", onPath(c, "copilot")]
|
|
316
|
+
]),
|
|
317
|
+
install: (c) => [
|
|
318
|
+
mergeJson(c, path.join(c.root, ".vscode", "mcp.json"), "servers", entry(c, { type: "stdio" })),
|
|
319
|
+
mergeSection(c, path.join(c.root, ".github", "copilot-instructions.md"))
|
|
320
|
+
]
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
id: "windsurf",
|
|
324
|
+
detect: (c) => why([
|
|
325
|
+
["running in it", running(c, "windsurf")],
|
|
326
|
+
["`windsurf` on PATH", onPath(c, "windsurf")],
|
|
327
|
+
[".windsurf/ or .devin/ in the project", inProject(c, ".windsurf") || inProject(c, ".devin")],
|
|
328
|
+
["~/.codeium/windsurf", inHome(c, ".codeium/windsurf")]
|
|
329
|
+
]),
|
|
330
|
+
install: (c) => [mergeJson(c, path.join(c.root, ".devin", "mcp_config.json"), "mcpServers", entry(c)), mergeSection(c, path.join(c.root, "AGENTS.md"))]
|
|
331
|
+
}
|
|
332
|
+
];
|
|
333
|
+
var AGENT_IDS = AGENTS.map((a) => a.id);
|
|
334
|
+
function witnessUrl(raw) {
|
|
335
|
+
if (raw === void 0) return void 0;
|
|
336
|
+
let u;
|
|
337
|
+
try {
|
|
338
|
+
u = new URL(raw.trim());
|
|
339
|
+
} catch {
|
|
340
|
+
throw new Error(`--url ${raw} is not a URL; use the witness's base URL, e.g. https://signafaide.internal.example.com`);
|
|
341
|
+
}
|
|
342
|
+
if (u.protocol !== "https:" && u.protocol !== "http:" || u.username || u.password || u.search || u.hash) {
|
|
343
|
+
throw new Error(`--url ${raw} must be an http(s) base URL without credentials, query or fragment`);
|
|
344
|
+
}
|
|
345
|
+
return u.toString().replace(/\/+$/, "");
|
|
346
|
+
}
|
|
347
|
+
var ALIASES = { claude: "claude-code", gemini: "gemini-cli", "github-copilot": "copilot", vscode: "copilot" };
|
|
348
|
+
function setup(opts) {
|
|
349
|
+
const ctx = { root: path.resolve(opts.root), home: opts.home ?? os.homedir(), env: opts.env ?? process.env, dryRun: !!opts.dryRun, url: witnessUrl(opts.url) };
|
|
350
|
+
let chosen;
|
|
351
|
+
if (opts.agents?.length) {
|
|
352
|
+
const ids = opts.agents.flatMap((a) => a.split(",")).map((a) => a.trim().toLowerCase()).filter(Boolean);
|
|
353
|
+
const wanted = ids.includes("all") ? AGENT_IDS : ids.map((a) => ALIASES[a] ?? a);
|
|
354
|
+
const unknown = wanted.filter((a) => !AGENT_IDS.includes(a));
|
|
355
|
+
if (unknown.length) throw new Error(`unknown agent ${unknown.join(", ")}; use one of ${AGENT_IDS.join(", ")} or all`);
|
|
356
|
+
chosen = AGENTS.filter((a) => wanted.includes(a.id)).map((agent) => ({ agent, reason: "asked for" }));
|
|
357
|
+
} else {
|
|
358
|
+
chosen = AGENTS.map((agent) => ({ agent, reason: agent.detect(ctx) ?? "" })).filter((a) => a.reason);
|
|
359
|
+
}
|
|
360
|
+
return chosen.map(({ agent, reason }) => ({ agent: agent.id, reason, changes: agent.install(ctx), ...agent.note ? { note: agent.note } : {} }));
|
|
361
|
+
}
|
|
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
|
+
|
|
724
|
+
// src/cli.ts
|
|
725
|
+
var out = (s) => console.log(s);
|
|
726
|
+
var err = (s) => console.error(s);
|
|
727
|
+
var recorder = () => configure({ quiet: true, via: "cli" });
|
|
728
|
+
var mask = (k) => k ? `${k.slice(0, 13)}...` : "none";
|
|
729
|
+
function siteOf(url) {
|
|
730
|
+
const u = new URL(url);
|
|
731
|
+
return `${u.protocol}//${u.host.replace(/^api\./, "")}`;
|
|
732
|
+
}
|
|
733
|
+
var UPGRADE_LIMIT = (site) => `upgrade: Solo (\xA319 a month) takes 100,000 live records a month and keeps recording past them at \xA35 per 100,000: ${site}/pricing`;
|
|
734
|
+
var UPGRADE_VERIFY = (site) => `upgrade: Solo (\xA319 a month) adds a verification report for a period and auditor links up to 90 days: ${site}/pricing`;
|
|
735
|
+
var HINT_EVERY_MS = 7 * 864e5;
|
|
736
|
+
async function upgradeAfterVerify(rec) {
|
|
737
|
+
if (!rec.apiKey?.startsWith("sfa_live_")) return;
|
|
738
|
+
const last = Date.parse(String(rec.state.config().upgrade_hint ?? ""));
|
|
739
|
+
if (Number.isFinite(last) && Date.now() - last < HINT_EVERY_MS) return;
|
|
740
|
+
try {
|
|
741
|
+
if ((await rec.client(3e3).usage())?.plan !== "free") return;
|
|
742
|
+
rec.state.saveConfig({ upgrade_hint: (/* @__PURE__ */ new Date()).toISOString() });
|
|
743
|
+
} catch {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
out(UPGRADE_VERIFY(siteOf(rec.url)));
|
|
747
|
+
}
|
|
748
|
+
async function statusLines(rec) {
|
|
749
|
+
const lines = [];
|
|
750
|
+
const s = rec.status();
|
|
751
|
+
if (s.test_mode) lines.push("test mode: records go to a temporary folder and nothing is sent (SIGNAFAIDE_TEST, or a test runner); SIGNAFAIDE_TEST=0 turns it off");
|
|
752
|
+
lines.push(
|
|
753
|
+
s.api_key_invalid ? `key: SIGNAFAIDE_API_KEY is set but is not a signafAIde key (sfa_test_ or sfa_live_ and 32 letters or digits); nothing is sent until it is fixed` : `key: ${mask(s.api_key)}` + (s.env ? ` (env ${s.env})` : "; records stay on this machine (signed and chained, nothing is sent). `signafaide init` creates a free sandbox and sends hashes; or set SIGNAFAIDE_API_KEY")
|
|
754
|
+
);
|
|
755
|
+
lines.push(`service: ${s.url}`);
|
|
756
|
+
let project = {};
|
|
757
|
+
if (s.api_key) {
|
|
758
|
+
try {
|
|
759
|
+
project = await rec.client(5e3).project() ?? {};
|
|
760
|
+
} catch {
|
|
761
|
+
project = {};
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
const name = typeof project.name === "string" && project.name ? project.name : null;
|
|
765
|
+
if (project.project) lines.push(name ? `project: ${name} (${project.project}, ${project.env})` : `project: ${project.project} (${project.env})`);
|
|
766
|
+
lines.push(`home: ${s.home}`);
|
|
767
|
+
if (!s.chains.length) lines.push("chains: none yet; wrap a function with record()");
|
|
768
|
+
const blocked = [];
|
|
769
|
+
for (const c of s.chains) {
|
|
770
|
+
const last = c.receipt_seq ? `last receipt seq ${c.receipt_seq} at ${c.receipt_time}` : c.chain.startsWith("_pending/") ? "drafts, signed for the project when they ship" : "no receipt yet";
|
|
771
|
+
const other = c.current || !c.waiting ? "" : `; ${c.waiting} waiting for the key of that project`;
|
|
772
|
+
const shown = name && project.project && c.chain.startsWith(`${project.project}/`) ? `${name}/${c.chain.slice(project.project.length + 1)}` : c.chain;
|
|
773
|
+
lines.push(`chain ${shown}: key ${c.key_id}, ${plural(c.records, "record")}, ${last}${other}${c.blocked ? "; BLOCKED" : ""}`);
|
|
774
|
+
if (c.blocked) blocked.push(`BLOCKED ${c.chain}: ${c.blocked.reason}; ${plural(c.blocked.records, "record")} cannot ship. Next: ${c.blocked.next}`);
|
|
775
|
+
}
|
|
776
|
+
if (s.chains.some((c) => c.agent === rec.defaultAgent)) {
|
|
777
|
+
lines.push(`note: agent "${rec.defaultAgent}" is named after the project folder; give agents explicit names in production (record(fn, { agent: "claims-bot" }))`);
|
|
778
|
+
}
|
|
779
|
+
for (const h of s.held) lines.push(`held: ${h.chain}: ${plural(h.records, "record")} set aside by \`signafaide repair\` in ${h.file} (kept, verifiable locally, not co-signed)`);
|
|
780
|
+
lines.push(`outbox: ${plural(s.outbox, "record")} waiting` + (s.outbox && !blocked.length ? "; run `signafaide flush`" : ""));
|
|
781
|
+
lines.push(...blocked);
|
|
782
|
+
let claimed = !!s.sandbox?.claimed;
|
|
783
|
+
if (s.api_key) {
|
|
784
|
+
try {
|
|
785
|
+
const u = await rec.client(5e3).usage();
|
|
786
|
+
lines.push(`quota: ${u.used} of ${u.quota} used in ${u.period}`);
|
|
787
|
+
if (u.plan === "free" && typeof u.quota === "number" && u.used >= u.quota && s.api_key.startsWith("sfa_live_")) lines.push(UPGRADE_LIMIT(siteOf(rec.url)));
|
|
788
|
+
} catch (e) {
|
|
789
|
+
lines.push(`quota: unknown (${e.message})`);
|
|
790
|
+
}
|
|
791
|
+
if (!claimed && s.api_key.startsWith("sfa_test_") && s.sandbox?.claim_url) {
|
|
792
|
+
try {
|
|
793
|
+
claimed = project.claimed === true;
|
|
794
|
+
if (claimed) rec.state.saveSandbox({ ...s.sandbox, claimed: true });
|
|
795
|
+
} catch {
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (s.sandbox?.claim_url && s.api_key?.startsWith("sfa_test_")) lines.push(claimed ? "claim: done (this sandbox belongs to an organisation now)" : `claim: ${s.sandbox.claim_url}`);
|
|
800
|
+
return lines;
|
|
801
|
+
}
|
|
802
|
+
var COMMANDS = {
|
|
803
|
+
init: {
|
|
804
|
+
help: "opt in to the witness: create a free sandbox if there is no API key (no account, email or card), print the claim link. The witness is https://api.signafaide.co.uk unless SIGNAFAIDE_URL names another (a self-hosted witness, or http://127.0.0.1:7474 for a local one). Adds .signafaide/ and .env to .gitignore",
|
|
805
|
+
usage: "init (environment: SIGNAFAIDE_URL=<witness url>, SIGNAFAIDE_API_KEY=<existing key>)",
|
|
806
|
+
async run() {
|
|
807
|
+
const rec = recorder();
|
|
808
|
+
rec.rememberAgent();
|
|
809
|
+
rec.state.ignoreInGit();
|
|
810
|
+
const had = rec.apiKey;
|
|
811
|
+
const key = await rec.createSandbox();
|
|
812
|
+
const claim = rec.state.sandbox()?.claim_url;
|
|
813
|
+
if (!had) {
|
|
814
|
+
out(`sandbox ready at ${rec.url}, key saved to ${path2.join(rec.state.root, ".env")}; claim it at ${claim}`);
|
|
815
|
+
out(termsNotice(rec.legal()));
|
|
816
|
+
out("from now on record hashes (never content) are sent for co-signature and public timestamps; records written before now ship with their original times on the next run or with `signafaide flush`");
|
|
817
|
+
} else if (key.startsWith("sfa_test_") && claim) out(`key found (${mask(key)}); claim the sandbox at ${claim}`);
|
|
818
|
+
else out(`key found (${mask(key)}, env ${envOf(key)}); nothing to create`);
|
|
819
|
+
return 0;
|
|
820
|
+
}
|
|
821
|
+
},
|
|
822
|
+
status: {
|
|
823
|
+
help: "key and env, agents, last receipt, outbox size, quota; BLOCKED chains (exit 1 when any)",
|
|
824
|
+
async run() {
|
|
825
|
+
const rec = recorder();
|
|
826
|
+
const lines = await statusLines(rec);
|
|
827
|
+
for (const line of lines) out(line);
|
|
828
|
+
return lines.some((l) => l.startsWith("BLOCKED ")) ? 1 : 0;
|
|
829
|
+
}
|
|
830
|
+
},
|
|
831
|
+
repair: {
|
|
832
|
+
help: "set aside records the witness can never take (clock went back, revoked key, clock ahead), rotate the agent's key and start a new segment; history is kept and verifiable",
|
|
833
|
+
usage: "repair [--agent X] [--dry-run]",
|
|
834
|
+
options: { agent: { type: "string" }, "dry-run": { type: "boolean" } },
|
|
835
|
+
async run(v) {
|
|
836
|
+
const rec = recorder();
|
|
837
|
+
const dry = !!v["dry-run"];
|
|
838
|
+
const done = rec.repair({ agent: v.agent, dryRun: dry });
|
|
839
|
+
if (!done.length) {
|
|
840
|
+
out(`nothing to repair${v.agent ? ` for agent ${v.agent}` : ""}: every chain can ship (\`signafaide status\` shows them)`);
|
|
841
|
+
return 0;
|
|
842
|
+
}
|
|
843
|
+
for (const r of done) {
|
|
844
|
+
out(`${r.chain}: ${r.blocked.reason}`);
|
|
845
|
+
if (dry) {
|
|
846
|
+
out(` would set aside seq ${r.blocked.seq} onwards (${plural(r.blocked.records, "record")}), give the agent a new key and write a signafaide.repair note`);
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
out(` set aside ${plural(r.records, "record")} in ${r.held} (kept; \`signafaide verify\` still checks their signatures and content hashes)`);
|
|
850
|
+
out(r.newKey ? ` new key ${r.newKey} signs from now on; the old key is retired on the witness at the next send` : " the key comes from a secret store: rotate it there if it was revoked");
|
|
851
|
+
out(` note ${r.note.slice(0, 12)} (signafaide.repair) commits to the held records' hashes and ships with the next send`);
|
|
852
|
+
}
|
|
853
|
+
if (!dry) out("next: `signafaide flush` sends the new segment, then `signafaide keys export` for your auditors (it lists the old and new keys)");
|
|
854
|
+
return 0;
|
|
855
|
+
}
|
|
856
|
+
},
|
|
857
|
+
verify: {
|
|
858
|
+
help: "verify a pack or the local state; exit 0 PASS, 1 FAIL, 2 unreadable input",
|
|
859
|
+
usage: "verify [pack.json] [--keys <file|url>] [--agent-keys <file|url>] [--head head.json] [--timestamp-check <url>] [--offline] [--trust-embedded-keys] [--trust-embedded-agent-keys] [--content <file|dir>] [--fail-on-pending-send] [--json]\n --head: the customer's .signafaide/head.json (their newest receipt per agent). It proves no record was cut from the end of the pack; the customer sends it with the pack.\n without a pack: checks this machine's records; a BLOCKED chain always exits 1, and --fail-on-pending-send also fails while records wait to be co-signed (for CI)",
|
|
860
|
+
options: {
|
|
861
|
+
keys: { type: "string" },
|
|
862
|
+
"trust-embedded-keys": { type: "boolean" },
|
|
863
|
+
"agent-keys": { type: "string" },
|
|
864
|
+
"trust-embedded-agent-keys": { type: "boolean" },
|
|
865
|
+
head: { type: "string" },
|
|
866
|
+
"timestamp-check": { type: "string" },
|
|
867
|
+
bitcoin: { type: "string" },
|
|
868
|
+
offline: { type: "boolean" },
|
|
869
|
+
content: { type: "string" },
|
|
870
|
+
json: { type: "boolean" },
|
|
871
|
+
"fail-on-pending-send": { type: "boolean" }
|
|
872
|
+
},
|
|
873
|
+
async run(v, pos) {
|
|
874
|
+
const rec = recorder();
|
|
875
|
+
let report;
|
|
876
|
+
try {
|
|
877
|
+
report = await verify(pos[0] ?? null, {
|
|
878
|
+
keys: v.keys,
|
|
879
|
+
trustEmbeddedKeys: v["trust-embedded-keys"],
|
|
880
|
+
agentKeys: v["agent-keys"],
|
|
881
|
+
trustEmbeddedAgentKeys: v["trust-embedded-agent-keys"],
|
|
882
|
+
head: v.head,
|
|
883
|
+
timestampCheck: v["timestamp-check"] ?? v.bitcoin,
|
|
884
|
+
offline: v.offline,
|
|
885
|
+
content: v.content,
|
|
886
|
+
failOnPendingSend: v["fail-on-pending-send"]
|
|
887
|
+
});
|
|
888
|
+
} catch (e) {
|
|
889
|
+
err(`signafaide: ${e.message}`);
|
|
890
|
+
await rec.event("verify");
|
|
891
|
+
return 2;
|
|
892
|
+
}
|
|
893
|
+
out(v.json ? JSON.stringify(report, null, 2) : String(report));
|
|
894
|
+
await rec.event("verify");
|
|
895
|
+
if (report.ok && !v.json) await upgradeAfterVerify(rec);
|
|
896
|
+
return report.ok ? 0 : 1;
|
|
897
|
+
}
|
|
898
|
+
},
|
|
899
|
+
keys: {
|
|
900
|
+
help: "export the agent public keys for auditors, make a key for a secret store, or rotate an agent's key",
|
|
901
|
+
usage: "keys export [-o agent-keys.json] | keys create [--agent X] [--env test|live] [--print] | keys rotate [--agent X] [--env test|live]",
|
|
902
|
+
options: { output: { type: "string", short: "o" }, agent: { type: "string" }, env: { type: "string" }, print: { type: "boolean" } },
|
|
903
|
+
async run(v, pos) {
|
|
904
|
+
const rec = recorder();
|
|
905
|
+
if (pos[0] === "export" && pos.length === 1) {
|
|
906
|
+
const doc = rec.state.exportAgentKeys();
|
|
907
|
+
if (!doc.keys.length) {
|
|
908
|
+
err("signafaide: no agent keys here yet; they are made with the first record");
|
|
909
|
+
return 1;
|
|
910
|
+
}
|
|
911
|
+
const file = v.output ?? "agent-keys.json";
|
|
912
|
+
fs2.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
|
913
|
+
out(`wrote ${file} (${plural(doc.keys.length, "key")}); give it to your auditors with pack.json and head.json: signafaide verify pack.json --keys <witness>/.well-known/signafaide-keys.json --agent-keys ${file} --head head.json`);
|
|
914
|
+
return 0;
|
|
915
|
+
}
|
|
916
|
+
if (pos[0] === "create" && pos.length === 1) {
|
|
917
|
+
const agent = v.agent ?? rec.defaultAgent;
|
|
918
|
+
const env = v.env ?? envOf(rec.apiKey);
|
|
919
|
+
if (env !== "test" && env !== "live") throw new SyntaxError("--env must be test or live");
|
|
920
|
+
const r = await rec.createKey(agent, env, !!v.print);
|
|
921
|
+
if (v.print) {
|
|
922
|
+
out(JSON.stringify(r.doc));
|
|
923
|
+
err(
|
|
924
|
+
`signafaide: registered key ${r.doc.key_id} for ${agent} (${r.project}, ${env}); no record was written and nothing was saved here. Store the line above in your secret store as ${r.envName} and keep a backup: it is the only copy of the private key.`
|
|
925
|
+
);
|
|
926
|
+
} else {
|
|
927
|
+
out(`${agent}: registered key ${r.doc.key_id} (${r.project}, ${env}) and saved it as ${r.file}; no record was written. For a secret store, store that file's content as ${r.envName}.`);
|
|
928
|
+
}
|
|
929
|
+
return 0;
|
|
930
|
+
}
|
|
931
|
+
if (pos[0] === "rotate" && pos.length === 1) {
|
|
932
|
+
const agent = v.agent ?? rec.defaultAgent;
|
|
933
|
+
const env = v.env ?? envOf(rec.apiKey);
|
|
934
|
+
if (env && env !== "test" && env !== "live") throw new SyntaxError("--env must be test or live");
|
|
935
|
+
const [old, next] = rec.state.rotateKey(agent, env === "live" ? "live" : "");
|
|
936
|
+
out(
|
|
937
|
+
`${agent}: new key ${next.keyId} signs from now on. Records the old key ${old.keyId} signed earlier ship first; the next record then registers the new key, signed by the old one, and the service retires the old key at that moment`
|
|
938
|
+
);
|
|
939
|
+
return 0;
|
|
940
|
+
}
|
|
941
|
+
err(`usage: signafaide ${COMMANDS.keys.usage}`);
|
|
942
|
+
return 2;
|
|
943
|
+
}
|
|
944
|
+
},
|
|
945
|
+
export: {
|
|
946
|
+
help: "download a verification pack of the whole project (every agent), or of one agent with --agent",
|
|
947
|
+
usage: "export [--agent X] [--from T] [--to T] [-o pack.json]",
|
|
948
|
+
options: { agent: { type: "string" }, from: { type: "string" }, to: { type: "string" }, output: { type: "string", short: "o" } },
|
|
949
|
+
async run(v) {
|
|
950
|
+
const rec = recorder();
|
|
951
|
+
const agent = v.agent;
|
|
952
|
+
let pack;
|
|
953
|
+
try {
|
|
954
|
+
pack = await rec.client(6e4).export(agent, v.from, v.to);
|
|
955
|
+
} catch (e) {
|
|
956
|
+
await rec.event("export");
|
|
957
|
+
throw e;
|
|
958
|
+
}
|
|
959
|
+
const n = pack.records?.length ?? 0;
|
|
960
|
+
const file = v.output ?? `${agent ?? pack.project}-pack.json`;
|
|
961
|
+
fs2.writeFileSync(file, JSON.stringify(pack, null, 2) + "\n");
|
|
962
|
+
out(`wrote ${file} (${plural(n, "record")}); check it with \`signafaide verify ${file}\``);
|
|
963
|
+
if (!n) err(agent ? `signafaide export: agent '${agent}' has no records in this range; leave out --agent to export every agent (\`signafaide status\` lists them)` : "signafaide export: this project has no records in this range, so the pack proves nothing");
|
|
964
|
+
await rec.event("export");
|
|
965
|
+
return 0;
|
|
966
|
+
}
|
|
967
|
+
},
|
|
968
|
+
replay: {
|
|
969
|
+
help: "show what an agent saw and did, from the local chain, each record verified; exit 1 on FAIL",
|
|
970
|
+
usage: "replay [--agent X] [--from T] [--to T] [--record <hash>] [--chain <id>] [--keys <file|url>] [--html out.html] [--json]",
|
|
971
|
+
options: {
|
|
972
|
+
agent: { type: "string" },
|
|
973
|
+
from: { type: "string" },
|
|
974
|
+
to: { type: "string" },
|
|
975
|
+
record: { type: "string" },
|
|
976
|
+
chain: { type: "string" },
|
|
977
|
+
keys: { type: "string" },
|
|
978
|
+
html: { type: "string" },
|
|
979
|
+
json: { type: "boolean" }
|
|
980
|
+
},
|
|
981
|
+
async run(v) {
|
|
982
|
+
recorder();
|
|
983
|
+
const r = await replay({ agent: v.agent, since: v.from, until: v.to, record: v.record, chain: v.chain, keys: v.keys });
|
|
984
|
+
out(v.json ? JSON.stringify(r, null, 2) : String(r));
|
|
985
|
+
if (v.html) {
|
|
986
|
+
fs2.writeFileSync(v.html, r.toHtml());
|
|
987
|
+
err(`wrote ${v.html}`);
|
|
988
|
+
}
|
|
989
|
+
return r.ok ? 0 : 1;
|
|
990
|
+
}
|
|
991
|
+
},
|
|
992
|
+
compare: {
|
|
993
|
+
help: "re-run recorded decisions with a candidate function and compare the results",
|
|
994
|
+
usage: "compare --module <file.js|package>:<function> [--agent X] [--name N] [--from T] [--to T] [--limit N] [--label L] [--upload [--values]] [--json]",
|
|
995
|
+
options: {
|
|
996
|
+
module: { type: "string" },
|
|
997
|
+
agent: { type: "string" },
|
|
998
|
+
name: { type: "string" },
|
|
999
|
+
from: { type: "string" },
|
|
1000
|
+
to: { type: "string" },
|
|
1001
|
+
limit: { type: "string" },
|
|
1002
|
+
label: { type: "string" },
|
|
1003
|
+
chain: { type: "string" },
|
|
1004
|
+
keys: { type: "string" },
|
|
1005
|
+
upload: { type: "boolean" },
|
|
1006
|
+
values: { type: "boolean" },
|
|
1007
|
+
json: { type: "boolean" }
|
|
1008
|
+
},
|
|
1009
|
+
async run(v) {
|
|
1010
|
+
if (!v.module) {
|
|
1011
|
+
err(`usage: signafaide ${COMMANDS.compare.usage}`);
|
|
1012
|
+
return 2;
|
|
1013
|
+
}
|
|
1014
|
+
recorder();
|
|
1015
|
+
const limit = v.limit === void 0 ? void 0 : Number(v.limit);
|
|
1016
|
+
if (limit !== void 0 && !Number.isInteger(limit)) throw new SyntaxError("--limit must be a whole number");
|
|
1017
|
+
const fn = await loadCandidate(v.module);
|
|
1018
|
+
const r = await compare(fn, { agent: v.agent, name: v.name, since: v.from, until: v.to, limit, label: v.label, chain: v.chain, keys: v.keys });
|
|
1019
|
+
if (v.upload) await r.upload(v.values ? true : void 0);
|
|
1020
|
+
out(v.json ? JSON.stringify({ ...r.toJSON(), ...r.uploaded ? { uploaded: r.uploaded } : {} }, null, 2) : String(r));
|
|
1021
|
+
return 0;
|
|
1022
|
+
}
|
|
1023
|
+
},
|
|
1024
|
+
claim: {
|
|
1025
|
+
help: "print the sandbox claim link again",
|
|
1026
|
+
async run() {
|
|
1027
|
+
const rec = recorder();
|
|
1028
|
+
if (rec.apiKey?.startsWith("sfa_live_")) {
|
|
1029
|
+
out("this project uses a live key; there is nothing to claim");
|
|
1030
|
+
return 0;
|
|
1031
|
+
}
|
|
1032
|
+
const claim = rec.state.sandbox()?.claim_url;
|
|
1033
|
+
if (!claim) {
|
|
1034
|
+
err("no sandbox here; run `signafaide init` first");
|
|
1035
|
+
return 1;
|
|
1036
|
+
}
|
|
1037
|
+
out(claim);
|
|
1038
|
+
return 0;
|
|
1039
|
+
}
|
|
1040
|
+
},
|
|
1041
|
+
agent: {
|
|
1042
|
+
help: "install the MCP server and the skill in your coding agents (Claude Code, Codex, Cursor, Gemini CLI, Copilot, Windsurf)",
|
|
1043
|
+
usage: "agent setup [--agent <name>]... [--url <self-hosted witness url>] [--dry-run] [--dir <project>]",
|
|
1044
|
+
options: { agent: { type: "string", multiple: true }, "dry-run": { type: "boolean" }, dir: { type: "string" }, url: { type: "string" } },
|
|
1045
|
+
async run(v, pos) {
|
|
1046
|
+
if (pos[0] !== "setup" || pos.length > 1) {
|
|
1047
|
+
err(`usage: signafaide ${COMMANDS.agent.usage}; agents: ${AGENT_IDS.join(", ")}, all`);
|
|
1048
|
+
return 2;
|
|
1049
|
+
}
|
|
1050
|
+
const dry = !!v["dry-run"];
|
|
1051
|
+
const root = v.dir ? path2.resolve(v.dir) : findRoot();
|
|
1052
|
+
const results = setup({ root, agents: v.agent, dryRun: dry, url: v.url });
|
|
1053
|
+
if (!dry && results.length) {
|
|
1054
|
+
const rec = configure({ quiet: true, via: "cli", root });
|
|
1055
|
+
rec.rememberAgent();
|
|
1056
|
+
rec.state.ignoreInGit();
|
|
1057
|
+
}
|
|
1058
|
+
if (!results.length) {
|
|
1059
|
+
out(`no coding agent found for ${root}; name one with --agent ${AGENT_IDS.join("|")} (or --agent all)`);
|
|
1060
|
+
return 1;
|
|
1061
|
+
}
|
|
1062
|
+
if (dry) out("dry run: nothing written");
|
|
1063
|
+
for (const r of results) {
|
|
1064
|
+
const what = r.changes.map((c) => `${c.file} ${dry && /^(created|updated)$/.test(c.result) ? `would be ${c.result}` : c.result}`).join(", ");
|
|
1065
|
+
out(`${r.agent}: ${what}${r.note ? `; note: ${r.note}` : ""}`);
|
|
1066
|
+
}
|
|
1067
|
+
for (const line of SETUP_NOTES(v.url)) out(line);
|
|
1068
|
+
return results.some((r) => r.changes.some((c) => c.result.startsWith("skipped"))) ? 1 : 0;
|
|
1069
|
+
}
|
|
1070
|
+
},
|
|
1071
|
+
mcp: {
|
|
1072
|
+
help: "run the signafAIde MCP server over stdio (the separate signafaide-mcp package; `agent setup` configures it)",
|
|
1073
|
+
async run() {
|
|
1074
|
+
const pkg = "signafaide-mcp";
|
|
1075
|
+
let mod;
|
|
1076
|
+
try {
|
|
1077
|
+
mod = await import(pkg);
|
|
1078
|
+
} catch {
|
|
1079
|
+
err(`signafaide: the MCP server is a separate package: run \`npx -y signafaide-mcp@${VERSION}\` (\`signafaide agent setup\` writes that into your coding agent's configuration)`);
|
|
1080
|
+
return 1;
|
|
1081
|
+
}
|
|
1082
|
+
await mod.serve();
|
|
1083
|
+
return 0;
|
|
1084
|
+
}
|
|
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
|
+
},
|
|
1113
|
+
flush: {
|
|
1114
|
+
help: "ship the outbox now",
|
|
1115
|
+
async run() {
|
|
1116
|
+
const rec = recorder();
|
|
1117
|
+
const r = await rec.flush();
|
|
1118
|
+
for (const [agent, e] of Object.entries(r.errors)) err(`${agent}: ${e.message}`);
|
|
1119
|
+
if (!rec.apiKey) {
|
|
1120
|
+
out(`no API key: nothing was sent; ${plural(r.left, "record")} stay local. Run \`signafaide init\` (or set SIGNAFAIDE_API_KEY) to send hashes`);
|
|
1121
|
+
return 0;
|
|
1122
|
+
}
|
|
1123
|
+
const local = Object.entries(r.errors).filter(([, e]) => e.code === "too_old" || e.code === "chain_exists").reduce((n, [c]) => n + rec.state.draftCount(c), 0);
|
|
1124
|
+
out(`shipped ${plural(r.shipped, "record")}, ${r.left - local} left in the outbox` + (local ? `; ${plural(local, "record")} kept on this machine only (see above)` : ""));
|
|
1125
|
+
for (const b of rec.diagnose()) out(`BLOCKED ${b.chain}: ${plural(b.records, "record")} cannot ship as they are. Next: ${b.next}`);
|
|
1126
|
+
if (rec.apiKey.startsWith("sfa_live_") && Object.values(r.errors).some((e) => e.code === "quota_exceeded")) out(UPGRADE_LIMIT(siteOf(rec.url)));
|
|
1127
|
+
return Object.keys(r.errors).length ? 1 : 0;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
async function loadCandidate(spec) {
|
|
1132
|
+
const i = spec.lastIndexOf(":");
|
|
1133
|
+
const [target, fn] = i > 0 ? [spec.slice(0, i), spec.slice(i + 1)] : ["", ""];
|
|
1134
|
+
if (!target || !fn) throw new SyntaxError("--module must be path/to/file.js:function or package:function");
|
|
1135
|
+
const local = /^[./]/.test(target) || /\.[cm]?[jt]s$/.test(target);
|
|
1136
|
+
let mod;
|
|
1137
|
+
try {
|
|
1138
|
+
mod = await (local ? import(pathToFileURL(path2.resolve(target)).href) : import(target));
|
|
1139
|
+
} catch (e) {
|
|
1140
|
+
throw new SyntaxError(`cannot load the candidate ${target}: ${e.message}`);
|
|
1141
|
+
}
|
|
1142
|
+
const f = mod[fn] ?? mod.default?.[fn];
|
|
1143
|
+
if (typeof f !== "function") throw new SyntaxError(`${spec}: no function "${fn}" in ${target}`);
|
|
1144
|
+
return f;
|
|
1145
|
+
}
|
|
1146
|
+
function help() {
|
|
1147
|
+
const width = Math.max(...Object.keys(COMMANDS).map((c) => c.length));
|
|
1148
|
+
return ["usage: signafaide <command> [options]", "", ...Object.entries(COMMANDS).map(([c, d]) => ` ${c.padEnd(width)} ${d.help}`)].join("\n");
|
|
1149
|
+
}
|
|
1150
|
+
var EXIT_WHEN_DONE = /* @__PURE__ */ new Set(["gateway"]);
|
|
1151
|
+
async function main(argv = process.argv.slice(2)) {
|
|
1152
|
+
const [name, ...rest] = argv;
|
|
1153
|
+
if (name === "--version" || name === "-v") {
|
|
1154
|
+
out(`signafaide ${VERSION}`);
|
|
1155
|
+
return 0;
|
|
1156
|
+
}
|
|
1157
|
+
const cmd = name ? COMMANDS[name] : void 0;
|
|
1158
|
+
if (!cmd) {
|
|
1159
|
+
(name && name !== "--help" && name !== "-h" ? err : out)(help());
|
|
1160
|
+
return name && name !== "--help" && name !== "-h" ? 1 : 0;
|
|
1161
|
+
}
|
|
1162
|
+
const own = rest.includes("--") ? rest.slice(0, rest.indexOf("--")) : rest;
|
|
1163
|
+
if (own.includes("--help") || own.includes("-h")) {
|
|
1164
|
+
out(`${cmd.help}
|
|
1165
|
+
usage: signafaide ${cmd.usage ?? name}`);
|
|
1166
|
+
return 0;
|
|
1167
|
+
}
|
|
1168
|
+
let parsed;
|
|
1169
|
+
try {
|
|
1170
|
+
parsed = parseArgs({ args: rest, options: cmd.options ?? {}, allowPositionals: true, strict: true });
|
|
1171
|
+
} catch (e) {
|
|
1172
|
+
err(`signafaide ${name}: ${e.message}; usage: signafaide ${cmd.usage ?? name}`);
|
|
1173
|
+
return 2;
|
|
1174
|
+
}
|
|
1175
|
+
try {
|
|
1176
|
+
return await cmd.run(parsed.values, parsed.positionals);
|
|
1177
|
+
} catch (e) {
|
|
1178
|
+
if (e instanceof SignafaideError || e instanceof StateError || e instanceof KeysRequired || e instanceof VerificationFailed || e instanceof SyntaxError || e.code) {
|
|
1179
|
+
err(`signafaide: ${e.message}`);
|
|
1180
|
+
return 1;
|
|
1181
|
+
}
|
|
1182
|
+
throw e;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// src/bin.ts
|
|
1187
|
+
main().then((code) => {
|
|
1188
|
+
process.exitCode = code;
|
|
1189
|
+
if (EXIT_WHEN_DONE.has(process.argv[2])) process.exit(code);
|
|
1190
|
+
});
|