@consiliency/agent-board-cli 1.22.5 → 1.24.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.
@@ -0,0 +1,1535 @@
1
+ // CREDPATH IF-0-CREDPATH-4 — where each harness registers the board, and how doctor reads it.
2
+ //
3
+ // `HARNESS_CONFIG_SCOPES` is the frozen scope table. The plan froze a first version and required
4
+ // SL-5 to re-check every row against the installed harness before building on it; the rows below
5
+ // are the re-checked table, and each amendment says what was measured and how (dev0, 2026-09-25):
6
+ //
7
+ // claude 2.1.282 The binary names `managed-mcp.json`, `managed-settings.d` and
8
+ // `CLAUDE_CONFIG_DIR`; the plan's row had none of them. Added: the managed MCP
9
+ // file, managed settings (`managedMcpServers` + `hooks`, file and `.d/*.json`
10
+ // drop-ins), and `CLAUDE_CONFIG_DIR` relocating `.claude.json` and
11
+ // `settings.json`. `~/.claude/settings.local.json` stays excluded: Claude Code
12
+ // does not read it at user scope (the v11 incident).
13
+ // codex 0.157.0 `codex mcp list` in a temp `CODEX_HOME` MEASURED a project layer the plan
14
+ // lacked: `<dir>/.codex/config.toml` for every dir from cwd up to the git root,
15
+ // each loaded only when the nearest `[projects."<path>"]` entry at or above it
16
+ // says `trust_level = "trusted"` (root trusted + sub untrusted → root only; no
17
+ // entry → not loaded).
18
+ // grok 0.1.220 Row kept as its README documents (`~/.grok/README.md:1873,1889`). The
19
+ // measurement is display-only: `grok mcp list` shows a user-scope server (the
20
+ // positive control) but not a project one, so it is not evidence either way.
21
+ // agy 1.2.11 `agy mcp add` / `disable` in a temp HOME wrote
22
+ // `{"mcpServers":{<name>:{command,args,disabled}}}`; an empty file is no entries.
23
+ // `~/.gemini/config/projects/*.json` holds only `id`/`name`: no project scope.
24
+ // opencode 1.18.32 `opencode debug config` MEASURED a walk the plan lacked: `opencode.json[c]` and
25
+ // `.opencode/opencode.json[c]` in every dir from cwd up to the git root are
26
+ // merged, and plugins are deduplicated BY NAME, ignoring the version (user
27
+ // `@0.1.1` + project bare → one entry). dev0's live entry is versioned.
28
+ // pi 0.87.1 Installed on dev0 (the brief said otherwise). Its `docs/packages.md`:
29
+ // `PI_CODING_AGENT_DIR` relocates `~/.pi/agent`; entries are strings or
30
+ // `{source}` objects; npm packages are identified by name WITHOUT the version,
31
+ // and a project entry replaces the personal one. dev0's live entry is
32
+ // `npm:@consiliency/agent-board-pi@0.3.1`.
33
+ //
34
+ // Consequence for "exactly once": a harness that deduplicates by name (claude, codex and grok by
35
+ // server name across layers; opencode and pi by package name) cannot load one server twice under
36
+ // one name, so duplicates are counted over DISTINCT names in the merged set. Two differently
37
+ // named board servers are two processes, and that is what fails.
38
+ //
39
+ // PRECEDENCE (code-review round 1, Astra 3). A same-named entry at a higher layer overrides the
40
+ // lower one, so doctor first builds each harness's EFFECTIVE server map and only then judges it:
41
+ // a board entry that a higher layer disables or re-points is not a registration. Each row carries
42
+ // `rank` (higher overrides) and, for a walked row, which end of the walk wins. Measured on dev0,
43
+ // 2026-09-26, in temp homes with real `git init` repositories:
44
+ // codex 0.157.1 `codex mcp list --json`: a trusted project layer overrides the user layer
45
+ // FIELD BY FIELD (`enabled = false` alone disables the user's server; `command`
46
+ // alone keeps the user's `args`), and the layer nearest cwd wins.
47
+ // opencode 1.18.32 `opencode debug config`: field-by-field too; user < every `opencode.json[c]`
48
+ // (nearest cwd wins) < every `.opencode/` file, where the FARTHEST (git root)
49
+ // wins — measured, and the opposite of the `opencode.json` walk.
50
+ // claude 2.1.283 `claude mcp get` with a temp `CLAUDE_CONFIG_DIR`: local (`projects[cwd]`)
51
+ // beats user (measured: it names the winning scope, not its fields). That the
52
+ // whole entry is REPLACED, not overlaid, is inferred from claude keeping each
53
+ // scope as its own map, not measured. project vs user could not be measured (an
54
+ // unapproved `.mcp.json` is not shown); the documented order local > project >
55
+ // user is used, and managed scopes are ranked above all.
56
+ // grok unmeasured (display-only, above): ranked like codex, which it mirrors.
57
+ //
58
+ // CCPLUGIN IF-0-CCPLUGIN-3 — claude plugins are read by a dedicated reader (`readClaudePluginScopes`),
59
+ // not a `ScopeRow`. SL-2.0 measured it with `claude plugin list --json`, `plugin install/enable/
60
+ // disable --scope …` and `claude mcp get` (which spawns the server, so a marker command shows
61
+ // WHICH tree ran), in temp `HOME`/`CLAUDE_CONFIG_DIR`s, claude 2.1.283 on dev0, 2026-09-26:
62
+ // enable `enabledPlugins` is per plugin id, local > project > user, the highest scope that
63
+ // holds a BOOLEAN wins (all 18 user/project/local combinations of true/false/absent
64
+ // measured). A non-boolean (`["1.31.0"]`, `"true"`, `1`, `null`, `{}`) defines
65
+ // nothing: `plugin list` shows it disabled at user scope and the user value
66
+ // standing under a local one. Managed settings are ranked above all (inferred: not
67
+ // writable without root).
68
+ // row `installed_plugins.json` is `{version: 2, plugins: {<id>: [row…]}}`, a row
69
+ // `{scope, installPath, version, installedAt, lastUpdated, gitCommitSha?,
70
+ // projectPath?}` (`projectPath` on local and project rows). AMENDS the plan: the
71
+ // loaded row is NOT the one whose scope matches the winning enable. It is the FIRST
72
+ // row, in array order, that applies to the cwd — scope user/managed, or a
73
+ // `projectPath` equal to the cwd or in the same git repository (the binary's
74
+ // `uw`). Measured by reordering rows: [user, local(cwd)] loads the user row's tree
75
+ // in the local row's own project; [local(cwd), user] loads the local one. Claude
76
+ // skips a first row whose path fails its own trust check and takes the next; doctor
77
+ // stops there with `plugin_path_untrusted` instead (stricter, deliberately).
78
+ // source a github marketplace source is `{source:"github", repo, ref?, path?,
79
+ // sparsePaths?, skipLfs?}`, identical in `extraKnownMarketplaces` and
80
+ // `known_marketplaces.json` (measured: `marketplace add anthropics/claude-code@main`).
81
+ // There is no marketplace-level `sha`; that is the plugin-entry source's, and the
82
+ // installed row carries `gitCommitSha`. `source_ref` is `ref`. Repos compare
83
+ // case-insensitively, as the binary's own matcher does.
84
+ // tree a github-sourced plugin runs from its row's `installPath` in the cache (measured:
85
+ // editing the cached `plugin.json` changed what ran, the marketplace clone's did
86
+ // not). A DIRECTORY-sourced plugin runs from the source directory, not the cache
87
+ // (editing the source without reinstalling changed what ran). Ours never has a
88
+ // directory source; for a FOREIGN directory plugin this is a floor: doctor reads
89
+ // the cache copy, which may not be what runs.
90
+ // auto-update off globally when `DISABLE_UPDATES` or `DISABLE_AUTOUPDATER` is truthy
91
+ // (1/true/yes/on), `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` is non-empty, or
92
+ // `.claude.json` has `autoUpdates: false` (unless `installMethod` is native and
93
+ // `autoUpdatesProtectedForNative`); `FORCE_AUTOUPDATE_PLUGINS` lifts that gate.
94
+ // Then per marketplace: the highest settings scope DECLARING it (its `autoUpdate`,
95
+ // if boolean), else `known_marketplaces.json`, else a default that is on only for
96
+ // official names (read from the binary, not run). Floor: the binary reads a repo
97
+ // scope's (project/local) declaration only behind an internal gate doctor cannot
98
+ // see; doctor counts it. Env inputs are as doctor sees them.
99
+ //
100
+ // disabled `.claude.json` `projects[<key>].disabledMcpServers` turns a server off in ONE project
101
+ // (measured 2026-09-26, `claude mcp get`/`mcp list` with marker commands: the
102
+ // disabled server shows "Disabled for this project" and is never spawned). `<key>`
103
+ // is the git root of the cwd's REALPATH, else that realpath: a subdirectory and a
104
+ // symlinked cwd are covered, a key naming the subdirectory itself is not, and a
105
+ // non-git parent's key does not reach a child. Entries match the server name
106
+ // EXACTLY (case- and space-sensitive); non-string entries are skipped and a
107
+ // non-array list is ignored. It disables a user, local or project (`.mcp.json`)
108
+ // server of that name, and a PLUGIN server by `plugin:<plugin>:<server>` (the bare
109
+ // server name or `plugin:<plugin>` does not). A LOCAL server
110
+ // (`projects[<key>].mcpServers`) is keyed the same way (measured: a local server
111
+ // under the root key runs in a subdirectory), so every `.claude.json` `projects`
112
+ // read goes through `claudeProjectKey` (pinned by DM-8's source scan).
113
+ // handlers (panel B2; read from the binary's hook schema and executors, not run) a hook
114
+ // handler's `type` is `command`, `http`, `prompt`, `agent` or `mcp_tool`.
115
+ // FileChanged runs through the outside-REPL executor, which runs command, http and
116
+ // mcp_tool handlers and refuses prompt and agent ("not yet supported outside
117
+ // REPL"). SessionStart runs through the main executor, where prompt and agent run
118
+ // when a conversation context exists (refused otherwise, and agent always on
119
+ // PermissionRequest). Stop, PreToolUse and UserPromptSubmit run all five. Doctor
120
+ // keeps EVERY handler: our plugin must be exactly the frozen set (both events, one
121
+ // command handler each, nothing else of any type on any event), and ANY non-command
122
+ // handler on SessionStart or FileChanged, in any scope, makes registration
123
+ // undetermined (`unjudgeable_hook`) — conservatively including a prompt/agent one
124
+ // on FileChanged that the binary refuses. Its url, prompt, server or tool is never
125
+ // kept, so never echoed. Floor: what such a handler does is not judged.
126
+ // hook policy (round-2 F027/F028) MEASURED with `claude --init-only` ("Run Setup and
127
+ // SessionStart:startup hooks, then exit": no model, no credentials in the temp
128
+ // config), 2.1.283, 2026-09-26, marker-writing SessionStart hooks in user settings
129
+ // and a directory plugin: `disableAllHooks: true` in user, project OR local
130
+ // settings stops every settings and plugin hook; the highest scope holding a
131
+ // boolean wins (local false over user true → hooks run); `allowManagedHooksOnly`
132
+ // and `strictPluginOnlyCustomization` outside managed settings change nothing;
133
+ // `CLAUDE_CODE_SAFE_MODE` or `CLAUDE_CODE_SIMPLE` truthy stops them all (`0` does
134
+ // not). MANAGED settings need root, so their rules are READ from the binary (hook
135
+ // loader and plugin-hook gate), not run: managed `disableAllHooks` → no hooks;
136
+ // managed `allowManagedHooksOnly`, safe mode, or a non-managed `disableAllHooks` →
137
+ // managed settings hooks only, and plugin hooks only from plugins managed
138
+ // `enabledPlugins` enables (none in safe mode); managed
139
+ // `strictPluginOnlyCustomization` (true, or listing "hooks") → settings hooks from
140
+ // managed settings only, plugins unaffected. Doctor applies this BEFORE counting:
141
+ // a wake hook the policy switches off is `hooks_disabled_by_policy` (a failure,
142
+ // never a pass). The binary takes the managed-plugin set from managed
143
+ // `prependPlugins`/`appendPlugins` when present; doctor cannot evaluate that, so a
144
+ // restricted policy with either is undetermined (`hook_policy_unjudgeable`).
145
+ // Floors: `--safe-mode` and `--bare` on a launch's argv are invisible (env only,
146
+ // as doctor sees it); a settings file the binary rejects as invalid is dropped
147
+ // whole by it and read by doctor.
148
+ // managed tier (round-3 N1, evidence-first per the owner; READ from the 2.1.283 binaries —
149
+ // linux-x64, darwin-arm64 and win32-x64 packages — not run) the managed tier is ONE
150
+ // source, first-wins: server-managed > MDM > the managed-settings file (+ `.d`).
151
+ // Doctor looks for EVIDENCE of each non-file source and never reads its content:
152
+ // - remote: `<config home>/remote-settings.json` (`TJ()`; the config home is
153
+ // `CLAUDE_CONFIG_DIR` or `~/.claude`; no env override). A planted file did not
154
+ // change `--init-only` in a temp config with no account (measured): it is
155
+ // honoured only for an eligible account. (`JE()` names a
156
+ // `CLAUDE_CODE_REMOTE_SETTINGS_PATH` override in a log line, but returns
157
+ // undefined in 2.1.283: compiled out.)
158
+ // - remote, storage-v5 (round 4): with the v5 backend on (`N()`), the payload is
159
+ // written to the state store, `Re.state("remote-settings")` = namespace `state`,
160
+ // id `remote-settings`, and the signed-cache sidecars
161
+ // `remote-settings.json.signature.json` / `.signature-iat.json` go beside the
162
+ // json path. The config home lists `state` and `storage-v2`, but the store's
163
+ // exact on-disk name (file or directory, extension) is NOT recoverable from
164
+ // strings, so doctor probes every candidate — `state/remote-settings[.json]`,
165
+ // `storage-v2/state/remote-settings[.json]` (source `remote_settings_state`) — and
166
+ // both sidecars (`remote_settings_signature`); any present is evidence. Whether v5
167
+ // is on is decided at runtime by a server-served flag (`tengu_hover_rest`, pinned
168
+ // once per process), so its default cannot be told from the binary.
169
+ // - macOS: `/Library/Managed Preferences/<login>/com.anthropic.claudecode.plist`
170
+ // ("per-user managed preferences", login = `os.userInfo().username`) and
171
+ // `/Library/Managed Preferences/com.anthropic.claudecode.plist` ("device-level");
172
+ // the binary converts them with `/usr/bin/plutil`. Existence only. No login name
173
+ // (os.userInfo() throws or gives none) → `managed_policy_check_failed:login_unavailable`,
174
+ // never a silent skip.
175
+ // - Windows: `%SYSTEMROOT%\System32\reg.exe query <key> /v Settings` (default
176
+ // `C:\Windows`) for `HKLM\SOFTWARE\Policies\ClaudeCode` and
177
+ // `HKCU\SOFTWARE\Policies\ClaudeCode`, 5 s timeout. Doctor runs the same query
178
+ // (argv array, no shell): exit 0 → evidence; reg's "unable to find" → none (the
179
+ // binary's own test); anything else, a timeout or no reg.exe →
180
+ // `managed_policy_check_failed:<reg_failed|reg_timeout|reg_unavailable>`.
181
+ // - WSL (the binary's test: `WSL_DISTRO_NAME`, or /proc/version naming microsoft or
182
+ // wsl): Windows policy is inherited only when an ADMIN Windows source sets
183
+ // `wslInheritsWindowsSettings` — `/mnt/c/Program Files/ClaudeCode/managed-settings.json`
184
+ // (+ `.d`) or HKLM, queried through `/mnt/c/Windows/System32/reg.exe` when present.
185
+ // Evidence of either → the tier is replaced; HKCU alone cannot apply on WSL and is
186
+ // not probed; no interop reg.exe → HKLM is not queried (the binary cannot either).
187
+ // Evidence (or a probe that cannot finish) → the managed FILE tier contributes no
188
+ // policy, hooks or servers, and registration is `hook_policy_unjudgeable` (or the
189
+ // check-failed code) unless a failure outside it stands. No evidence → judged
190
+ // normally. The `managed_policy` report field and its one text line say what was
191
+ // checked; they never change the exit code. The managed-settings directory
192
+ // defaults to the binary's per platform (`Zh()`): `/etc/claude-code`,
193
+ // `/Library/Application Support/ClaudeCode`, `C:\Program Files\ClaudeCode`.
194
+ // Floors: a macOS profile delivering settings under another domain, a storage-v5
195
+ // layout other than the probed candidates, and a plist's, key's or store's
196
+ // CONTENT are not read — presence is the whole signal. `scripts/check-child-argv-credentials.mjs`
197
+ // passes but does not inspect this call: its program is a variable, which that
198
+ // checker skips (probed: a planted credential in this argv still scanned clean).
199
+ // trust the binary skips hook execution when the workspace trust dialog is not
200
+ // accepted (`Tie()`); doctor does not read trust (floor).
201
+ // matchers (round-2 F029) a group's `matcher` filters: on SessionStart the start source
202
+ // (measured: "resume" did not fire on startup; "" and "*" did), on FileChanged
203
+ // basename(file_path), where it also adds static watch paths (binary). Our frozen
204
+ // groups carry none (absent or ""); a settings or foreign wake hook with any other
205
+ // matcher is `unjudgeable_hook`.
206
+ // freeze (F025) our hooks are compared on type, command, matcher, and the exact handler
207
+ // and group key NAMES: FileChanged `{type, command, asyncRewake: true}`,
208
+ // SessionStart `{type, command}`, so a `timeout`, `if`, `statusMessage` or a
209
+ // SessionStart `asyncRewake` is a mismatch. Floor: the hook script's bytes are not
210
+ // hashed (a github source of our repository is trusted by source).
211
+ // disabled vs identity (panel S4, intended): the foreign-plugin and stanza checks read a
212
+ // plugin's declared tree whether or not `disabledMcpServers` turns its server off
213
+ // here. What a plugin IS does not depend on one project's switch; only the
214
+ // registration count and the pin (which need a launched server) honour it.
215
+ //
216
+ // Floors: codex plugins are not read; a plugin's `userConfig`, managed servers under
217
+ // `disabledMcpServers` (unmeasured, so never excluded), and an `--add-dir`/`--settings` layer are
218
+ // not modelled; claude's `.mcp.json`
219
+ // approval state and pi/grok project trust are not modelled (a
220
+ // project entry is counted as loaded); opencode's `OPENCODE_CONFIG`/`OPENCODE_CONFIG_DIR` are not
221
+ // read; `managed-mcp.json`'s exclusive-control mode is not modelled; `claude mcp get` still showed
222
+ // a local entry carrying `disabled: true`, so doctor's reading of that field as "not launched" is
223
+ // stricter than claude.
224
+ //
225
+ // Config text is DATA. Every object the readers build has a null prototype and every lookup is an
226
+ // own-property lookup; a `__proto__`, `constructor` or `prototype` key makes the file unparseable
227
+ // (round 1, Astra 1: `[__proto__.mcp_servers.x]` used to write onto `Object.prototype`).
228
+ import { spawnSync } from "node:child_process";
229
+ import * as nodeFs from "node:fs";
230
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
231
+ export const BOARD_MCP_MARKER = "agent-board-mcp";
232
+ export const METADATA_ONLY_FLAG = "--metadata-only";
233
+ export const OPENCODE_PLUGIN_PACKAGE = "@consiliency/agent-board-opencode";
234
+ export const PI_PACKAGE = "npm:@consiliency/agent-board-pi";
235
+ /** Claude Code events that each need exactly one `wake-hook.sh` hook (`docs/local-wake-signal.md`). */
236
+ export const CLAUDE_REQUIRED_HOOK_EVENTS = Object.freeze(["SessionStart", "FileChanged"]);
237
+ /** A wake hook is a command with a token whose BASENAME is this (see `namesWakeHook`). */
238
+ export const WAKE_HOOK_MARKER = "wake-hook.sh";
239
+ const WRAPPER_MAX_BYTES = 64 * 1024;
240
+ // IF-0-CCPLUGIN-1, as frozen on outcome B (`plans/CCPLUGIN-SPIKE.md`). SL-1's manifest check owns
241
+ // the plugin tree; these are the same frozen strings, read from the plan, not from its files.
242
+ export const CLAUDE_PLUGIN_ID = "agent-board@message-board";
243
+ export const CLAUDE_PLUGIN_MARKETPLACE = "message-board";
244
+ /** Compared lower-cased, as Claude Code compares repos. */
245
+ export const CLAUDE_PLUGIN_REPO = "consiliency/message-board";
246
+ export const CLAUDE_PLUGIN_SERVER = "agent-board";
247
+ export const CLAUDE_PLUGIN_MCP_PACKAGE = "@consiliency/agent-board-mcp";
248
+ /** The SL-0 outcome: `B` = the FileChanged hook carries `asyncRewake: true`. */
249
+ export const CLAUDE_WAKE_SHAPE = "B";
250
+ export const CLAUDE_PLUGIN_HOOK_COMMANDS = Object.freeze({
251
+ SessionStart: "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/wake-hook.sh\" SessionStart",
252
+ FileChanged: "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/wake-hook.sh\" FileChanged",
253
+ });
254
+ /** The stanza a plugin of version `version` must carry: the pin IS the plugin version. */
255
+ export function claudePluginStanza(version) {
256
+ return { command: "npx", args: ["-y", `${CLAUDE_PLUGIN_MCP_PACKAGE}@${version}`, "--harness", "claude"] };
257
+ }
258
+ /**
259
+ * Whether a hook command names the wake hook. The tokenizer is frozen: split on whitespace, strip
260
+ * quotes, no comment handling; a token matches when its basename is exactly `wake-hook.sh`, so
261
+ * `codex-wake-hook.sh` does not. Floor: a hook that never names the basename is invisible.
262
+ */
263
+ export function namesWakeHook(command) {
264
+ // Quotes are removed by split/join, not a regex: a quote inside a regex literal desyncs the
265
+ // comment/literal stripper that the #403 class checks run over this file.
266
+ return command.split(/\s+/).some((token) => token.split("\"").join("").split("'").join("").split("/").pop() === WAKE_HOOK_MARKER);
267
+ }
268
+ const missing = (error) => {
269
+ const code = error.code;
270
+ return code === "ENOENT" || code === "ENOTDIR";
271
+ };
272
+ export const nodeRegistrationFs = {
273
+ readFile(path) {
274
+ try {
275
+ return nodeFs.readFileSync(path, "utf8");
276
+ }
277
+ catch (error) {
278
+ if (missing(error))
279
+ return null;
280
+ throw error;
281
+ }
282
+ },
283
+ readdir(path) {
284
+ try {
285
+ return nodeFs.readdirSync(path).sort();
286
+ }
287
+ catch (error) {
288
+ if (missing(error))
289
+ return null;
290
+ throw error;
291
+ }
292
+ },
293
+ lstat(path) {
294
+ try {
295
+ const stats = nodeFs.lstatSync(path);
296
+ return { uid: stats.uid, size: stats.size, isFile: stats.isFile() };
297
+ }
298
+ catch (error) {
299
+ if (missing(error))
300
+ return null;
301
+ throw error;
302
+ }
303
+ },
304
+ exists(path) {
305
+ try {
306
+ nodeFs.lstatSync(path);
307
+ return true;
308
+ }
309
+ catch {
310
+ return false;
311
+ }
312
+ },
313
+ realpath(path) {
314
+ try {
315
+ return nodeFs.realpathSync(path);
316
+ }
317
+ catch (error) {
318
+ if (missing(error))
319
+ return null;
320
+ throw error;
321
+ }
322
+ },
323
+ getuid: () => process.getuid?.() ?? -1,
324
+ };
325
+ // ---------------------------------------------------------------------------------------------
326
+ // Readers
327
+ // ---------------------------------------------------------------------------------------------
328
+ export class UnparseableConfigError extends Error {
329
+ constructor(message) {
330
+ super(message);
331
+ this.name = "UnparseableConfigError";
332
+ }
333
+ }
334
+ /** Keys that name a prototype slot rather than data: a file carrying one is refused. */
335
+ export const FORBIDDEN_CONFIG_KEYS = new Set(["__proto__", "constructor", "prototype"]);
336
+ /** A prototype-free object: an inherited property cannot answer a lookup on it. */
337
+ const dict = () => Object.create(null);
338
+ /**
339
+ * A prototype-free copy of the sources' OWN enumerable properties, later sources winning. Not
340
+ * `Object.assign`, which the #403 code-provenance class check (fleet_diagnosability A10) reserves
341
+ * because it can copy an error's `code`.
342
+ */
343
+ function overlay(...sources) {
344
+ const out = dict();
345
+ for (const source of sources)
346
+ for (const key of Object.keys(source))
347
+ out[key] = source[key];
348
+ return out;
349
+ }
350
+ /** An OWN property of `value`, else undefined: nothing is read through a prototype. */
351
+ export function own(value, key) {
352
+ return value !== null && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key)
353
+ ? value[key]
354
+ : undefined;
355
+ }
356
+ /** JSON.parse reviver: refuse prototype keys and rebuild every object without a prototype. */
357
+ function prototypeFree(key, value) {
358
+ if (FORBIDDEN_CONFIG_KEYS.has(key))
359
+ throw new UnparseableConfigError(`refused key ${key}`);
360
+ if (value === null || typeof value !== "object" || Array.isArray(value))
361
+ return value;
362
+ return overlay(value);
363
+ }
364
+ function parseJson(text) {
365
+ try {
366
+ return JSON.parse(text, prototypeFree);
367
+ }
368
+ catch (error) {
369
+ if (error instanceof UnparseableConfigError)
370
+ throw error;
371
+ throw new UnparseableConfigError(error.message);
372
+ }
373
+ }
374
+ /** JSONC: `//` and `/* *\/` comments and trailing commas removed outside strings, then JSON. */
375
+ export function parseJsonc(text) {
376
+ let out = "";
377
+ let index = 0;
378
+ while (index < text.length) {
379
+ const char = text[index];
380
+ if (char === "\"") {
381
+ let end = index + 1;
382
+ while (end < text.length && text[end] !== "\"")
383
+ end += text[end] === "\\" ? 2 : 1;
384
+ out += text.slice(index, end + 1);
385
+ index = end + 1;
386
+ }
387
+ else if (char === "/" && text[index + 1] === "/") {
388
+ while (index < text.length && text[index] !== "\n")
389
+ index += 1;
390
+ }
391
+ else if (char === "/" && text[index + 1] === "*") {
392
+ const end = text.indexOf("*/", index + 2);
393
+ if (end < 0)
394
+ throw new UnparseableConfigError("unterminated block comment");
395
+ index = end + 2;
396
+ }
397
+ else {
398
+ out += char;
399
+ index += 1;
400
+ }
401
+ }
402
+ return parseJson(out.replace(/,(\s*[}\]])/g, "$1"));
403
+ }
404
+ function parseJsonDocument(text, allowEmpty) {
405
+ if (allowEmpty && text.trim() === "")
406
+ return dict();
407
+ return parseJson(text);
408
+ }
409
+ /**
410
+ * A restricted TOML reader: enough of the grammar to read `[mcp_servers.*]` and `[projects.*]`
411
+ * tables without a new dependency, and fail-closed on anything outside it. It handles bare and
412
+ * quoted dotted keys, `[table]` and `[[array.table]]` headers, basic/literal (and multi-line)
413
+ * strings, arrays over several lines, inline tables, and bare scalars. Sub-tables nest, so
414
+ * `[mcp_servers.x.env]` is `x`'s `env`, never a server named `x.env`.
415
+ */
416
+ export function parseRestrictedToml(text) {
417
+ const root = dict();
418
+ let table = root;
419
+ let pos = 0;
420
+ const fail = (what) => {
421
+ const line = text.slice(0, pos).split("\n").length;
422
+ throw new UnparseableConfigError(`TOML line ${line}: ${what}`);
423
+ };
424
+ const skipInline = () => {
425
+ while (pos < text.length && (text[pos] === " " || text[pos] === "\t"))
426
+ pos += 1;
427
+ if (text[pos] === "#")
428
+ while (pos < text.length && text[pos] !== "\n")
429
+ pos += 1;
430
+ };
431
+ const skipAll = () => {
432
+ for (;;) {
433
+ skipInline();
434
+ if (text[pos] === "\n" || text[pos] === "\r")
435
+ pos += 1;
436
+ else
437
+ return;
438
+ }
439
+ };
440
+ const readBasic = () => {
441
+ if (text.startsWith("\"\"\"", pos)) {
442
+ const end = text.indexOf("\"\"\"", pos + 3);
443
+ if (end < 0)
444
+ fail("unterminated multi-line string");
445
+ const raw = text.slice(pos + 3, end).replace(/^\r?\n/, "");
446
+ pos = end + 3;
447
+ return raw;
448
+ }
449
+ let end = pos + 1;
450
+ while (end < text.length && text[end] !== "\"" && text[end] !== "\n")
451
+ end += text[end] === "\\" ? 2 : 1;
452
+ if (text[end] !== "\"")
453
+ fail("unterminated string");
454
+ const raw = text.slice(pos, end + 1);
455
+ pos = end + 1;
456
+ try {
457
+ return JSON.parse(raw.replace(/\\U([0-9A-Fa-f]{8})/g, (_m, hex) => String.fromCodePoint(parseInt(hex, 16))));
458
+ }
459
+ catch {
460
+ return fail("invalid escape in string");
461
+ }
462
+ };
463
+ const readLiteral = () => {
464
+ if (text.startsWith("'''", pos)) {
465
+ const end = text.indexOf("'''", pos + 3);
466
+ if (end < 0)
467
+ fail("unterminated multi-line literal string");
468
+ const raw = text.slice(pos + 3, end).replace(/^\r?\n/, "");
469
+ pos = end + 3;
470
+ return raw;
471
+ }
472
+ const end = text.indexOf("'", pos + 1);
473
+ if (end < 0 || text.slice(pos, end).includes("\n"))
474
+ fail("unterminated literal string");
475
+ const raw = text.slice(pos + 1, end);
476
+ pos = end + 1;
477
+ return raw;
478
+ };
479
+ const readKeyPart = () => {
480
+ skipInline();
481
+ const part = text[pos] === "\"" ? readBasic() : text[pos] === "'" ? readLiteral() : (() => {
482
+ const match = /^[A-Za-z0-9_-]+/.exec(text.slice(pos));
483
+ if (!match)
484
+ return fail("expected a key");
485
+ pos += match[0].length;
486
+ return match[0];
487
+ })();
488
+ if (FORBIDDEN_CONFIG_KEYS.has(part))
489
+ fail(`refused key ${part}`);
490
+ return part;
491
+ };
492
+ const readKey = () => {
493
+ const parts = [readKeyPart()];
494
+ for (;;) {
495
+ skipInline();
496
+ if (text[pos] !== ".")
497
+ return parts;
498
+ pos += 1;
499
+ parts.push(readKeyPart());
500
+ }
501
+ };
502
+ const readValue = () => {
503
+ skipInline();
504
+ const char = text[pos];
505
+ if (char === "\"")
506
+ return readBasic();
507
+ if (char === "'")
508
+ return readLiteral();
509
+ if (char === "[") {
510
+ pos += 1;
511
+ const items = [];
512
+ for (;;) {
513
+ skipAll();
514
+ if (text[pos] === "]") {
515
+ pos += 1;
516
+ return items;
517
+ }
518
+ items.push(readValue());
519
+ skipAll();
520
+ if (text[pos] === ",")
521
+ pos += 1;
522
+ else if (text[pos] !== "]")
523
+ fail("expected , or ] in array");
524
+ }
525
+ }
526
+ if (char === "{") {
527
+ pos += 1;
528
+ const inline = dict();
529
+ skipInline();
530
+ if (text[pos] === "}") {
531
+ pos += 1;
532
+ return inline;
533
+ }
534
+ for (;;) {
535
+ const key = readKey();
536
+ skipInline();
537
+ if (text[pos] !== "=")
538
+ fail("expected = in inline table");
539
+ pos += 1;
540
+ assign(inline, key, readValue());
541
+ skipInline();
542
+ if (text[pos] === ",")
543
+ pos += 1;
544
+ else if (text[pos] === "}") {
545
+ pos += 1;
546
+ return inline;
547
+ }
548
+ else
549
+ fail("expected , or } in inline table");
550
+ }
551
+ }
552
+ const match = /^[A-Za-z0-9_:.+\-]+(?:[ T][0-9:.+\-Z]+)?/.exec(text.slice(pos));
553
+ if (!match)
554
+ return fail("unsupported value");
555
+ pos += match[0].length;
556
+ const bare = match[0];
557
+ if (bare === "true")
558
+ return true;
559
+ if (bare === "false")
560
+ return false;
561
+ if (/^[+-]?[0-9_]+$/.test(bare))
562
+ return Number(bare.replace(/_/g, ""));
563
+ if (/^[+-]?[0-9_]+(\.[0-9_]+)?([eE][+-]?[0-9]+)?$/.test(bare))
564
+ return Number(bare.replace(/_/g, ""));
565
+ if (/^\d{4}-\d{2}-\d{2}/.test(bare) || /^\d{2}:\d{2}/.test(bare) || /^[+-]?(inf|nan)$/.test(bare))
566
+ return bare;
567
+ return fail("unsupported bare value");
568
+ };
569
+ const descend = (from, key, arrayTable) => {
570
+ let node = from;
571
+ key.forEach((part, index) => {
572
+ const last = index === key.length - 1;
573
+ let next = own(node, part);
574
+ if (last && arrayTable) {
575
+ if (next === undefined)
576
+ node[part] = next = [];
577
+ if (!Array.isArray(next))
578
+ fail(`${part} is not an array of tables`);
579
+ const entry = dict();
580
+ next.push(entry);
581
+ node = entry;
582
+ return;
583
+ }
584
+ if (Array.isArray(next))
585
+ next = next[next.length - 1];
586
+ if (next === undefined)
587
+ node[part] = next = dict();
588
+ if (!next || typeof next !== "object" || Array.isArray(next))
589
+ fail(`${part} is not a table`);
590
+ node = next;
591
+ });
592
+ return node;
593
+ };
594
+ const assign = (into, key, value) => {
595
+ const parent = descend(into, key.slice(0, -1), false);
596
+ const last = key[key.length - 1];
597
+ if (Object.prototype.hasOwnProperty.call(parent, last))
598
+ fail(`duplicate key ${last}`);
599
+ parent[last] = value;
600
+ };
601
+ for (;;) {
602
+ skipAll();
603
+ if (pos >= text.length)
604
+ return root;
605
+ if (text[pos] === "[") {
606
+ const arrayTable = text[pos + 1] === "[";
607
+ pos += arrayTable ? 2 : 1;
608
+ const key = readKey();
609
+ skipInline();
610
+ if (text[pos] !== "]" || (arrayTable && text[pos + 1] !== "]"))
611
+ fail("unterminated table header");
612
+ pos += arrayTable ? 2 : 1;
613
+ table = descend(root, key, arrayTable);
614
+ }
615
+ else {
616
+ const key = readKey();
617
+ skipInline();
618
+ if (text[pos] !== "=")
619
+ fail("expected =");
620
+ pos += 1;
621
+ assign(table, key, readValue());
622
+ }
623
+ skipInline();
624
+ if (pos < text.length && text[pos] !== "\n" && text[pos] !== "\r")
625
+ fail("trailing characters");
626
+ }
627
+ }
628
+ /** A `type: "command"` handler with a command: the only kind doctor can judge. */
629
+ const isCommand = (hook) => hook.type === "command" && hook.command !== null;
630
+ const empty = () => ({ mcp: [], hooks: Object.create(null), packages: [] });
631
+ const isObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
632
+ /** Judge-relevant fields of one entry, from its own fields only. */
633
+ export function mcpEntry(name, fields, shape) {
634
+ let command = null;
635
+ let args = [];
636
+ const rawCommand = own(fields, "command");
637
+ if (shape === "opencode") {
638
+ if (Array.isArray(rawCommand)) {
639
+ command = typeof rawCommand[0] === "string" ? rawCommand[0] : null;
640
+ args = rawCommand.slice(1).filter((item) => typeof item === "string");
641
+ }
642
+ }
643
+ else {
644
+ command = typeof rawCommand === "string" ? rawCommand : null;
645
+ const rawArgs = own(fields, "args");
646
+ args = Array.isArray(rawArgs) ? rawArgs.filter((item) => typeof item === "string") : [];
647
+ }
648
+ const disabled = own(fields, "disabled") === true || own(fields, "enabled") === false;
649
+ return { name, command, args, disabled, fields, shape };
650
+ }
651
+ function mcpFromMap(map, shape) {
652
+ if (map === undefined || map === null)
653
+ return [];
654
+ if (!isObject(map))
655
+ throw new UnparseableConfigError("MCP server map is not an object");
656
+ return Object.keys(map).map((name) => {
657
+ const raw = own(map, name);
658
+ if (!isObject(raw))
659
+ throw new UnparseableConfigError(`MCP server ${name} is not an object`);
660
+ return mcpEntry(name, overlay(raw), shape);
661
+ });
662
+ }
663
+ function hooksFrom(settings) {
664
+ const out = Object.create(null);
665
+ const hooksMap = own(settings, "hooks");
666
+ if (!isObject(settings) || hooksMap === undefined)
667
+ return out;
668
+ if (!isObject(hooksMap))
669
+ throw new UnparseableConfigError("hooks is not an object");
670
+ for (const event of Object.keys(hooksMap)) {
671
+ const groups = own(hooksMap, event);
672
+ if (!Array.isArray(groups))
673
+ throw new UnparseableConfigError(`hooks.${event} is not an array`);
674
+ for (const group of groups) {
675
+ const hooks = isObject(group) ? own(group, "hooks") : undefined;
676
+ if (!Array.isArray(hooks))
677
+ throw new UnparseableConfigError(`hooks.${event} has a group with no hooks array`);
678
+ // EVERY handler is kept, whatever its type: an http/mcp_tool/prompt/agent handler still
679
+ // runs (panel B2), so dropping it would hide it from the exact-set and unjudgeable checks.
680
+ const matcher = own(group, "matcher");
681
+ for (const hook of hooks) {
682
+ if (!isObject(hook))
683
+ throw new UnparseableConfigError(`hooks.${event} has a handler that is not an object`);
684
+ const type = own(hook, "type");
685
+ const command = own(hook, "command");
686
+ (out[event] ??= []).push({
687
+ type: typeof type === "string" ? type : "",
688
+ command: type === "command" && typeof command === "string" ? command : null,
689
+ asyncRewake: own(hook, "asyncRewake") === true,
690
+ hasMatcher: matcher !== undefined && matcher !== "",
691
+ handlerKeys: Object.keys(hook).sort(),
692
+ groupKeys: Object.keys(group).sort(),
693
+ });
694
+ }
695
+ }
696
+ }
697
+ return out;
698
+ }
699
+ function packageSpecs(list, field) {
700
+ if (list === undefined)
701
+ return [];
702
+ if (!Array.isArray(list))
703
+ throw new UnparseableConfigError(`${field} is not an array`);
704
+ return list.flatMap((item) => {
705
+ if (typeof item === "string")
706
+ return [item];
707
+ const source = own(item, "source");
708
+ if (isObject(item) && typeof source === "string")
709
+ return [source];
710
+ if (Array.isArray(item) && typeof item[0] === "string")
711
+ return [item[0]];
712
+ return [];
713
+ });
714
+ }
715
+ /** Directories from `cwd` up to its git root (inclusive); just `cwd` outside a repository. */
716
+ export function walkToGitRoot(cwd, fs) {
717
+ const dirs = [];
718
+ let current = resolve(cwd);
719
+ for (;;) {
720
+ dirs.push(current);
721
+ if (fs.exists(join(current, ".git")))
722
+ return dirs;
723
+ const parent = dirname(current);
724
+ if (parent === current)
725
+ return [resolve(cwd)];
726
+ current = parent;
727
+ }
728
+ }
729
+ const claudeConfigDir = (ctx) => {
730
+ const value = ctx.env.CLAUDE_CONFIG_DIR;
731
+ return value && isAbsolute(value) ? value : null;
732
+ };
733
+ const claudeJsonPath = (ctx) => join(claudeConfigDir(ctx) ?? ctx.home, ".claude.json");
734
+ const claudeUserDir = (ctx) => claudeConfigDir(ctx) ?? join(ctx.home, ".claude");
735
+ const managedSettingsPaths = (ctx) => [
736
+ join(ctx.claudeManagedDir, "managed-settings.json"),
737
+ ...(ctx.fs.readdir(join(ctx.claudeManagedDir, "managed-settings.d")) ?? [])
738
+ .filter((name) => name.endsWith(".json"))
739
+ .map((name) => join(ctx.claudeManagedDir, "managed-settings.d", name)),
740
+ ];
741
+ const codexHome = (ctx) => {
742
+ const value = ctx.env.CODEX_HOME;
743
+ return value && isAbsolute(value) ? value : join(ctx.home, ".codex");
744
+ };
745
+ const xdgConfig = (ctx) => {
746
+ const value = ctx.env.XDG_CONFIG_HOME;
747
+ return value && value.trim() !== "" && isAbsolute(value) ? value : join(ctx.home, ".config");
748
+ };
749
+ const piAgentDir = (ctx) => {
750
+ const value = ctx.env.PI_CODING_AGENT_DIR;
751
+ return value && isAbsolute(value) ? value : join(ctx.home, ".pi", "agent");
752
+ };
753
+ /** codex loads `<dir>/.codex/config.toml` only when the nearest `[projects]` entry at or above `<dir>` is trusted. */
754
+ function codexLayerTrusted(path, ctx) {
755
+ const text = ctx.fs.readFile(join(codexHome(ctx), "config.toml"));
756
+ if (text === null)
757
+ return false;
758
+ const projects = own(parseRestrictedToml(text), "projects");
759
+ if (!isObject(projects))
760
+ return false;
761
+ let dir = dirname(dirname(path));
762
+ for (;;) {
763
+ const entry = own(projects, dir);
764
+ if (isObject(entry))
765
+ return own(entry, "trust_level") === "trusted";
766
+ const parent = dirname(dir);
767
+ if (parent === dir)
768
+ return false;
769
+ dir = parent;
770
+ }
771
+ }
772
+ const row = (definition) => Object.freeze(definition);
773
+ /** The frozen, re-checked scope table (see the header for each amendment and its evidence). */
774
+ export const HARNESS_CONFIG_SCOPES = Object.freeze({
775
+ claude: Object.freeze([
776
+ row({ name: "claude_user_mcp", parser: "json", rank: 10, paths: (ctx) => [claudeJsonPath(ctx)],
777
+ extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "mcpServers"), "claude") }) }),
778
+ row({ name: "claude_local_mcp", parser: "json", rank: 30, paths: (ctx) => [claudeJsonPath(ctx)],
779
+ extract: (doc, ctx) => {
780
+ const project = own(own(doc, "projects"), claudeProjectKey(ctx));
781
+ return { ...empty(), mcp: mcpFromMap(own(project, "mcpServers"), "claude") };
782
+ } }),
783
+ row({ name: "claude_project_mcp", parser: "json", rank: 20, paths: (ctx) => [join(ctx.cwd, ".mcp.json")],
784
+ extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "mcpServers"), "claude") }) }),
785
+ row({ name: "claude_managed_mcp", parser: "json", rank: 40, paths: (ctx) => [join(ctx.claudeManagedDir, "managed-mcp.json")],
786
+ extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "mcpServers"), "claude") }) }),
787
+ row({ name: "claude_managed_settings", parser: "json", rank: 40, paths: managedSettingsPaths,
788
+ extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "managedMcpServers"), "claude"), hooks: hooksFrom(doc) }) }),
789
+ row({ name: "claude_user_settings", parser: "json", rank: 10, paths: (ctx) => [join(claudeUserDir(ctx), "settings.json")],
790
+ extract: (doc) => ({ ...empty(), hooks: hooksFrom(doc) }) }),
791
+ row({ name: "claude_project_settings", parser: "json", rank: 20, paths: (ctx) => [join(ctx.cwd, ".claude", "settings.json")],
792
+ extract: (doc) => ({ ...empty(), hooks: hooksFrom(doc) }) }),
793
+ row({ name: "claude_project_local_settings", parser: "json", rank: 30, paths: (ctx) => [join(ctx.cwd, ".claude", "settings.local.json")],
794
+ extract: (doc) => ({ ...empty(), hooks: hooksFrom(doc) }) }),
795
+ ]),
796
+ codex: Object.freeze([
797
+ row({ name: "codex_user", parser: "toml", rank: 10, paths: (ctx) => [join(codexHome(ctx), "config.toml")],
798
+ extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "mcp_servers"), "toml") }) }),
799
+ row({ name: "codex_project", parser: "toml", rank: 20, walkWins: "nearest",
800
+ paths: (ctx) => walkToGitRoot(ctx.cwd, ctx.fs).map((dir) => join(dir, ".codex", "config.toml"))
801
+ .filter((path) => path !== join(codexHome(ctx), "config.toml")),
802
+ loaded: codexLayerTrusted,
803
+ extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "mcp_servers"), "toml") }) }),
804
+ ]),
805
+ grok: Object.freeze([
806
+ row({ name: "grok_user", parser: "toml", rank: 10, paths: (ctx) => [join(ctx.home, ".grok", "config.toml")],
807
+ extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "mcp_servers"), "toml") }) }),
808
+ row({ name: "grok_project", parser: "toml", rank: 20, walkWins: "nearest",
809
+ paths: (ctx) => walkToGitRoot(ctx.cwd, ctx.fs).map((dir) => join(dir, ".grok", "config.toml"))
810
+ .filter((path) => path !== join(ctx.home, ".grok", "config.toml")),
811
+ extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "mcp_servers"), "toml") }) }),
812
+ ]),
813
+ agy: Object.freeze([
814
+ row({ name: "agy_user", parser: "json_or_empty", rank: 10, paths: (ctx) => [join(ctx.home, ".gemini", "config", "mcp_config.json")],
815
+ extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "mcpServers"), "agy") }) }),
816
+ ]),
817
+ opencode: Object.freeze([
818
+ row({ name: "opencode_user", parser: "jsonc", rank: 10,
819
+ paths: (ctx) => ["opencode.json", "opencode.jsonc"].map((name) => join(xdgConfig(ctx), "opencode", name)),
820
+ extract: (doc) => opencodeContribution(doc) }),
821
+ // Nearest directory wins, but INSIDE one directory .json is applied before .jsonc: measured on
822
+ // opencode 1.18.32 (`opencode debug config`, dev0, 2026-09-26) — .jsonc won in both orders. So the directories are reversed here and the pair keeps its order: a flat
823
+ // `walkWins: "nearest"` reverse would also swap the two files (Q-6).
824
+ row({ name: "opencode_project", parser: "jsonc", rank: 20,
825
+ paths: (ctx) => [...walkToGitRoot(ctx.cwd, ctx.fs)].reverse().flatMap((dir) => ["opencode.json", "opencode.jsonc"].map((name) => join(dir, name))),
826
+ extract: (doc) => opencodeContribution(doc) }),
827
+ row({ name: "opencode_project_dotdir", parser: "jsonc", rank: 30, walkWins: "farthest",
828
+ paths: (ctx) => walkToGitRoot(ctx.cwd, ctx.fs).flatMap((dir) => ["opencode.json", "opencode.jsonc"].map((name) => join(dir, ".opencode", name))),
829
+ extract: (doc) => opencodeContribution(doc) }),
830
+ ]),
831
+ pi: Object.freeze([
832
+ row({ name: "pi_user", parser: "json", rank: 10, paths: (ctx) => [join(piAgentDir(ctx), "settings.json")],
833
+ extract: (doc) => ({ ...empty(), packages: packageSpecs(own(doc, "packages"), "packages") }) }),
834
+ row({ name: "pi_project", parser: "json", rank: 20, paths: (ctx) => [join(ctx.cwd, ".pi", "settings.json")],
835
+ extract: (doc) => ({ ...empty(), packages: packageSpecs(own(doc, "packages"), "packages") }) }),
836
+ ]),
837
+ });
838
+ /**
839
+ * How a harness combines a same-named entry across its layers (see the header): codex and
840
+ * opencode overlay the higher layer's fields (measured); claude replaces the whole entry
841
+ * (inferred); grok is assumed to behave like codex (unmeasured); agy and pi have one layer, so
842
+ * their value is never used.
843
+ */
844
+ export const MCP_LAYER_MERGE = Object.freeze({
845
+ claude: "replace", codex: "fields", grok: "fields", agy: "fields", opencode: "fields", pi: "fields",
846
+ });
847
+ function opencodeContribution(doc) {
848
+ if (!isObject(doc))
849
+ throw new UnparseableConfigError("opencode config is not an object");
850
+ return { ...empty(), mcp: mcpFromMap(own(doc, "mcp"), "opencode"), packages: packageSpecs(own(doc, "plugin"), "plugin") };
851
+ }
852
+ // ---------------------------------------------------------------------------------------------
853
+ // Claude Code plugins (IF-0-CCPLUGIN-3; the measurements are in the header)
854
+ // ---------------------------------------------------------------------------------------------
855
+ /** Synthetic row names: authored, so no plugin id, marketplace name or path reaches a reason. */
856
+ export const CLAUDE_PLUGIN_ROW = "claude_plugin";
857
+ export const CLAUDE_FOREIGN_PLUGIN_ROW = "claude_plugin_foreign";
858
+ const INSTALLED_PLUGINS_SCHEMA = 2;
859
+ // Managed (admin) policy sources other than the managed-settings file, exactly as Claude Code
860
+ // 2.1.283 reads them (strings of the linux-x64, darwin-arm64 and win32-x64 binaries; read, not
861
+ // run; see the header's `managed tier`). Doctor looks for EVIDENCE of each and never reads content.
862
+ export const CLAUDE_REMOTE_SETTINGS_FILE = "remote-settings.json";
863
+ /**
864
+ * Storage-v5 (round 4): the binary writes the server-managed payload to its state store
865
+ * (`Re.state("remote-settings")`, a `state` namespace in the config home) and a signed-cache
866
+ * sidecar beside the json path. The store's exact on-disk name is not recoverable from strings,
867
+ * so every candidate is probed; any one present is evidence.
868
+ */
869
+ export const CLAUDE_REMOTE_SETTINGS_STATE_CANDIDATES = Object.freeze([
870
+ join("state", "remote-settings"), join("state", "remote-settings.json"),
871
+ join("storage-v2", "state", "remote-settings"), join("storage-v2", "state", "remote-settings.json"),
872
+ ]);
873
+ export const CLAUDE_REMOTE_SETTINGS_SIDECARS = Object.freeze([
874
+ "remote-settings.json.signature.json", "remote-settings.json.signature-iat.json",
875
+ ]);
876
+ export const MACOS_MANAGED_PREFERENCES = "/Library/Managed Preferences";
877
+ export const MACOS_POLICY_PLIST = "com.anthropic.claudecode.plist";
878
+ export const WINDOWS_POLICY_KEYS = Object.freeze({
879
+ hklm: "HKLM\\SOFTWARE\\Policies\\ClaudeCode",
880
+ hkcu: "HKCU\\SOFTWARE\\Policies\\ClaudeCode",
881
+ });
882
+ export const WINDOWS_POLICY_VALUE = "Settings";
883
+ /** Where WSL's Windows interop puts reg.exe; the binary reads HKLM policy through this path. */
884
+ export const WSL_REG_EXE = "/mnt/c/Windows/System32/reg.exe";
885
+ export const WSL_WINDOWS_MANAGED_DIR = "/mnt/c/Program Files/ClaudeCode";
886
+ const REG_TIMEOUT_MS = 5000;
887
+ /**
888
+ * The binary's own query: `reg query <key> /v Settings`, an argv array, no shell, 5 s. Exit 0 →
889
+ * the value is there; reg's "unable to find" → not there (the binary's own test); anything else —
890
+ * no reg.exe, a timeout, another failure — is an error, never "absent".
891
+ */
892
+ export function regQueryWith(timeoutMs) {
893
+ return (regExe, key) => {
894
+ const result = spawnSync(regExe, ["query", key, "/v", WINDOWS_POLICY_VALUE], {
895
+ encoding: "utf8", timeout: timeoutMs, windowsHide: true, stdio: ["ignore", "pipe", "pipe"],
896
+ });
897
+ if (result.error) {
898
+ return { kind: "error", cause: result.error.code === "ETIMEDOUT" ? "reg_timeout" : "reg_unavailable" };
899
+ }
900
+ if (result.signal !== null)
901
+ return { kind: "error", cause: "reg_timeout" };
902
+ if (result.status === 0)
903
+ return { kind: "present" };
904
+ return /unable to find/i.test(`${result.stdout ?? ""}${result.stderr ?? ""}`) ? { kind: "absent" } : { kind: "error", cause: "reg_failed" };
905
+ };
906
+ }
907
+ export const nodeRegQuery = regQueryWith(REG_TIMEOUT_MS);
908
+ /** The binary's WSL test: `WSL_DISTRO_NAME`, or /proc/version naming microsoft or wsl. */
909
+ function onWsl(ctx) {
910
+ if (ctx.env.WSL_DISTRO_NAME)
911
+ return true;
912
+ const version = (ctx.fs.readFile("/proc/version") ?? "").toLowerCase();
913
+ return version.includes("microsoft") || version.includes("wsl");
914
+ }
915
+ export function probeManagedPolicy(ctx) {
916
+ const checked = [];
917
+ const evidence = [];
918
+ const errors = [];
919
+ const present = (name, path) => {
920
+ checked.push(name);
921
+ if (ctx.fs.exists(path))
922
+ evidence.push(name);
923
+ };
924
+ const registry = (name, regExe, key) => {
925
+ checked.push(name);
926
+ const outcome = (ctx.regQuery ?? nodeRegQuery)(regExe, key);
927
+ if (outcome.kind === "present")
928
+ evidence.push(name);
929
+ else if (outcome.kind === "error")
930
+ errors.push(outcome.cause);
931
+ };
932
+ const anyOf = (name, paths) => {
933
+ checked.push(name);
934
+ if (paths.some((path) => ctx.fs.exists(path)))
935
+ evidence.push(name);
936
+ };
937
+ const home = claudeUserDir(ctx);
938
+ present("remote_settings", join(home, CLAUDE_REMOTE_SETTINGS_FILE));
939
+ anyOf("remote_settings_state", CLAUDE_REMOTE_SETTINGS_STATE_CANDIDATES.map((relative) => join(home, relative)));
940
+ anyOf("remote_settings_signature", CLAUDE_REMOTE_SETTINGS_SIDECARS.map((relative) => join(home, relative)));
941
+ const platform = ctx.platform ?? "linux";
942
+ if (platform === "darwin") {
943
+ // No login name → the per-user plist cannot be located: an unfinished probe, never a skip.
944
+ if (ctx.userName)
945
+ present("macos_user_plist", join(MACOS_MANAGED_PREFERENCES, ctx.userName, MACOS_POLICY_PLIST));
946
+ else {
947
+ checked.push("macos_user_plist");
948
+ errors.push("login_unavailable");
949
+ }
950
+ present("macos_device_plist", join(MACOS_MANAGED_PREFERENCES, MACOS_POLICY_PLIST));
951
+ return { checked, evidence, errors, unreadableSourcesPossible: true, platformLabel: "macOS" };
952
+ }
953
+ if (platform === "win32") {
954
+ const regExe = `${ctx.env.SYSTEMROOT || "C:\\Windows"}\\System32\\reg.exe`;
955
+ registry("windows_hklm", regExe, WINDOWS_POLICY_KEYS.hklm);
956
+ registry("windows_hkcu", regExe, WINDOWS_POLICY_KEYS.hkcu);
957
+ return { checked, evidence, errors, unreadableSourcesPossible: true, platformLabel: "Windows" };
958
+ }
959
+ if (onWsl(ctx)) {
960
+ // WSL inherits Windows policy only when an ADMIN Windows source enables it (HKLM or the
961
+ // Program Files file); HKCU alone cannot, so it is not probed here.
962
+ present("wsl_program_files_settings", join(WSL_WINDOWS_MANAGED_DIR, "managed-settings.json"));
963
+ present("wsl_program_files_settings_d", join(WSL_WINDOWS_MANAGED_DIR, "managed-settings.d"));
964
+ if (ctx.fs.exists(WSL_REG_EXE))
965
+ registry("wsl_windows_hklm", WSL_REG_EXE, WINDOWS_POLICY_KEYS.hklm);
966
+ return { checked, evidence, errors, unreadableSourcesPossible: true, platformLabel: "WSL" };
967
+ }
968
+ return { checked, evidence, errors, unreadableSourcesPossible: false, platformLabel: "Linux" };
969
+ }
970
+ /** The binary's `Le`: an env flag is on when it is 1/true/yes/on. */
971
+ const truthyFlag = (value) => value !== undefined && ["1", "true", "yes", "on"].includes(value.toLowerCase().trim());
972
+ /** Evidence of (or an unfinished probe for) a tier that replaces the managed settings FILE. */
973
+ export const managedFileTierReplaced = (probe) => probe.evidence.length > 0 || probe.errors.length > 0;
974
+ function claudeHookPolicy(ctx, allLayers, probe) {
975
+ // The caller drops the managed FILE layers when a tier doctor cannot read replaces them (round-4
976
+ // codex: one filter, at the read, for every consumer); this function only reports the fact.
977
+ const managedTierUnreadable = managedFileTierReplaced(probe);
978
+ const layers = allLayers;
979
+ // The highest layer holding a boolean wins (measured for disableAllHooks: local false over user true).
980
+ const lastBoolean = (key, only) => {
981
+ let value;
982
+ for (const layer of layers) {
983
+ if (only === "managed" && !layer.managed)
984
+ continue;
985
+ const candidate = own(layer.document, key);
986
+ if (typeof candidate === "boolean")
987
+ value = candidate;
988
+ }
989
+ return value;
990
+ };
991
+ const managedIds = new Set();
992
+ let strict;
993
+ let unjudgeable = false;
994
+ for (const layer of layers) {
995
+ if (!layer.managed)
996
+ continue;
997
+ const enabled = own(layer.document, "enabledPlugins");
998
+ if (isObject(enabled)) {
999
+ for (const id of Object.keys(enabled)) {
1000
+ // As the binary merges managed documents (read, not run): its schema takes a boolean, a
1001
+ // version array or an object here, and reads anything else as UNSET, so a later string,
1002
+ // number or null leaves the earlier value standing. A valid non-true value replaces it,
1003
+ // and `eb()` counts only a literal true.
1004
+ const value = own(enabled, id);
1005
+ if (value === true && id.includes("@"))
1006
+ managedIds.add(id);
1007
+ else if (value === false || Array.isArray(value) || isObject(value))
1008
+ managedIds.delete(id);
1009
+ }
1010
+ }
1011
+ const value = own(layer.document, "strictPluginOnlyCustomization");
1012
+ if (value !== undefined)
1013
+ strict = value;
1014
+ if (own(layer.document, "prependPlugins") !== undefined || own(layer.document, "appendPlugins") !== undefined)
1015
+ unjudgeable = true;
1016
+ }
1017
+ const managedDisableAll = lastBoolean("disableAllHooks", "managed") === true;
1018
+ const managedOnly = lastBoolean("allowManagedHooksOnly", "managed") === true;
1019
+ const anyDisableAll = lastBoolean("disableAllHooks") === true;
1020
+ const safeMode = truthyFlag(ctx.env.CLAUDE_CODE_SAFE_MODE);
1021
+ const bare = truthyFlag(ctx.env.CLAUDE_CODE_SIMPLE);
1022
+ const strictHooks = strict === true || (Array.isArray(strict) && strict.includes("hooks"));
1023
+ const managedPluginIds = managedIds;
1024
+ if (bare || managedDisableAll)
1025
+ return { settings: "none", plugins: "none", managedPluginIds, unjudgeable: false, managedTierUnreadable, probe };
1026
+ const settings = managedOnly || safeMode || strictHooks || anyDisableAll ? "managed_only" : "all";
1027
+ const plugins = safeMode ? "none" : managedOnly || anyDisableAll ? "managed_only" : "all";
1028
+ return { settings, plugins, managedPluginIds, unjudgeable: unjudgeable && plugins === "managed_only", managedTierUnreadable, probe };
1029
+ }
1030
+ class UntrustedPluginPathError extends Error {
1031
+ }
1032
+ const claudePluginsDir = (ctx) => join(claudeUserDir(ctx), "plugins");
1033
+ /** Settings files that carry `enabledPlugins`/`extraKnownMarketplaces`, in ascending precedence. */
1034
+ const claudeSettingsLayers = (ctx) => [
1035
+ join(claudeUserDir(ctx), "settings.json"),
1036
+ join(ctx.cwd, ".claude", "settings.json"),
1037
+ join(ctx.cwd, ".claude", "settings.local.json"),
1038
+ ...managedSettingsPaths(ctx),
1039
+ ];
1040
+ const within = (path, root) => path.startsWith(root.endsWith(sep) ? root : root + sep);
1041
+ /** The git repository root at or above `dir`, or null outside one. */
1042
+ function gitRootOf(dir, fs) {
1043
+ const walked = walkToGitRoot(dir, fs);
1044
+ const top = walked[walked.length - 1];
1045
+ return fs.exists(join(top, ".git")) ? top : null;
1046
+ }
1047
+ /**
1048
+ * Claude Code's row applicability (`uw`): user/managed rows everywhere, a project's rows in its
1049
+ * repository. Measured from a symlinked cwd (panel S2): Claude's cwd is the REALPATH, while the
1050
+ * row's `projectPath` is compared as recorded — a projectPath that is itself a symlink never
1051
+ * applies, even from that symlink.
1052
+ */
1053
+ function rowApplies(row, ctx) {
1054
+ const scope = own(row, "scope");
1055
+ if (scope === "user" || scope === "managed")
1056
+ return true;
1057
+ const projectPath = own(row, "projectPath");
1058
+ if (typeof projectPath !== "string" || projectPath === "")
1059
+ return false;
1060
+ const cwd = ctx.fs.realpath(ctx.cwd) ?? resolve(ctx.cwd);
1061
+ if (resolve(projectPath) === cwd)
1062
+ return true;
1063
+ const root = gitRootOf(cwd, ctx.fs);
1064
+ return root !== null && gitRootOf(resolve(projectPath), ctx.fs) === root;
1065
+ }
1066
+ function readJsonFile(fs, path) {
1067
+ const text = fs.readFile(path);
1068
+ return text === null ? null : parseJson(text);
1069
+ }
1070
+ const truthyEnv = (value) => value !== undefined && ["1", "true", "yes", "on"].includes(value.toLowerCase().trim());
1071
+ /** `source` names a github marketplace of this repository, with no alternate manifest path. */
1072
+ function isOurMarketplaceSource(source) {
1073
+ const repo = own(source, "repo");
1074
+ return isObject(source) && own(source, "source") === "github" && typeof repo === "string"
1075
+ && repo.toLowerCase() === CLAUDE_PLUGIN_REPO && own(source, "path") === undefined;
1076
+ }
1077
+ /**
1078
+ * Reads one plugin tree, confined to the cache. `installPath` must resolve (realpath) strictly
1079
+ * inside `<configDir>/plugins/cache/`, and so must every file read under it, else
1080
+ * `UntrustedPluginPathError` and nothing beyond it is read: the only call made on an untrusted
1081
+ * path is the `realpath` that judged it.
1082
+ */
1083
+ function readPluginTree(ctx, installPath, serverPrefix) {
1084
+ if (!isAbsolute(installPath))
1085
+ throw new UntrustedPluginPathError();
1086
+ const cache = ctx.fs.realpath(join(claudePluginsDir(ctx), "cache"));
1087
+ const root = cache === null ? null : ctx.fs.realpath(installPath);
1088
+ if (cache === null || root === null || !within(root, cache))
1089
+ throw new UntrustedPluginPathError();
1090
+ const confined = (relative) => {
1091
+ const lexical = resolve(root, relative);
1092
+ if (!within(lexical, root))
1093
+ throw new UntrustedPluginPathError();
1094
+ const real = ctx.fs.realpath(lexical);
1095
+ if (real !== null && !within(real, root))
1096
+ throw new UntrustedPluginPathError();
1097
+ return real;
1098
+ };
1099
+ // Each file is read at most once: a manifest naming the default file (`"hooks":
1100
+ // "./hooks/hooks.json"`) must not count its hooks twice.
1101
+ const consumed = new Set();
1102
+ const readConfined = (relative) => {
1103
+ const real = confined(relative);
1104
+ if (real === null || consumed.has(real))
1105
+ return null;
1106
+ consumed.add(real);
1107
+ return readJsonFile(ctx.fs, real);
1108
+ };
1109
+ const manifest = readConfined(join(".claude-plugin", "plugin.json")) ?? dict();
1110
+ const contribution = empty();
1111
+ const addServers = (map) => {
1112
+ for (const entry of mcpFromMap(map, "claude"))
1113
+ contribution.mcp.push({ ...entry, name: `${serverPrefix}:${entry.name}` });
1114
+ };
1115
+ const serversIn = (document) => own(document, "mcpServers") ?? document;
1116
+ const addHooks = (document) => {
1117
+ const hooks = hooksFrom(isObject(own(document, "hooks")) ? document : overlay({ hooks: document }));
1118
+ for (const event of Object.keys(hooks))
1119
+ (contribution.hooks[event] ??= []).push(...hooks[event]);
1120
+ };
1121
+ // A manifest field is an inline object, a path relative to the plugin root, or a list of either.
1122
+ const eachDeclared = (value, field, add, inline) => {
1123
+ if (value === undefined)
1124
+ return;
1125
+ for (const item of Array.isArray(value) ? value : [value]) {
1126
+ if (typeof item === "string") {
1127
+ const document = readConfined(item);
1128
+ if (document !== null)
1129
+ add(document);
1130
+ }
1131
+ else if (isObject(item))
1132
+ inline(item);
1133
+ else
1134
+ throw new UnparseableConfigError(`plugin ${field} is neither a path nor an object`);
1135
+ }
1136
+ };
1137
+ eachDeclared(own(manifest, "mcpServers"), "mcpServers", (document) => addServers(serversIn(document)), addServers);
1138
+ const dotMcp = readConfined(".mcp.json");
1139
+ if (dotMcp !== null)
1140
+ addServers(serversIn(dotMcp));
1141
+ const hooksFile = readConfined(join("hooks", "hooks.json"));
1142
+ if (hooksFile !== null)
1143
+ addHooks(hooksFile);
1144
+ eachDeclared(own(manifest, "hooks"), "hooks", addHooks, addHooks);
1145
+ return { contribution, manifest };
1146
+ }
1147
+ /**
1148
+ * One handler, exactly as frozen (round-2 F025/F029): type `command`, the frozen command, no
1149
+ * filtering matcher, `asyncRewake: true` on FileChanged and ABSENT on SessionStart, and no other
1150
+ * handler or group field (so a `timeout`, `if` or `statusMessage` is a mismatch too).
1151
+ */
1152
+ function matchesFrozenHandler(event, hook) {
1153
+ const handlerKeys = event === "FileChanged" && CLAUDE_WAKE_SHAPE === "B" ? ["asyncRewake", "command", "type"] : ["command", "type"];
1154
+ return isCommand(hook) && hook.command === CLAUDE_PLUGIN_HOOK_COMMANDS[event]
1155
+ && !hook.hasMatcher && hook.groupKeys.every((key) => key === "hooks" || key === "matcher")
1156
+ && JSON.stringify(hook.handlerKeys) === JSON.stringify(handlerKeys)
1157
+ && (handlerKeys.includes("asyncRewake") ? hook.asyncRewake : true);
1158
+ }
1159
+ function sameKeys(value, keys) {
1160
+ return isObject(value) && Object.keys(value).sort().join("\n") === [...keys].sort().join("\n");
1161
+ }
1162
+ /** Ours carries exactly the frozen stanza, derived from its own `version`, and exactly the frozen hooks. */
1163
+ function oursMatchesFreeze(contribution, version) {
1164
+ if (version === null)
1165
+ return false;
1166
+ const expected = claudePluginStanza(version);
1167
+ const servers = contribution.mcp;
1168
+ const stanzaOk = servers.length === 1
1169
+ && servers[0].name.endsWith(`:${CLAUDE_PLUGIN_SERVER}`)
1170
+ && sameKeys(servers[0].fields, ["command", "args"])
1171
+ && own(servers[0].fields, "command") === expected.command
1172
+ && JSON.stringify(own(servers[0].fields, "args")) === JSON.stringify(expected.args);
1173
+ // Exactly the frozen set: both events, one command handler each, and no other handler of any
1174
+ // type on any event (panel B2a, S3).
1175
+ const events = Object.keys(contribution.hooks).filter((event) => contribution.hooks[event].length > 0).sort();
1176
+ const hooksOk = JSON.stringify(events) === JSON.stringify(Object.keys(CLAUDE_PLUGIN_HOOK_COMMANDS).sort())
1177
+ && events.every((event) => contribution.hooks[event].length === 1 && matchesFrozenHandler(event, contribution.hooks[event][0]));
1178
+ return stanzaOk && hooksOk;
1179
+ }
1180
+ function claudeAutoUpdate(ctx, declarations, known) {
1181
+ const envDisabled = truthyEnv(ctx.env.DISABLE_UPDATES) || truthyEnv(ctx.env.DISABLE_AUTOUPDATER)
1182
+ || !!ctx.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
1183
+ let claudeJson;
1184
+ try {
1185
+ claudeJson = readJsonFile(ctx.fs, claudeJsonPath(ctx));
1186
+ }
1187
+ catch (error) {
1188
+ if (!(error instanceof UnparseableConfigError))
1189
+ throw error;
1190
+ return { effective: false, source: "undetermined" };
1191
+ }
1192
+ const configDisabled = own(claudeJson, "autoUpdates") === false
1193
+ && !(own(claudeJson, "installMethod") === "native" && own(claudeJson, "autoUpdatesProtectedForNative") === true);
1194
+ if ((envDisabled || configDisabled) && !truthyEnv(ctx.env.FORCE_AUTOUPDATE_PLUGINS)) {
1195
+ return { effective: false, source: envDisabled ? "env_disabled" : "config_disabled" };
1196
+ }
1197
+ // The highest settings scope that declares the marketplace replaces the lower ones whole.
1198
+ const fromSettings = declarations.length > 0 ? own(declarations[declarations.length - 1], "autoUpdate") : undefined;
1199
+ if (typeof fromSettings === "boolean")
1200
+ return { effective: fromSettings, source: "settings" };
1201
+ const fromKnown = own(own(known, CLAUDE_PLUGIN_MARKETPLACE), "autoUpdate");
1202
+ if (typeof fromKnown === "boolean")
1203
+ return { effective: fromKnown, source: "known_marketplaces" };
1204
+ return { effective: false, source: "default" };
1205
+ }
1206
+ /**
1207
+ * IF-0-CCPLUGIN-3: every plugin enabled for `ctx.cwd`, followed from `enabledPlugins` to its
1208
+ * installed row and read from the cache. Ours is the plugin id `agent-board@message-board` whose
1209
+ * marketplace is declared, EVERYWHERE it is declared, as the github repository
1210
+ * Consiliency/message-board; anything else contributing a board server or a wake hook is foreign.
1211
+ */
1212
+ export function readClaudePluginScopes(ctx) {
1213
+ const unparseable = new Set();
1214
+ const failures = new Set();
1215
+ const files = [];
1216
+ let untrusted = false;
1217
+ const summary = {
1218
+ installed: false, serverActive: false, version: null, pinnedMcpVersion: null, sourceRef: null, autoUpdate: { effective: false, source: "default" },
1219
+ };
1220
+ const tolerant = (read, fallback) => {
1221
+ try {
1222
+ return read();
1223
+ }
1224
+ catch (error) {
1225
+ if (!(error instanceof UnparseableConfigError))
1226
+ throw error;
1227
+ unparseable.add(CLAUDE_PLUGIN_ROW);
1228
+ return fallback;
1229
+ }
1230
+ };
1231
+ // Round-4 codex: when a tier doctor cannot read replaces the managed settings FILE, that file is
1232
+ // not read at all, so it feeds NO consumer — plugin enablement, marketplace declarations (and so
1233
+ // provenance and "ours"), the source ref, auto-update, the hook policy — and its parse errors are
1234
+ // not reported. Only the probe's own evidence speaks for it.
1235
+ const probe = probeManagedPolicy(ctx);
1236
+ const managedPaths = new Set(managedSettingsPaths(ctx));
1237
+ const fileTierReplaced = managedFileTierReplaced(probe);
1238
+ const layers = claudeSettingsLayers(ctx).flatMap((path) => {
1239
+ const managed = managedPaths.has(path);
1240
+ if (managed && fileTierReplaced)
1241
+ return [];
1242
+ const document = tolerant(() => readJsonFile(ctx.fs, path), null);
1243
+ return document === null ? [] : [{ managed, document }];
1244
+ });
1245
+ const settings = layers.map((layer) => layer.document);
1246
+ const hookPolicy = claudeHookPolicy(ctx, layers, probe);
1247
+ const known = tolerant(() => readJsonFile(ctx.fs, join(claudePluginsDir(ctx), "known_marketplaces.json")), null);
1248
+ const installed = tolerant(() => {
1249
+ const document = readJsonFile(ctx.fs, join(claudePluginsDir(ctx), "installed_plugins.json"));
1250
+ if (document !== null && (own(document, "version") !== INSTALLED_PLUGINS_SCHEMA || !isObject(own(document, "plugins")))) {
1251
+ throw new UnparseableConfigError("installed_plugins.json has an unknown shape");
1252
+ }
1253
+ return document;
1254
+ }, null);
1255
+ // enabledPlugins: the highest scope holding a boolean wins; anything else defines nothing.
1256
+ const enabled = new Map();
1257
+ for (const document of settings) {
1258
+ const map = own(document, "enabledPlugins");
1259
+ if (!isObject(map))
1260
+ continue;
1261
+ for (const id of Object.keys(map)) {
1262
+ const value = own(map, id);
1263
+ if (typeof value === "boolean")
1264
+ enabled.set(id, value);
1265
+ }
1266
+ }
1267
+ const declarationsOf = (marketplace) => settings.flatMap((document) => {
1268
+ const declaration = own(own(document, "extraKnownMarketplaces"), marketplace);
1269
+ return declaration === undefined ? [] : [declaration];
1270
+ });
1271
+ const ourDeclarations = declarationsOf(CLAUDE_PLUGIN_MARKETPLACE);
1272
+ const ourKnown = own(known, CLAUDE_PLUGIN_MARKETPLACE);
1273
+ const everyDeclaration = [...ourDeclarations, ...(ourKnown === undefined ? [] : [ourKnown])];
1274
+ const marketplaceIsOurs = everyDeclaration.length > 0
1275
+ && everyDeclaration.every((declaration) => isOurMarketplaceSource(own(declaration, "source")));
1276
+ if (marketplaceIsOurs) {
1277
+ // The winning declaration's ref: the highest settings scope, else known_marketplaces.json.
1278
+ const ref = own(own(everyDeclaration[ourDeclarations.length > 0 ? ourDeclarations.length - 1 : 0], "source"), "ref");
1279
+ summary.sourceRef = typeof ref === "string" ? ref : null;
1280
+ }
1281
+ summary.autoUpdate = tolerant(() => claudeAutoUpdate(ctx, ourDeclarations, known), { effective: false, source: "undetermined" });
1282
+ for (const [id, on] of enabled) {
1283
+ if (!on)
1284
+ continue;
1285
+ const at = id.lastIndexOf("@");
1286
+ if (at <= 0)
1287
+ continue;
1288
+ const name = id.slice(0, at);
1289
+ const rows = own(own(installed, "plugins"), id);
1290
+ if (rows === undefined)
1291
+ continue;
1292
+ if (!Array.isArray(rows)) {
1293
+ unparseable.add(CLAUDE_PLUGIN_ROW);
1294
+ continue;
1295
+ }
1296
+ // The first applicable row is THE row: one without a string installPath stops here like an
1297
+ // untrusted path, rather than falling through to a later row (panel S8).
1298
+ const row = rows.find((candidate) => rowApplies(candidate, ctx));
1299
+ if (row === undefined)
1300
+ continue;
1301
+ if (typeof own(row, "installPath") !== "string") {
1302
+ untrusted = true;
1303
+ continue;
1304
+ }
1305
+ const ours = id === CLAUDE_PLUGIN_ID && marketplaceIsOurs;
1306
+ let tree;
1307
+ try {
1308
+ tree = readPluginTree(ctx, own(row, "installPath"), `plugin:${name}`);
1309
+ }
1310
+ catch (error) {
1311
+ if (error instanceof UntrustedPluginPathError) {
1312
+ untrusted = true;
1313
+ continue;
1314
+ }
1315
+ if (!(error instanceof UnparseableConfigError))
1316
+ throw error;
1317
+ unparseable.add(ours ? CLAUDE_PLUGIN_ROW : CLAUDE_FOREIGN_PLUGIN_ROW);
1318
+ continue;
1319
+ }
1320
+ const { contribution, manifest } = tree;
1321
+ files.push({ row: ours ? CLAUDE_PLUGIN_ROW : CLAUDE_FOREIGN_PLUGIN_ROW, path: own(row, "installPath"), contribution, pluginId: id });
1322
+ if (ours) {
1323
+ const version = own(manifest, "version");
1324
+ summary.installed = true;
1325
+ summary.version = typeof version === "string" ? version : null;
1326
+ const pinArg = contribution.mcp[0]?.args.find((arg) => arg.startsWith(`${CLAUDE_PLUGIN_MCP_PACKAGE}@`));
1327
+ summary.pinnedMcpVersion = pinArg === undefined ? null : pinArg.slice(CLAUDE_PLUGIN_MCP_PACKAGE.length + 1);
1328
+ if (!oursMatchesFreeze(contribution, summary.version))
1329
+ failures.add("plugin_stanza_mismatch");
1330
+ }
1331
+ else {
1332
+ const boardServer = contribution.mcp.some((entry) => judgeMcpEntry(entry, ctx.fs).board);
1333
+ const wakeHook = Object.keys(contribution.hooks).some((event) => contribution.hooks[event].some((hook) => isCommand(hook) && namesWakeHook(hook.command)));
1334
+ if (boardServer || wakeHook)
1335
+ failures.add("foreign_board_plugin");
1336
+ }
1337
+ }
1338
+ return { files, unparseable: [...unparseable], untrusted, failures: [...failures], summary, hookPolicy };
1339
+ }
1340
+ // ---------------------------------------------------------------------------------------------
1341
+ // Judgement
1342
+ // ---------------------------------------------------------------------------------------------
1343
+ /** A package spec's identity without its version: `@scope/name@1.2.3` → `@scope/name`. */
1344
+ export function packageIdentity(spec) {
1345
+ const at = spec.lastIndexOf("@");
1346
+ const scopeEnd = spec.indexOf("/");
1347
+ return at > 0 && at > scopeEnd && spec[at - 1] !== ":" ? spec.slice(0, at) : spec;
1348
+ }
1349
+ /** A board MCP entry names `agent-board-mcp` in its command/args, or through ONE user-owned wrapper file. */
1350
+ export function judgeMcpEntry(entry, fs) {
1351
+ const words = [entry.command ?? "", ...entry.args];
1352
+ const flagged = words.some((word) => word === METADATA_ONLY_FLAG || word.startsWith(`${METADATA_ONLY_FLAG}=`));
1353
+ if (words.some((word) => word.includes(BOARD_MCP_MARKER)))
1354
+ return { board: true, flagged };
1355
+ if (entry.command && isAbsolute(entry.command)) {
1356
+ const stats = fs.lstat(entry.command);
1357
+ if (stats && stats.isFile && stats.uid === fs.getuid() && stats.size < WRAPPER_MAX_BYTES) {
1358
+ const text = fs.readFile(entry.command) ?? "";
1359
+ if (text.includes(BOARD_MCP_MARKER))
1360
+ return { board: true, flagged: flagged || text.includes(METADATA_ONLY_FLAG) };
1361
+ }
1362
+ }
1363
+ return { board: false, flagged: false };
1364
+ }
1365
+ /**
1366
+ * Read every scope row of `harness` for `cwd`. A file that fails to parse is reported, not guessed.
1367
+ * `files` is in ascending precedence: a later file's same-named entry overrides an earlier one's.
1368
+ */
1369
+ export function readScopes(harness, ctx) {
1370
+ const files = [];
1371
+ const unparseable = [];
1372
+ const rows = [...HARNESS_CONFIG_SCOPES[harness]].sort((a, b) => a.rank - b.rank);
1373
+ for (const scope of rows) {
1374
+ // `paths` is in application order (the last file wins), except a walked row with `walkWins`,
1375
+ // whose paths come cwd first and are reversed here when the nearest directory must win.
1376
+ const paths = scope.paths(ctx);
1377
+ for (const path of scope.walkWins === "nearest" ? [...paths].reverse() : paths) {
1378
+ const text = ctx.fs.readFile(path);
1379
+ if (text === null)
1380
+ continue;
1381
+ try {
1382
+ if (scope.loaded && !scope.loaded(path, ctx))
1383
+ continue;
1384
+ const document = scope.parser === "toml" ? parseRestrictedToml(text)
1385
+ : scope.parser === "jsonc" ? parseJsonc(text)
1386
+ : parseJsonDocument(text, scope.parser === "json_or_empty");
1387
+ files.push({ row: scope.name, path, contribution: scope.extract(document, ctx) });
1388
+ }
1389
+ catch (error) {
1390
+ if (!(error instanceof UnparseableConfigError))
1391
+ throw error;
1392
+ unparseable.push(scope.name);
1393
+ }
1394
+ }
1395
+ }
1396
+ return { files, unparseable };
1397
+ }
1398
+ const MANAGED_MCP_ROWS = new Set(["claude_managed_mcp", "claude_managed_settings"]);
1399
+ /** The `.claude.json` `projects` key Claude Code uses for `cwd`: the git root of its realpath, else the realpath. */
1400
+ export function claudeProjectKey(ctx) {
1401
+ const real = ctx.fs.realpath(ctx.cwd) ?? resolve(ctx.cwd);
1402
+ return gitRootOf(real, ctx.fs) ?? real;
1403
+ }
1404
+ /**
1405
+ * Server names `projects[<key>].disabledMcpServers` turns off here (see the header). An
1406
+ * unparseable `.claude.json` disables nothing: its own row already reports it.
1407
+ */
1408
+ function claudeDisabledServers(ctx) {
1409
+ let document;
1410
+ try {
1411
+ document = readJsonFile(ctx.fs, claudeJsonPath(ctx));
1412
+ }
1413
+ catch (error) {
1414
+ if (error instanceof UnparseableConfigError)
1415
+ return new Set();
1416
+ throw error;
1417
+ }
1418
+ const list = own(own(own(document, "projects"), claudeProjectKey(ctx)), "disabledMcpServers");
1419
+ return new Set(Array.isArray(list) ? list.filter((name) => typeof name === "string") : []);
1420
+ }
1421
+ /** IF-0-CREDPATH-4 registration, for one working directory. */
1422
+ export function judgeRegistration(harness, ctx) {
1423
+ const scopes = readScopes(harness, ctx);
1424
+ // claude plugins are synthetic files ranked above user settings. Their server names are
1425
+ // namespaced (`plugin:<name>:<server>`), so no same-name override crosses them either way.
1426
+ const plugins = harness === "claude" ? readClaudePluginScopes(ctx) : null;
1427
+ // When a server-managed or MDM tier replaces the managed settings FILE (round-3 N1), that file
1428
+ // contributes nothing — neither servers nor hooks; the verdict is held undetermined below.
1429
+ const fileTierReplaced = plugins?.hookPolicy.managedTierUnreadable === true;
1430
+ const files = [...scopes.files.filter((file) => !(fileTierReplaced && file.row === "claude_managed_settings")), ...(plugins?.files ?? [])];
1431
+ const scopeUnparseable = scopes.unparseable.filter((row) => !(fileTierReplaced && row === "claude_managed_settings"));
1432
+ const unparseable = plugins ? [...scopeUnparseable, ...plugins.unparseable] : scopes.unparseable;
1433
+ const failures = plugins ? [...plugins.failures] : [];
1434
+ // The EFFECTIVE server map first: a higher layer's same-named entry overrides (replacing it, or
1435
+ // overlaying its fields), so a board entry disabled or re-pointed above is judged as such.
1436
+ const effective = new Map();
1437
+ for (const file of files) {
1438
+ for (const entry of file.contribution.mcp) {
1439
+ const lower = effective.get(entry.name)?.entry;
1440
+ const merged = lower && MCP_LAYER_MERGE[harness] === "fields"
1441
+ ? mcpEntry(entry.name, overlay(lower.fields, entry.fields), entry.shape)
1442
+ : entry;
1443
+ effective.set(entry.name, { entry: merged, row: file.row });
1444
+ }
1445
+ }
1446
+ const boardNames = new Set();
1447
+ const boardRows = new Set();
1448
+ let flagged = false;
1449
+ const disabledHere = harness === "claude" ? claudeDisabledServers(ctx) : new Set();
1450
+ for (const { entry, row: scopeRow } of effective.values()) {
1451
+ if (entry.disabled)
1452
+ continue;
1453
+ if (disabledHere.has(entry.name) && !MANAGED_MCP_ROWS.has(scopeRow))
1454
+ continue;
1455
+ const verdict = judgeMcpEntry(entry, ctx.fs);
1456
+ if (!verdict.board)
1457
+ continue;
1458
+ boardNames.add(entry.name);
1459
+ boardRows.add(scopeRow);
1460
+ flagged ||= verdict.flagged;
1461
+ }
1462
+ if (flagged)
1463
+ failures.push("metadata_only_flag");
1464
+ if (boardNames.size > 1)
1465
+ failures.push(`duplicate_registration:${[...boardRows].sort().join("+")}`);
1466
+ if (harness === "opencode" || harness === "pi") {
1467
+ const wanted = harness === "opencode" ? OPENCODE_PLUGIN_PACKAGE : PI_PACKAGE;
1468
+ const present = files.some((file) => file.contribution.packages.some((spec) => packageIdentity(spec) === wanted));
1469
+ if (!present)
1470
+ failures.push("no_registration");
1471
+ }
1472
+ else if (boardNames.size === 0) {
1473
+ failures.push("no_registration");
1474
+ }
1475
+ let hookCounts;
1476
+ let unjudgeable = false;
1477
+ let policyUnjudgeable = false;
1478
+ let policyCheckFailed = [];
1479
+ if (harness === "claude" && plugins) {
1480
+ // The EFFECTIVE hooks first (round-2 F027/F028): a file whose hooks the policy switches off
1481
+ // contributes none. Dropping a wake hook that way is a failure of its own, never a pass.
1482
+ const policy = plugins.hookPolicy;
1483
+ const isPluginFile = (file) => file.row === CLAUDE_PLUGIN_ROW || file.row === CLAUDE_FOREIGN_PLUGIN_ROW;
1484
+ const runs = (file) => isPluginFile(file)
1485
+ ? policy.plugins === "all" || (policy.plugins === "managed_only" && (policy.unjudgeable || policy.managedPluginIds.has(file.pluginId ?? "")))
1486
+ : policy.settings === "all" || (policy.settings === "managed_only" && file.row === "claude_managed_settings");
1487
+ const namesAWakeHook = (file) => Object.keys(file.contribution.hooks)
1488
+ .some((event) => file.contribution.hooks[event].some((hook) => isCommand(hook) && namesWakeHook(hook.command)));
1489
+ if (files.some((file) => !runs(file) && namesAWakeHook(file)))
1490
+ failures.push("hooks_disabled_by_policy");
1491
+ policyUnjudgeable = policy.unjudgeable || policy.probe.evidence.length > 0;
1492
+ policyCheckFailed = [...new Set(policy.probe.errors)].map((cause) => `managed_policy_check_failed:${cause}`);
1493
+ const hooksOf = (file, event) => runs(file) ? file.contribution.hooks[event] ?? [] : [];
1494
+ const wakeHooks = (event) => files.flatMap((file) => hooksOf(file, event).filter((hook) => isCommand(hook) && namesWakeHook(hook.command)));
1495
+ // A non-command handler on a wake event runs, but doctor cannot tell what it does (panel B2b);
1496
+ // nor can it tell what a settings or foreign wake hook filtered by a matcher fires on (F029).
1497
+ // Ours with a matcher is a stanza mismatch instead.
1498
+ unjudgeable = CLAUDE_REQUIRED_HOOK_EVENTS.some((event) => files.some((file) => hooksOf(file, event)
1499
+ .some((hook) => !isCommand(hook) || (hook.hasMatcher && file.row !== CLAUDE_PLUGIN_ROW && namesWakeHook(hook.command)))));
1500
+ hookCounts = { SessionStart: 0, FileChanged: 0 };
1501
+ for (const event of CLAUDE_REQUIRED_HOOK_EVENTS) {
1502
+ const count = wakeHooks(event).length;
1503
+ hookCounts[event] = count;
1504
+ if (count === 0)
1505
+ failures.push(`missing_hook:${event}`);
1506
+ if (count > 1)
1507
+ failures.push(`duplicate_hook:${event}`);
1508
+ }
1509
+ // Shape B: a FileChanged wake hook wakes an idle session only through asyncRewake.
1510
+ if (CLAUDE_WAKE_SHAPE === "B" && wakeHooks("FileChanged").some((hook) => !hook.asyncRewake))
1511
+ failures.push("hook_not_rewake");
1512
+ }
1513
+ const extras = plugins
1514
+ ? { plugin: { ...plugins.summary, serverActive: boardRows.has(CLAUDE_PLUGIN_ROW) }, hookCounts, managedPolicy: plugins.hookPolicy.probe }
1515
+ : {};
1516
+ // An absence is only evidence when every file was read: the missing entry may be in the file
1517
+ // that did not parse, under the plugin path doctor refused to follow, or a handler doctor
1518
+ // cannot judge. A duplicate or the flag, seen in files that DID parse, stands.
1519
+ const complete = unparseable.length === 0 && !plugins?.untrusted && !unjudgeable && !policyUnjudgeable && policyCheckFailed.length === 0;
1520
+ const standing = complete
1521
+ ? failures
1522
+ : failures.filter((failure) => failure !== "no_registration" && !failure.startsWith("missing_hook:"));
1523
+ if (standing.length > 0)
1524
+ return { status: "fail", reason: standing.join(","), ...extras };
1525
+ const unknowns = [
1526
+ ...(unparseable.length > 0 ? [`unparseable:${[...new Set(unparseable)].join("+")}`] : []),
1527
+ ...(plugins?.untrusted ? ["plugin_path_untrusted"] : []),
1528
+ ...(unjudgeable ? ["unjudgeable_hook"] : []),
1529
+ ...(policyUnjudgeable ? ["hook_policy_unjudgeable"] : []),
1530
+ ...policyCheckFailed,
1531
+ ];
1532
+ if (unknowns.length > 0)
1533
+ return { status: "undetermined", reason: unknowns.join(","), ...extras };
1534
+ return { status: "pass", reason: "registered", ...extras };
1535
+ }