@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.
- package/README.md +77 -2
- package/dist/api/gateway-request.js +49 -0
- package/dist/api/gateway-request.js.map +1 -0
- package/dist/api/projects.js +67 -0
- package/dist/api/projects.js.map +1 -1
- package/dist/api/repository-link.js +157 -0
- package/dist/api/repository-link.js.map +1 -0
- package/dist/auth/auth-store.js +107 -25
- package/dist/auth/auth-store.js.map +1 -1
- package/dist/auth/auth.js +21 -0
- package/dist/auth/auth.js.map +1 -1
- package/dist/clients/cursor.js +5 -1
- package/dist/clients/cursor.js.map +1 -1
- package/dist/clients/vscode.js +38 -29
- package/dist/clients/vscode.js.map +1 -1
- package/dist/commands/mcp/allow.js +219 -0
- package/dist/commands/mcp/allow.js.map +1 -0
- package/dist/commands/mcp/connect.js +337 -0
- package/dist/commands/mcp/connect.js.map +1 -0
- package/dist/commands/mcp/index.js +170 -0
- package/dist/commands/mcp/index.js.map +1 -0
- package/dist/commands/mcp/proxy.js +264 -0
- package/dist/commands/mcp/proxy.js.map +1 -0
- package/dist/commands/mcp/scan.js +349 -0
- package/dist/commands/mcp/scan.js.map +1 -0
- package/dist/commands/mcp/sync.js +215 -0
- package/dist/commands/mcp/sync.js.map +1 -0
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -1
- package/dist/diagnostics.js +78 -12
- package/dist/diagnostics.js.map +1 -1
- package/dist/doctor.js +19 -0
- package/dist/doctor.js.map +1 -1
- package/dist/guards-evaluator/analysis-types.js +27 -0
- package/dist/guards-evaluator/analysis-types.js.map +1 -0
- package/dist/guards-evaluator/command-analysis.js +572 -0
- package/dist/guards-evaluator/command-analysis.js.map +1 -0
- package/dist/guards-evaluator/command-view.js +586 -0
- package/dist/guards-evaluator/command-view.js.map +1 -0
- package/dist/guards-evaluator/env.js +11 -0
- package/dist/guards-evaluator/env.js.map +1 -0
- package/dist/guards-evaluator/evaluate.js +91 -41
- package/dist/guards-evaluator/evaluate.js.map +1 -1
- package/dist/guards-evaluator/index.js +2 -1
- package/dist/guards-evaluator/index.js.map +1 -1
- package/dist/guards-evaluator/inline-code.js +259 -0
- package/dist/guards-evaluator/inline-code.js.map +1 -0
- package/dist/guards-evaluator/normalize.js +716 -0
- package/dist/guards-evaluator/normalize.js.map +1 -0
- package/dist/guards-evaluator/preprocess.js +13 -5
- package/dist/guards-evaluator/preprocess.js.map +1 -1
- package/dist/guards-evaluator/redact.js +19 -0
- package/dist/guards-evaluator/redact.js.map +1 -1
- package/dist/guards-evaluator/regex.js +25 -3
- package/dist/guards-evaluator/regex.js.map +1 -1
- package/dist/guards-evaluator/safe-commands.js +155 -0
- package/dist/guards-evaluator/safe-commands.js.map +1 -0
- package/dist/guards-evaluator/triage.js +315 -0
- package/dist/guards-evaluator/triage.js.map +1 -0
- package/dist/hook-runner.js +24873 -5068
- package/dist/hooks/install.js +38 -0
- package/dist/hooks/install.js.map +1 -1
- package/dist/hooks/runtime/config.js +38 -0
- package/dist/hooks/runtime/config.js.map +1 -1
- package/dist/hooks/runtime/emit.js +126 -53
- package/dist/hooks/runtime/emit.js.map +1 -1
- package/dist/hooks/runtime/fetch-rules.js +27 -7
- package/dist/hooks/runtime/fetch-rules.js.map +1 -1
- package/dist/hooks/runtime/gateway-http-error.js +34 -0
- package/dist/hooks/runtime/gateway-http-error.js.map +1 -0
- package/dist/hooks/runtime/guard-rules-cache.js +139 -17
- package/dist/hooks/runtime/guard-rules-cache.js.map +1 -1
- package/dist/hooks/runtime/guard-violations-buffer.js +321 -48
- package/dist/hooks/runtime/guard-violations-buffer.js.map +1 -1
- package/dist/hooks/runtime/judge-cache.js +6 -0
- package/dist/hooks/runtime/judge-cache.js.map +1 -1
- package/dist/hooks/runtime/judge-triage.js +193 -0
- package/dist/hooks/runtime/judge-triage.js.map +1 -0
- package/dist/hooks/runtime/judge.js +7 -1
- package/dist/hooks/runtime/judge.js.map +1 -1
- package/dist/hooks/runtime/pre-compact.js +21 -6
- package/dist/hooks/runtime/pre-compact.js.map +1 -1
- package/dist/hooks/runtime/resolve.js +108 -17
- package/dist/hooks/runtime/resolve.js.map +1 -1
- package/dist/hooks/runtime/retry-policy.js +57 -0
- package/dist/hooks/runtime/retry-policy.js.map +1 -0
- package/dist/hooks/runtime/review-sessions.js +74 -0
- package/dist/hooks/runtime/review-sessions.js.map +1 -0
- package/dist/hooks/runtime/script-resolve.js +695 -0
- package/dist/hooks/runtime/script-resolve.js.map +1 -0
- package/dist/hooks/runtime/session-activation.js +226 -0
- package/dist/hooks/runtime/session-activation.js.map +1 -0
- package/dist/hooks/runtime/session-review.js +174 -46
- package/dist/hooks/runtime/session-review.js.map +1 -1
- package/dist/hooks/runtime/session-start.js +35 -4
- package/dist/hooks/runtime/session-start.js.map +1 -1
- package/dist/hooks/runtime/session-writes.js +232 -0
- package/dist/hooks/runtime/session-writes.js.map +1 -0
- package/dist/hooks/runtime/sniff.js +26 -7
- package/dist/hooks/runtime/sniff.js.map +1 -1
- package/dist/hooks/runtime/target-resolve.js +336 -0
- package/dist/hooks/runtime/target-resolve.js.map +1 -0
- package/dist/hooks/runtime/unmapped-tools.js +93 -0
- package/dist/hooks/runtime/unmapped-tools.js.map +1 -0
- package/dist/hooks/runtime/usage-block.js +4 -0
- package/dist/hooks/runtime/usage-block.js.map +1 -1
- package/dist/hooks/runtime/user-prompt-submit.js +8 -2
- package/dist/hooks/runtime/user-prompt-submit.js.map +1 -1
- package/dist/hooks/runtime/wire-text.js +41 -0
- package/dist/hooks/runtime/wire-text.js.map +1 -0
- package/dist/hooks/runtime/work-dir.js +84 -0
- package/dist/hooks/runtime/work-dir.js.map +1 -0
- package/dist/index.js +45 -12
- package/dist/index.js.map +1 -1
- package/dist/install.js +174 -11
- package/dist/install.js.map +1 -1
- package/dist/link.js +74 -48
- package/dist/link.js.map +1 -1
- package/dist/login.js +99 -14
- package/dist/login.js.map +1 -1
- package/dist/mcp/auth/agent-sign-ins.js +174 -0
- package/dist/mcp/auth/agent-sign-ins.js.map +1 -0
- package/dist/mcp/auth/callback.js +200 -0
- package/dist/mcp/auth/callback.js.map +1 -0
- package/dist/mcp/auth/connect.js +392 -0
- package/dist/mcp/auth/connect.js.map +1 -0
- package/dist/mcp/auth/discovery.js +399 -0
- package/dist/mcp/auth/discovery.js.map +1 -0
- package/dist/mcp/auth/oauth.js +326 -0
- package/dist/mcp/auth/oauth.js.map +1 -0
- package/dist/mcp/auth/probe.js +128 -0
- package/dist/mcp/auth/probe.js.map +1 -0
- package/dist/mcp/auth/secrets.js +446 -0
- package/dist/mcp/auth/secrets.js.map +1 -0
- package/dist/mcp/auth/store.js +260 -0
- package/dist/mcp/auth/store.js.map +1 -0
- package/dist/mcp/auth/tokenSource.js +55 -0
- package/dist/mcp/auth/tokenSource.js.map +1 -0
- package/dist/mcp/config/atomicWrite.js +98 -0
- package/dist/mcp/config/atomicWrite.js.map +1 -0
- package/dist/mcp/config/clients.js +171 -0
- package/dist/mcp/config/clients.js.map +1 -0
- package/dist/mcp/config/cybedefend-servers.js +48 -0
- package/dist/mcp/config/cybedefend-servers.js.map +1 -0
- package/dist/mcp/config/discover.js +114 -0
- package/dist/mcp/config/discover.js.map +1 -0
- package/dist/mcp/config/hook.js +123 -0
- package/dist/mcp/config/hook.js.map +1 -0
- package/dist/mcp/config/index.js +11 -0
- package/dist/mcp/config/index.js.map +1 -0
- package/dist/mcp/config/install.js +132 -0
- package/dist/mcp/config/install.js.map +1 -0
- package/dist/mcp/config/installed.js +37 -0
- package/dist/mcp/config/installed.js.map +1 -0
- package/dist/mcp/config/jsonEdit.js +119 -0
- package/dist/mcp/config/jsonEdit.js.map +1 -0
- package/dist/mcp/config/lock.js +102 -0
- package/dist/mcp/config/lock.js.map +1 -0
- package/dist/mcp/config/proxyBinary.js +211 -0
- package/dist/mcp/config/proxyBinary.js.map +1 -0
- package/dist/mcp/config/rewrite.js +388 -0
- package/dist/mcp/config/rewrite.js.map +1 -0
- package/dist/mcp/config/types.js +34 -0
- package/dist/mcp/config/types.js.map +1 -0
- package/dist/mcp/proxy/audit.js +83 -0
- package/dist/mcp/proxy/audit.js.map +1 -0
- package/dist/mcp/proxy/authInterceptor.js +119 -0
- package/dist/mcp/proxy/authInterceptor.js.map +1 -0
- package/dist/mcp/proxy/expand.js +93 -0
- package/dist/mcp/proxy/expand.js.map +1 -0
- package/dist/mcp/proxy/inject.js +397 -0
- package/dist/mcp/proxy/inject.js.map +1 -0
- package/dist/mcp/proxy/intercept.js +37 -0
- package/dist/mcp/proxy/intercept.js.map +1 -0
- package/dist/mcp/proxy/jsonrpc.js +153 -0
- package/dist/mcp/proxy/jsonrpc.js.map +1 -0
- package/dist/mcp/proxy/log.js +109 -0
- package/dist/mcp/proxy/log.js.map +1 -0
- package/dist/mcp/proxy/paths.js +53 -0
- package/dist/mcp/proxy/paths.js.map +1 -0
- package/dist/mcp/proxy/proxy.js +510 -0
- package/dist/mcp/proxy/proxy.js.map +1 -0
- package/dist/mcp/proxy/spec.js +203 -0
- package/dist/mcp/proxy/spec.js.map +1 -0
- package/dist/mcp/proxy/sse.js +77 -0
- package/dist/mcp/proxy/sse.js.map +1 -0
- package/dist/mcp/proxy/upstream.js +2 -0
- package/dist/mcp/proxy/upstream.js.map +1 -0
- package/dist/mcp/proxy/upstreamHttp.js +499 -0
- package/dist/mcp/proxy/upstreamHttp.js.map +1 -0
- package/dist/mcp/proxy/upstreamStdio.js +188 -0
- package/dist/mcp/proxy/upstreamStdio.js.map +1 -0
- package/dist/mcp/proxy/upstreams.js +143 -0
- package/dist/mcp/proxy/upstreams.js.map +1 -0
- package/dist/mcp/proxy/verifyInterceptor.js +559 -0
- package/dist/mcp/proxy/verifyInterceptor.js.map +1 -0
- package/dist/mcp/proxy/windowsCommand.js +99 -0
- package/dist/mcp/proxy/windowsCommand.js.map +1 -0
- package/dist/mcp/scan/args.js +228 -0
- package/dist/mcp/scan/args.js.map +1 -0
- package/dist/mcp/scan/results.js +253 -0
- package/dist/mcp/scan/results.js.map +1 -0
- package/dist/mcp/scan/types.js +40 -0
- package/dist/mcp/scan/types.js.map +1 -0
- package/dist/mcp/verify/api.js +475 -0
- package/dist/mcp/verify/api.js.map +1 -0
- package/dist/mcp/verify/backend.js +143 -0
- package/dist/mcp/verify/backend.js.map +1 -0
- package/dist/mcp/verify/cache.js +182 -0
- package/dist/mcp/verify/cache.js.map +1 -0
- package/dist/mcp/verify/contract-errors.js +198 -0
- package/dist/mcp/verify/contract-errors.js.map +1 -0
- package/dist/mcp/verify/digest.js +91 -0
- package/dist/mcp/verify/digest.js.map +1 -0
- package/dist/mcp/verify/factory.js +56 -0
- package/dist/mcp/verify/factory.js.map +1 -0
- package/dist/mcp/verify/identity.js +173 -0
- package/dist/mcp/verify/identity.js.map +1 -0
- package/dist/mcp/verify/policy-store.js +63 -0
- package/dist/mcp/verify/policy-store.js.map +1 -0
- package/dist/mcp/verify/policy.js +158 -0
- package/dist/mcp/verify/policy.js.map +1 -0
- package/dist/mcp/verify/pool.js +27 -0
- package/dist/mcp/verify/pool.js.map +1 -0
- package/dist/mcp/verify/token-refresh.js +44 -0
- package/dist/mcp/verify/token-refresh.js.map +1 -0
- package/dist/mcp/verify/token.js +67 -0
- package/dist/mcp/verify/token.js.map +1 -0
- package/dist/mcp/verify/verifier.js +1009 -0
- package/dist/mcp/verify/verifier.js.map +1 -0
- package/dist/org-gate.js +1 -1
- package/dist/org-gate.js.map +1 -1
- package/dist/org.js +146 -0
- package/dist/org.js.map +1 -0
- package/dist/project-create.js +129 -0
- package/dist/project-create.js.map +1 -0
- package/dist/prompts.js +21 -0
- package/dist/prompts.js.map +1 -1
- package/dist/re2.wasm +0 -0
- package/dist/status.js +61 -0
- package/dist/status.js.map +1 -1
- package/dist/uninstall.js +49 -4
- package/dist/uninstall.js.map +1 -1
- package/dist/utils.js +62 -6
- package/dist/utils.js.map +1 -1
- 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
|