toolfunnel 0.7.0 → 0.8.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.
Files changed (73) hide show
  1. package/README.md +238 -235
  2. package/SECURITY.md +84 -84
  3. package/bin/toolfunnel.js +530 -451
  4. package/docs/KNOWN_BUGS.md +100 -80
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -84
  7. package/docs/packaging.md +116 -115
  8. package/hooks/hooks.manifest.json +27 -27
  9. package/hooks/scripts/example-audit.js +21 -21
  10. package/hooks/scripts/example-deny-dangerous.js +39 -39
  11. package/mcp/expose.example.json +30 -30
  12. package/mcp/servers/mock-modern/server.js +147 -147
  13. package/mcp/servers/mock-upstream/server.js +379 -380
  14. package/package.json +67 -67
  15. package/src/auth/config.js +172 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +358 -353
  18. package/src/core/atomic-write.js +97 -0
  19. package/src/core/child-reaper.js +61 -61
  20. package/src/core/config-home.js +226 -134
  21. package/src/core/events.js +105 -105
  22. package/src/core/hook-engine.js +304 -329
  23. package/src/core/hook-loader.js +766 -824
  24. package/src/core/hook-runner.js +615 -566
  25. package/src/core/logger.js +295 -297
  26. package/src/core/matcher.js +100 -100
  27. package/src/core/metrics.js +90 -75
  28. package/src/core/output-cap.js +31 -0
  29. package/src/core/rename-migration.js +178 -163
  30. package/src/core/requires.js +138 -138
  31. package/src/core/server-config.js +98 -98
  32. package/src/extend/add-hook.md +155 -152
  33. package/src/extend/add-mcp.md +119 -119
  34. package/src/extend/configure.md +88 -88
  35. package/src/extend/create-tool.md +168 -168
  36. package/src/extend/howto.js +102 -102
  37. package/src/extend/package.md +190 -190
  38. package/src/extend/wrap.md +99 -99
  39. package/src/mcp/aggregator.js +2248 -1674
  40. package/src/mcp/expose-store.js +755 -577
  41. package/src/mcp/gated-run.js +210 -210
  42. package/src/mcp/http-transport.js +1657 -1563
  43. package/src/mcp/mcp-client.js +1174 -1070
  44. package/src/mcp/modern.js +409 -406
  45. package/src/mcp/protocol.js +473 -468
  46. package/src/mcp/server.js +3317 -3112
  47. package/src/mcp/tool-approvals.js +396 -0
  48. package/src/tools/registry.js +607 -661
  49. package/src/tools/tool-state.js +170 -170
  50. package/src/ui/public/app.js +1246 -1187
  51. package/src/ui/public/index.html +286 -286
  52. package/src/ui/public/styles.css +535 -535
  53. package/src/ui/server.js +1742 -1616
  54. package/tools/scripts/base64.js +139 -139
  55. package/tools/scripts/danger.js +61 -61
  56. package/tools/scripts/echo.js +39 -39
  57. package/tools/scripts/hash.js +101 -101
  58. package/tools/scripts/json.js +109 -109
  59. package/tools/scripts/text-stats.js +131 -131
  60. package/tools/scripts/tf-accept-change.js +67 -0
  61. package/tools/scripts/tf-env.js +43 -43
  62. package/tools/scripts/tf-hook-add.js +151 -151
  63. package/tools/scripts/tf-hook-set.js +120 -120
  64. package/tools/scripts/tf-list.js +192 -192
  65. package/tools/scripts/tf-log.js +86 -86
  66. package/tools/scripts/tf-mcp-add.js +130 -117
  67. package/tools/scripts/tf-mcp-set.js +121 -121
  68. package/tools/scripts/tf-pack.js +237 -237
  69. package/tools/scripts/tf-tool-add.js +134 -134
  70. package/tools/scripts/tf-tool-set.js +142 -142
  71. package/tools/scripts/tf-wrap.js +103 -103
  72. package/tools/scripts/uuid.js +102 -102
  73. package/tools/tools.register.json +16 -5
@@ -0,0 +1,396 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * tool-approvals.js - tool descriptor approval ("hash-pinning").
5
+ *
6
+ * An upstream can change what its tools SAY after you attached it. The description (and every
7
+ * parameter description inside the input schema) goes straight into the model's context, so a
8
+ * changed descriptor is the attack whether or not the tool is ever called. This module holds the
9
+ * PURE rules plus the state file; the aggregator applies them at its single listing chokepoint
10
+ * (_listAndCache) so every listing shows only approved text and every execution path can refuse.
11
+ *
12
+ * Rules, per upstream (evaluate):
13
+ * - no record yet -> FIRST USE: approve every tool as listed.
14
+ * - same version, a tool changed/new -> PENDING: the listing keeps the APPROVED descriptor (a new
15
+ * tool stays hidden) and calls refuse until accepted.
16
+ * - version changed -> UPGRADE: auto-approved and reported, unless `strict`
17
+ * (expose.json strictApproval) - then pending as above.
18
+ * - the server NAME changed -> the same as a version change (reported as server-changed);
19
+ * never a fresh first use, or a rename would get past strict.
20
+ * - a server that reports no version -> the stricter branch (treated as "same version").
21
+ * - a removed tool -> its approval stays (harmless); a pending entry for it goes.
22
+ * Known limit, documented: a hostile maintainer can bump the version too - that is what strict is for.
23
+ *
24
+ * What is hashed: the model-facing fields, as canonical JSON (sorted keys): HASHED_FIELDS. `_meta`
25
+ * and `icons` are presentation and excluded. sha256 from node:crypto (zero-dep).
26
+ *
27
+ * The state file (mcp/tool-approvals.json) is USER STATE, never seeded or shipped. It carries the
28
+ * pending entries too, because the UI and CLI (separate processes) accept by moving pending ->
29
+ * approved in the file. Missing = empty. Unreadable = THROW: treating it as empty would silently
30
+ * approve everything as first use, which is exactly what corrupting it would be for.
31
+ *
32
+ * Tool names and upstream ids come from outside, so every map write goes through put() (an own
33
+ * data property even for "__proto__") and every read through own().
34
+ *
35
+ * CommonJS, node built-ins only.
36
+ */
37
+
38
+ const fs = require('node:fs');
39
+ const crypto = require('node:crypto');
40
+ const { atomicWriteJson } = require('../core/atomic-write');
41
+
42
+ const HASHED_FIELDS = ['name', 'title', 'description', 'inputSchema', 'outputSchema', 'annotations'];
43
+ const FILE_VERSION = 1;
44
+
45
+ const hasOwn = (o, k) => o !== null && typeof o === 'object' && Object.prototype.hasOwnProperty.call(o, k);
46
+ const own = (o, k) => (hasOwn(o, k) ? o[k] : undefined);
47
+ function put(o, k, v) {
48
+ Object.defineProperty(o, k, { value: v, enumerable: true, writable: true, configurable: true });
49
+ }
50
+ const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
51
+ const str = (v) => (typeof v === 'string' ? v : '');
52
+
53
+ /** Stable JSON: object keys sorted at every level, undefined members dropped (as JSON does). */
54
+ function canonical(v) {
55
+ if (v === null || typeof v !== 'object') return JSON.stringify(v === undefined ? null : v);
56
+ if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']';
57
+ const keys = Object.keys(v).filter((k) => v[k] !== undefined).sort();
58
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}';
59
+ }
60
+
61
+ /** The hashed, stored and served part of a tool: its HASHED_FIELDS that are present. */
62
+ function descriptorOf(tool) {
63
+ const d = {};
64
+ for (const f of HASHED_FIELDS) {
65
+ if (tool && tool[f] !== undefined) put(d, f, clone(tool[f]));
66
+ }
67
+ return d;
68
+ }
69
+
70
+ /** 'sha256:<hex>' over the canonical descriptor. */
71
+ function hashTool(tool) {
72
+ return 'sha256:' + crypto.createHash('sha256').update(canonical(descriptorOf(tool)), 'utf8').digest('hex');
73
+ }
74
+
75
+ // C0, DEL, C1 and the bidi controls: a server's text must not drive a terminal or reorder a line.
76
+ const UNPRINTABLE = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
77
+ const NAMED_ESCAPES = { '\n': '\\n', '\r': '\\r', '\t': '\\t' };
78
+
79
+ /** A server's text made safe to print: control and bidi characters shown as escapes. */
80
+ function printable(s) {
81
+ return String(s).replace(UNPRINTABLE, (c) => NAMED_ESCAPES[c] || '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
82
+ }
83
+
84
+ /** The HASHED_FIELDS that differ between two descriptors (every present one when `approved` is null). */
85
+ function changedFields(approved, current) {
86
+ return HASHED_FIELDS.filter((f) => hasOwn(approved, f) !== hasOwn(current, f) || canonical(own(approved, f)) !== canonical(own(current, f)));
87
+ }
88
+
89
+ /**
90
+ * Apply the rules to one upstream's current listing.
91
+ * @param {object} o
92
+ * @param {object} [o.record] this upstream's stored record, or undefined (never approved)
93
+ * @param {object} [o.serverInfo] { name, version } the upstream reported this connect
94
+ * @param {object[]} o.tools the raw tools/list result
95
+ * @param {boolean} [o.strict] strictApproval: a version change does not auto-approve
96
+ * @param {string} [o.now] ISO time for new entries (tests pass a fixed one)
97
+ * @param {object} [o.writer] { pid, id } of the gateway evaluating, stamped on each pending
98
+ * entry as `by` so another run can tell a live writer from a gone one
99
+ * @returns {{ record: object, effective: object[], pending: object[], events: object[], changed: boolean }}
100
+ * record the new record to store; effective the tools to SERVE (approved descriptors, in the
101
+ * upstream's order; unapproved new tools absent); pending [{tool, reason, approved, current,
102
+ * changed}] for the refusal; events [{kind:'first-use'|'server-changed'|'upgrade', ...}];
103
+ * changed = the record differs from the one passed in (save it).
104
+ */
105
+ function evaluate({ record, serverInfo, tools, strict, now, writer } = {}) {
106
+ const at = typeof now === 'string' && now ? now : new Date().toISOString();
107
+ const seen = { name: str(serverInfo && serverInfo.name), version: str(serverInfo && serverInfo.version) };
108
+ const current = new Map(); // name -> { tool, hash, descriptor }, first occurrence wins (listing order)
109
+ for (const t of Array.isArray(tools) ? tools : []) {
110
+ if (!t || typeof t.name !== 'string' || !t.name.length || current.has(t.name)) continue;
111
+ current.set(t.name, { tool: t, hash: hashTool(t), descriptor: descriptorOf(t) });
112
+ }
113
+ const before = record ? canonical(record) : null;
114
+ const events = [];
115
+
116
+ if (!record) {
117
+ const approved = {};
118
+ for (const [n, c] of current) put(approved, n, { hash: c.hash, descriptor: c.descriptor, at, how: 'first-use' });
119
+ const rec = { server: { ...seen }, seen: { ...seen }, approved, pending: {} };
120
+ events.push({ kind: 'first-use', tools: [...current.keys()] });
121
+ return { record: rec, effective: [...current.values()].map((c) => c.tool), pending: [], events, changed: true };
122
+ }
123
+
124
+ const rec = { server: { ...record.server }, seen: { ...seen }, approved: {}, pending: {} };
125
+ for (const k of Object.keys(record.approved || {})) put(rec.approved, k, record.approved[k]);
126
+ const versionChanged = !!seen.version && !!record.server.version && seen.version !== record.server.version;
127
+ // A different server NAME is an identity change like a version change: approved (and reported)
128
+ // when not strict, pending under strictApproval. It is never a fresh first use - that let a
129
+ // server rename itself past strictApproval, or release a pending change by flipping its name.
130
+ const nameChanged = !!seen.name && !!record.server.name && seen.name !== record.server.name;
131
+ const autoApprove = (versionChanged || nameChanged) && !strict;
132
+ const effective = [];
133
+ const pending = [];
134
+ const upgraded = [];
135
+ for (const [n, c] of current) {
136
+ const a = own(rec.approved, n);
137
+ if (a && a.hash === c.hash) { effective.push(c.tool); continue; }
138
+ const reason = a ? 'changed' : 'new';
139
+ if (autoApprove) {
140
+ put(rec.approved, n, { hash: c.hash, descriptor: c.descriptor, at, how: 'upgrade' });
141
+ effective.push(c.tool);
142
+ upgraded.push({ tool: n, reason });
143
+ continue;
144
+ }
145
+ const prior = own(record.pending, n);
146
+ put(rec.pending, n, {
147
+ hash: c.hash,
148
+ descriptor: c.descriptor,
149
+ since: prior && prior.hash === c.hash && prior.since ? prior.since : at,
150
+ reason,
151
+ ...(writer ? { by: writer } : {}),
152
+ });
153
+ pending.push({
154
+ tool: n,
155
+ hash: c.hash, // what an accept must match: the descriptor the user was shown, not a later one
156
+ reason,
157
+ approved: a ? clone(a.descriptor) : null,
158
+ current: clone(c.descriptor),
159
+ changed: changedFields(a && a.descriptor, c.descriptor),
160
+ });
161
+ if (a) effective.push(clone(a.descriptor)); // serve the APPROVED text; the name is unchanged
162
+ }
163
+ // The approved identity follows the server only while nothing is waiting: a pending change was
164
+ // seen at the new version, and accepting it (accept) is what moves the identity forward.
165
+ if (Object.keys(rec.pending).length === 0) {
166
+ if (seen.name) rec.server.name = seen.name;
167
+ if (seen.version) rec.server.version = seen.version;
168
+ }
169
+ if (upgraded.length && nameChanged) events.push({ kind: 'server-changed', from: record.server.name, to: seen.name, tools: upgraded });
170
+ else if (upgraded.length) events.push({ kind: 'upgrade', from: record.server.version, to: seen.version, tools: upgraded });
171
+ return { record: rec, effective, pending, events, changed: canonical(rec) !== before };
172
+ }
173
+
174
+ /**
175
+ * Several gateways can share one home, each with its own child of an upstream, so each can see a
176
+ * different listing. Before this process writes its record, carry over the pending entries in the
177
+ * STORED record that another process wrote: an entry this record neither holds as pending nor has
178
+ * approved at that hash, and that this process did not write itself (`ownWritten(tool, hash)`; its
179
+ * own entry that it no longer sees means its server reverted, so that one goes), and whose writer
180
+ * is still running (`isAlive(by)`; an entry left by a gateway that has exited - this gateway's own
181
+ * previous run, say - is no one's view any more, and kept it would never clear). One pending slot
182
+ * per tool: this process's own pending entry for a tool wins.
183
+ * @returns {object} a NEW record
184
+ */
185
+ function keepForeignPending(stored, next, ownWritten, isAlive) {
186
+ const rec = clone(next);
187
+ if (!rec.pending || typeof rec.pending !== 'object') rec.pending = {};
188
+ const sp = stored && stored.pending && typeof stored.pending === 'object' ? stored.pending : {};
189
+ for (const n of Object.keys(sp)) {
190
+ const e = sp[n];
191
+ if (!e || typeof e.hash !== 'string' || hasOwn(rec.pending, n)) continue;
192
+ const a = own(rec.approved, n);
193
+ if (a && a.hash === e.hash) continue; // accepted since
194
+ if (typeof ownWritten === 'function' && ownWritten(n, e.hash)) continue;
195
+ if (e.by && typeof isAlive === 'function' && !isAlive(e.by)) continue;
196
+ put(rec.pending, n, clone(e));
197
+ }
198
+ return rec;
199
+ }
200
+
201
+ /**
202
+ * Accept pending descriptors: one tool, or every pending one when `toolName` is undefined.
203
+ * @returns {{ record: object, accepted: string[] }} a NEW record (the input is not modified)
204
+ */
205
+ function accept(record, toolName, now) {
206
+ const at = typeof now === 'string' && now ? now : new Date().toISOString();
207
+ const rec = clone(record || {});
208
+ if (!rec.approved || typeof rec.approved !== 'object') rec.approved = {};
209
+ if (!rec.pending || typeof rec.pending !== 'object') rec.pending = {};
210
+ const names = toolName === undefined ? Object.keys(rec.pending) : (hasOwn(rec.pending, toolName) ? [toolName] : []);
211
+ for (const n of names) {
212
+ const p = rec.pending[n];
213
+ put(rec.approved, n, { hash: p.hash, descriptor: p.descriptor, at, how: 'accepted' });
214
+ delete rec.pending[n];
215
+ }
216
+ if (Object.keys(rec.pending).length === 0 && rec.seen && rec.server) {
217
+ if (rec.seen.name) rec.server.name = rec.seen.name;
218
+ if (rec.seen.version) rec.server.version = rec.seen.version;
219
+ }
220
+ return { record: rec, accepted: names };
221
+ }
222
+
223
+ // ── the state file ────────────────────────────────────────────────────────────────────────────
224
+
225
+ function emptyApprovals() {
226
+ return { version: FILE_VERSION, upstreams: {} };
227
+ }
228
+
229
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
230
+
231
+ /** Throw, naming the file and the field, on anything that is not a well-formed approvals file. */
232
+ function validateApprovals(doc, filePath) {
233
+ const fail = (what) => { throw new Error(`tool approvals: ${filePath}: ${what}`); };
234
+ if (!isPlainObject(doc)) fail('must be a JSON object');
235
+ if (typeof doc.version !== 'number') fail('"version" must be a number');
236
+ if (!isPlainObject(doc.upstreams)) fail('"upstreams" must be an object keyed by upstream id');
237
+ for (const id of Object.keys(doc.upstreams)) {
238
+ const r = doc.upstreams[id];
239
+ const where = `upstream ${JSON.stringify(id)}`;
240
+ if (!isPlainObject(r)) fail(`${where} must be an object`);
241
+ if (!isPlainObject(r.server) || typeof r.server.name !== 'string' || typeof r.server.version !== 'string') {
242
+ fail(`${where}: "server" must be { name: string, version: string }`);
243
+ }
244
+ for (const part of ['approved', 'pending']) {
245
+ if (!isPlainObject(r[part])) fail(`${where}: "${part}" must be an object keyed by tool name`);
246
+ for (const n of Object.keys(r[part])) {
247
+ const e = r[part][n];
248
+ if (!isPlainObject(e) || typeof e.hash !== 'string' || !e.hash.startsWith('sha256:') || !isPlainObject(e.descriptor)) {
249
+ fail(`${where}: "${part}" entry ${JSON.stringify(n)} must be { hash: "sha256:...", descriptor: {...} }`);
250
+ }
251
+ if (e.descriptor.name !== n) fail(`${where}: "${part}" entry ${JSON.stringify(n)} has a descriptor named ${JSON.stringify(e.descriptor.name)}`);
252
+ }
253
+ }
254
+ }
255
+ }
256
+
257
+ /** Read the approvals file. Missing -> empty. Unreadable or malformed -> throw naming the file. */
258
+ function loadApprovals(filePath) {
259
+ let raw;
260
+ try {
261
+ raw = fs.readFileSync(filePath, 'utf8');
262
+ } catch (err) {
263
+ if (err && err.code === 'ENOENT') return emptyApprovals();
264
+ throw new Error(`tool approvals: cannot read ${filePath}: ${(err && err.message) || String(err)}`);
265
+ }
266
+ let doc;
267
+ try {
268
+ doc = JSON.parse(raw);
269
+ } catch (err) {
270
+ throw new Error(`tool approvals: ${filePath} is not valid JSON: ${(err && err.message) || String(err)}`);
271
+ }
272
+ validateApprovals(doc, filePath);
273
+ return doc;
274
+ }
275
+
276
+ /** Write the approvals file atomically (the shared temp + fsync + rename writer). */
277
+ function saveApprovals(filePath, doc) {
278
+ // A home without an mcp/ folder must still record first use (else nothing is ever remembered).
279
+ fs.mkdirSync(require('node:path').dirname(filePath), { recursive: true });
280
+ atomicWriteJson(filePath, doc);
281
+ }
282
+
283
+ /**
284
+ * The ONE accept every route shares - the tf_accept_change management tool, the CLI, the UI server
285
+ * and the gateway itself: read the CURRENT file, accept, write it back atomically. With
286
+ * `expectHash`, only if the pending descriptor is still the one the user was shown (a server that
287
+ * changed again is not approved sight unseen). Living in the engine, it reaches every install -
288
+ * including an external config home, whose copied scripts and register never refresh.
289
+ * Throws on an unreadable file.
290
+ * @returns {{ accepted: string[], pending: string[], stale?: true, unknown?: true }}
291
+ */
292
+ function acceptInFile(filePath, upstreamId, toolName, expectHash, now) {
293
+ const doc = loadApprovals(filePath);
294
+ const rec = getRecord(doc, upstreamId);
295
+ if (!rec) return { accepted: [], pending: [], unknown: true };
296
+ if (expectHash !== undefined && expectHash !== null) {
297
+ const p = toolName !== undefined ? own(rec.pending, toolName) : undefined;
298
+ if (!p || p.hash !== expectHash) return { accepted: [], pending: Object.keys(rec.pending || {}), stale: true };
299
+ }
300
+ const res = accept(rec, toolName, now);
301
+ if (res.accepted.length) {
302
+ setRecord(doc, upstreamId, res.record);
303
+ saveApprovals(filePath, doc);
304
+ }
305
+ return { accepted: res.accepted, pending: Object.keys(res.record.pending || {}) };
306
+ }
307
+
308
+ /**
309
+ * Every change waiting for acceptance in a loaded doc, flat - the one view the CLI (`changes`) and
310
+ * the UI (/api/tool-changes) both render. Descriptions are the SERVER's text: render as text only.
311
+ * `fields` holds { approved, new } for every other changed field (a schema's parameter descriptions
312
+ * reach the model too, so the user must be able to read them before accepting).
313
+ * @returns {Array<{upstream, tool, reason, since, hash, approvedDescription, newDescription, changed, fields}>}
314
+ */
315
+ function listPending(doc) {
316
+ const out = [];
317
+ const ups = (doc && doc.upstreams) || {};
318
+ for (const id of Object.keys(ups)) {
319
+ const rec = ups[id] || {};
320
+ const pend = rec.pending || {};
321
+ for (const name of Object.keys(pend)) {
322
+ const p = pend[name] || {};
323
+ const a = own(rec.approved, name);
324
+ const changed = changedFields(a && a.descriptor, p.descriptor);
325
+ out.push({
326
+ upstream: id,
327
+ tool: name,
328
+ reason: p.reason || (a ? 'changed' : 'new'),
329
+ since: p.since || null,
330
+ hash: p.hash,
331
+ approvedDescription: a && a.descriptor && typeof a.descriptor.description === 'string' ? a.descriptor.description : null,
332
+ newDescription: p.descriptor && typeof p.descriptor.description === 'string' ? p.descriptor.description : null,
333
+ changed,
334
+ fields: Object.fromEntries(changed.filter((f) => f !== 'name' && f !== 'description')
335
+ .map((f) => [f, { approved: a ? own(a.descriptor, f) ?? null : null, new: own(p.descriptor, f) ?? null }])),
336
+ });
337
+ }
338
+ }
339
+ return out;
340
+ }
341
+
342
+ /** Tools approved automatically on a server version upgrade, latest first (at most `limit`). */
343
+ function listUpgrades(doc, limit) {
344
+ const out = [];
345
+ const ups = (doc && doc.upstreams) || {};
346
+ for (const id of Object.keys(ups)) {
347
+ const appr = (ups[id] && ups[id].approved) || {};
348
+ for (const name of Object.keys(appr)) {
349
+ if (appr[name] && appr[name].how === 'upgrade') out.push({ upstream: id, tool: name, at: appr[name].at || null });
350
+ }
351
+ }
352
+ out.sort((x, y) => String(y.at).localeCompare(String(x.at)));
353
+ return typeof limit === 'number' ? out.slice(0, limit) : out;
354
+ }
355
+
356
+ /** Why an acceptInFile result accepted nothing (the wording every route shares), or null when it did. */
357
+ function acceptFailure(res, upstreamId, toolName) {
358
+ if (res && res.unknown) return `no tool approvals are recorded for upstream "${upstreamId}"`;
359
+ if (res && res.stale) return 'that change is no longer the one pending - the server changed the tool again';
360
+ if (!res || !res.accepted || res.accepted.length === 0) {
361
+ return toolName ? `tool "${toolName}" has no change waiting on upstream "${upstreamId}"` : `upstream "${upstreamId}" has no changes waiting`;
362
+ }
363
+ return null;
364
+ }
365
+
366
+ /** One upstream's record from a loaded doc (own property only), or undefined. */
367
+ function getRecord(doc, upstreamId) {
368
+ return own(doc && doc.upstreams, upstreamId);
369
+ }
370
+
371
+ /** Set one upstream's record on a loaded doc (an own property, whatever the id). */
372
+ function setRecord(doc, upstreamId, record) {
373
+ put(doc.upstreams, upstreamId, record);
374
+ }
375
+
376
+ module.exports = {
377
+ HASHED_FIELDS,
378
+ canonical,
379
+ descriptorOf,
380
+ hashTool,
381
+ changedFields,
382
+ printable,
383
+ evaluate,
384
+ keepForeignPending,
385
+ accept,
386
+ emptyApprovals,
387
+ validateApprovals,
388
+ loadApprovals,
389
+ saveApprovals,
390
+ getRecord,
391
+ setRecord,
392
+ acceptInFile,
393
+ listPending,
394
+ listUpgrades,
395
+ acceptFailure,
396
+ };