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