@consiliency/agent-board-cli 1.23.0 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets.d.ts +22 -0
- package/dist/assets.js +61 -0
- package/dist/assets.json +6 -0
- package/dist/cli.d.ts +37 -3
- package/dist/cli.js +68 -26
- package/dist/config_edit.d.ts +37 -0
- package/dist/config_edit.js +549 -0
- package/dist/doctor.d.ts +46 -3
- package/dist/doctor.js +242 -7
- package/dist/doctor_scopes.d.ts +162 -2
- package/dist/doctor_scopes.js +794 -16
- package/dist/identity_writer.d.ts +9 -7
- package/dist/identity_writer.js +18 -30
- package/dist/setup.d.ts +116 -0
- package/dist/setup.js +603 -0
- package/dist/setup_storage.d.ts +117 -0
- package/dist/setup_storage.js +447 -0
- package/dist/setup_writers.d.ts +128 -0
- package/dist/setup_writers.js +653 -0
- package/keyslot/agent-board-keyslot-load@.service +20 -0
- package/keyslot/agent-board-keyslot@.service +20 -0
- package/keyslot/constants.sh +73 -0
- package/keyslot/keygen.mjs +67 -0
- package/keyslot/keyslot-load.sh +78 -0
- package/keyslot/lib.sh +599 -0
- package/keyslot/optin.sh +185 -0
- package/keyslot/probe.sh +135 -0
- package/keyslot/provision.sh +530 -0
- package/keyslot/seam.sh +48 -0
- package/keyslot/status.sh +254 -0
- package/package.json +8 -4
package/dist/doctor_scopes.js
CHANGED
|
@@ -55,8 +55,168 @@
|
|
|
55
55
|
// user is used, and managed scopes are ranked above all.
|
|
56
56
|
// grok unmeasured (display-only, above): ranked like codex, which it mirrors.
|
|
57
57
|
//
|
|
58
|
-
//
|
|
59
|
-
// not
|
|
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
|
|
60
220
|
// project entry is counted as loaded); opencode's `OPENCODE_CONFIG`/`OPENCODE_CONFIG_DIR` are not
|
|
61
221
|
// read; `managed-mcp.json`'s exclusive-control mode is not modelled; `claude mcp get` still showed
|
|
62
222
|
// a local entry carrying `disabled: true`, so doctor's reading of that field as "not launched" is
|
|
@@ -65,16 +225,46 @@
|
|
|
65
225
|
// Config text is DATA. Every object the readers build has a null prototype and every lookup is an
|
|
66
226
|
// own-property lookup; a `__proto__`, `constructor` or `prototype` key makes the file unparseable
|
|
67
227
|
// (round 1, Astra 1: `[__proto__.mcp_servers.x]` used to write onto `Object.prototype`).
|
|
228
|
+
import { spawnSync } from "node:child_process";
|
|
68
229
|
import * as nodeFs from "node:fs";
|
|
69
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
230
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
70
231
|
export const BOARD_MCP_MARKER = "agent-board-mcp";
|
|
71
232
|
export const METADATA_ONLY_FLAG = "--metadata-only";
|
|
72
233
|
export const OPENCODE_PLUGIN_PACKAGE = "@consiliency/agent-board-opencode";
|
|
73
234
|
export const PI_PACKAGE = "npm:@consiliency/agent-board-pi";
|
|
74
235
|
/** Claude Code events that each need exactly one `wake-hook.sh` hook (`docs/local-wake-signal.md`). */
|
|
75
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`). */
|
|
76
238
|
export const WAKE_HOOK_MARKER = "wake-hook.sh";
|
|
77
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
|
+
}
|
|
78
268
|
const missing = (error) => {
|
|
79
269
|
const code = error.code;
|
|
80
270
|
return code === "ENOENT" || code === "ENOTDIR";
|
|
@@ -120,6 +310,16 @@ export const nodeRegistrationFs = {
|
|
|
120
310
|
return false;
|
|
121
311
|
}
|
|
122
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
|
+
},
|
|
123
323
|
getuid: () => process.getuid?.() ?? -1,
|
|
124
324
|
};
|
|
125
325
|
// ---------------------------------------------------------------------------------------------
|
|
@@ -425,6 +625,8 @@ export function parseRestrictedToml(text) {
|
|
|
425
625
|
fail("trailing characters");
|
|
426
626
|
}
|
|
427
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;
|
|
428
630
|
const empty = () => ({ mcp: [], hooks: Object.create(null), packages: [] });
|
|
429
631
|
const isObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
430
632
|
/** Judge-relevant fields of one entry, from its own fields only. */
|
|
@@ -473,10 +675,22 @@ function hooksFrom(settings) {
|
|
|
473
675
|
const hooks = isObject(group) ? own(group, "hooks") : undefined;
|
|
474
676
|
if (!Array.isArray(hooks))
|
|
475
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");
|
|
476
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");
|
|
477
685
|
const command = own(hook, "command");
|
|
478
|
-
|
|
479
|
-
|
|
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
|
+
});
|
|
480
694
|
}
|
|
481
695
|
}
|
|
482
696
|
}
|
|
@@ -563,7 +777,7 @@ export const HARNESS_CONFIG_SCOPES = Object.freeze({
|
|
|
563
777
|
extract: (doc) => ({ ...empty(), mcp: mcpFromMap(own(doc, "mcpServers"), "claude") }) }),
|
|
564
778
|
row({ name: "claude_local_mcp", parser: "json", rank: 30, paths: (ctx) => [claudeJsonPath(ctx)],
|
|
565
779
|
extract: (doc, ctx) => {
|
|
566
|
-
const project = own(own(doc, "projects"),
|
|
780
|
+
const project = own(own(doc, "projects"), claudeProjectKey(ctx));
|
|
567
781
|
return { ...empty(), mcp: mcpFromMap(own(project, "mcpServers"), "claude") };
|
|
568
782
|
} }),
|
|
569
783
|
row({ name: "claude_project_mcp", parser: "json", rank: 20, paths: (ctx) => [join(ctx.cwd, ".mcp.json")],
|
|
@@ -636,6 +850,494 @@ function opencodeContribution(doc) {
|
|
|
636
850
|
return { ...empty(), mcp: mcpFromMap(own(doc, "mcp"), "opencode"), packages: packageSpecs(own(doc, "plugin"), "plugin") };
|
|
637
851
|
}
|
|
638
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
|
+
// ---------------------------------------------------------------------------------------------
|
|
639
1341
|
// Judgement
|
|
640
1342
|
// ---------------------------------------------------------------------------------------------
|
|
641
1343
|
/** A package spec's identity without its version: `@scope/name@1.2.3` → `@scope/name`. */
|
|
@@ -693,10 +1395,42 @@ export function readScopes(harness, ctx) {
|
|
|
693
1395
|
}
|
|
694
1396
|
return { files, unparseable };
|
|
695
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
|
+
}
|
|
696
1421
|
/** IF-0-CREDPATH-4 registration, for one working directory. */
|
|
697
1422
|
export function judgeRegistration(harness, ctx) {
|
|
698
|
-
const
|
|
699
|
-
|
|
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] : [];
|
|
700
1434
|
// The EFFECTIVE server map first: a higher layer's same-named entry overrides (replacing it, or
|
|
701
1435
|
// overlaying its fields), so a board entry disabled or re-pointed above is judged as such.
|
|
702
1436
|
const effective = new Map();
|
|
@@ -712,9 +1446,12 @@ export function judgeRegistration(harness, ctx) {
|
|
|
712
1446
|
const boardNames = new Set();
|
|
713
1447
|
const boardRows = new Set();
|
|
714
1448
|
let flagged = false;
|
|
1449
|
+
const disabledHere = harness === "claude" ? claudeDisabledServers(ctx) : new Set();
|
|
715
1450
|
for (const { entry, row: scopeRow } of effective.values()) {
|
|
716
1451
|
if (entry.disabled)
|
|
717
1452
|
continue;
|
|
1453
|
+
if (disabledHere.has(entry.name) && !MANAGED_MCP_ROWS.has(scopeRow))
|
|
1454
|
+
continue;
|
|
718
1455
|
const verdict = judgeMcpEntry(entry, ctx.fs);
|
|
719
1456
|
if (!verdict.board)
|
|
720
1457
|
continue;
|
|
@@ -735,23 +1472,64 @@ export function judgeRegistration(harness, ctx) {
|
|
|
735
1472
|
else if (boardNames.size === 0) {
|
|
736
1473
|
failures.push("no_registration");
|
|
737
1474
|
}
|
|
738
|
-
|
|
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 };
|
|
739
1501
|
for (const event of CLAUDE_REQUIRED_HOOK_EVENTS) {
|
|
740
|
-
const count =
|
|
1502
|
+
const count = wakeHooks(event).length;
|
|
1503
|
+
hookCounts[event] = count;
|
|
741
1504
|
if (count === 0)
|
|
742
1505
|
failures.push(`missing_hook:${event}`);
|
|
743
1506
|
if (count > 1)
|
|
744
1507
|
failures.push(`duplicate_hook:${event}`);
|
|
745
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");
|
|
746
1512
|
}
|
|
1513
|
+
const extras = plugins
|
|
1514
|
+
? { plugin: { ...plugins.summary, serverActive: boardRows.has(CLAUDE_PLUGIN_ROW) }, hookCounts, managedPolicy: plugins.hookPolicy.probe }
|
|
1515
|
+
: {};
|
|
747
1516
|
// An absence is only evidence when every file was read: the missing entry may be in the file
|
|
748
|
-
// that did not parse
|
|
749
|
-
|
|
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
|
|
750
1521
|
? failures
|
|
751
1522
|
: failures.filter((failure) => failure !== "no_registration" && !failure.startsWith("missing_hook:"));
|
|
752
1523
|
if (standing.length > 0)
|
|
753
|
-
return { status: "fail", reason: standing.join(",") };
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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 };
|
|
757
1535
|
}
|