@vantageos/mcp-doctor 0.7.0 → 0.8.1

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/CHANGELOG.md CHANGED
@@ -3,6 +3,220 @@
3
3
  All notable changes to `@vantageos/mcp-doctor` are documented here.
4
4
  This project adheres to [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## 0.8.1 — 2026-10-13
7
+
8
+ Standard re-pinned to ElPi-Corp `6d463a1ecc5256cd59dd1afe5aa5e335accacc90`: the direct child of
9
+ `24c1ffc97375581c05c1e8058afb2faf78da602e` (mcp-standard v2.6) that adds S-35 and nothing else
10
+ (`gh api repos/elpiarthera/ElPi-Corp/compare/24c1ffc9...6d463a1e` -> ahead 1, behind 0). Coverage is
11
+ derived, never typed here:
12
+
13
+ ```
14
+ npx tsx scripts/standard-coverage.ts 6d463a1ecc5256cd59dd1afe5aa5e335accacc90 <ElPi-Corp checkout>
15
+ ```
16
+
17
+ ### Added
18
+ - **S35** scope-in-read (S-35): a tool's scope predicate is applied inside the read it delegates to.
19
+ Reads every `<client>.query|action(<door>, <args>)` and the narrowing that follows it in the same
20
+ block, in each form the rule names: `scopeFilterList`, `scopeFilterGet`, a `.filter` reading an
21
+ organisation / namespace / recipient / owner field or the caller context, a skipping branch in a loop.
22
+ Red when the call's arguments carry no scope predicate; a door named by an expression, or arguments
23
+ spread from an unread value, is could-not-judge.
24
+
25
+ ### Changed
26
+ - **C-11 (b), dynamic** (v2.6, SEP-1303): the introspector sends an invalid cursor to every protocol list
27
+ method the server declares (`-32602` required) and as the `cursor` argument of every served tool that
28
+ takes one (a tool result with `isError: true` and `structuredContent.error.code` `invalid_cursor`
29
+ required). A protocol error, a silent page, a missing or different code is red; no answer, or a tool
30
+ that requires another argument, is could-not-judge. The outcome domain is a typed union judged by an
31
+ exhaustive switch.
32
+ - **C-9** accepts the v2.6 read verb `prepare`; **C-15** partitions it as a read verb.
33
+ - **C-13** reads the context ceiling in every declaration form: the server manifest's `contextCeiling`
34
+ (`mcp.json` / `mcp-server.json`) and a `CONTEXT_CEILING` / `contextCeiling` const or field, inline or
35
+ behind a same-file const. A form present but unreadable (a manifest that does not parse, a computed
36
+ value) is named: could-not-judge when nothing readable is declared, named in the verdict otherwise.
37
+ Two readable ceilings that disagree are could-not-judge. `findCeiling` (C-19, C-21) reads the same census.
38
+
39
+ ### Fixed
40
+ - **A target with nothing to read exits 2, not 1** (Theta, task k17dna3w0a5jsq3vezmbwexpmh8gy1nb). A
41
+ missing path used to crash on an uncaught `ENOENT`, which Node exits as 1, the code for "a
42
+ violation". An empty directory was judged, and its absent README and lockfile were reported as
43
+ violations. `loadTree` now throws `SubjectUnreadableError`, and the CLI answers
44
+ `could not judge … (exit 2)` for every form a target can take that yields no readable file:
45
+ - the stat fails: missing, permission, a path through a file;
46
+ - the directory cannot be listed;
47
+ - the directory is empty;
48
+ - the walk reaches no file;
49
+ - the target is a file other than `.ts`/`.tsx`;
50
+ - the target is neither a file nor a directory.
51
+
52
+ A directory holding one file is still judged, and a real violation there still exits 1. Inside
53
+ a readable subject, a subdirectory that cannot be listed, or an entry that cannot be read, is
54
+ now named in `skipped directories` instead of being dropped in silence.
55
+ - **S-22** (Argus, #56): an `allowedOrigins` element bound to a const — an identifier or a spread —
56
+ resolves to the literal it holds, same file first; a wildcard or an empty list behind a const is red.
57
+ A type annotation (`allowedOrigins: readonly string[];`) is no value and no longer reads as an
58
+ unresolved one.
59
+ - **DLocalIdentity** (Eta, vantage-mcp-boilerplate #14): a bare hash over local content — file bytes, a
60
+ directory listing, a digest of them — is a content fingerprint of the tree, not a credential digest.
61
+ Told apart by what the hash consumes, never by a file or function name; a held hash that is updated
62
+ with anything else, or handed elsewhere, stays a site, and so does every keyed primitive.
63
+ - **D3 / D4**: a `content: [` site is attributed to the tool whose extent CONTAINS it (a registration
64
+ call's parentheses, a descriptor object), never to the last tool name seen above it. An unrelated
65
+ `{ name: "list_items" }` row no longer turns a shared content helper into a list builder.
66
+ - **S35** (Eta, #57 REVISE): the narrowing is read when the returned value is not bound by
67
+ `const x = await <call>`. Three forms that fell through to `present` are now judged: the call as an
68
+ argument of `scopeFilterList` / `scopeFilterGet` or the receiver of a `.filter` chain; a `.then(cb)`
69
+ chained on the call (cb's body is read; a callback the scan cannot read is could-not-judge, naming
70
+ the site); one plain rebinding `const y = x`. Narrowing present over an unscoped read is red;
71
+ narrowing absent in that form stays `present`. Declared, not read: a call passed as an argument of
72
+ `.filter(...)` itself, and a second rebinding.
73
+ - **Foreign-material probe** (Eta, #57 REVISE): `BP_MAIN_REF` is pinned to `45f4fbe2` (no longer
74
+ `origin/main`); the missing G6 SHA default is removed and the G6 substrate for `c13` / `d3` is derived
75
+ by overlaying `fixtures/v081-bp-g6-factory/head/src` on the pinned main export (the manifest form of
76
+ the lost G6 head is re-created and declared as constructed); the G6 substrate is built only when
77
+ `c13` or `d3` is requested, so main-only sections run without it; the `s35` section states what it
78
+ checks at the pinned ref.
79
+
80
+ Foreign-material bite proof: `BP_REPO=<vantage-mcp-boilerplate checkout> npm run test:v081-foreign-bite`
81
+ (`scripts/v081-foreign-bite-probe.sh`; the `c11dyn` section builds and launches the boilerplate).
82
+
83
+ ### Security
84
+
85
+ - Upgrade dev toolchain (vitest, vite, esbuild...) — no runtime change; published package has no dependencies.
86
+
87
+
88
+ ## 0.8.0 — 2026-10-11
89
+
90
+ Measured against the same standard pin as 0.7.0, `b714129a110237c5521d5269ee61ab919408a17f` (91
91
+ rules). 0.7.0 covered 64 of them; 0.8.0 adds a detector for each of the 27 left, so the registry
92
+ covers all of them:
93
+
94
+ ```
95
+ npx tsx scripts/standard-coverage.ts b714129a110237c5521d5269ee61ab919408a17f ../elpi-corp-std
96
+ -> 91/91 rules covered by a registered detector (pin b714129a110237c5521d5269ee61ab919408a17f)
97
+ ```
98
+
99
+ The 27 are derived from that script's `UNCOVERED` rows at 0.7.0, not listed from memory: **C-23 C-24
100
+ C-27 C-28 · S-8 S-9 S-10 S-11 S-13 S-14 S-15 S-16 S-17 S-18 S-19 S-20 S-21 S-22 S-23 S-24 S-25 S-26
101
+ S-27 · E-5 · A-3 A-4 A-5**. Every one has a RED test committed before its implementation and a bite
102
+ mutation on foreign material, one per form of the guarded thing, with its legitimate pole
103
+ (`scripts/v080-foreign-bite-probe.sh`, `npm run test:v080-foreign-bite`).
104
+
105
+ ### Added — one detector per uncovered rule
106
+ - **Trunk gates, static half read, gate half named as owed:** **C23** judgement-sites, **C24**
107
+ acceptance-at-dispatch, **C27** subpackage-types-in-isolation, **C28** deploy-failure-signal.
108
+ - **Server:** **S8** token-passthrough, **S9** confused-deputy-proxy, **S10** ssrf-guard, **S11**
109
+ result-contract, **S13** progress-cancel, **S14** version-negotiation, **S15** completion-security,
110
+ **S16** local-footprint, **S17** forward-auth, **S18** session-integrity, **S19** auth-url-open,
111
+ **S20** scope-minimization, **S21** stdio-proxy-sandbox, **S22** origin-validation, **S23**
112
+ protocol-version-header, **S24** tool-rate-limit, **S25** resource-discipline, **S26**
113
+ list-changed-parity, **S27** elicitation-floor.
114
+ - **Extension:** **E5** extension-isolation. **App:** **A3** sandbox-respected, **A4** wcag-axe-gate,
115
+ **A5** performance-budgets.
116
+ - A dynamique or gate row keeps the 0.7.0 semantics: red where the tree contradicts the rule, `absent`
117
+ only when the subject is provably absent (each matcher carries a positive control and a failed
118
+ control throws), otherwise `could-not-judge` naming the half that is owed. Nothing that has an unread
119
+ half returns `present`.
120
+ - Per-detector reading, limits and verdicts: README, "Detectors added in 0.8.0".
121
+
122
+ ### Changed — Argus rulings on 0.7.0 (PR #55)
123
+ 1. **An owed human verdict is a structured field, not a state and not a prefix.** There is no fifth
124
+ state. A `could-not-judge` result may carry `owedVerdict: { kind: "human", rule: "C-23", sites:
125
+ [{ anchor, element?, what }] }`; the runner refuses it on any other state or with zero sites. S-5
126
+ (i)/(j) use it in place of the `VERDICT DUE (C-23)` prefix (removed from the prose), and so do C-24,
127
+ C-28 and S-20. The human report prints an "owed human verdicts (N sites)" section; the new `--json`
128
+ flag prints `{ surface, exitCode, owedHumanVerdicts, results, skipped }` with the field in each
129
+ result.
130
+ 2. **S-5 (g) and (l) match a quoted key.** `"client_id_metadata_document_supported": true` (and the
131
+ single-quoted spelling, and the same for `authorization_response_iss_parameter_supported`) was a
132
+ false red; each spelling x each key x `true` / `false` is now a test.
133
+ 3. **A-11 recognises every literal form of the sandbox domain.** Any quoted `*.claudemcpcontent.com`
134
+ with a label in front of the suffix is a typed literal: any label (hex or not, any case, hyphens,
135
+ several labels), any quoting (double, single, a template with no interpolation), a scheme prefix,
136
+ and a literal joined to the suffix (`"vp" + ".claudemcpcontent.com"`). The bare suffix constant and
137
+ an interpolated label are not literals. One test per form.
138
+
139
+ ### Fixed — found by running the new detectors on foreign material
140
+ Each was a false positive or a false green found on `vantage-mcp-boilerplate` or `vantage-peers`,
141
+ turned into a RED test before the fix:
142
+ - **C-28**: a deploy named in a YAML comment is not a deploy step.
143
+ - **C-27**: a workflow is read for the commands its `run:` keys run (inline and block), not for every
144
+ line holding the word; an `echo`, a job name or a comment is not a type check or an install.
145
+ - **S-8, S-24**: a marker word inside a string (an error message, a description) is prose, not an
146
+ audience check or a rate guard; a quoted property key still counts.
147
+ - **S-10**: a blocklist written as CIDR pairs (`["10.0.0.0", 8]`, what `node:net` BlockList takes) states
148
+ the union; the two metadata hostnames are satisfied by name resolution; a host classification
149
+ (`startsWith("127.")` choosing http or https) is not address validation outside a file that reaches
150
+ out.
151
+ - **S-13**: a TypeScript method signature named `fetch(request: Request, ...)` is not an outbound call.
152
+ - **S-17**: `iss` is validated in the file that redeems the code, not anywhere in the tree.
153
+ - **S-22**: a quoted `"0.0.0.0"` in a blocklist constant is not a bind; CORS `origin: "*"` is not an
154
+ Origin validation.
155
+ - **S-25**: an interpolated `${proto}://${host}` is a computed URL, not a custom scheme.
156
+ - **A-3, A-4**: a tree that serves `ui://` resources and holds no UI code is `could-not-judge`, not a
157
+ verdict on code that is not there.
158
+
159
+ ### Revised after review (PR #56, Argus REVISE at `b60df486`)
160
+ **The exit-0 pole is reachable.** `computeExitCode` returned 2 on any `could-not-judge`, and six new rules
161
+ had no way to say `present`, so no server that registered a tool could be approved. Each rule now declares
162
+ its choice, in its detector header and here:
163
+ - **S-11, S-13, S-16, A-4, A-5, A-3 — choice (b), a static positive path on positive evidence.** `present`
164
+ names the half it read (`static half: ...`) and the half it did NOT read (`NOT read by this run: ...`);
165
+ nothing is claimed about the unread half.
166
+ - S-11: every registered tool declares an `outputSchema` and no error code contradicts; a tool with none
167
+ whose window shows no result shape is `could-not-judge`, named.
168
+ - S-13: every outbound call carries a timeout (or none is emitted), no stale id, no answering cancel handler.
169
+ - S-16: no read outside the object, and a transport posture is identified (stdio, HTTP with a token check, a
170
+ Unix socket); tools with no identifiable transport are `could-not-judge`.
171
+ - A-4: an axe dependency and something that runs it; the CI result stays the gate's.
172
+ - A-5: the measured gzipped size is under 250 KB; with no built bundle in the tree it is `could-not-judge`.
173
+ - A-3: UI code, HTML shells and HTML held in a string literal (`ui://` served from `text: \`<html>\``) are
174
+ read; `ui://` served with no UI code anywhere stays `could-not-judge` (the code is elsewhere).
175
+ - S-14 (not in the review's list, same defect, found by the green-pole test): an SDK-first server whose
176
+ tree shows the official SDK (import or dependency) is `present` on its static half; a server class with
177
+ no SDK evidence stays `could-not-judge`.
178
+ - **S-20 — choice (c), an owed human verdict.** A result whose only open question is the human verdict on the
179
+ exposed sites declares `owedVerdict.mechanicalHalfComplete: true`. **Exit-code treatment, defined and
180
+ tested (`tests/v080-exit-code-owed.test.ts`):** such a result is OWED, not unreadable. It does not raise
181
+ exit 2 (exit 0 when nothing else does) and its sites stay printed and counted in `owedHumanVerdicts`. A
182
+ `could-not-judge` with no such declaration (S-5, whose probes are also owed) exits 2 as before, and a
183
+ violation still exits 1. Not defined: S-23 and S-10 on an SDK HTTP server still end in `could-not-judge`
184
+ (the SDK version that validates the header is not readable from the tree) — they hold exit 2 for such a tree.
185
+ - `reconcile()` (the `--mode=both` divergence) now carries the owed sites of either side.
186
+ - `fixtures/oracle-green-conforming` is repaired to conform to the 0.8.0 rules it failed: a real token-bucket
187
+ rate guard on the tool path (S-24), a stdio transport, a bridge-only UI shell, an axe gate, a built bundle.
188
+ A test asserts it exits 0 statically with S-11/13/16/24 and A-3/4/5 `present`, and exits 1 once the guard
189
+ is removed.
190
+
191
+ **Two security detectors no longer fail open.**
192
+ - **S-8** read the incoming Authorization header only through `.get("authorization")` or a quoted bracket, so
193
+ `authorization: c.req.raw.headers.authorization` forwarded to `fetch` read `present`. The census of reads
194
+ (a call, a bracket, DOT access with or without optional chaining, a destructuring renamed or not, any case)
195
+ is one test per form.
196
+ - **S-22** accepted a bare `allowedOrigins:` as protection. It is now read by VALUE: a wildcard (bare, in a
197
+ list, as a host pattern) and an empty list are red; a list of concrete origins is `present`; a variable is
198
+ resolved in the tree (wildcard / empty / concrete) or reported `could-not-judge` naming it; the protection
199
+ flag with no list is red. One test per shape.
200
+
201
+ **Sweep.**
202
+ - Source the scan does not read (`.js .mjs .cjs .jsx .mts .cts`; declarations, tests, config and
203
+ `_generated/` excluded): the code-reading rules (C-23, S-8..S-27, E-5, A-3) say `could-not-judge` naming the
204
+ files instead of `absent`. `src/bin/**` is product source (only a top-level `bin/` is tooling), and tools
205
+ registered by tooling are not the server's.
206
+ - `present` on a word: S-9 (needs a consent lookup, an equality test on `redirect_uri`, a random state), S-20
207
+ (a bare challenge declares no scope), S-24 (a rate-limit identifier must be CALLED), S-17 (the issuer is a
208
+ word of the store key, not the letters of `permissionId`), S-16 (a `footprint` KEY with content, not the
209
+ word), E-5 (zero files read is `could-not-judge`).
210
+ - Comment text as evidence: C-23 (a genre is declared by a code literal or a comment opening with `genre:`),
211
+ A-4 (workflow and test-file comments are not a run).
212
+ - Silent skips: S-15 (case-clause handlers are read; unreadable sites are named), A-5 (oversize, dangling or
213
+ unreadable UI paths are `could-not-judge`, named), C-27 (an unreadable deployable directory is named).
214
+
215
+ ### Process
216
+ - `tests/__oracle__/oracle-complete-server.static.json` is regenerated: 27 rows added, no existing row
217
+ changed.
218
+ - Version `0.8.0` is strictly above the live `latest` (`0.7.0`), read from the registry.
219
+
6
220
  ## 0.7.0 — 2026-10-10
7
221
 
8
222
  Measured against the ElPi-Corp standard pinned at `b714129a110237c5521d5269ee61ab919408a17f`
package/README.md CHANGED
@@ -46,7 +46,7 @@ The path is the root of the MCP server sources (the folder holding `server.ts` /
46
46
  The binary's own `--help` is the authority; this is what it prints:
47
47
 
48
48
  ```
49
- usage: mcp-doctor <source-tree-path> [--mode=static|dynamic|both] [--backend=<convex-dir>]
49
+ usage: mcp-doctor <source-tree-path> [--mode=static|dynamic|both] [--backend=<convex-dir>] [--json]
50
50
 
51
51
  --mode=static read the tree only (default)
52
52
  --mode=dynamic launch the server and judge what it serves
@@ -56,10 +56,12 @@ usage: mcp-doctor <source-tree-path> [--mode=static|dynamic|both] [--backend=<co
56
56
  fails (default 0)
57
57
  --backend=DIR the backend source (a convex/ dir) whose door list S-28 reads;
58
58
  default: the nearest convex/ above the tree
59
+ --json print the report as JSON (results, owedHumanVerdicts, exitCode)
59
60
  --version print the version and exit
60
61
  --help print this and exit
61
62
 
62
- exit: 0 clean, 1 a violation, 2 an abstention with no violation
63
+ exit: 0 clean, 1 a violation, 2 an abstention with no violation (including a target
64
+ that names nothing readable: missing, empty, or not a source tree)
63
65
  ```
64
66
 
65
67
  ## States and exit codes
@@ -77,6 +79,26 @@ The exit code keeps three outcomes distinct, which is what makes the tool safe i
77
79
  - **1** — at least one violation.
78
80
  - **2** — no violation, but at least one detector abstained. **An abstention is never a pass**: it
79
81
  means part of the tree was not judged, and the row names which part and why.
82
+ **Exception, defined in 0.8.0:** a `could-not-judge` that carries an `owedVerdict` declaring
83
+ `mechanicalHalfComplete: true` is an OWED human verdict, not an abstention: the detector ran its whole
84
+ mechanical half and exposed the sites a person must decide (C-23: "green is: the sites are exposed"). It does
85
+ not raise exit 2; the sites stay printed and counted (`owedHumanVerdicts`). Any other `could-not-judge`, with
86
+ or without an `owedVerdict`, still exits 2.
87
+
88
+ **A target with nothing to read exits 2, and no detector runs.** The doctor stops before judging
89
+ anything, prints `could not judge <target> — <what it found>` on stderr, and exits 2. Under `--json`,
90
+ stdout carries `exitCode: 2`, the same text in `subjectUnreadable`, and an empty `results`. This
91
+ covers six cases:
92
+ - the path is missing, or cannot be read;
93
+ - the path is a directory that cannot be listed;
94
+ - the path is an empty directory;
95
+ - the path is a directory where no file can be reached;
96
+ - the path is a file other than `.ts`/`.tsx`;
97
+ - the path is something that is neither a file nor a directory.
98
+
99
+ A directory holding even one file is a subject and is judged, so a missing README or lockfile there is
100
+ still a violation (exit 1). Inside a judged tree, a subdirectory that cannot be listed is named under
101
+ `skipped directories`; it is never dropped in silence.
80
102
 
81
103
  **One known limit in 0.3.2, stated rather than hidden:**
82
104
 
@@ -122,7 +144,7 @@ Each detector maps one standard item and is proven **bipolar** on real material:
122
144
  | **D7** protocol-kit | `@vantageos/mcp-protocol-kit` listed in `package.json` **and actually called** (builder invoked, not merely declared) | listed+called / listed-not-called / hand-rolled. Conformant too on `@modelcontextprotocol/server` >= 2 with no hand-rolled result builder (the kit is required only where builders are written by hand) |
123
145
  | **D15** cache-coherence | Server Rule 15 — every tool-set mutation (RegisteredTool `.enable/.disable/.update/.remove`, `unregisterTool`) is correlated with a `notifications/tools/list_changed` emission | present / absent / non-conforming / could-not-judge |
124
146
  | **D16** version-observable | Server Rule 16 — `server/discover` (or an HTTP `/health` route) exposes a `version` field sourced from `package.json`, reachable pre-auth; an SDK v2 `new McpServer({ name, version })` carrying a `package.json`-derived version counts (pre-auth reach is the dynamic half) | present / absent / non-conforming / could-not-judge |
125
- | **DLocalIdentity** no-local-identity | No MCP server defines its own identity primitive: resolving a caller to a tenant or agent (local JWT verify, a populated allow-list attached to a bearer), asserting org membership or role, filtering rows by caller scope (`fromAllowList`, a name allow-list, a row's `orgId` or acting-name field such as `createdBy` / `assignedTo` compared to the caller's, by `===`, `==`, `Object.is` or after `toLowerCase()`), or validating a credential (local `timingSafeEqual`, a digest of a presented value, HMAC; a hash over a static literal is not a site). A call to a binding imported from `@vantageos/cloud-identity` is not a site. Minting/signing sites (`new SignJWT`, `jwt.sign`, a private-key import) are listed as `MINTING/SIGNING` and not counted; verification and claim reads stay violations whoever issued the token (no issuer carve-out). | present (0, or EXEMPT) / non-conforming (count > ceiling, default 0) / could-not-judge |
147
+ | **DLocalIdentity** no-local-identity | No MCP server defines its own identity primitive: resolving a caller to a tenant or agent (local JWT verify, a populated allow-list attached to a bearer), asserting org membership or role, filtering rows by caller scope (`fromAllowList`, a name allow-list, a row's `orgId` or acting-name field such as `createdBy` / `assignedTo` compared to the caller's, by `===`, `==`, `Object.is` or after `toLowerCase()`), or validating a credential (local `timingSafeEqual`, a digest of a presented value, HMAC; a hash over a static literal, or over local content — file bytes, a directory listing, a digest of them, i.e. a content fingerprint of the built tree — is not a site). A call to a binding imported from `@vantageos/cloud-identity` is not a site. Minting/signing sites (`new SignJWT`, `jwt.sign`, a private-key import) are listed as `MINTING/SIGNING` and not counted; verification and claim reads stay violations whoever issued the token (no issuer carve-out). | present (0, or EXEMPT) / non-conforming (count > ceiling, default 0) / could-not-judge |
126
148
  | **S-28** claim-forwarded | Server complement S-28 (standard `bacc54a`) — a tool forwards the verified claim of its caller (`verifiedOrg` / `verifiedPerson`) to every backend door it calls and refuses when it holds none. Reads each `<client>.query\|mutation\|action` call, the helpers it spreads, the refusal before it, and the backend source's door list (`--backend=<convex-dir>`, else the nearest `convex/` above the tree). A door that is service-account-only or declared `fleet-wide:` needs no claim; no backend found, an unknown door, a computed door name are could-not-judge. | present / non-conforming / could-not-judge / absent (no Convex client imported) |
127
149
 
128
150
  ### Detectors added in 0.7.0 (standard `b714129a`, ElPi-Corp #243)
@@ -140,7 +162,7 @@ where the tree contradicts the rule, and otherwise `could-not-judge` naming the
140
162
  | **C32** host-key-additive | C-32 | `_meta` objects (followed through spreads and consts): `openai/outputTemplate` needs `ui.resourceUri`, `openai/visibility` needs `ui.visibility` | absent / non-conforming / could-not-judge (served `tools/list` owed) |
141
163
  | **C33** identity-tool | C-33 | on an authenticated server, the identity tool: no argument, `readOnlyHint: true`, an `outputSchema` that declares `id` (inline or a same-tree const) | absent / non-conforming / could-not-judge |
142
164
  | **S29**..**S34** | S-29..S-34 | the `events` capability and the three `events/*` handler literals; descriptors (`name`, bilingual description, non-empty `delivery`, `inputSchema`, `payloadSchema`); the `-32012`/`-32602`/`whsec_`, `-32015`/`verification`, five-header/`standardwebhooks`/262144-byte/410-413 evidence; `events.accessRecheckSeconds > 0` in the server manifest | absent (no events surface) / non-conforming / could-not-judge (dynamic half named) |
143
- | **S5** authz-elements (f)-(l) | S-5 (f)..(l) | PRM `resource` and its path-suffixed route, CIMD + `"none"`, `iss` flag, `/authorize` handler (auto-approval, organisation from a query parameter), jwt-bearer, OIDC discovery | absent / non-conforming / could-not-judge (`VERDICT DUE (C-23)` sites exposed; probes owed) |
165
+ | **S5** authz-elements (f)-(l) | S-5 (f)..(l) | PRM `resource` and its path-suffixed route, CIMD + `"none"`, `iss` flag, `/authorize` handler (auto-approval, organisation from a query parameter), jwt-bearer, OIDC discovery | absent / non-conforming / could-not-judge (sites exposed in `owedVerdict`; probes owed) |
144
166
  | **E7** middleware-events | E-7 | writes to `delivery`/`arguments`/`id`/`refreshBefore`/`cursor` and replacement of `ctx.output` inside an `events/*` branch of a hook | absent / present / non-conforming |
145
167
  | **E8** official-ext-declared | E-8 | `capabilities.extensions` as an object, each official key's MUST methods registered | absent / present / non-conforming / could-not-judge (unknown official extension) |
146
168
  | **E9** host-invoked-tools | E-9 | `openai/settings.readTool`, `openai/mentions.searchTool`: registered, `readOnlyHint: true`, `outputSchema`, no required input, `"app"` visibility, ResourceLink | absent / present / non-conforming |
@@ -153,6 +175,75 @@ where the tree contradicts the rule, and otherwise `could-not-judge` naming the
153
175
  | **A11** sandbox-domain-derived | A-11 | a `ui://` resource served over HTTP and a SHA-256 + `.claudemcpcontent.com` derivation | absent / non-conforming / could-not-judge |
154
176
  | **A12** file-write | A-12 | `openai/resources/write`: `ifMatch`, `conflict` + `too-large` handled, received URI only | absent / present / non-conforming |
155
177
 
178
+ ### Detectors added in 0.8.0 (standard `b714129a`) — the 27 rules 0.7.0 left uncovered
179
+
180
+ One detector per remaining rule, so `scripts/standard-coverage.ts` derives **91/91**. Each keeps the
181
+ semantics of a dynamique row: red where the tree contradicts the rule, `absent` only when the subject
182
+ is provably absent (a positive control proves the matcher can see it), otherwise `could-not-judge`
183
+ naming the half that is owed. Comments never count; where a marker word could be prose (a description,
184
+ an error message) it counts only as code or as a quoted property key.
185
+
186
+ | ID | Rule | The static run reads | Verdicts |
187
+ |----|------|----------------------|----------|
188
+ | **C23** judgement-sites | C-23 | every control that declares a judgement genre (the declared column, split on `+`, is the authority): what it returns, whether the due sites travel in `owedVerdict`, a positive control beside any zero | absent / present (sites exposed) / non-conforming |
189
+ | **C24** acceptance-at-dispatch | C-24 | dispatch documents (`tasks/`, `dispatch/`, `briefs/`, issue templates) that build, extend or run a detector / conformance instrument, and the acceptance-property block in them (EN or FR) | absent / non-conforming / could-not-judge (the verdict-side equality is the owed gate; `owedVerdict`) |
190
+ | **C27** subpackage-types-in-isolation | C-27 | each deployable sub-package with a `tsconfig` (C-26's own derivation): a type check that runs after a FROZEN install, read from the Dockerfile `RUN` lines, the railway `buildCommand` and the CI `run:` commands | absent / non-conforming (none / before any install / after a non-frozen install) / could-not-judge (the run result is the owed gate) |
191
+ | **C28** deploy-failure-signal | C-28 | CI deploy steps (railway, vercel, fly, wrangler, netlify, serverless, gcloud run, kubectl, helm); an `if: failure()` step that notifies, or a served-identity-equals-commit check | absent / non-conforming / could-not-judge (a platform-managed deploy, or a signal present; that a person received it is the owed human verdict, `owedVerdict`) |
192
+ | **S8** token-passthrough | S-8 | token verifiers (audience in the same file for `jwtVerify` / `jwt.verify`, anywhere for a library verifier) and any outbound `Authorization` built from the caller's own header, `authInfo.token`, or a variable assigned from them | absent / present / non-conforming / could-not-judge |
193
+ | **S9** confused-deputy-proxy | S-9 | a proxy file (`/authorize` route, `redirect(`, an upstream): consent registry, `redirect_uri` compared exactly (not by `startsWith` / `includes` / `endsWith` / `match` / a regular expression), `__Host-` cookies, `state` after the consent check | absent / present / non-conforming |
194
+ | **S10** ssrf-guard | S-10 | outbound calls whose URL is a caller member (`args.url`, `delivery.url` ...), directly, aliased or destructured; hand-written address tests; `redirect: "follow"`; a blocklist that states at least three members of the union but not all (CIDR pairs count; the metadata hostnames are satisfied by name resolution) | absent / non-conforming / could-not-judge (the loopback-callback and 302 probes are owed) |
195
+ | **S11** result-contract | S-11 | tools returning `structuredContent` without an `outputSchema`; error codes in the reserved range -32020..-32099; a string `code` in a JSON-RPC error | absent / non-conforming / could-not-judge (served conformance owed) |
196
+ | **S13** progress-cancel | S-13 | outbound calls without a `signal` / `timeout`; a literal `requestId` / `progressToken` in an emitted notification; a cancellation handler that answers | absent / non-conforming / could-not-judge (progress monotony, late cancel, `initialize` owed) |
197
+ | **S14** version-negotiation | S-14 | a hand-rolled `initialize` answering a fixed `protocolVersion` | absent / non-conforming / could-not-judge (the negotiation probe is owed) |
198
+ | **S15** completion-security | S-15 | the `completions` capability, `completion/complete`, `completable(`: input validation and a rate limit in the handler | absent / non-conforming / could-not-judge (no-disclosure owed) |
199
+ | **S16** local-footprint | S-16 | reads of `~/.ssh`, cloud credentials, shell history; home-directory reads with no `footprint` in the manifest; HTTP bound to loopback with no token and no socket | absent / non-conforming / could-not-judge (the runtime file trace is owed) |
200
+ | **S17** forward-auth | S-17 | an authorization code redeemed without `iss` handled in the redeeming file; credentials keyed without the issuer; dynamic registration with no CIMD | absent / present / non-conforming |
201
+ | **S18** session-integrity | S-18 | `sessionIdGenerator`: predictable, not a CSPRNG, not bound `<user>:<id>`; a session lookup used as authentication | absent / present / non-conforming |
202
+ | **S19** auth-url-open | S-19 | URL opening through a shell, scheme validation by blocklist, no http(s) allowlist | absent / present / non-conforming |
203
+ | **S20** scope-minimization | S-20 | omnibus scopes, the whole catalogue in `scopes_supported`; the sites that check a CLAIMED scope | absent / present / non-conforming / could-not-judge (`owedVerdict`: does authorization rest on the claim alone) |
204
+ | **S21** stdio-proxy-sandbox | S-21 | child-process launches: a sandbox marker, a log within eight lines, an authorization word for a dangerous command | absent / present / non-conforming |
205
+ | **S22** origin-validation | S-22 | a Streamable HTTP transport: the SDK's DNS rebinding protection, or an `Origin` read followed by a 403; `0.0.0.0` named | absent / present / non-conforming |
206
+ | **S23** protocol-version-header | S-23 | the `MCP-Protocol-Version` read (any case) followed by a 400; a hand-rolled route that never reads it | absent / present / non-conforming / could-not-judge (the SDK owns the check; a probe is owed) |
207
+ | **S24** tool-rate-limit | S-24 | each registered tool's window for a rate-limit word as code (a quoted key counts), or a central `CallToolRequestSchema` handler that limits; each unguarded tool is named | absent / present / non-conforming |
208
+ | **S25** resource-discipline | S-25 | unvalidated dereference of a caller-derived value in a resource handler, a custom scheme that is not RFC 3986, `blob:` that is not base64, binary content without `mimeType` | absent / present / non-conforming |
209
+ | **S26** list-changed-parity | S-26 | `resources` / `prompts` declaring `listChanged: true` against the matching `list_changed` emission, per kind | absent / present / non-conforming |
210
+ | **S27** elicitation-floor | S-27 | sensitive property keys of a `requestedSchema`; MRTR `input_required` with no elicitation fallback and no floor declaration (`midCallInputAtFloor: false`) | absent / non-conforming / could-not-judge (the floor probe is owed) |
211
+ | **E5** extension-isolation | E-5 | global writes (`globalThis`, `process.env`, built-in prototypes), writes to an `identifier`, writes into the shared `extensions` map under a key that is not the extension's own | absent / present / non-conforming |
212
+ | **A3** sandbox-respected | A-3 | `window.parent`, `window.top`, `localStorage`, `sessionStorage`, `document.cookie` in UI code and in the `<script>` blocks of HTML shells | absent / present / non-conforming / could-not-judge (ui:// served, no UI code in the tree) |
213
+ | **A4** wcag-axe-gate | A-4 | an axe dependency and a script, workflow or test that runs it | absent / non-conforming / could-not-judge (0 blocking violations is the owed gate) |
214
+ | **A5** performance-budgets | A-5 | gzipped size of the UI bundle files (`dist/` and `build/` read for size only): one file or one directory over 250 KB | absent / non-conforming / could-not-judge (render, roundtrip, memory probes owed) |
215
+
216
+ ### Changes in 0.8.1 (standard `6d463a1e` = v2.6 `24c1ffc9` + S-35)
217
+
218
+ | ID | Rule | The static run reads | Verdicts |
219
+ |----|------|----------------------|----------|
220
+ | **S35** scope-in-read | S-35 | each `<client>.query\|action(<door>, <args>)` and, in the same block, a narrowing of what it returned (`scopeFilterList`, `scopeFilterGet`, a `.filter` on an organisation / namespace / recipient / owner field or the caller context, a skipping branch in a loop) | absent / present / non-conforming / could-not-judge (door named by an expression) |
221
+
222
+ **C-11 (b)** dynamic: an invalid cursor is sent to each protocol list method (`-32602`) and as the cursor
223
+ argument of each tool that takes one (`isError: true`, `structuredContent.error.code` = `invalid_cursor`).
224
+ **C-9** accepts `prepare` (a read verb). **C-13** reads the ceiling from the server manifest's
225
+ `contextCeiling` as well as from a source constant. **S-22** resolves an `allowedOrigins` element held by a
226
+ same-file const. **DLocalIdentity** does not count a hash over local file content. **D3 / D4** attribute a
227
+ result builder to the tool that contains it.
228
+
229
+ **What `present` means for a rule whose other half is dynamic (0.8.0).** S-11, S-13, S-14, S-16, A-3, A-4
230
+ and A-5 return `present` on a static positive path: the verdict says `static half: ...` and names the half it
231
+ did NOT read (`NOT read by this run: ...`) — the served output conforms, progress is monotone, the negotiation
232
+ probe, the runtime file trace, the CI result of the axe run, render/roundtrip/memory. `--mode=dynamic` does not
233
+ implement those reads. Read `present` on these rows as "the static half holds", never as "the rule is held".
234
+
235
+ **An owed human verdict is a field, not a state.** There is no fifth state. A `could-not-judge` whose
236
+ owed half is judgement carries `owedVerdict: { kind: "human", rule: "C-23", sites: [{ anchor, element,
237
+ what }] }` (S-5 (i)/(j), C-24, C-28, S-20). The human report lists the sites in an "owed human verdicts"
238
+ section, counted; `--json` prints `{ surface, exitCode, owedHumanVerdicts, results, skipped }` and the
239
+ field is in each result. The `VERDICT DUE (C-23)` prose prefix is gone: a consumer counts owed
240
+ verdicts from the field, never from prose.
241
+
242
+ **S-5 (g) and (l)** match their key bare, double-quoted or single-quoted. **A-11** calls a typed
243
+ literal any quoted `*.claudemcpcontent.com` (any label, any case, any quoting, a scheme, several
244
+ labels, or a literal joined to the suffix); the bare suffix constant and an interpolated label are not
245
+ literals.
246
+
156
247
  Amended rules, adapted in place: **C-2** (a tool name is at most 64 characters), **C-15** (a `title`,
157
248
  and no enumerated parameter spanning a read and a write verb), **C-16** (event payloads), **S-3**
158
249
  (`skills/list` and `skills/get`), **S-4** (the closed trigger: manifest `minProtocolVersion >=
@@ -166,7 +257,7 @@ destructured binding or an object property.
166
257
 
167
258
  The coverage grid (`analysis/mcp-doctor-coverage-grid.md`) is derived by
168
259
  `npx tsx scripts/standard-coverage.ts <pin> <ElPi-Corp checkout>`; the foreign-material bite proof is
169
- `npm run test:v070-foreign-bite` (see `scripts/v070-foreign-bite-probe.sh`).
260
+ `npm run test:v070-foreign-bite` and `npm run test:v080-foreign-bite` (see `scripts/v070-foreign-bite-probe.sh` and `scripts/v080-foreign-bite-probe.sh`).
170
261
 
171
262
  **DLocalIdentity exemptions.** Two, both reported as `EXEMPT` rather than hidden: the
172
263
  `@vantageos/cloud-identity` package itself (read from the target's `package.json` name), and a site
package/dist/doctor.d.ts CHANGED
@@ -50,9 +50,70 @@ interface Tree {
50
50
  * so when it finds none. Unset means "not supplied", never "no backend".
51
51
  */
52
52
  backendRoot?: string;
53
+ /**
54
+ * Product-looking source the scan did NOT read (`.js` `.mjs` `.cjs` `.jsx` `.mts` `.cts`, relative paths;
55
+ * declarations, tests and config files excluded). A rule that says `absent` on the strength of the files
56
+ * it read must not say it while this list is non-empty: it says could-not-judge and names them.
57
+ */
58
+ unscannedSource?: string[];
59
+ }
60
+ /**
61
+ * The target names nothing the doctor can read, so there is no subject to judge. Thrown by
62
+ * `loadTree` before any detector runs; the CLI turns it into exit 2 ("could not judge"), never 1:
63
+ * a verdict of "violation" about a tree that was never read is a finding manufactured from nothing.
64
+ *
65
+ * The forms a target can take come from `fs.statSync` and `fs.readdirSync`, and every one of them
66
+ * that yields no readable file ends here, named:
67
+ * - stat fails (does not exist, permission, a path through a file, a loop);
68
+ * - a directory that cannot be listed;
69
+ * - a directory with no entries;
70
+ * - a directory whose walk reaches no readable file (only empty, skipped, unreadable or
71
+ * non-regular entries — a FIFO, socket or device is never read: reading one can block);
72
+ * - a regular file that is not `.ts` / `.tsx` (the only single files the scan reads);
73
+ * - a `.ts` / `.tsx` file that cannot be read;
74
+ * - anything that is neither a directory nor a regular file.
75
+ * A directory holding at least one file IS a subject: the detectors judge it, and a missing README
76
+ * or lockfile there is a real violation.
77
+ */
78
+ declare class SubjectUnreadableError extends Error {
79
+ readonly target: string;
80
+ readonly form: string;
81
+ constructor(target: string, form: string);
53
82
  }
54
83
 
55
84
  type State = "present" | "absent" | "non-conforming" | "could-not-judge";
85
+ /**
86
+ * One site on which a human verdict is due (C-23): the mechanical half of the rule ran in full and
87
+ * the remaining question is judgement, which an instrument may not answer.
88
+ */
89
+ interface OwedVerdictSite {
90
+ /** file:line the verdict is about. */
91
+ anchor: string;
92
+ /** What the human must decide at that site, in one sentence. */
93
+ what: string;
94
+ /** The rule element the site belongs to, when the rule has lettered elements (e.g. "S-5 (i)"). */
95
+ element?: string;
96
+ }
97
+ /**
98
+ * "A human verdict is owed", as a STRUCTURED field of a could-not-judge result. There is NO fifth
99
+ * state: C-23's "sites exposed, verdict due" is a could-not-judge whose owed half is judgement, and
100
+ * a consumer counts owed verdicts by reading this field, never by scanning prose (derive-never-type).
101
+ * Only a could-not-judge may carry it, and never with zero sites (a zero is an event, not a rest).
102
+ */
103
+ interface OwedVerdict {
104
+ kind: "human";
105
+ /** The rule that makes the verdict owed: C-23 (a judgement control exposes its sites). */
106
+ rule: string;
107
+ /**
108
+ * True when the detector ran the whole mechanical half of its rule and the ONLY thing left is the
109
+ * human verdict on `sites` (C-23: "green is: the sites are exposed"). The exit code then treats the
110
+ * result as OWED, not unreadable: it does not raise exit 2, and the sites stay printed and counted
111
+ * (`owedHumanVerdicts`). Absent or false: something besides the human verdict is unread (a probe, a
112
+ * dynamic half), and the result exits 2 like any could-not-judge.
113
+ */
114
+ mechanicalHalfComplete?: boolean;
115
+ sites: OwedVerdictSite[];
116
+ }
56
117
  interface DetectorResult {
57
118
  id: string;
58
119
  /** display anchor(s) file:line — the site the verdict is about. */
@@ -65,6 +126,8 @@ interface DetectorResult {
65
126
  * and the runner refuses it. Ignored for the three decisive states.
66
127
  */
67
128
  reason?: string;
129
+ /** Present only on a could-not-judge whose owed half is a human verdict. See `OwedVerdict`. */
130
+ owedVerdict?: OwedVerdict;
68
131
  }
69
132
  /** A single tool as reported by the RUNNING server over the MCP `tools/list` call. */
70
133
  interface RuntimeTool {
@@ -84,6 +147,39 @@ interface RuntimeTool {
84
147
  * eval file has `evalCaseCount` unset, which D8 reads as zero coverage.
85
148
  */
86
149
  evalCaseCount?: number;
150
+ /**
151
+ * True when the served `inputSchema` declares a `cursor` property: the tool takes a cursor as an
152
+ * ARGUMENT, which C-11 (b) probes with an invalid value (SEP-1303).
153
+ */
154
+ cursorArgument?: boolean;
155
+ }
156
+ /**
157
+ * What one invalid-cursor probe read (C-11). The domain is the JSON-RPC response shapes: an `error`
158
+ * member, a `result` member (with or without `isError`, with or without
159
+ * `structuredContent.error.code`), no answer before the deadline, or a probe the introspector
160
+ * refused to send (it never invents a value for another required argument).
161
+ */
162
+ type CursorProbeOutcome = {
163
+ kind: "error";
164
+ code: unknown;
165
+ message?: string;
166
+ } | {
167
+ kind: "result";
168
+ isError: boolean;
169
+ errorCode?: unknown;
170
+ } | {
171
+ kind: "no-answer";
172
+ reason: string;
173
+ } | {
174
+ kind: "not-sent";
175
+ reason: string;
176
+ };
177
+ /** One invalid-cursor probe: where the cursor arrived (C-11 (a) list method, (b) tool argument). */
178
+ interface CursorProbe {
179
+ /** The protocol list method (`tools/list` ...) or the tool name. */
180
+ target: string;
181
+ surface: "list-method" | "tool-argument";
182
+ outcome: CursorProbeOutcome;
87
183
  }
88
184
  /**
89
185
  * What the RUNNING target server exposes, gathered by launching it and speaking
@@ -126,6 +222,12 @@ interface RuntimeContext {
126
222
  * length and asserts that shape rather than assuming it.
127
223
  */
128
224
  expectedCommit?: string;
225
+ /**
226
+ * The invalid-cursor round trips (C-11): one per protocol list method the server declares, and one
227
+ * per served tool that takes a cursor argument. Unset when the launch failed, or when the
228
+ * introspector recorded none (C-11 reads that as could-not-judge, never a pass).
229
+ */
230
+ cursorProbes?: CursorProbe[];
129
231
  }
130
232
  interface Detector {
131
233
  id: string;
@@ -285,6 +387,20 @@ declare function runDoctorAsync(target: string, opts?: {
285
387
  */
286
388
  declare function renderSkipped(skipped: SkippedDir[]): string;
287
389
  declare function renderTable(surface: string, results: DetectorResult[], skipped?: SkippedDir[]): string;
390
+ /** Total owed human-verdict sites across the results — counted from the structured field. */
391
+ declare function countOwedHumanVerdicts(results: DetectorResult[]): number;
392
+ /**
393
+ * The owed-human-verdicts section: every site a human must decide, counted, read from the
394
+ * structured `owedVerdict` field. Empty string (section omitted) when none are owed — a count of
395
+ * zero is printed by the JSON output, which always carries it.
396
+ */
397
+ declare function renderOwedVerdicts(results: DetectorResult[]): string;
398
+ /**
399
+ * The machine-readable report (`--json`). The same results the table prints, plus the exit code and
400
+ * the owed-human-verdict count, so a consumer reads "a verdict is owed" from a field and never from
401
+ * prose.
402
+ */
403
+ declare function renderJson(surface: string, results: DetectorResult[], skipped: SkippedDir[]): string;
288
404
  /**
289
405
  * The process exit code a run's results carry, three-way with precedence:
290
406
  * 1 — a VIOLATION: at least one detector is `non-conforming` (something IS
@@ -297,4 +413,5 @@ declare function renderTable(surface: string, results: DetectorResult[], skipped
297
413
  */
298
414
  declare function computeExitCode(results: DetectorResult[]): number;
299
415
 
300
- export { type LiveDetector, type RunDoctorOptions, type RunMode, type Session, computeExitCode, isLiveDetector, renderSkipped, renderTable, runDoctor, runDoctorAsync, runDoctorReport };
416
+ export { SubjectUnreadableError, computeExitCode, countOwedHumanVerdicts, isLiveDetector, renderJson, renderOwedVerdicts, renderSkipped, renderTable, runDoctor, runDoctorAsync, runDoctorReport };
417
+ export type { LiveDetector, RunDoctorOptions, RunMode, Session };