@cybedefend/vibedefend 1.5.0 → 1.7.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 (264) hide show
  1. package/README.md +71 -2
  2. package/dist/api/gateway-request.js +49 -0
  3. package/dist/api/gateway-request.js.map +1 -0
  4. package/dist/api/projects.js +67 -0
  5. package/dist/api/projects.js.map +1 -1
  6. package/dist/api/repository-link.js +157 -0
  7. package/dist/api/repository-link.js.map +1 -0
  8. package/dist/auth/auth-store.js +107 -25
  9. package/dist/auth/auth-store.js.map +1 -1
  10. package/dist/auth/auth.js +21 -0
  11. package/dist/auth/auth.js.map +1 -1
  12. package/dist/clients/cursor.js +5 -1
  13. package/dist/clients/cursor.js.map +1 -1
  14. package/dist/clients/vscode.js +38 -29
  15. package/dist/clients/vscode.js.map +1 -1
  16. package/dist/commands/mcp/allow.js +219 -0
  17. package/dist/commands/mcp/allow.js.map +1 -0
  18. package/dist/commands/mcp/connect.js +337 -0
  19. package/dist/commands/mcp/connect.js.map +1 -0
  20. package/dist/commands/mcp/index.js +170 -0
  21. package/dist/commands/mcp/index.js.map +1 -0
  22. package/dist/commands/mcp/proxy.js +320 -0
  23. package/dist/commands/mcp/proxy.js.map +1 -0
  24. package/dist/commands/mcp/scan.js +349 -0
  25. package/dist/commands/mcp/scan.js.map +1 -0
  26. package/dist/commands/mcp/sync.js +215 -0
  27. package/dist/commands/mcp/sync.js.map +1 -0
  28. package/dist/config.js +6 -0
  29. package/dist/config.js.map +1 -1
  30. package/dist/diagnostics.js +78 -12
  31. package/dist/diagnostics.js.map +1 -1
  32. package/dist/doctor.js +19 -0
  33. package/dist/doctor.js.map +1 -1
  34. package/dist/guards-evaluator/analysis-types.js +27 -0
  35. package/dist/guards-evaluator/analysis-types.js.map +1 -0
  36. package/dist/guards-evaluator/command-analysis.js +572 -0
  37. package/dist/guards-evaluator/command-analysis.js.map +1 -0
  38. package/dist/guards-evaluator/command-view.js +586 -0
  39. package/dist/guards-evaluator/command-view.js.map +1 -0
  40. package/dist/guards-evaluator/env.js +11 -0
  41. package/dist/guards-evaluator/env.js.map +1 -0
  42. package/dist/guards-evaluator/evaluate.js +91 -41
  43. package/dist/guards-evaluator/evaluate.js.map +1 -1
  44. package/dist/guards-evaluator/index.js +2 -1
  45. package/dist/guards-evaluator/index.js.map +1 -1
  46. package/dist/guards-evaluator/inline-code.js +259 -0
  47. package/dist/guards-evaluator/inline-code.js.map +1 -0
  48. package/dist/guards-evaluator/normalize.js +716 -0
  49. package/dist/guards-evaluator/normalize.js.map +1 -0
  50. package/dist/guards-evaluator/preprocess.js +13 -5
  51. package/dist/guards-evaluator/preprocess.js.map +1 -1
  52. package/dist/guards-evaluator/redact.js +19 -0
  53. package/dist/guards-evaluator/redact.js.map +1 -1
  54. package/dist/guards-evaluator/regex.js +25 -3
  55. package/dist/guards-evaluator/regex.js.map +1 -1
  56. package/dist/guards-evaluator/safe-commands.js +155 -0
  57. package/dist/guards-evaluator/safe-commands.js.map +1 -0
  58. package/dist/guards-evaluator/triage.js +315 -0
  59. package/dist/guards-evaluator/triage.js.map +1 -0
  60. package/dist/hook-runner.js +24679 -5079
  61. package/dist/hooks/install.js +38 -0
  62. package/dist/hooks/install.js.map +1 -1
  63. package/dist/hooks/runtime/config.js +38 -0
  64. package/dist/hooks/runtime/config.js.map +1 -1
  65. package/dist/hooks/runtime/emit.js +94 -61
  66. package/dist/hooks/runtime/emit.js.map +1 -1
  67. package/dist/hooks/runtime/fetch-rules.js +22 -7
  68. package/dist/hooks/runtime/fetch-rules.js.map +1 -1
  69. package/dist/hooks/runtime/gateway-http-error.js +34 -0
  70. package/dist/hooks/runtime/gateway-http-error.js.map +1 -0
  71. package/dist/hooks/runtime/guard-rules-cache.js +139 -17
  72. package/dist/hooks/runtime/guard-rules-cache.js.map +1 -1
  73. package/dist/hooks/runtime/guard-violations-buffer.js +321 -48
  74. package/dist/hooks/runtime/guard-violations-buffer.js.map +1 -1
  75. package/dist/hooks/runtime/judge-cache.js +6 -0
  76. package/dist/hooks/runtime/judge-cache.js.map +1 -1
  77. package/dist/hooks/runtime/judge-triage.js +193 -0
  78. package/dist/hooks/runtime/judge-triage.js.map +1 -0
  79. package/dist/hooks/runtime/judge.js +7 -1
  80. package/dist/hooks/runtime/judge.js.map +1 -1
  81. package/dist/hooks/runtime/pre-compact.js +12 -6
  82. package/dist/hooks/runtime/pre-compact.js.map +1 -1
  83. package/dist/hooks/runtime/resolve.js +90 -8
  84. package/dist/hooks/runtime/resolve.js.map +1 -1
  85. package/dist/hooks/runtime/retry-policy.js +57 -0
  86. package/dist/hooks/runtime/retry-policy.js.map +1 -0
  87. package/dist/hooks/runtime/review-sessions.js +74 -0
  88. package/dist/hooks/runtime/review-sessions.js.map +1 -0
  89. package/dist/hooks/runtime/script-resolve.js +695 -0
  90. package/dist/hooks/runtime/script-resolve.js.map +1 -0
  91. package/dist/hooks/runtime/session-activation.js +5 -1
  92. package/dist/hooks/runtime/session-activation.js.map +1 -1
  93. package/dist/hooks/runtime/session-review.js +163 -45
  94. package/dist/hooks/runtime/session-review.js.map +1 -1
  95. package/dist/hooks/runtime/session-start.js +2 -1
  96. package/dist/hooks/runtime/session-start.js.map +1 -1
  97. package/dist/hooks/runtime/session-writes.js +232 -0
  98. package/dist/hooks/runtime/session-writes.js.map +1 -0
  99. package/dist/hooks/runtime/sniff.js +26 -7
  100. package/dist/hooks/runtime/sniff.js.map +1 -1
  101. package/dist/hooks/runtime/target-resolve.js +336 -0
  102. package/dist/hooks/runtime/target-resolve.js.map +1 -0
  103. package/dist/hooks/runtime/unmapped-tools.js +93 -0
  104. package/dist/hooks/runtime/unmapped-tools.js.map +1 -0
  105. package/dist/hooks/runtime/usage-block.js +4 -0
  106. package/dist/hooks/runtime/usage-block.js.map +1 -1
  107. package/dist/hooks/runtime/user-prompt-submit.js +2 -1
  108. package/dist/hooks/runtime/user-prompt-submit.js.map +1 -1
  109. package/dist/hooks/runtime/wire-text.js +41 -0
  110. package/dist/hooks/runtime/wire-text.js.map +1 -0
  111. package/dist/hooks/runtime/work-dir.js +84 -0
  112. package/dist/hooks/runtime/work-dir.js.map +1 -0
  113. package/dist/index.js +45 -12
  114. package/dist/index.js.map +1 -1
  115. package/dist/install.js +174 -11
  116. package/dist/install.js.map +1 -1
  117. package/dist/link.js +74 -48
  118. package/dist/link.js.map +1 -1
  119. package/dist/login.js +77 -11
  120. package/dist/login.js.map +1 -1
  121. package/dist/mcp/auth/agent-sign-ins.js +174 -0
  122. package/dist/mcp/auth/agent-sign-ins.js.map +1 -0
  123. package/dist/mcp/auth/callback.js +200 -0
  124. package/dist/mcp/auth/callback.js.map +1 -0
  125. package/dist/mcp/auth/connect.js +392 -0
  126. package/dist/mcp/auth/connect.js.map +1 -0
  127. package/dist/mcp/auth/discovery.js +399 -0
  128. package/dist/mcp/auth/discovery.js.map +1 -0
  129. package/dist/mcp/auth/oauth.js +326 -0
  130. package/dist/mcp/auth/oauth.js.map +1 -0
  131. package/dist/mcp/auth/probe.js +128 -0
  132. package/dist/mcp/auth/probe.js.map +1 -0
  133. package/dist/mcp/auth/secrets.js +446 -0
  134. package/dist/mcp/auth/secrets.js.map +1 -0
  135. package/dist/mcp/auth/store.js +260 -0
  136. package/dist/mcp/auth/store.js.map +1 -0
  137. package/dist/mcp/auth/tokenSource.js +55 -0
  138. package/dist/mcp/auth/tokenSource.js.map +1 -0
  139. package/dist/mcp/config/atomicWrite.js +98 -0
  140. package/dist/mcp/config/atomicWrite.js.map +1 -0
  141. package/dist/mcp/config/clients.js +171 -0
  142. package/dist/mcp/config/clients.js.map +1 -0
  143. package/dist/mcp/config/cybedefend-servers.js +48 -0
  144. package/dist/mcp/config/cybedefend-servers.js.map +1 -0
  145. package/dist/mcp/config/discover.js +114 -0
  146. package/dist/mcp/config/discover.js.map +1 -0
  147. package/dist/mcp/config/hook.js +123 -0
  148. package/dist/mcp/config/hook.js.map +1 -0
  149. package/dist/mcp/config/index.js +11 -0
  150. package/dist/mcp/config/index.js.map +1 -0
  151. package/dist/mcp/config/install.js +132 -0
  152. package/dist/mcp/config/install.js.map +1 -0
  153. package/dist/mcp/config/installed.js +37 -0
  154. package/dist/mcp/config/installed.js.map +1 -0
  155. package/dist/mcp/config/jsonEdit.js +119 -0
  156. package/dist/mcp/config/jsonEdit.js.map +1 -0
  157. package/dist/mcp/config/lock.js +102 -0
  158. package/dist/mcp/config/lock.js.map +1 -0
  159. package/dist/mcp/config/proxyBinary.js +211 -0
  160. package/dist/mcp/config/proxyBinary.js.map +1 -0
  161. package/dist/mcp/config/rewrite.js +388 -0
  162. package/dist/mcp/config/rewrite.js.map +1 -0
  163. package/dist/mcp/config/types.js +34 -0
  164. package/dist/mcp/config/types.js.map +1 -0
  165. package/dist/mcp/proxy/audit.js +83 -0
  166. package/dist/mcp/proxy/audit.js.map +1 -0
  167. package/dist/mcp/proxy/authInterceptor.js +119 -0
  168. package/dist/mcp/proxy/authInterceptor.js.map +1 -0
  169. package/dist/mcp/proxy/expand.js +93 -0
  170. package/dist/mcp/proxy/expand.js.map +1 -0
  171. package/dist/mcp/proxy/inject.js +412 -0
  172. package/dist/mcp/proxy/inject.js.map +1 -0
  173. package/dist/mcp/proxy/intercept.js +63 -0
  174. package/dist/mcp/proxy/intercept.js.map +1 -0
  175. package/dist/mcp/proxy/jsonrpc.js +153 -0
  176. package/dist/mcp/proxy/jsonrpc.js.map +1 -0
  177. package/dist/mcp/proxy/log.js +109 -0
  178. package/dist/mcp/proxy/log.js.map +1 -0
  179. package/dist/mcp/proxy/paths.js +57 -0
  180. package/dist/mcp/proxy/paths.js.map +1 -0
  181. package/dist/mcp/proxy/proxy.js +520 -0
  182. package/dist/mcp/proxy/proxy.js.map +1 -0
  183. package/dist/mcp/proxy/serverCheckTimer.js +75 -0
  184. package/dist/mcp/proxy/serverCheckTimer.js.map +1 -0
  185. package/dist/mcp/proxy/sessionLifecycle.js +29 -0
  186. package/dist/mcp/proxy/sessionLifecycle.js.map +1 -0
  187. package/dist/mcp/proxy/spec.js +203 -0
  188. package/dist/mcp/proxy/spec.js.map +1 -0
  189. package/dist/mcp/proxy/sse.js +77 -0
  190. package/dist/mcp/proxy/sse.js.map +1 -0
  191. package/dist/mcp/proxy/upstream.js +2 -0
  192. package/dist/mcp/proxy/upstream.js.map +1 -0
  193. package/dist/mcp/proxy/upstreamHttp.js +499 -0
  194. package/dist/mcp/proxy/upstreamHttp.js.map +1 -0
  195. package/dist/mcp/proxy/upstreamStdio.js +188 -0
  196. package/dist/mcp/proxy/upstreamStdio.js.map +1 -0
  197. package/dist/mcp/proxy/upstreams.js +143 -0
  198. package/dist/mcp/proxy/upstreams.js.map +1 -0
  199. package/dist/mcp/proxy/verifyInterceptor.js +718 -0
  200. package/dist/mcp/proxy/verifyInterceptor.js.map +1 -0
  201. package/dist/mcp/proxy/windowsCommand.js +99 -0
  202. package/dist/mcp/proxy/windowsCommand.js.map +1 -0
  203. package/dist/mcp/report/buffer.js +351 -0
  204. package/dist/mcp/report/buffer.js.map +1 -0
  205. package/dist/mcp/report/coalesce.js +165 -0
  206. package/dist/mcp/report/coalesce.js.map +1 -0
  207. package/dist/mcp/report/constants.js +51 -0
  208. package/dist/mcp/report/constants.js.map +1 -0
  209. package/dist/mcp/report/project.js +83 -0
  210. package/dist/mcp/report/project.js.map +1 -0
  211. package/dist/mcp/report/reporter.js +443 -0
  212. package/dist/mcp/report/reporter.js.map +1 -0
  213. package/dist/mcp/report/types.js +3 -0
  214. package/dist/mcp/report/types.js.map +1 -0
  215. package/dist/mcp/report/validate.js +179 -0
  216. package/dist/mcp/report/validate.js.map +1 -0
  217. package/dist/mcp/scan/args.js +228 -0
  218. package/dist/mcp/scan/args.js.map +1 -0
  219. package/dist/mcp/scan/results.js +253 -0
  220. package/dist/mcp/scan/results.js.map +1 -0
  221. package/dist/mcp/scan/types.js +40 -0
  222. package/dist/mcp/scan/types.js.map +1 -0
  223. package/dist/mcp/verify/api.js +514 -0
  224. package/dist/mcp/verify/api.js.map +1 -0
  225. package/dist/mcp/verify/backend.js +143 -0
  226. package/dist/mcp/verify/backend.js.map +1 -0
  227. package/dist/mcp/verify/cache.js +200 -0
  228. package/dist/mcp/verify/cache.js.map +1 -0
  229. package/dist/mcp/verify/contract-errors.js +198 -0
  230. package/dist/mcp/verify/contract-errors.js.map +1 -0
  231. package/dist/mcp/verify/digest.js +91 -0
  232. package/dist/mcp/verify/digest.js.map +1 -0
  233. package/dist/mcp/verify/factory.js +56 -0
  234. package/dist/mcp/verify/factory.js.map +1 -0
  235. package/dist/mcp/verify/identity.js +286 -0
  236. package/dist/mcp/verify/identity.js.map +1 -0
  237. package/dist/mcp/verify/policy-store.js +63 -0
  238. package/dist/mcp/verify/policy-store.js.map +1 -0
  239. package/dist/mcp/verify/policy.js +160 -0
  240. package/dist/mcp/verify/policy.js.map +1 -0
  241. package/dist/mcp/verify/pool.js +27 -0
  242. package/dist/mcp/verify/pool.js.map +1 -0
  243. package/dist/mcp/verify/token-refresh.js +44 -0
  244. package/dist/mcp/verify/token-refresh.js.map +1 -0
  245. package/dist/mcp/verify/token.js +67 -0
  246. package/dist/mcp/verify/token.js.map +1 -0
  247. package/dist/mcp/verify/verifier.js +1166 -0
  248. package/dist/mcp/verify/verifier.js.map +1 -0
  249. package/dist/org-gate.js +1 -1
  250. package/dist/org-gate.js.map +1 -1
  251. package/dist/org.js +146 -0
  252. package/dist/org.js.map +1 -0
  253. package/dist/project-create.js +129 -0
  254. package/dist/project-create.js.map +1 -0
  255. package/dist/prompts.js +21 -0
  256. package/dist/prompts.js.map +1 -1
  257. package/dist/re2.wasm +0 -0
  258. package/dist/status.js +61 -0
  259. package/dist/status.js.map +1 -1
  260. package/dist/uninstall.js +49 -4
  261. package/dist/uninstall.js.map +1 -1
  262. package/dist/utils.js +62 -6
  263. package/dist/utils.js.map +1 -1
  264. package/package.json +10 -3
@@ -0,0 +1,1166 @@
1
+ /**
2
+ * Deciding what to do with a `tools/list`, and asking the backend as little
3
+ * as possible to get there.
4
+ *
5
+ * ─── The shape of a verification ────────────────────────────────────────────
6
+ *
7
+ * Every tool is hashed with the shared digest (`digest.ts`), one hash per
8
+ * tool and never a hash of the list — a list-level hash would make one added
9
+ * tool invalidate every verdict on the other twenty. The hashes are looked up
10
+ * in the local cache; if they are all there and still fresh, nothing leaves
11
+ * the machine at all, which is the common case after the first session.
12
+ *
13
+ * Otherwise the backend is asked, and WHEN it is asked is the whole design:
14
+ *
15
+ * - at `tools/list`, digests only (`verify`). Free, and it answers for
16
+ * every tool anybody has ever submitted — which, once the park is
17
+ * seeded, is most of them. Nothing is judged here: a catalogue of
18
+ * eighty tools of which the agent will call two is not eighty
19
+ * judgements.
20
+ * - at `tools/call`, the CONTENT of the one tool being called
21
+ * (`verifyOne`), and only if nobody has ever submitted that digest.
22
+ * Billable, and the only thing that ever leaves the machine beyond a
23
+ * hash. A digest is bought at most once (`ToolRecord.submittedAt`) —
24
+ * with one exception, at the list: a FLAGGED tool the dictionary no
25
+ * longer knows (the judge moved on) is bought again, so a false positive
26
+ * corrected upstream can reach the user. See `withdrawn`.
27
+ *
28
+ * Arguments and results of a `tools/call` are never part of either. That is
29
+ * not a policy this file enforces; it is that they are not in scope here at
30
+ * all, and Phase 4 keeps them local by construction.
31
+ *
32
+ * ─── Time ───────────────────────────────────────────────────────────────────
33
+ *
34
+ * The verification is allowed to hold the `tools/list` answer for four
35
+ * seconds. Past that it fails open: the list goes through, the verdict is
36
+ * `pending`, and the work continues in the background so the NEXT session
37
+ * has the answer. A cold LLM judgement was measured at up to two minutes on
38
+ * the previous generation, and there is no version of "the agent waits two
39
+ * minutes for its tool list" that is acceptable.
40
+ */
41
+ import { randomUUID } from 'node:crypto';
42
+ import { McpContractError, DigestVersionUnsupportedError, UntrustedApiBaseError, checkMcpServers, getOrgPolicy, verifyMcpTools, } from './api.js';
43
+ import { ADVISORY_ID_PATTERN, MAX_ADVISORY_IDS, isExpired, mergeVerdict, sameStamp, emptyCacheFile, } from './cache.js';
44
+ import { toolDigest } from './digest.js';
45
+ import { hasPublicCoordinate, upstreamCheckInput } from './identity.js';
46
+ import { ORG_BLOCK_CLOCK_SKEW_MS, ORG_BLOCK_STICKY_MAX_MS } from '../report/constants.js';
47
+ import { reportToolName, toReportVerdict } from '../report/coalesce.js';
48
+ import { runPool } from './pool.js';
49
+ import { actionFor, decide, DEFAULT_ORG_POLICY, failClosed, worstAction, worstVerdict, } from './policy.js';
50
+ import { readStoredPolicy, writeStoredPolicy } from './policy-store.js';
51
+ import { PERSISTENT_AFTER_SESSIONS, recordContractError, listContractErrors, } from './contract-errors.js';
52
+ /** How long a verification may hold the `tools/list` answer. */
53
+ export const DEFAULT_VERIFY_TIMEOUT_MS = 4000;
54
+ /**
55
+ * How long a `tools/call` may wait for the judgement of the one tool it
56
+ * names. One definition is judged in a second or two; past this the call
57
+ * goes through as `pending` and the verdict lands for the next call. Kept
58
+ * under the HTTP timeout of `api.ts`, so the request outlives the wait and
59
+ * the answer is still written when it arrives.
60
+ */
61
+ export const DEFAULT_CALL_TIMEOUT_MS = 8000;
62
+ /** Definitions per request when `judgeAll` buys a whole catalogue. */
63
+ export const JUDGE_ALL_BATCH = 50;
64
+ /** Slices of `JUDGE_ALL_BATCH` in flight together during an audit. */
65
+ export const JUDGE_ALL_CONCURRENCY = 3;
66
+ /**
67
+ * How long a submission mark on a record that never got its verdict counts
68
+ * as a purchase.
69
+ *
70
+ * The mark is written BEFORE the answer, so a process killed mid-submission
71
+ * leaves `pending` + `submittedAt` behind and nothing ever comes back for it;
72
+ * `mergeVerdict` then carries the mark across every later free lookup. Past
73
+ * this the mark is stale — nobody is on it any more — and the tool is bought
74
+ * again. A judgement takes seconds, so ten minutes is generous.
75
+ */
76
+ /** `learnedAt` of a policy nobody ever served this machine: the epoch, so any real one is newer. */
77
+ const NEVER_LEARNED = new Date(0).toISOString();
78
+ export const SUBMISSION_MARK_TTL_MS = 10 * 60 * 1000;
79
+ /** The finding kind of a server the organization blocks. */
80
+ export const ORG_BLOCK_KIND = 'blocked by your organization';
81
+ /** Flagged tools that travel with the inventory: the gateway takes 20. */
82
+ const MAX_FLAGGED_TOOLS = 20;
83
+ export class Verifier {
84
+ options;
85
+ now;
86
+ /** Digests seen at the start of the session, for the `list_changed` check. */
87
+ baseline;
88
+ changedDuringSession = false;
89
+ /** The tools of the latest list verification, with their verdicts. */
90
+ lastListTools;
91
+ /** Why the latest outcome blocked, for the inventory. */
92
+ lastBlockedBy;
93
+ /** Kept so a background completion cannot outlive its usefulness. */
94
+ pending;
95
+ /** Judgements in flight, by digest: two calls to one tool buy it once. */
96
+ inFlight = new Map();
97
+ /**
98
+ * The file as last read or written, with the stamp it had then. Served
99
+ * again while the stamp has not moved — a `tools/call` on a known tool
100
+ * then costs one `stat`, not a read and a parse. Treated as READ-ONLY by
101
+ * everything but `mutate`.
102
+ */
103
+ loaded;
104
+ /** Set when a backend call failed or never happened during this verification. */
105
+ backendAnswered = false;
106
+ /**
107
+ * Set when the backend ANSWERED and refused us (400 / 409 / 422).
108
+ *
109
+ * Kept apart from `backendAnswered` on purpose: a refusal IS an answer, so
110
+ * folding the two would make a client whose every request is rejected read
111
+ * as a healthy one.
112
+ */
113
+ contractError;
114
+ sessionId;
115
+ /**
116
+ * The freshest policy known, with the moment it was learned — so a cached
117
+ * action can be told apart from one the policy has since overtaken.
118
+ */
119
+ currentPolicy;
120
+ /** `GET …/policy` is read at most once per instance. */
121
+ policyFetched;
122
+ /** The organization, once resolved — see `BackendAccess.resolveOrganizationId`. */
123
+ organizationResolved;
124
+ organizationIdResolved;
125
+ warnedNoOrganization = false;
126
+ constructor(options) {
127
+ this.options = options;
128
+ this.now = options.now ?? (() => new Date());
129
+ this.sessionId = options.sessionId ?? randomUUID();
130
+ }
131
+ /** The policy this verifier decides under right now (without `learnedAt`). */
132
+ policy() {
133
+ const { mode, strictIssuer } = this.knownPolicy();
134
+ return { mode, strictIssuer };
135
+ }
136
+ get organizationId() {
137
+ return this.options.organizationId ?? this.options.backend?.organizationId ?? this.organizationIdResolved;
138
+ }
139
+ /**
140
+ * Everything a backend call needs, or `null` when there is no organization
141
+ * to make it as. Resolved once per instance, at the first call that needs
142
+ * it. Said once, as a count-free line: the reason is in the resolver's
143
+ * own log line, which names how many organizations there were.
144
+ */
145
+ async backendCtx(backend, token) {
146
+ let organizationId = backend.organizationId ?? this.organizationIdResolved;
147
+ if (!organizationId && backend.resolveOrganizationId) {
148
+ this.organizationResolved ??= backend.resolveOrganizationId().catch((error) => {
149
+ this.options.logger.warn('verify: could not resolve the organization', { error: describe(error) });
150
+ return null;
151
+ });
152
+ organizationId = (await this.organizationResolved) ?? undefined;
153
+ if (organizationId)
154
+ this.organizationIdResolved = organizationId;
155
+ }
156
+ if (!organizationId) {
157
+ if (!this.warnedNoOrganization) {
158
+ this.warnedNoOrganization = true;
159
+ this.options.logger.warn('verify: no organization selected, nothing is asked — fail-open', {
160
+ serverId: this.options.serverId,
161
+ });
162
+ }
163
+ return null;
164
+ }
165
+ return { apiBase: backend.apiBase, token, mcpName: backend.mcpName, organizationId };
166
+ }
167
+ /**
168
+ * In order: what a response said during this instance, what an earlier
169
+ * session left on disk for this organization, the default. The default is
170
+ * stamped at the epoch so that ANY action filed on a record outranks it —
171
+ * the default is for nothing ever learned, not for overriding what was.
172
+ */
173
+ knownPolicy() {
174
+ if (this.currentPolicy)
175
+ return this.currentPolicy;
176
+ const organizationId = this.organizationId;
177
+ const stored = organizationId ? readStoredPolicy(organizationId, { env: this.options.env }) : null;
178
+ this.currentPolicy = stored ?? { ...DEFAULT_ORG_POLICY, learnedAt: NEVER_LEARNED };
179
+ return this.currentPolicy;
180
+ }
181
+ /** A policy the backend just served: remembered, and kept on disk for the degraded session. */
182
+ notePolicy(policy, now) {
183
+ if (!policy)
184
+ return;
185
+ this.currentPolicy = { mode: policy.mode, strictIssuer: policy.strictIssuer, learnedAt: now.toISOString() };
186
+ const organizationId = this.organizationId;
187
+ if (!organizationId)
188
+ return;
189
+ try {
190
+ writeStoredPolicy(organizationId, policy, { env: this.options.env, now });
191
+ }
192
+ catch (error) {
193
+ this.options.logger.warn('verify: could not keep the organization policy on disk', { error: describe(error) });
194
+ }
195
+ }
196
+ /**
197
+ * Reads `GET …/policy` once per instance, before the first decision that
198
+ * asks the backend. Every verify/check response carries the policy too;
199
+ * this is what refreshes it for the decisions only the cache will answer.
200
+ * Fail-open like the other calls: what is known stands.
201
+ */
202
+ ensurePolicy(ctx, opts) {
203
+ if (this.policyFetched)
204
+ return this.policyFetched;
205
+ this.policyFetched = getOrgPolicy(ctx, opts)
206
+ .then((policy) => this.notePolicy(policy, this.now()))
207
+ .catch((error) => {
208
+ if (!this.noteContractError(error, 'policy')) {
209
+ this.options.logger.warn('verify: could not read the organization policy, using the last known one', {
210
+ error: describe(error),
211
+ });
212
+ }
213
+ });
214
+ return this.policyFetched;
215
+ }
216
+ /**
217
+ * Files a contract refusal and remembers it for this verification.
218
+ *
219
+ * Returns `true` when the error WAS a contract refusal, so each call site
220
+ * can keep its own fail-open path for everything else — a network error, a
221
+ * timeout or a 5xx still means "could not ask", and still says nothing.
222
+ */
223
+ noteContractError(error, route) {
224
+ // Not a contract error and not an outage either: WE refused to call. Said
225
+ // as "backend unreachable" it sends the reader to check a network that is
226
+ // fine — see `UntrustedApiBaseError`.
227
+ if (error instanceof UntrustedApiBaseError) {
228
+ this.options.logger.warn('verify: apiBase is not a trusted origin — nothing was sent, fail-open', { serverId: this.options.serverId, route, apiBase: error.apiBase });
229
+ return true;
230
+ }
231
+ if (!(error instanceof McpContractError))
232
+ return false;
233
+ this.contractError = error;
234
+ recordContractError({
235
+ route,
236
+ status: error.status,
237
+ code: error instanceof DigestVersionUnsupportedError ? error.code : undefined,
238
+ supported: error instanceof DigestVersionUnsupportedError ? error.supported : undefined,
239
+ sessionId: this.sessionId,
240
+ }, { env: this.options.env, now: this.now() });
241
+ this.options.logger.warn('verify: the backend refused our request — contract error', {
242
+ serverId: this.options.serverId,
243
+ route,
244
+ status: error.status,
245
+ ...(error instanceof DigestVersionUnsupportedError
246
+ ? { code: error.code, supported: error.supported }
247
+ : {}),
248
+ });
249
+ return true;
250
+ }
251
+ /**
252
+ * Told when the upstream announced its list changed.
253
+ *
254
+ * The announcement alone is NOT a finding. `server-everything` — the
255
+ * reference implementation — registers its conditional tools once it has
256
+ * seen the client's capabilities and announces the change immediately
257
+ * after `initialized`, before anybody has listed anything. Treating that as
258
+ * a rug-pull flagged the reference server on a clean run, and every real
259
+ * server that does the same would have been flagged with it.
260
+ *
261
+ * So the announcement only counts once there is a list to compare against,
262
+ * and what actually raises the finding is the digest comparison in
263
+ * `noteBaseline`: the CONTENT changed, not merely that somebody said so.
264
+ */
265
+ noteListChanged() {
266
+ if (this.baseline)
267
+ this.changedDuringSession = true;
268
+ }
269
+ get listChanged() {
270
+ return this.changedDuringSession;
271
+ }
272
+ /** Waits for whatever is still running. Tests only. */
273
+ async settle() {
274
+ await this.pending;
275
+ }
276
+ async verify(tools) {
277
+ const outcome = await this.verifyWith(tools, tools.map(safeDigest));
278
+ this.lastListTools = outcome.tools;
279
+ return outcome;
280
+ }
281
+ /** `verify`, with the digests already computed — once per call, by whoever calls. */
282
+ async verifyWith(tools, hashes) {
283
+ const now = this.now();
284
+ this.backendAnswered = false;
285
+ this.contractError = undefined;
286
+ const digests = tools.map((tool, index) => ({ tool, digest: hashes[index] ?? null }));
287
+ const seen = new Set(digests.map((entry) => entry.digest).filter((digest) => digest !== null));
288
+ this.noteBaseline(seen);
289
+ // The advisory lookup only means something for a server with a public
290
+ // coordinate: a URL, or a pinned package. A local command has none, so
291
+ // treating its server record as perpetually stale would re-ask the
292
+ // backend about the same tools every single session.
293
+ const serverCheckApplies = hasPublicCoordinate(this.options.spec);
294
+ // What this list changes about the server record is written now and once,
295
+ // per field — never the whole file after the network call, which would
296
+ // put back the stale view read here over whatever a `tools/call` bought
297
+ // in the meantime.
298
+ const file = this.mutate((file) => {
299
+ file.server.lastSeen = now.toISOString();
300
+ // A server with no public coordinate has no advisory list to be absent
301
+ // from, so "we never asked" is the wrong thing to record: it would make
302
+ // the aggregate verdict of every locally-spawned server `unknown` for
303
+ // ever, and `status` would show a park of question marks whose tools
304
+ // were all verified. What is true is that no advisory applies.
305
+ if (!serverCheckApplies && file.server.verdict === 'unknown') {
306
+ file.server.verdict = 'ok';
307
+ file.server.verdictAt = now.toISOString();
308
+ }
309
+ // The whole list as it is right now, not just what we asked about: the
310
+ // field is what a later session compares against to see the list
311
+ // change between runs, and half of it would be worse than none.
312
+ file.server.toolDigests = [...seen];
313
+ });
314
+ const unknown = digests.filter((entry) => {
315
+ if (!entry.digest)
316
+ return false; // a tool we cannot hash cannot be asked about
317
+ const record = file.tools[entry.digest];
318
+ return !record || isExpired(record, now);
319
+ });
320
+ const serverStale = serverCheckApplies && isExpired(file.server, now);
321
+ // A `malicious` on file is never served from the cache alone, even though
322
+ // it has no TTL. Two reasons, and they pull the same way:
323
+ //
324
+ // - a verdict can be WITHDRAWN. A false positive corrected upstream must
325
+ // reach the user, and a verdict that never expires would never let it.
326
+ // - it is how the outage is detected at all. Without this call there is
327
+ // nothing to fail, so "the backend is down and this server is on the
328
+ // local blacklist" — the one case section 0 fails closed on — could
329
+ // not be told apart from ordinary operation.
330
+ //
331
+ // It costs one round trip per session for the rare server that is on the
332
+ // list, and nothing at all for every other.
333
+ const locallyMalicious = file.server.verdict === 'malicious' ||
334
+ Object.values(file.tools).some((record) => record.verdict === 'malicious');
335
+ if (unknown.length === 0 && !serverStale && !locallyMalicious) {
336
+ return this.conclude(file, tools, true, 'cache', hashes);
337
+ }
338
+ if (!this.options.backend || !this.options.backend.token) {
339
+ this.options.logger.info('verify: no backend credential, fail-open', {
340
+ serverId: this.options.serverId,
341
+ unknown: unknown.length,
342
+ });
343
+ return this.conclude(file, tools, true, 'no-backend', hashes);
344
+ }
345
+ // What the backend is actually asked about: what we do not know, plus
346
+ // what we know to be malicious and want re-confirmed. A tool with a fresh
347
+ // local verdict is left out — asking again would cost a judgement for an
348
+ // answer already on disk, and the second call is the billable one.
349
+ const toAsk = digests.filter((entry) => {
350
+ if (!entry.digest)
351
+ return false;
352
+ const record = file.tools[entry.digest];
353
+ return !record || isExpired(record, now) || record.verdict === 'malicious';
354
+ });
355
+ // The list waits, but only for so long. Whatever is still running when
356
+ // the deadline passes keeps going and writes the cache for next time.
357
+ const work = this.ask(toAsk, now).catch((error) => {
358
+ this.options.logger.warn('verify: backend call failed', { error: describe(error) });
359
+ });
360
+ this.pending = work;
361
+ const finished = await race(work, this.options.timeoutMs ?? DEFAULT_VERIFY_TIMEOUT_MS);
362
+ if (!finished) {
363
+ this.options.logger.warn('verify: timed out, forwarding the list and finishing in the background', {
364
+ serverId: this.options.serverId,
365
+ timeoutMs: this.options.timeoutMs ?? DEFAULT_VERIFY_TIMEOUT_MS,
366
+ });
367
+ // `pending` on the unresolved tools, so the decision is made on what we
368
+ // actually know rather than on an optimistic blank. A view for this
369
+ // decision only: nothing here is written.
370
+ const view = overlay(file);
371
+ for (const entry of unknown) {
372
+ if (!entry.digest)
373
+ continue;
374
+ view.tools[entry.digest] ??= {
375
+ name: entry.tool.name,
376
+ verdict: 'pending',
377
+ verdictAt: now.toISOString(),
378
+ };
379
+ }
380
+ this.applyContractError(view, digests, now);
381
+ return this.conclude(view, tools, false, this.backendAnswered ? 'backend' : 'unreachable', hashes);
382
+ }
383
+ const refreshed = overlay(this.load(now));
384
+ this.applyContractError(refreshed, digests, now);
385
+ return this.conclude(refreshed, tools, false, this.backendAnswered ? 'backend' : 'unreachable', hashes);
386
+ }
387
+ /**
388
+ * The file as it is right now, or an empty one. READ-ONLY: it may be the
389
+ * instance's loaded copy, served again while the store's stamp has not
390
+ * moved. A store with no `stat` is read every time, as before.
391
+ */
392
+ load(now) {
393
+ const cache = this.options.cache;
394
+ const serverId = this.options.serverId;
395
+ if (!cache.stat)
396
+ return cache.read(serverId) ?? emptyCacheFile(serverId, now);
397
+ const stamp = cache.stat(serverId);
398
+ if (this.loaded && sameStamp(this.loaded.stamp, stamp))
399
+ return this.loaded.file;
400
+ const file = cache.read(serverId) ?? emptyCacheFile(serverId, now);
401
+ this.loaded = { file, stamp };
402
+ return file;
403
+ }
404
+ /**
405
+ * The ONE way anything is written.
406
+ *
407
+ * Loads the file, applies the change, writes. Every caller changes only
408
+ * its own digests (through `mergeVerdict`) or its own server fields, so two
409
+ * writers — the list's free lookup finishing late, the call that bought a
410
+ * verdict in between, `scan` in another process — compose instead of the
411
+ * last one winning the whole file. Returns `false` from `fn` to write
412
+ * nothing.
413
+ */
414
+ mutate(fn) {
415
+ const cache = this.options.cache;
416
+ const file = this.load(this.now());
417
+ if (fn(file) === false)
418
+ return file;
419
+ cache.write(file);
420
+ // What was just written IS the current content: remember it under the
421
+ // stamp the store gives it, so the next `load` is a `stat`, not a read.
422
+ if (cache.stat)
423
+ this.loaded = { file, stamp: cache.stat(this.options.serverId) };
424
+ return file;
425
+ }
426
+ /** The first list of a session is the baseline every later one is compared to. */
427
+ noteBaseline(digests) {
428
+ if (!this.baseline) {
429
+ this.baseline = digests;
430
+ return;
431
+ }
432
+ for (const digest of digests) {
433
+ if (!this.baseline.has(digest)) {
434
+ this.changedDuringSession = true;
435
+ this.options.logger.warn('verify: the tool list changed during the session', {
436
+ serverId: this.options.serverId,
437
+ });
438
+ this.baseline = digests;
439
+ return;
440
+ }
441
+ }
442
+ this.baseline = digests;
443
+ }
444
+ /**
445
+ * The judgement, bought at the call for the one tool being called.
446
+ *
447
+ * Nothing leaves the machine when the answer is already here: a fresh
448
+ * `ok`, a `malicious` (which never expires), a user override, or the mark
449
+ * that this exact digest was already submitted — whatever the judge made
450
+ * of it, it is not bought twice; the free lookup at the next list will
451
+ * pick up a verdict somebody else has bought since.
452
+ *
453
+ * Otherwise the definition is submitted, alone, and the call waits up to
454
+ * `callTimeoutMs` for the verdict. Past that it goes through as `pending`
455
+ * and the work finishes in the background, exactly like the list does.
456
+ */
457
+ async verifyOne(tool) {
458
+ const now = this.now();
459
+ const serverId = this.options.serverId;
460
+ const file = this.load(now);
461
+ const digest = safeDigest(tool);
462
+ if (!digest)
463
+ return this.conclude(file, [tool], true, 'cache', [digest]);
464
+ // In flight in THIS process comes first: the mark is already on disk, and
465
+ // read as "answered" it would let this call through as `pending` while
466
+ // the judge is seconds from an answer that may be MALICIOUS.
467
+ let work = this.inFlight.get(digest);
468
+ if (!work && this.isAnswered(file, file.tools[digest], now)) {
469
+ return this.conclude(file, [tool], true, 'cache', [digest]);
470
+ }
471
+ if (!this.options.backend?.token) {
472
+ this.options.logger.info('verify: no backend credential, the call is not judged', {
473
+ serverId,
474
+ tool: tool.name,
475
+ });
476
+ return this.conclude(file, [tool], true, 'no-backend', [digest]);
477
+ }
478
+ if (!work) {
479
+ this.mutate((file) => this.markSubmitted(file, [{ tool, digest }], now));
480
+ this.options.logger.info('verify: submitting the called tool for judgement', {
481
+ serverId,
482
+ tool: tool.name,
483
+ });
484
+ work = this.submit([{ tool, digest }]).finally(() => this.inFlight.delete(digest));
485
+ this.inFlight.set(digest, work);
486
+ const previous = this.pending;
487
+ this.pending = Promise.allSettled([previous, work]).then(() => undefined);
488
+ }
489
+ const finished = await race(work, this.options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS);
490
+ if (!finished) {
491
+ this.options.logger.warn('verify: the judgement did not arrive in time, the call goes through as pending', {
492
+ serverId,
493
+ tool: tool.name,
494
+ });
495
+ }
496
+ return this.conclude(this.load(now), [tool], false, this.backendAnswered ? 'backend' : 'unreachable', [digest]);
497
+ }
498
+ /**
499
+ * The billable call: these definitions, and nothing else.
500
+ *
501
+ * On failure the marks come OFF. A 401, a 5xx or a dead network means the
502
+ * judge never saw the definition, and a mark that survived it would turn
503
+ * one expired token into a permanent exemption for every tool called while
504
+ * it was expired. The mark is for "the judge was asked", not "we tried".
505
+ */
506
+ async submit(entries, timeoutMs) {
507
+ const backend = this.options.backend;
508
+ if (!backend?.token || entries.length === 0)
509
+ return;
510
+ const opts = timeoutMs ? { ...(backend.options ?? {}), timeoutMs } : (backend.options ?? {});
511
+ const ctx = await this.backendCtx(backend, backend.token);
512
+ if (!ctx) {
513
+ // No organization to ask as: the judge never saw the definition, so the
514
+ // mark comes off, exactly as after a failed request.
515
+ this.unmark(entries.map((entry) => entry.digest));
516
+ return;
517
+ }
518
+ await this.ensurePolicy(ctx, opts);
519
+ const submissions = entries.map(({ tool, digest }) => ({
520
+ digest,
521
+ definition: {
522
+ name: tool.name,
523
+ title: tool.title,
524
+ description: tool.description,
525
+ inputSchema: tool.inputSchema,
526
+ outputSchema: tool.outputSchema,
527
+ annotations: tool.annotations,
528
+ },
529
+ }));
530
+ try {
531
+ const judged = await verifyMcpTools(ctx, submissions, opts);
532
+ this.backendAnswered = true;
533
+ const now = this.now();
534
+ this.notePolicy(judged.policy, now);
535
+ const byDigest = new Map(judged.verdicts.map((verdict) => [verdict.digest, verdict]));
536
+ this.mutate((file) => this.record(file, entries, byDigest, now));
537
+ }
538
+ catch (error) {
539
+ this.unmark(entries.map((entry) => entry.digest));
540
+ if (!this.noteContractError(error, 'tools/verify')) {
541
+ this.options.logger.warn('verify: backend unreachable, the call goes through unjudged', {
542
+ route: 'tools/verify (definition)',
543
+ tools: entries.map((entry) => entry.tool.name),
544
+ error: describe(error),
545
+ });
546
+ }
547
+ }
548
+ }
549
+ /**
550
+ * Whether nothing needs to leave the machine for this tool.
551
+ *
552
+ * A user override, a fresh verdict, a `malicious` (never expires) — or the
553
+ * mark that this digest was already bought, which holds for a record that
554
+ * carries a real verdict whatever it is, and for a `pending` / `unknown`
555
+ * only while the mark is young: see `SUBMISSION_MARK_TTL_MS`.
556
+ */
557
+ isAnswered(file, record, now) {
558
+ if (file.server.override !== undefined)
559
+ return true;
560
+ if (!record)
561
+ return false;
562
+ if (!isExpired(record, now))
563
+ return true;
564
+ if (record.submittedAt === undefined)
565
+ return false;
566
+ if (record.verdict !== 'pending' && record.verdict !== 'unknown')
567
+ return true;
568
+ return this.markedRecently(record, now);
569
+ }
570
+ /** Whether the "bought" mark on this record is younger than `SUBMISSION_MARK_TTL_MS`. */
571
+ markedRecently(record, now) {
572
+ if (record.submittedAt === undefined)
573
+ return false;
574
+ const at = Date.parse(record.submittedAt);
575
+ return !Number.isNaN(at) && now.getTime() - at <= SUBMISSION_MARK_TTL_MS;
576
+ }
577
+ /**
578
+ * Puts the "bought" mark on, BEFORE the answer: a second call while this
579
+ * one is in flight, and a process that dies mid-way, must both know it.
580
+ */
581
+ markSubmitted(file, entries, now) {
582
+ const at = now.toISOString();
583
+ for (const { tool, digest } of entries) {
584
+ file.tools[digest] = mergeVerdict(file.tools[digest], {
585
+ name: tool.name,
586
+ verdict: 'pending',
587
+ verdictAt: at,
588
+ submittedAt: at,
589
+ });
590
+ }
591
+ }
592
+ /** Takes the "bought" mark off digests the judge never actually received. */
593
+ unmark(digests) {
594
+ this.mutate((file) => {
595
+ let changed = false;
596
+ for (const digest of digests) {
597
+ const record = file.tools[digest];
598
+ if (record?.submittedAt === undefined)
599
+ continue;
600
+ delete record.submittedAt;
601
+ if (record.verdict === 'pending')
602
+ record.verdict = 'unknown';
603
+ changed = true;
604
+ }
605
+ return changed ? undefined : false;
606
+ });
607
+ }
608
+ /**
609
+ * The audit: every tool the dictionary does not know, bought now, in one
610
+ * request. This is `vibedefend scan`, and nothing else calls it — a list
611
+ * in an agent session must never cost eighty judgements, but a user who
612
+ * typed `scan` asked exactly that question and is waiting for the answer.
613
+ *
614
+ * Runs the free lookup first, so what somebody else already bought is not
615
+ * bought again, then submits the rest. Waits for the whole thing: the
616
+ * judge takes a second per tool, and a scan that reported `unknown` for
617
+ * what it just paid for would be a scan nobody trusts.
618
+ */
619
+ async judgeAll(tools, options = {}) {
620
+ const outcome = await this.judgeAllTools(tools, options);
621
+ this.lastListTools = outcome.tools;
622
+ return outcome;
623
+ }
624
+ async judgeAllTools(tools, options) {
625
+ const hashes = tools.map(safeDigest);
626
+ await this.verifyWith(tools, hashes);
627
+ await this.settle();
628
+ const now = this.now();
629
+ const serverId = this.options.serverId;
630
+ if (!this.options.backend?.token) {
631
+ this.options.logger.info('verify: no backend credential, nothing is judged', { serverId });
632
+ return this.conclude(this.load(now), tools, true, 'no-backend', hashes);
633
+ }
634
+ const toBuy = [];
635
+ const file = this.mutate((file) => {
636
+ tools.forEach((tool, index) => {
637
+ const digest = hashes[index];
638
+ if (!digest)
639
+ return;
640
+ if (!this.isAnswered(file, file.tools[digest], now))
641
+ toBuy.push({ tool, digest });
642
+ });
643
+ if (toBuy.length === 0)
644
+ return false;
645
+ this.markSubmitted(file, toBuy, now);
646
+ });
647
+ if (toBuy.length === 0) {
648
+ return this.conclude(file, tools, true, this.backendAnswered ? 'backend' : 'cache', hashes);
649
+ }
650
+ this.options.logger.info('verify: submitting every unknown tool for judgement', {
651
+ serverId,
652
+ count: toBuy.length,
653
+ });
654
+ // In slices: the backend caps a request, and a slice that fails takes
655
+ // only its own marks off. A few slices at a time, and ONE deadline for
656
+ // all of them — `timeoutMs` is the bound on the audit, not on each of
657
+ // its requests, or a catalogue of five hundred would wait ten times the
658
+ // documented limit. A slice that starts past the deadline is not sent.
659
+ const slices = [];
660
+ for (let index = 0; index < toBuy.length; index += JUDGE_ALL_BATCH) {
661
+ slices.push(toBuy.slice(index, index + JUDGE_ALL_BATCH));
662
+ }
663
+ const deadline = options.timeoutMs === undefined ? undefined : now.getTime() + options.timeoutMs;
664
+ await runPool(slices, JUDGE_ALL_CONCURRENCY, async (slice) => {
665
+ const remaining = deadline === undefined ? undefined : deadline - this.now().getTime();
666
+ if (remaining !== undefined && remaining <= 0) {
667
+ this.unmark(slice.map((entry) => entry.digest));
668
+ return;
669
+ }
670
+ await this.submit(slice, remaining);
671
+ });
672
+ return this.conclude(this.load(now), tools, false, this.backendAnswered ? 'backend' : 'unreachable', hashes);
673
+ }
674
+ /**
675
+ * The two backend calls, plus the advisory lookup on the server itself.
676
+ *
677
+ * Written to be safe to abandon: it writes the cache when it finishes,
678
+ * whether or not anybody is still waiting for it.
679
+ */
680
+ async ask(digests, now) {
681
+ const backend = this.options.backend;
682
+ if (!backend?.token)
683
+ return;
684
+ const opts = backend.options ?? {};
685
+ const ctx = await this.backendCtx(backend, backend.token);
686
+ if (!ctx)
687
+ return;
688
+ await this.ensurePolicy(ctx, opts);
689
+ // The server itself: is this URL or package on a list, or blocked by the organization?
690
+ await this.checkServerWith(ctx, opts, now);
691
+ const unresolved = digests.filter((entry) => entry.digest !== null);
692
+ if (unresolved.length === 0)
693
+ return;
694
+ // Call 1: digests alone. Free, and it answers for anything anyone has
695
+ // submitted before.
696
+ let verdicts = [];
697
+ try {
698
+ const answer = await verifyMcpTools(ctx, unresolved.map((entry) => ({ digest: entry.digest })), opts);
699
+ this.backendAnswered = true;
700
+ this.notePolicy(answer.policy, now);
701
+ verdicts = answer.verdicts;
702
+ }
703
+ catch (error) {
704
+ if (!this.noteContractError(error, 'tools/verify')) {
705
+ this.options.logger.warn('verify: backend unreachable, fail-open', {
706
+ route: 'tools/verify (digests)',
707
+ error: describe(error),
708
+ });
709
+ }
710
+ return;
711
+ }
712
+ // And that is all a list costs. What the backend did not know stays
713
+ // `unknown` until the agent actually CALLS it — see `verifyOne`.
714
+ const byDigest = new Map(verdicts.map((verdict) => [verdict.digest, verdict]));
715
+ const withdrawn = this.withdrawn(unresolved, byDigest, now);
716
+ this.mutate((file) => this.record(file, unresolved, byDigest, now));
717
+ if (withdrawn.length === 0)
718
+ return;
719
+ // With one exception. The dictionary is keyed by the judge's version: a
720
+ // judge upgrade empties it for every digest, and the lookup then answers
721
+ // `known: false` for a tool an EARLIER judge had flagged. `record` kept
722
+ // that verdict — a withdrawal is not "nothing to report" — but this is
723
+ // precisely the moment a false positive corrected upstream can reach the
724
+ // user, and the only one: a flagged tool is never bought at the call
725
+ // (`isAnswered`). So the definition is bought again, once per mark. An
726
+ // undecided judge is not a withdrawal: the old verdict stands.
727
+ this.mutate((file) => this.markSubmitted(file, withdrawn, now));
728
+ this.options.logger.info('verify: re-judging flagged tools the dictionary no longer knows', {
729
+ serverId: this.options.serverId,
730
+ tools: withdrawn.map((entry) => entry.tool.name),
731
+ });
732
+ await this.submit(withdrawn);
733
+ }
734
+ /**
735
+ * Asks the backend about the server itself, for the whole life of the proxy
736
+ * (also called by the periodic check, not only by `ask`).
737
+ *
738
+ * `'blocked'` / `'unblocked'` only when the organization block CHANGED;
739
+ * `'unreachable'` when nothing was learned (no token, no organization, or
740
+ * any failure), in which case the record is left exactly as it was: a known
741
+ * block stays, and nothing is ever invented.
742
+ */
743
+ async checkServer() {
744
+ const backend = this.options.backend;
745
+ if (!backend?.token)
746
+ return 'unreachable';
747
+ const ctx = await this.backendCtx(backend, backend.token);
748
+ if (!ctx)
749
+ return 'unreachable';
750
+ return this.checkServerWith(ctx, backend.options ?? {}, this.now());
751
+ }
752
+ /**
753
+ * The organization block in force, or `null`: confirmed no longer than
754
+ * `ORG_BLOCK_STICKY_MAX_MS` ago, and no more than `ORG_BLOCK_CLOCK_SKEW_MS` ahead.
755
+ */
756
+ organizationBlock() {
757
+ return this.blockOf(this.load(this.now()));
758
+ }
759
+ /** The block recorded on `file`, if still in force. Reads nothing. */
760
+ blockOf(file) {
761
+ const block = file.server.organizationBlock;
762
+ if (!block)
763
+ return null;
764
+ const age = this.now().getTime() - Date.parse(block.confirmedAt);
765
+ // A date further in the future than a clock correction explains is no
766
+ // confirmation: it would outlive the limit.
767
+ return Number.isNaN(age) || age < -ORG_BLOCK_CLOCK_SKEW_MS || age > ORG_BLOCK_STICKY_MAX_MS
768
+ ? null
769
+ : { since: block.confirmedAt };
770
+ }
771
+ /**
772
+ * What the inventory report says about this server, or `null` before any
773
+ * list was verified when no organization block is known either.
774
+ */
775
+ inventorySnapshot() {
776
+ const now = this.now();
777
+ const file = this.load(now);
778
+ const blocked = this.blockOf(file) !== null;
779
+ if (!blocked && this.lastListTools === undefined && file.server.toolDigests === undefined)
780
+ return null;
781
+ const policy = this.knownPolicy();
782
+ const records = (file.server.toolDigests ?? [])
783
+ .map((digest) => file.tools[digest])
784
+ .filter((record) => record !== undefined);
785
+ const flagged = records.filter((record) => record.verdict === 'malicious' || record.verdict === 'suspicious');
786
+ const verdict = worstVerdict([file.server.verdict, ...records.map((record) => record.verdict)]);
787
+ const action = blocked
788
+ ? 'block'
789
+ : worstAction([effectiveAction(file.server, policy), ...records.map((record) => effectiveAction(record, policy))]);
790
+ const flaggedTools = (this.lastListTools ?? [])
791
+ .filter((tool) => tool.verdict === 'malicious' || tool.verdict === 'suspicious')
792
+ .sort((a, b) => Number(b.verdict === 'malicious') - Number(a.verdict === 'malicious'))
793
+ .slice(0, MAX_FLAGGED_TOOLS)
794
+ .map((tool) => ({ name: reportToolName(tool.name), digest: tool.digest }));
795
+ return {
796
+ verdict: toReportVerdict(verdict) ?? 'unverified',
797
+ action,
798
+ blockedBy: blocked ? 'organization' : (this.lastBlockedBy ?? null),
799
+ advisoryIds: [...(file.server.advisoryIds ?? [])],
800
+ toolCount: file.server.toolDigests?.length ?? 0,
801
+ flaggedToolCount: flagged.length,
802
+ flaggedTools,
803
+ };
804
+ }
805
+ /** What a backend call needs (token and organization included), or `null` when there is none to make. */
806
+ async backendContext() {
807
+ const backend = this.options.backend;
808
+ if (!backend?.token)
809
+ return null;
810
+ return this.backendCtx(backend, backend.token);
811
+ }
812
+ warnedServerKeyRefused = false;
813
+ async checkServerWith(ctx, opts, now) {
814
+ const checkInput = upstreamCheckInput(this.options.spec);
815
+ if (!checkInput)
816
+ return 'unreachable';
817
+ try {
818
+ const answer = await checkMcpServers(ctx, [checkInput], opts);
819
+ this.backendAnswered = true;
820
+ this.notePolicy(answer.policy, now);
821
+ const [result] = answer.results;
822
+ if (!result)
823
+ return 'unreachable';
824
+ const wasBlocked = this.organizationBlock() !== null;
825
+ const blocked = result.blockedBy === 'organization';
826
+ this.mutate((file) => {
827
+ // A server with no public coordinate has no advisory verdict to learn:
828
+ // what was true before the check stays true.
829
+ if (hasPublicCoordinate(this.options.spec)) {
830
+ file.server.verdict = advisoryVerdict(result.advisories);
831
+ file.server.verdictAt = now.toISOString();
832
+ file.server.reason = result.advisories[0]?.summary;
833
+ }
834
+ // The organization block lives ONLY in `organizationBlock`, which expires
835
+ // offline. The stored action is what advisories and mode alone give, or
836
+ // the block would outlive its limit through `effectiveAction`.
837
+ file.server.action = blocked ? (result.advisories.length > 0 ? (answer.policy?.mode ?? this.policy().mode) : 'allow') : result.action;
838
+ file.server.advisoryIds = result.advisories
839
+ .map((advisory) => advisory.id)
840
+ .filter((id) => ADVISORY_ID_PATTERN.test(id))
841
+ .slice(0, MAX_ADVISORY_IDS);
842
+ if (blocked)
843
+ file.server.organizationBlock = { confirmedAt: now.toISOString() };
844
+ else
845
+ delete file.server.organizationBlock;
846
+ });
847
+ if (blocked === wasBlocked)
848
+ return 'unchanged';
849
+ return blocked ? 'blocked' : 'unblocked';
850
+ }
851
+ catch (error) {
852
+ if (error instanceof McpContractError &&
853
+ error.status === 400 &&
854
+ checkInput.serverKey !== undefined &&
855
+ !checkInput.url &&
856
+ !checkInput.purl) {
857
+ // A gateway older than the `serverKey` field refuses it. Not a contract
858
+ // error of the verification: said once, and the record is left alone.
859
+ if (!this.warnedServerKeyRefused) {
860
+ this.warnedServerKeyRefused = true;
861
+ this.options.logger.warn('verify: the backend does not take a serverKey check yet, ignored', {
862
+ serverId: this.options.serverId,
863
+ });
864
+ }
865
+ return 'unreachable';
866
+ }
867
+ if (!this.noteContractError(error, 'servers/check')) {
868
+ this.options.logger.warn('verify: backend unreachable, fail-open', {
869
+ route: 'servers/check',
870
+ error: describe(error),
871
+ });
872
+ }
873
+ return 'unreachable';
874
+ }
875
+ }
876
+ /**
877
+ * Flagged on file, unknown to the backend now, and not bought within the
878
+ * mark: a verdict the dictionary has withdrawn since it was given.
879
+ */
880
+ withdrawn(entries, verdicts, now) {
881
+ const file = this.load(now);
882
+ return entries.filter(({ digest }) => {
883
+ if (verdicts.get(digest)?.known)
884
+ return false;
885
+ const record = file.tools[digest];
886
+ if (record?.verdict !== 'suspicious' && record?.verdict !== 'malicious')
887
+ return false;
888
+ return !this.markedRecently(record, now);
889
+ });
890
+ }
891
+ record(file, entries, verdicts, now) {
892
+ for (const entry of entries) {
893
+ const verdict = verdicts.get(entry.digest);
894
+ const next = {
895
+ name: entry.tool.name,
896
+ verdict: verdict ? toolVerdict(verdict) : 'unknown',
897
+ reason: verdict?.reason || undefined,
898
+ category: verdict?.category || undefined,
899
+ severity: verdict?.severity || undefined,
900
+ verdictAt: now.toISOString(),
901
+ // The server's decision, filed with the verdict it was made on.
902
+ ...(verdict ? { action: verdict.action } : {}),
903
+ };
904
+ file.tools[entry.digest] = mergeVerdict(file.tools[entry.digest], next);
905
+ }
906
+ }
907
+ /**
908
+ * Writes `contract-error` where a refusal left a hole.
909
+ *
910
+ * ⚠️ It only ever fills `pending` / `unknown` — never a verdict somebody
911
+ * actually reached. Overwriting a `malicious` would turn breaking the
912
+ * contract into a way of whitewashing a blacklisted server, and overwriting
913
+ * an `ok` would report a server as broken when what is broken is us.
914
+ */
915
+ applyContractError(file, digests, now) {
916
+ if (!this.contractError)
917
+ return;
918
+ const at = now.toISOString();
919
+ for (const entry of digests) {
920
+ if (!entry.digest)
921
+ continue;
922
+ const record = file.tools[entry.digest];
923
+ if (!record) {
924
+ file.tools[entry.digest] = {
925
+ name: entry.tool.name,
926
+ verdict: 'contract-error',
927
+ verdictAt: at,
928
+ };
929
+ }
930
+ else if (record.verdict === 'pending' || record.verdict === 'unknown') {
931
+ record.verdict = 'contract-error';
932
+ record.verdictAt = at;
933
+ }
934
+ }
935
+ if (file.server.verdict === 'unknown' || file.server.verdict === 'pending') {
936
+ file.server.verdict = 'contract-error';
937
+ file.server.verdictAt = at;
938
+ }
939
+ }
940
+ /** The record for the refusal we just hit, if it is on file. */
941
+ currentContractRecord() {
942
+ const error = this.contractError;
943
+ if (!error)
944
+ return undefined;
945
+ const code = error instanceof DigestVersionUnsupportedError ? error.code : undefined;
946
+ return listContractErrors(this.options.env).find((record) => record.route === error.route &&
947
+ record.status === error.status &&
948
+ (record.code ?? undefined) === code);
949
+ }
950
+ conclude(file, tools, fromCache, source,
951
+ /** The tools' digests, by index, when the caller already has them. */
952
+ hashes) {
953
+ const relevant = [];
954
+ const verified = [];
955
+ // A tool with no record at all — never asked about, or asked and not
956
+ // answered — counts as `unknown` in the aggregate, exactly like a
957
+ // record that says so. Left out, a list nobody verified would read as
958
+ // clean on the strength of the server record alone.
959
+ const actions = [];
960
+ const policy = this.knownPolicy();
961
+ for (const [index, tool] of tools.entries()) {
962
+ const digest = hashes ? (hashes[index] ?? null) : safeDigest(tool);
963
+ if (!digest)
964
+ continue;
965
+ const record = file.tools[digest];
966
+ if (record)
967
+ relevant.push(record);
968
+ actions.push(record ? effectiveAction(record, policy) : 'pending');
969
+ verified.push({
970
+ name: tool.name,
971
+ digest,
972
+ verdict: record?.verdict ?? 'unknown',
973
+ ...(record?.reason ? { reason: record.reason } : {}),
974
+ ...(record?.category ? { category: record.category } : {}),
975
+ ...(record?.severity ? { severity: record.severity } : {}),
976
+ });
977
+ }
978
+ const verdict = worstVerdict([file.server.verdict, ...verified.map((tool) => tool.verdict)]);
979
+ // The server's action for each record — fresh from a response, or
980
+ // recomputed from the verdict under the freshest policy — and the worst
981
+ // of them for the aggregate, as for the verdict.
982
+ const action = worstAction([effectiveAction(file.server, policy), ...actions]);
983
+ const contractRecord = this.currentContractRecord();
984
+ const contractErrorPersisted = (contractRecord?.sessions ?? 0) >= PERSISTENT_AFTER_SESSIONS;
985
+ const organizationBlock = this.blockOf(file) !== null;
986
+ let decision = decide({
987
+ action,
988
+ verdict,
989
+ organizationBlock,
990
+ override: file.server.override,
991
+ changedDuringSession: this.changedDuringSession,
992
+ contractErrorPersisted,
993
+ });
994
+ // A server whose cached verdict is still `ok` from a previous session
995
+ // would otherwise never surface a persistent refusal: the aggregate never
996
+ // becomes `contract-error`, so `decide` never sees it. The disagreement
997
+ // is about US, not about this server, so it is raised here rather than by
998
+ // degrading a verdict somebody actually reached — and never over a real
999
+ // security finding, which is the more urgent thing to say.
1000
+ if (this.contractError &&
1001
+ contractErrorPersisted &&
1002
+ decision.action === 'pass' &&
1003
+ verdict !== 'malicious' &&
1004
+ verdict !== 'suspicious' &&
1005
+ !file.server.override &&
1006
+ !organizationBlock) {
1007
+ decision = { action: 'alert', rationale: 'contract-error' };
1008
+ }
1009
+ // The one place the proxy fails CLOSED, and the reason the asymmetry
1010
+ // exists: an outage must not turn every server off, and it must not turn
1011
+ // a known-bad one back on either.
1012
+ //
1013
+ // "Degraded" is both shapes of the same thing — a backend that could not
1014
+ // be reached, and no backend configured at all. In either case the only
1015
+ // thing we have is what this machine already knew, and a `malicious` in
1016
+ // there is not something to serve on the strength of a `warn` default
1017
+ // that was chosen for LIVE verdicts a user can act on.
1018
+ // An outage neither opens the door nor hardens the policy. A cached
1019
+ // `malicious` was already decided above under the last known policy of
1020
+ // the organization (warn ⇒ alert, block ⇒ block), and losing the backend
1021
+ // must not change that answer — that is what `policy.json` is for. The
1022
+ // one fail-closed case left is a machine that never learned any policy:
1023
+ // there, the default `warn` was chosen by nobody, so a known-bad server
1024
+ // blocks rather than run on a guess.
1025
+ const degraded = source === 'unreachable' || source === 'no-backend';
1026
+ const policyNeverLearned = policy.learnedAt === NEVER_LEARNED;
1027
+ if (!organizationBlock && degraded && policyNeverLearned && failClosed(verdict, file.server.override)) {
1028
+ this.options.logger.warn('verify: no live verdict, no policy ever learned, and the local cache says malicious — failing closed', { serverId: this.options.serverId, source });
1029
+ decision = { action: 'block', rationale: 'malicious' };
1030
+ }
1031
+ else if (source === 'unreachable') {
1032
+ this.options.logger.info('verify: backend unreachable, fail-open', {
1033
+ serverId: this.options.serverId,
1034
+ });
1035
+ }
1036
+ const flagged = relevant.filter((record) => record.verdict === 'malicious' || record.verdict === 'suspicious');
1037
+ let blockedBy;
1038
+ if (organizationBlock) {
1039
+ blockedBy = 'organization';
1040
+ }
1041
+ else if (decision.action === 'block' && decision.rationale !== 'denied-by-user') {
1042
+ blockedBy =
1043
+ effectiveAction(file.server, policy) === 'block' && file.server.verdict !== 'ok'
1044
+ ? 'advisory'
1045
+ : 'verdict';
1046
+ }
1047
+ this.lastBlockedBy = blockedBy;
1048
+ return {
1049
+ decision,
1050
+ action: organizationBlock ? 'block' : action,
1051
+ advisoryIds: [...(file.server.advisoryIds ?? [])],
1052
+ ...(blockedBy ? { blockedBy } : {}),
1053
+ verdict,
1054
+ fromCache,
1055
+ source,
1056
+ policy: { mode: policy.mode, strictIssuer: policy.strictIssuer },
1057
+ tools: verified,
1058
+ finding: {
1059
+ serverName: this.options.serverName,
1060
+ kind: kindOf(decision, verdict),
1061
+ tools: flagged.map((record) => record.name),
1062
+ reason: flagged[0]?.reason ?? file.server.reason,
1063
+ ...(organizationBlock ? { byOrganization: true } : {}),
1064
+ },
1065
+ };
1066
+ }
1067
+ }
1068
+ /**
1069
+ * The action a record is executed under.
1070
+ *
1071
+ * A FRESH server action always wins over one recomputed here, and a fresh
1072
+ * policy always wins over a stale cached action: the action filed on the
1073
+ * record is used only when it was decided no earlier than the policy was
1074
+ * learned; otherwise the verdict is re-read under the policy of today. That
1075
+ * is how an organization that moves to `block` on Monday blocks a tool
1076
+ * judged malicious the week before, without re-buying its judgement.
1077
+ */
1078
+ function effectiveAction(record, policy) {
1079
+ if (record.action && record.verdictAt && record.verdictAt >= policy.learnedAt)
1080
+ return record.action;
1081
+ return actionFor(record.verdict, policy);
1082
+ }
1083
+ function kindOf(decision, verdict) {
1084
+ if (decision.rationale === 'blocked-by-organization')
1085
+ return ORG_BLOCK_KIND;
1086
+ if (decision.rationale === 'denied-by-user')
1087
+ return 'refused by you (vibedefend deny)';
1088
+ if (decision.rationale === 'changed-mid-session') {
1089
+ return 'its tool list changed during this session';
1090
+ }
1091
+ if (verdict === 'malicious')
1092
+ return 'known malicious';
1093
+ if (verdict === 'suspicious')
1094
+ return 'suspicious';
1095
+ return verdict;
1096
+ }
1097
+ /** A copy shallow enough to add records to without touching the original. */
1098
+ function overlay(file) {
1099
+ const tools = {};
1100
+ for (const [digest, record] of Object.entries(file.tools))
1101
+ tools[digest] = { ...record };
1102
+ return { ...file, server: { ...file.server }, tools };
1103
+ }
1104
+ /**
1105
+ * A digest, or `null` for a tool we cannot hash.
1106
+ *
1107
+ * A schema nested a hundred levels deep throws by design (`digest.ts`); the
1108
+ * tool is then carried through as un-hashable rather than taking the whole
1109
+ * list down with it.
1110
+ */
1111
+ function safeDigest(tool) {
1112
+ try {
1113
+ return toolDigest(tool);
1114
+ }
1115
+ catch {
1116
+ return null;
1117
+ }
1118
+ }
1119
+ /**
1120
+ * Advisories to a verdict.
1121
+ *
1122
+ * Only a CRITICAL advisory is a blocking fact. The rest — a deprecation, a
1123
+ * low-severity note, an impersonation report nobody has confirmed — is worth
1124
+ * telling the user about and is not worth taking a server away over.
1125
+ */
1126
+ export function advisoryVerdict(advisories) {
1127
+ if (advisories.length === 0)
1128
+ return 'ok';
1129
+ return advisories.some((advisory) => advisory.severity.toLowerCase() === 'critical')
1130
+ ? 'malicious'
1131
+ : 'suspicious';
1132
+ }
1133
+ /**
1134
+ * A backend tool verdict to ours.
1135
+ *
1136
+ * `known: false` is "not verified", never "nothing to report": it maps to
1137
+ * `unknown`, which adds nothing to the agent's view and leaves the tool to be
1138
+ * asked about again.
1139
+ */
1140
+ export function toolVerdict(verdict) {
1141
+ if (!verdict.known)
1142
+ return 'unknown';
1143
+ if (verdict.verdict === 'MALICIOUS')
1144
+ return 'malicious';
1145
+ if (verdict.verdict === 'SUSPICIOUS')
1146
+ return 'suspicious';
1147
+ if (verdict.verdict === 'CLEAN')
1148
+ return 'ok';
1149
+ // A verdict this binary does not know is named, not collapsed into `ok`.
1150
+ return 'unknown';
1151
+ }
1152
+ export { worstVerdict };
1153
+ async function race(work, ms) {
1154
+ return Promise.race([
1155
+ work.then(() => true),
1156
+ new Promise((resolve) => {
1157
+ const timer = setTimeout(() => resolve(false), ms);
1158
+ if (typeof timer.unref === 'function')
1159
+ timer.unref();
1160
+ }),
1161
+ ]);
1162
+ }
1163
+ function describe(error) {
1164
+ return error instanceof Error ? error.message : String(error);
1165
+ }
1166
+ //# sourceMappingURL=verifier.js.map