fix(claude-ops): correct inventory field bleed, manifest paths, and scope coverage - #2350
Conversation
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
Six findings from the automated review, all real: - **Bundled-skill fields could bleed from the next registration.** The extractor used a 4000-char window around each registration — the exact failure mode build_brace_map exists to prevent for commands, and one this skill's own reference doc names as the thing not to do. A registration omitting a description adopted the following one's. Fields are now bound to their own literal via the brace map, and an unmatched brace is counted and surfaced rather than silently skipped. - **Manifest-declared component paths were ignored.** PLUGIN_COMPONENTS carried a manifest key per component and a comment claiming the manifest is read before the tree; nothing read it. A declared path replaces the default directory, so scanning defaults regardless reported components a plugin does not ship. Dotted keys resolve the experimental block. - **Installed plugins were never read.** Only marketplace catalogs were scanned, so a plugin installed from a marketplace that is not cached was invisible. disk.installed_plugins now walks the plugin cache; catalog, installed, and enabled are three distinct sets and the skill says so. - **Project scope was missing.** A project's .claude tree contributes skills, agents, and hooks that no machine-scope scan sees. Added, with --project-dir defaulting to cwd. Wired hook events are reported, never hook scripts on disk, which would repeat the present-versus-active error. - **--self-check lost its diagnostic when no binary was found.** pick_binary stores its explanation under "reason"; the self-check path read only "error" and printed a generic message. It now reads both. - **An unreadable CLI version passed silently.** It is itself a drift signal, so it degrades the verdict rather than skipping the comparison. Tests grow 25 to 31, covering field-binding non-bleed, manifest path resolution including the dotted and array forms, and the version-unknown advisory. One existing fixture needed a version literal — it was asserting ok against a build the new check correctly calls degraded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…age error --self-check returned 2 for a degraded verdict, which is also argparse's exit code for a usage error. A CI gate treating 2 as "degraded, warn" would silently swallow a mistyped flag as a warning instead of failing. Degraded is now 3, leaving 2 to argparse: 0 ok, 1 broken, 2 usage error, 3 degraded. Verified all four against the live build. Also records the 0.30.1 changelog entry covering this and the review findings carried over from #2313, which merged before those fixes landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ba60302 to
f0c17db
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ba60302902
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Claude finished @kyle-sexton's task in 4m 20s —— View job Security review
Reviewed the diff ( One real finding in IMPORTANT — Manifest-declared component paths allow traversal outside the plugin directory (path traversal / arbitrary directory & file-existence disclosure)Confidence: High (straightforward to trace, no mitigating check anywhere in the file)
Python's
Notably this isn't limited to plugins the user has installed and enabled: For context, the code this replaced ( Impact is filename/path disclosure and a file-existence oracle (not file-content read or code execution), and the precondition (a malicious/compromised plugin manifest) is somewhat overlapping with the fact that an installed plugin can typically already run code via its own hooks/commands — but the marketplace-catalog path above means this is reachable even for plugins never installed, which is a meaningfully lower trust bar than "the user chose to run this plugin's code." Suggested fix: reject absolute declared paths, and after joining, resolve both root_resolved = root.resolve()
candidate = (root / d).resolve()
if not candidate.is_relative_to(root_resolved):
continue # or skip this declared entryNo other security-relevant issues found in this diff: no |
|
Claude finished @kyle-sexton's task in 6m 1s —— View job Claude's reviewReviewed the 5 changed files against The five stated fixes (field-bleed via brace map,
Test-coverage gap not anchorable to a single line: none of the rewritten disk-scanning functions — Minor: the PR description says |
| report["project"] = { | ||
| "root": str(project_root), | ||
| "present": project_claude.is_dir(), | ||
| "components": scan_config_scope(project_claude) if project_claude.is_dir() else {}, |
There was a problem hiding this comment.
Project-scope report silently drops settings.local.json and reads .mcp.json from the wrong path.
This reuses scan_config_scope (designed for the machine-scope config dir) for the new project-scope block, but it doesn't have the fidelity the function it replaces (scan_project_scope, deleted in this same diff) had:
settings.local.jsonis never read. The deletedscan_project_scopeexplicitly merged hooks/enabledPluginsfrom bothsettings.jsonandsettings.local.json, labeling themprojectvslocal.scan_config_scopeonly readsroot / "settings.json"(line 850). A hook wired only in.claude/settings.local.json— a very common place for personal/gitignored overrides — is now invisible to the project-scope scan, even though it's genuinely active. That's exactly the "wired vs. present" gap this PR's own SKILL.md language (A hook script on disk is not a wired hook) warns against, just from the opposite direction: a wired hook now goes unreported.enabledPluginsis dropped entirely for project scope. The old code reported per-scopeenabled/disabledplugin lists forproject/local;scan_config_scopehas noenabledPluginshandling at all, so this information no longer appears anywhere in the project section..mcp.jsonis read from the wrong location.scan_config_scope'smcp = _load_json(root / ".mcp.json")(line 853) is called here withroot = project_claude(i.e.<project>/.claude), so it looks for<project>/.claude/.mcp.json. The deleted code correctly readproject_root / ".mcp.json"— Claude Code's project MCP config lives at the project root, not inside.claude/. As written, project-scopemcp_serverswill essentially never be populated.
Given the PR's whole point is "no machine-scope scan sees project wiring" and "listing it wrong repeats the present-versus-active error," these are worth fixing rather than accepting as scope trims — nothing in the SKILL.md update says local-scope hooks or .mcp.json were deliberately dropped.
|
|
||
|
|
||
| def _scan_component(root: Path, spec: dict[str, str], manifest: dict[str, Any]) -> list[str]: | ||
| declared = _manifest_paths(manifest, spec.get("manifest", "")) |
There was a problem hiding this comment.
An explicit empty-array manifest declaration is indistinguishable from "not declared," silently falling back to the default directory.
_manifest_paths returns [] (not None) when a manifest key resolves to an empty list (isinstance(node, list) branch two functions up). Both the kind == "file" branch (line 810: if declared:) and the directory branch (line 816: if declared:) treat [] the same as None because both are falsy in Python — so a plugin that explicitly declares e.g. "commands": [] (meaning "I ship zero commands, don't scan the default commands/ dir either") still gets the default directory scanned instead.
This contradicts the stated contract in _manifest_paths's own docstring: "A declared path replaces the default directory rather than adding to it." For a non-empty declaration that's true; for an explicit empty declaration it silently reverts to "adds to it" (falls back to defaults), which is the one case the docstring says can't happen.
Narrow edge case, but worth a is not None check instead of truthiness if the empty-list case matters in practice: if declared is not None:.
| notes["resolved"] = len(out) | ||
| if unresolved: | ||
| notes["unresolved_dynamic_names"] = sorted(set(unresolved)) | ||
| if unbounded: |
There was a problem hiding this comment.
unbounded_registrations is surfaced in the raw notes but never wired into check_integrity's human-readable advisory, and has no test covering the self-check path.
check_integrity (further down, around line 651-657) already has logic that fires when registrations_seen > resolved:
advisories.append(
f"{seen - resolved} bundled-skill registration(s) used a computed name and "
"were not resolved; ..."
)That gap can now come from either cause — an unresolved computed name (pre-existing) or a brace-map desync counted here as unbounded (new) — but the advisory text unconditionally attributes it to "used a computed name," which is inaccurate when the real cause is unbounded_registrations. The PR description says an unmatched brace "is counted and surfaced rather than silently skipped," which is true for the raw JSON (bundled_skill_notes.unbounded_registrations), but the summarized --self-check advisory a CI gate would actually alert on still mislabels it.
Also: test_inventory.py has no test constructing a source with an actual unmatched/desynced brace to exercise unbounded_registrations end-to-end (the existing TestBundledSkillFieldBinding/TestManifestComponentPaths tests cover the other two fixes thoroughly, but this one is untested).
| report["project"] = { | ||
| "root": str(project_root), | ||
| "present": project_claude.is_dir(), | ||
| "components": scan_config_scope(project_claude) if project_claude.is_dir() else {}, |
There was a problem hiding this comment.
Project-scope report silently drops settings.local.json and reads .mcp.json from the wrong path.
This reuses scan_config_scope (designed for the machine-scope config dir) for the new project-scope block, but it doesn't have the fidelity the function it replaces (scan_project_scope, deleted in this same diff) had:
settings.local.jsonis never read. The deletedscan_project_scopeexplicitly merged hooks/enabledPluginsfrom bothsettings.jsonandsettings.local.json, labeling themprojectvslocal.scan_config_scopeonly readsroot / "settings.json"(line 850 below). A hook wired only in.claude/settings.local.json— a very common place for personal/gitignored overrides — is now invisible to the project-scope scan, even though it's genuinely active. That's exactly the "wired vs. present" gap this PR's own SKILL.md language (A hook script on disk is not a wired hook) warns against, just from the opposite direction: a wired hook now goes unreported.enabledPluginsis dropped entirely for project scope. The old code reported per-scopeenabled/disabledplugin lists forproject/local;scan_config_scopehas noenabledPluginshandling at all, so this information no longer appears anywhere in the project section..mcp.jsonis read from the wrong location.scan_config_scope'smcp = _load_json(root / ".mcp.json")(line 853) is called here withroot = project_claude(i.e.<project>/.claude), so it looks for<project>/.claude/.mcp.json. The deleted code correctly readproject_root / ".mcp.json"— Claude Code's project MCP config lives at the project root, not inside.claude/. As written, project-scopemcp_serverswill essentially never be populated.
Given the PR's whole point is "no machine-scope scan sees project wiring" and "listing it wrong repeats the present-versus-active error," these are worth fixing rather than accepting as scope trims — nothing in the SKILL.md update says local-scope hooks or .mcp.json were deliberately dropped.
|
Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
Summary
Follow-up to #2313, which merged before these fixes landed — auto-merge fired when its checks went green and squashed only the first two commits, so main currently carries the defects below.
Six findings, all from the automated review on #2313 and all real:
Correctness
build_brace_mapexists to prevent for commands, and the onereference/extraction.mdnames as the thing not to do. A registration omitting a description adopted the following one's. Fields are now bound to their own literal via the brace map; an unmatched brace is counted and surfaced rather than silently skipped.PLUGIN_COMPONENTScarried a manifest key per component and a comment claiming the manifest is read before the tree; nothing read it. A declared path replaces the default directory, so scanning defaults regardless reported components a plugin does not ship. Dotted keys resolve theexperimentalblock.--self-checklost its diagnostic when no binary was found.pick_binarystores its explanation underreason; the self-check path read onlyerrorand printed a generic message.0ok,1broken,2usage error,3degraded.Coverage
disk.installed_pluginsnow walks the plugin cache, and catalog / installed / enabled are reported as three distinct sets. A project's.claudetree contributes skills, agents, and wired hook events that no machine-scope scan sees;--project-dirdefaults to cwd. Wired hook events are reported, never hook scripts on disk — listing an unreferenced script as a hook would repeat the present-versus-active error the skill exists to avoid.Test plan
test_inventory.py— 31 hermetic tests (was 25), adding field-binding non-bleed, manifest path resolution incl. dotted and array forms, and the version-unknown advisoryokagainst a build the new version check correctly callsdegraded0ok,1broken (missing binary, and a non-binary file),2usage error,3degraded--binary(reason preserved in output), mutually exclusive flags, empty config dir, non-binary fileinstalled_plugins67 across 4 marketplaces vs 121 catalog entries — the distinction this PR addscheck-skill.shPASS 0 errors ·check-evals-quality.shPASS · portability clean ·validate-plugins.shclean · cheat sheet in syncRelated
No linked issue. Direct follow-up to #2313 (merged), addressing that PR's own review comments, which arrived after auto-merge had already squashed and merged it.
claude-ops0.30.0 → 0.30.1.🤖 Generated with Claude Code