Repository navigation
fix(plugin): review fixes for plugin evaluation (stacked on #28) - #180
Conversation
Plugin context deduplication split MAX_PLUGIN_DEDUP_LLM_CALLS (100) across the bundled skills and passed the share to IntraSkillValidator as max_llm_clusters. A plugin with exactly one bundled skill got 100, above CONTENT_DEDUP_MAX_LLM_CLUSTERS (50), so the validator constructor raised ValueError outside the per-skill guard and run_plugin_dedup_scan crashed whenever an embedding provider was configured. Clamp the share at CONTENT_DEDUP_MAX_LLM_CLUSTERS. The reported max_llm_calls metadata is derived from the clamped share, so it stays truthful (50 for one skill). Plugins with two or more skills are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
IntraSkillValidator.validate turned every SkillCollectionError into a CRITICAL CONTENT_DEDUP finding without security_failure metadata. Plugin Tier 2 (_make_advisory) then capped it at MEDIUM and marked it passed, so a hard-linked, symlinked, or special file inside a bundled skill became a passing advisory and standalone `tier2 PLUGIN` exited 0, contrary to the documented "unsafe input is a blocking execution failure" contract. When the collection error is an unsafe-input code (unsafe_path, unsafe_hardlink, unsafe_root, invalid_root, path_access_error, secure_open_unavailable), mark the result with security_failure, execution_status "failed", and optional False, the keys _make_advisory and apply_policy already honor. Size, count, complexity, and encoding limit codes are unchanged and stay advisory for plugins. Skill Tier 2 already failed closed with a CRITICAL finding; the new metadata only stops a policy from re-rating that finding. Reporters do not serialize these result-level keys. Remove the `except (SecurePathError, SkillCollectionError)` clause around validator.validate in run_plugin_skill_context_dedup: collect_files converts every SecurePathError into SkillCollectionError, validate catches SkillCollectionError and returns a result, and nothing else in validate touches secure_fs, so the clause could not run. The unused SkillCollectionError import goes with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…g text _make_advisory saved the legacy error strings, capped finding severities, called recalculate_from_findings() (which clears and rebuilds errors and warnings from findings), then appended the saved strings as warnings. A HIGH finding therefore appeared twice ([CAT-MEDIUM] and the stale [CAT-HIGH]), and plain warnings such as provider or skip notes were silently dropped. Separate notes (errors and warnings that are not a finding's legacy string) before capping, cap findings with catalog_checks.advisory_severity, recalculate, and re-add each note once as a warning. passed=True, advisory_tier2, and the security-failure early return are unchanged. _finalize_catalog_result is left as is: catalog findings are already capped when created and the catalog checks add no errors, so it does not have this bug, and routing it through _make_advisory would reorder its legacy warnings (notes such as "was not compared" or "reported the top K" are interleaved with findings and would move after them). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
The shell inline-program check iterated the raw 'args' value, so an MCP or
LSP server such as {"command": "bash", "args": 5} raised TypeError during
Tier 1 validation and plugin inventory instead of reporting the
mcp_args_not_list finding.
The check now reads the guarded argument list and also catches forms it
missed: a '.exe' shell name, a short-option cluster containing 'c'
('-lc', '-ec', '-xc'), and a whole command line in 'command'
("bash -c node"), which is split argv-style as classify_mcp_pinning does.
Long options such as '--config' are not treated as '-c'.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
The floating-version check matched its markers anywhere in a token, so an exactly pinned package whose npm scope starts with a marker word, such as npx -y @nextcloud/mcp-server@1.2.3, @headlessui/..., @mainstay/... or @next-auth/..., raised the blocking HIGH mcp_command_floating_version finding although classify_mcp_pinning calls it pinned. A marker now counts only when it is attached to a package or image name and does not run on into a longer word or host name. An '@' that starts a token or follows a separator (space, '=', ',', ':', a slash, a quote) is a scope. The marker list is unchanged, and every attached form is still reported, including pkg@latest-beta, a@latest,b, pkg[cli]@latest and ${IMAGE}:main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
_outside_root_reference flagged any relative path containing a '..'
segment, so a hook argument such as ./a/../b raised a MEDIUM
plugin_hook_outside_root finding ("climbs out of the working directory")
although it stays inside. It now uses _contained_path, which tracks the
depth, so only paths such as ../x and a/../../x are reported.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
The evals source copied into a plugin package can be the plugin's own evals/, so an authored evals/environment/plugin_mcp_servers.toml was kept instead of the generated file. Its servers then reached the with-plugin arm without the Tier 1 MCP checks or secret redaction, while provenance reported the plugin's validated servers as staged. This also happened when the plugin had no runnable servers. Treat the file name as reserved: staging now fails with a clear error when the evals source already contains it (a symlink counts), before the "no runnable servers" early return. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Tier 1 flags .ENV and .Env.local as shipped env files, but the native Harbor staging ignore compared the raw name with ".env", so those files were still copied into the with-plugin container. Both sites now use one case-folding plugin_components.is_env_file(), and native_staging drops its duplicate template-suffix set. The linked-repo context filter in the Harbor adapter already case-folds and allows fewer template suffixes (no .defaults or .tmpl); it is left as is, with a comment, because adopting the shared rule would copy more files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
read_harness_log_prefix promises None for a missing log, but it checked for links with secure_fs.is_link_or_reparse, which raises SecurePathError for any lstat error, including FileNotFoundError. The load census collector does not guard the call, so a native Claude Code trial with a census file but no claude-code.txt failed result collection. Call lstat directly: a missing path returns None, a link or reparse point is still refused without being followed, and any other inspection error still raises SecurePathError as before. secure_fs.is_link_or_reparse had no other callers and is removed, so the only function with that name is utils.tier2_paths.is_link_or_reparse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
In a bundled-skill pass the npm and container audits used the plugin-relative label (skills/foo/package.json) as the finding path. The plugin-tree walk then rebased it onto skills/foo again, so findings pointed at skills/foo/skills/foo/package.json and skills/foo/skills/foo/Dockerfile. Python findings were already correct because they report the directory-relative file name. npm and Dockerfile findings now carry the path relative to the audited directory, as the Python ones do; the plugin-relative label is kept for messages and warnings. The eco helpers already use their `source` argument only as the finding path, so no signature change was needed there. The plugin-root pass is unchanged: there the two paths are equal, and MCP-launched images and runner packages keep their manifest label. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…ings Python packages that uvx / pipx run MCP servers install were audited through _audit_declarations(Path(label), ...), which reported only source.name. Their findings, messages, and summary errors therefore said "plugin.json" while the npm runner findings said ".claude-plugin/plugin.json", and servers declared by .claude-plugin and .codex-plugin manifests could not be told apart. _audit_declarations and the pip-audit helpers now take the display path (a POSIX path relative to the audited directory) as a string: requirements and pyproject files pass their file name as before, MCP runners pass the root-relative manifest path. The hand-rolled Python unverified finding is replaced by eco.unverified_finding(ecosystem="python"), which gains an optional line_number and a per-ecosystem suggestion table. Check name and INFO severity are unchanged. Visible differences for Python findings: - metadata now carries "ecosystem": "python" like npm and container; - the message reads "... is not an exact version or digest" (the shared wording) instead of "... is not an exact '==' pin"; the suggestion keeps the name==x.y.z hint; - a declaration without a parseable package name reports its raw text as package_name instead of None, as npm already does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…st as unreadable
A selected manifest that was not UTF-8 or was over the 1 MiB read bound
was reported as HIGH manifest_unsafe ("Replace links/hardlinks/special
manifests ...") with security_failure set. Tier 1 therefore stopped after
the schema check, and the manifest's hooks and MCP servers were never
inventoried, although the same bytes in an additional manifest already
got an accurate plugin_manifest_additional_unreadable and a lenient read.
The selected-manifest readers now branch on PluginManifestPathError
.content_error:
- contained JSON manifests (Claude Code, Agent Plugins, Codex, Cursor)
get HIGH manifest_unreadable and are read leniently through the same
_parse_unreadable helper as additional manifests (generalized with a
`selected` flag), so declared components are still inventoried and
statically checked; no manifest success row is recorded;
- agent_plugin.yaml gets HIGH manifest_unreadable without a lenient read,
as additional bundle manifests are not read leniently either.
Neither sets security_failure, so later Tier 1 checks run and the HIGH
finding still fails the run. Links, hard links, special files, and
manifests changed after discovery keep HIGH manifest_unsafe with
security_failure. PluginManifestLocation gains read_lenient_text, like
PluginManifestCandidate. The race-only unsafe message for an additional
manifest now reads "additional manifest" instead of "additional plugin
manifest".
Existing tests that asserted the old behavior were updated:
- test_plugin_manifest_review_fixes.py: the oversize Agent Plugins root
tests (including the one over the lenient bound) now expect
manifest_unreadable instead of manifest_unsafe, and no security failure;
- test_plugin_review_fixes_b.py: the non-UTF-8 root manifest tests now
expect manifest_unreadable plus the field findings the lenient read
reveals ('café' fails the Agent Plugins name pattern; the directly
named legacy manifest also lacks $schema).
docs/plugin-evaluation.mdx described the old manifest_unsafe outcome and
is updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
declared_dependencies, shown in the HTML, Markdown, and SARIF plugin
sections, counted different things per manifest path. The Claude Code
path counted every list-valued field, so {"keywords": ["a", "b"],
"mcpServers": "./mcp.json", "commands": ["./c.md"]} gave
{"keywords": 2, "commands": 1}: keywords became a dependency and the
string mcpServers path was missing. The Codex, Cursor, and Agent Plugins
path counted only its profile's component fields.
Both paths now use one helper that counts the format profile's component
fields (CLAUDE_PROFILE on the Claude Code path): a list by its length and
any other non-null value as one, excluding $schema and extensions. The
probe above now gives {"mcpServers": 1, "commands": 1} on both paths.
The Claude profile's component fields are skills, rules, commands,
agents, hooks, mcpServers, lspServers, outputStyles, experimental,
monitors, and settings, so those are the keys that can appear. No
existing test asserted the old counts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
json.loads turns a long digit string into an int that float() cannot convert, so a single oversized token or cost counter in a trajectory's final_metrics or result.json agent_result raised OverflowError and aborted collect_harbor_results, although the usage statistics are advisory only. Replace the separate finite-number helpers in the Harbor metrics, stats and collector modules with one overflow-safe finite_number() in tier3/harbor/metrics.py. Oversized values now count as missing, and values that fit in a float behave as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…success A partial plugin run whose raw verdict is pass shows INCOMPLETE in the HTML report's Tier 3 Verdict dashboard card, but the card took its color from the raw verdict and rendered the text green. The hero and the Tier 3 tier card already use the warning color. Compute the displayed Tier 3 verdict and its tone once, next to the existing tier3_plugin_incomplete flag, and use them for both the tier card and the dashboard card. The dashboard card gets a warning color for the warn tone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…block
A result whose metadata carries an empty plugin block ({}) made the
Markdown reporter fall back to an empty view and then raise KeyError on
view["status"]. Render the section only when the Tier 1 plugin view
exists, as the HTML report already does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
validate kept using the lexical target argument after resolving it, so `validate .` showed "Target: ." in the HTML report and the footer's rerun hint, and a manifest-file target such as SKILL.md became the SARIF scan root, turning relative finding paths into "SKILL.md/SKILL.md". After the existing no-follow root checks, derive one report root from the resolved target and use it for the report target, the SARIF scan and repository roots (still the Git root, else the directory), and the footer. The earlier safety checks are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…d calls The status-code branch of _FAILED_CALL_RE matched a 4xx/5xx number followed by ":" or "-" anywhere in a result, so successful answers such as "Open issues:\n#451 - Crash", "Found in src/app.py:404: ..." or "Total: 500 - all good" were counted as failed MCP calls. That skewed mcp_calls.failed, the success rate, activation availability and the mcp_proof "used-successfully" signal. Anchor the status code to the start of a line, optionally after an "Error", "HTTP/x.y" or "status code" prefix, and compile the pattern with re.MULTILINE. The other failure markers are unchanged, and "403: forbidden", "Error: 403: ...", "500: boom" and "HTTP/1.1 404 - x" still count as failures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
float() raises OverflowError on an integer too large for a float, which json.loads produces from a long digit string. Harbor reward parsing for single-step and per-step verifier results called float() directly, so one such reward stopped collect_harbor_results for the whole run. Convert an oversized reward to infinity so it follows the existing non-finite path: the trial is reported as unscored. The CLI result display now reuses metrics.finite_number instead of its own unguarded copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
rng1995
left a comment
There was a problem hiding this comment.
Review of the batch-1 fixes. All 9 inline comments are below; the first four are correctness issues to address before merging.
- Blocking false positive: the shell
-ccheck scans the script's own arguments (-config,-recursive→ CRITICAL). - Policy can hide unchecked manifests: an unreadable selected manifest whose contents couldn't be parsed can now be downgraded by policy.
- Missed MCP failures: errors like
status 404: Not Foundin the middle of a line are counted as successful calls. - Collection can abort: a harness-log
lstaterror can stop Harbor result collection.
The rest are a removed fail-closed backstop, SARIF path consistency, a KeyError on an unknown ecosystem, and two cleanups. Every correctness item except the backstop was reproduced against 7608e96f8.
| # An unsplit path with spaces, e.g. "C:\Program Files\Git\bin\bash.exe". | ||
| or _command_basename(command) in _SHELL_INTERPRETERS | ||
| ) | ||
| shell_args = [*command_words[1:], *arg_list] |
There was a problem hiding this comment.
[correctness] The shell -c check now flags the script's own arguments.
shell_args includes every argument after the shell, so it covers the script path and the script's own options. _SHELL_INLINE_PROGRAM_FLAG_RE also matches any single-dash word containing c. Reproduced:
{"command": "bash", "args": ["server.sh", "-config", "x.yml"]}→ CRITICALmcp_command_dangerous_form["run.sh", "--watch", "-recursive"]→ CRITICALmcp_command_dangerous_form
That blocks safe plugins. bash and sh only read options until the first operand (the script) or --, so stop scanning there:
def _shell_runs_inline_program(args: list[str]) -> bool:
for arg in args:
token = arg.strip()
if token == "--" or not token.startswith("-"):
return False # the script path (or end of options) ends the shell's options
if not token.startswith("--") and _SHELL_INLINE_PROGRAM_FLAG_RE.fullmatch(token):
return True
return FalseOption values such as -o pipefail or --rcfile x need care; a test with script arguments after the script path would have caught this.
There was a problem hiding this comment.
Fixed in 23b3bcd. A new _shell_runs_inline_program() reads shell options only up to the first operand (the script) or --/-. It skips the value of -o/-O/+o/+O (also inside a cluster such as -eo pipefail), --rcfile and --init-file. bash server.sh -config x.yml and bash run.sh --watch -recursive are no longer flagged. -c, -lc, -l -c, -o pipefail -c, --rcfile x -c, bash.exe -c and "bash -c node" still are. It also now catches +c, which bash, sh and zsh treat as -c. Tests are in tests/validators/test_mcp_static.py, and the docs row is updated.
| try: | ||
| raw = location.read_text(encoding="utf-8-sig") | ||
| except PluginManifestPathError as exc: | ||
| if exc.content_error: |
There was a problem hiding this comment.
[correctness / security] A policy override can now hide a manifest that was never checked.
manifest_unreadable no longer sets security_failure, so apply_policy will apply severity overrides to it (PLUGIN_SCHEMA.manifest_unreadable or PLUGIN_SCHEMA.*). That is fine when the lenient parse succeeded and the components were checked.
It is not fine when nothing was parsed:
_parse_unreadablereturnsNonefor a manifest over the 8 MiB lenient bound, or one that isn't JSON even when read leniently;- the
agent_plugin.yamlbranch at line 522 never reads leniently.
In both cases none of the plugin's hooks, MCP servers or LSP servers were inventoried, so a downgrade lets Tier 1 pass a plugin with no component checks. Before this change, security_failure kept these blocking regardless of policy.
Suggested fix: set result.metadata["security_failure"] = True (or another marker that policy can't override) whenever the selected manifest yields no data. Keep it overridable only when the lenient parse produced data that was inventoried.
There was a problem hiding this comment.
Fixed in 4941acf. When nothing can be read from the selected manifest, it now sets security_failure, so a policy override cannot downgrade it and Tier 1 stops after the schema step, as it does for an unsafe manifest. This covers the agent_plugin.yaml content-error branch and the JSON case where even the lenient read returns nothing. A manifest that does parse leniently can still be downgraded. Tests in tests/validators/test_plugin_review_fixes_b.py cover the policy cases (3 parametrized cases), Tier 1 stopping, and the lenient-read path.
| # references and totals inside a successful answer are not failures. | ||
| _FAILED_CALL_RE = re.compile( | ||
| r"(?:\b[45]\d{2}\b\s*[:\-])" | ||
| r"(?:^[ \t]*(?:(?:error|http/\d(?:\.\d)?|status(?:[ _]code)?)[ \t:=]*)?[45]\d{2}\b[ \t]*[:\-])" |
There was a problem hiding this comment.
[correctness] Anchoring the status code to line start drops real failures.
The old pattern caught these mid-line errors; the new one counts them as successful calls (reproduced):
Request failed with status 404: Not FoundError calling tool: 404: not foundGET /repos/x: 404 - Not Found
That overstates mcp_calls.success_rate and can wrongly give mcp_proof a used-successfully status. Keep the line-start form for bare codes, and also accept a code after an error word earlier on the same line, e.g.:
r"(?:^[ \t]*(?:(?:error|http/\d(?:\.\d)?|status(?:[ _]code)?)[ \t:=]*)?[45]\d{2}\b[ \t]*[:\-])"
r"|(?:\b(?:error|failed|status(?:[ _]code)?)\b[^\n]{0,80}?\b[45]\d{2}\b[ \t]*[:\-])"Add these three strings to test_failure_markers_mark_failure, keeping the three new negative cases.
There was a problem hiding this comment.
Fixed in 5610ad9. The line-start form stays. Two new patterns now count as failures:
- a request line at the start of a line, e.g.
GET /repos/x: 404 - Not Found; - a 4xx/5xx code after an error word (
error,failed,status) on the same line, e.g.Request failed with status 404: Not Found. The code has to follow a space, sosrc/app.py:404:still doesn't match.
Every gap in the pattern is bounded, so it stays linear: about 0.06 s on 210k characters of adversarial input. The three strings are added to test_failure_markers_mark_failure, and the existing negatives still pass.
| metadata = path.lstat() | ||
| except FileNotFoundError: | ||
| return None | ||
| except OSError as exc: |
There was a problem hiding this comment.
[correctness] A harness-log lstat error can abort all of Harbor collection.
Only FileNotFoundError maps to None. NotADirectoryError (an agent path component that is a file) and PermissionError still raise SecurePathError. The only caller, collector._trial_load_census, which is reached from _attach_load_census, doesn't catch it. So one trial's unreadable log stops collect_harbor_results for the whole run, even though the load census is advisory.
Suggest treating NotADirectoryError like a missing file here. Then either return None for any other OSError, or catch SecurePathError in _trial_load_census and keep the census without harness evidence.
There was a problem hiding this comment.
Fixed in f1d5f28. read_harness_log_prefix now returns None for NotADirectoryError as well as for a missing file. _trial_load_census catches SecurePathError/OSError and keeps the census without harness evidence. Links are still refused. Tests are in tests/tier3/test_plugin_native.py.
| try: | ||
| # Unsafe skill content is returned, not raised, as a result marked | ||
| # security_failure; _make_advisory keeps that result blocking. | ||
| skill_result = validator.validate(skill_dir) |
There was a problem hiding this comment.
[correctness] The fail-closed backstop for unsafe input is gone.
Unsafe input reported through SkillCollectionError is now covered by the security_failure metadata, but the except (SecurePathError, SkillCollectionError) branch was removed with it. Any SecurePathError that escapes validate() now falls into the generic except Exception. That branch builds a MEDIUM context_dedup_error without security_failure, and _make_advisory passes it. Examples are a code path that doesn't go through collect_files, or a future refactor.
Suggest keeping a narrow except SecurePathError as exc: skill_result = _unsafe_plugin_result(exc) ahead of the generic handler, so unsafe filesystem input stays blocking whichever path raises it.
There was a problem hiding this comment.
Fixed in 11c3c76. Restored a narrow except SecurePathError as exc: skill_result = _unsafe_plugin_result(exc) ahead of the generic handler. SkillCollectionError is still handled inside validate(), which marks only the unsafe-input codes as security failures. The new test test_plugin_context_scan_keeps_a_raised_unsafe_path_blocking covers it.
| target_display = resolve_git_remote_url(target_path) or str(target_path) | ||
| sarif_repository_root = resolve_git_root(target_path) | ||
| # Reports and the footer name the validated content root, not the lexical "." or manifest-file argument. | ||
| report_root = resolved_target.resolve() |
There was a problem hiding this comment.
[altitude] The resolved root fixes the label, but SARIF paths are still inconsistent.
cd parent && skillevaluator validate sample -r sarif still produces mixed artifact URIs for one file: ['SKILL.md', 'sample', 'sample/SKILL.md'] (reproduced). sample/SKILL.md resolved under the scan root .../sample points to a file that doesn't exist.
The root cause is upstream. run_validation receives the lexical relative resolved_target, so some validators emit paths relative to the working directory. Consider passing report_root (absolute) to the validators, or normalizing finding paths against report_root in one place before reporting.
There was a problem hiding this comment.
Fixed in 5ed62ab. I normalized the paths in one place instead of passing an absolute target to run_validation, which would have put absolute host paths into the JSON, HTML and Markdown reports and changed the folder-structure verdicts. The new _content_relative_finding_paths() rewrites finding paths that start with the typed relative target the way validate . reports them, keeping any [skill] label. A path that names a real entry under the content root is left alone. All safety checks still run on the lexical target. A real Tier 1 run of validate skills/sample now gives only SKILL.md as its SARIF URI. Note that JSON, HTML and Markdown file_path values for a relative target now match validate . as well.
| else "Pin the image by digest (image@sha256:...) or an exact version tag, then rerun the dependency audit." | ||
| ), | ||
| line_number=line_number, | ||
| suggestion=_UNVERIFIED_SUGGESTIONS[ecosystem], |
There was a problem hiding this comment.
[correctness] An unknown ecosystem now raises KeyError.
_UNVERIFIED_SUGGESTIONS[ecosystem] raises for any ecosystem other than python, npm or container. The previous code fell back to a default suggestion. Suggest _UNVERIFIED_SUGGESTIONS.get(ecosystem, _DEFAULT_UNVERIFIED_SUGGESTION), so a new runner ecosystem (deno, pypi aliases, …) produces an INFO finding instead of aborting the audit.
There was a problem hiding this comment.
Fixed in 759774c. Any other ecosystem now gets a generic _DEFAULT_UNVERIFIED_SUGGESTION instead of a KeyError. Test: test_unverified_finding_for_another_ecosystem_gets_the_default_suggestion.
| """Read the discovered inode through the anchored plugin root descriptor.""" | ||
| return _read_secure_manifest(self.secure_file, self.declared_path, encoding=encoding, max_bytes=max_bytes) | ||
|
|
||
| def read_lenient_text(self, *, max_bytes: int = CONTENT_DEDUP_MAX_TOTAL_BYTES) -> str: |
There was a problem hiding this comment.
[reuse] This duplicates PluginManifestCandidate.read_lenient_text (line 224) line for line.
Suggest one module-level helper, e.g. _read_lenient_manifest(secure_file, declared_path, max_bytes), called by both classes. That way the bound, decoding and error mapping can't drift apart.
There was a problem hiding this comment.
Fixed in fdca13f. Both classes now call one module-level _read_lenient_manifest(). No behaviour change.
| {% endif %} | ||
| <div class="dashboard"> | ||
| <div class="dashboard-card {{ 'success' if tier3.verdict == 'pass' else ('danger' if tier3.verdict == 'fail' else '') }}"> | ||
| <div class="dashboard-card {{ 'success' if tier3_tone == 'pass' else ('danger' if tier3_tone == 'fail' else ('warning' if tier3_tone == 'warn' else '')) }}"> |
There was a problem hiding this comment.
[simplification] Use a lookup instead of the nested conditional for the tone mapping.
| <div class="dashboard-card {{ 'success' if tier3_tone == 'pass' else ('danger' if tier3_tone == 'fail' else ('warning' if tier3_tone == 'warn' else '')) }}"> | |
| <div class="dashboard-card {{ {'pass': 'success', 'fail': 'danger', 'warn': 'warning'}.get(tier3_tone, '') }}"> |
There was a problem hiding this comment.
Fixed in 620e816, as suggested. The partial-run test still renders the card as warning, and a complete run renders success.
The shell '-c' check scanned every argument after the shell, so a
script's own options ('bash server.sh -config x.yml', 'bash run.sh
--watch -recursive') raised a blocking CRITICAL
mcp_command_dangerous_form. A shell reads options only until its first
operand or an end-of-options marker ('--' or '-').
Scan shell options only up to the script path or '--', and skip the
value of '-o name', '-O name' (also in a cluster such as '-eo pipefail'
and the '+' forms), '--rcfile file', and '--init-file file' so it is
not mistaken for the script. A command that names only the shell,
including a Windows path with spaces, takes its options from 'args'.
'+c', which bash, sh, and zsh also run as '-c', is now detected too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
… manifest manifest_unreadable no longer set security_failure, so a policy override (PLUGIN_SCHEMA.manifest_unreadable or PLUGIN_SCHEMA.*) could lower it and let Tier 1 pass. That is fine when the lenient parse produced the manifest and its components were inventoried and checked, but not when nothing was parsed: a plugin.json over the 8 MiB lenient bound or not JSON even leniently, or an agent_plugin.yaml, which is never read leniently. Then no declared hook, MCP, or LSP server was checked. Mark the result a security failure in those cases, so policy keeps the HIGH finding and Tier 1 stops after the schema check, as for an unsafe manifest. The check name and message stay manifest_unreadable, and a leniently parsed manifest stays overridable. test_non_utf8_bundle_manifest_is_unreadable_not_unsafe now expects security_failure for the Latin-1 agent_plugin.yaml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Anchoring the 4xx/5xx branch of _FAILED_CALL_RE to the start of a line stopped issue numbers and file:line references from failing a call, but it also counted real failures as successful calls: "Request failed with status 404: Not Found", "Error calling tool: 404: not found", and "GET /repos/x: 404 - Not Found". Keep the line-start form, and also accept a code that follows a request line (an HTTP method and a path) or a space after an error word earlier on the same line. Each gap is bounded, so long adversarial lines stay linear. "#451 - Crash", "src/app.py:404: def handler()", and "Total: 500 - all good" are still not failures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…lection read_harness_log_prefix mapped only FileNotFoundError to None, so a log under a path component that is not a directory, or one that cannot be inspected (PermissionError), raised SecurePathError. Its only caller, _trial_load_census, runs unguarded from collect_harbor_results, so one trial's log aborted the whole Harbor result collection although the load census is advisory. Read a log under a non-directory as missing, and have _trial_load_census keep the trial's census without harness evidence when the log cannot be inspected safely. Links are still refused and never followed. Also import os, stat, and stat_is_link_or_reparse once at module level instead of on every call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
The except (SecurePathError, SkillCollectionError) branch around validator.validate(skill_dir) was removed. IntraSkillValidator returns unsafe skill content as a security_failure result, but a SecurePathError that still escapes validate() reached the generic handler, became a MEDIUM context_dedup_error without security_failure, and _make_advisory passed it. Restore a narrow except SecurePathError that builds the blocking _unsafe_plugin_result. SkillCollectionError stays handled inside validate(), which marks only unsafe-input checks as security failures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…ontent root
Naming the resolved content root fixed the report label, but validate
still hands validators the target as typed. For `validate sample` from
the parent directory, some validators join it into their paths
("sample/SKILL.md", relative to the working directory) while others
report paths relative to the content root ("SKILL.md"), so one file got
the SARIF URIs "SKILL.md" and "sample/SKILL.md", and the latter, read
against the scan root, pointed nowhere.
Before reporting, rewrite each finding path that starts with the typed
relative target as `validate .` would report it ("sample/SKILL.md" ->
"SKILL.md", "sample" -> "."), keeping a bundled skill's "[skill]"
label. A path that names an existing entry under the content root is
kept, so a skill "examples" with its own examples/ folder is not
misread; a target with ".." is never ambiguous.
Passing an absolute path to run_validation was not used: the JSON,
HTML, and Markdown reports and validator messages would then show
absolute host paths where `validate .` and `validate sample` showed
relative ones, and folder_hierarchy reads the target's lexical parts,
so it would change verdicts. Every safety check still runs on the
lexical target.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…tems unverified_finding looked its suggestion up with _UNVERIFIED_SUGGESTIONS[ecosystem], so any ecosystem other than python, npm, or container raised KeyError; the code it replaced had a default. Fall back to a generic suggestion instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
PluginManifestLocation.read_lenient_text duplicated PluginManifestCandidate.read_lenient_text. Both now call one module-level helper, _read_lenient_manifest. No behavior change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…th a lookup Replace the nested conditional on the Tier 3 verdict card with a pass/fail/warn lookup that falls back to no class. Same output. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
rng1995
left a comment
There was a problem hiding this comment.
Re-review: all 9 review comments are addressed in 23b3bcd..620e816, one commit per comment, each with a regression test. Verified locally on 620e816: full suite 13,089 passed, 26 skipped, 0 failed; ruff check and git diff --check clean; all commits signed off. (GitHub does not allow the PR author to approve, so this is recorded as a comment review.)
0f22819
into
naren/plugin-evaluation-all-tiers
PR #28 pinned Harbor 0.13.2. Its trajectory conversion drops data the plugin checks read: Codex per-step tokens (check 25), Claude Code subagent steps and their MCP calls (checks 18, 19, 22, 24, 28), and parallel Codex calls in one step (check 18's same-step guard). This commit carries PR #83's Harbor port (0.13.2 to 0.22, with its conflicts against the plugin code resolved), then moves on to Harbor 0.24.0 and LiteLLM 1.92+. On top of the port: - native Claude Code loading knows Harbor's new launch shape; - Codex runs convert the main thread when the agent spawns a subagent, and fold child threads in as sidechain steps; - the environment kwarg contract and native staging follow 0.24 (cwsandbox owns the W&B backend; separate-verifier rule). Fixes proof H4 and the Codex child-rollout part of L26. Overlap with #180: both sides stopped oversized usage counters and rewards from aborting collection (#180 b1c3410, 7608e96). The tree keeps one finite_number() helper and the port's fail-closed reward rule; the collector's private copy is gone. This branch is cut per file. collector.py lands here whole, so it also holds the security attribution (runtime security commit), codex.txt call pairing (MCP execution), lift arms (lift) and usage (context cost) hunks; adapter.py holds the agent HOME variables for the verifier; runner.py holds the long plugin name and MCP input-schema hunks; metrics.py holds a shared lift basis hunk. PR #83's hunks in tier3_report.py and cli.py land with the lift and gate commits, and its docs land in the docs commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
… them Check 1 read only the manifest name. It now checks a Claude Code manifest like Claude Code does: field types, unknown keys, the dependencies shape (resolved offline) and the lspServers schema. Codex interface fields are type-checked, and severities follow what each client really installs. claude plugin validate parity compares findings, not only verdicts, and runs without --strict. Component paths and the inventory follow the clients too: paths without ./, agents given as a folder, Codex paths that Codex drops, a Cursor mcpServers file next to a root mcp.json, components inside pruned folders (node_modules, .venv, .git), agents and commands under evals/, results/ and versions/, lenient frontmatter, broken rows shown as broken, commands-map folders, and deep YAML that no longer crashes. Fixes proof H7, H11, H12, H17, M17, M19, M20, M38, M40, L1, L6, L7; the invalid-LSP-config part of M22; the invalid-YAML part of L29; and the two symlink parts of L15. Overlap with #180: L1 replaces #180's declared_dependencies rule (1e72692), because components are not dependencies; #180's test now checks our rule and that both manifest paths agree. For an unreadable selected manifest (L6) the tree keeps #180's lenient reader and its fail-closed rule (c1bfdc7, 4941acf, fdca13f), plus our validator for all four contained formats and its reason metadata. This branch is cut per file. plugin_components.py lands here whole, so it also holds the hook, privilege, LSP and monitor hunks (hooks commit) and the context-cost rows (context cost commit). Its new calls into plugin_component_risk.py, mcp_static.py and plugin_dependencies.py work once those commits land. The Tier 3 half of M38 (plugin names over 64 characters) is in the dependencies commit, with plugin_eval.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
- A benign hooks file, or a mention of a file the plugin does not ship, no longer makes default Tier 1 INCOMPLETE: SkillSpector's partial reasons count as covered when our own analyzer read the file. - Skill-frontmatter hooks are hook components: Tier 1 checks them, and Tier 3 refuses their bypass flags and censuses them in native mode. - Codex hooks use Codex names; PowerShell counts as a shell. - A Claude-only allowed-tools grant does not fail a Codex plugin, and grants are read the way Claude Code reads them. - LSP env gets the MCP env checks; monitor findings land on the monitor. - Hook comments, if conditions, context outputs and relative script paths are read right; missed bypass forms are covered; the codex token rule has fewer false hits. - Hygiene failures become findings, HTML escapes bidi and tag characters, and two static evasions are caught with --no-llm. - SkillSpector code snippets and texts mask secrets. Fixes proof H10, M6, M16, M18, L10, L11, L12, L13; M22 except the invalid-LSP-config part; L15 except the two symlink parts; an agent file without frontmatter (M17); and the SkillSpector-snippet part of H9. M6 is partly fixed: wrapper mode still runs a member skill's frontmatter hooks without a census (its coverage row says so). Overlap with #180: its hook '..' rule (132e485) is kept. One row of its test moved to "a/../b", because L10 reports every ./ word in a hook (the client resolves it against the user's project); a new test checks the '..' rule on "./a/../b" directly. This branch is cut per file. plugin_component_risk.py also holds hook pinning (MCP static commit); security.py holds the PII message redaction (H9, MCP static commit); plugin_native.py holds the load-census merge (reports commit). The H9 snippet test is in the MCP static commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
- ${VAR}, $VAR, ${VAR:-default} and ${VAR:-} are accepted per client;
Claude Code defaults are expanded before the URL is read, and Codex
gets a "will not expand" finding instead of a dangerous scheme.
- No finding message or metadata copies a secret; redacted_url strips
userinfo anywhere in the string.
- Floating tags are read at the version position, and runners behind
wrappers (cmd /c, env, bun x, uv run --with, pnpm dlx) are unwrapped.
Hooks get the same pinning rules.
- Plaintext loopback MCP URLs follow the hook rule; OAuth metadata URLs
are checked.
- Secret and shell-operator checks follow meaning (argv has no shell);
Codex auth fields warn at Tier 1.
- Endpoint checks report a failed lookup as INCOMPLETE, not a pass, and
name the right host.
Fixes proof H8, H9 (Tier 1 half), H13, M23, M24, L9, L14, L16.
Overlap with #180: for H13 the tree keeps our classifier and adds
#180's two extra tag cases (cf1c88c). For the shell -c reader it keeps
ours (it looks through wrappers) and adds #180's +c/+lc forms,
-oe pipefail -c, and the non-list args crash guard (571264e, 23b3bcd).
A git ref on a moving branch now counts as a floating version.
This branch is cut per file. mcp_static.py also holds the override and
bypass region (hooks commit, L13). The SkillSpector snippet test (H9)
is here; its code is in the hooks commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
…ier 3 - Python CVEs are real findings, so a policy override no longer drops them, and they show in SARIF, BENCHMARK and HTML. Advisory severity comes from the advisory data. - Unreadable Python manifests and unknown pins are findings or INCOMPLETE, not silent passes. yarn.lock, pnpm-lock.yaml and Safety 3.x are audited or reported as skipped. - A bundled skill keeps its non-blocking findings, a CRITICAL in one skill does not stop the next, and severity counts include them. - More than 256 refs reports a finding and keeps the gate on. - Tier 3 resolves refs like Tier 1 (same repo root). A same-named bundled skill does not satisfy a missing dependency, and a plugin with only external refs, or only a provider-only MCP server, is INCOMPLETE instead of skipped with exit 0. - Packages that wrapped MCP runners install are audited. - Tier 2 and Tier 3 take plugin names longer than 64 characters (the wrapper skill name is cut to 64 with a short hash). Fixes proof H14, M5, M8, M9, M25, L8, L17; the provider-only part of L25; the wrapped-runner audit of M24 and L16; and the Tier 3 half of M38. Overlap with #180: both fixed the doubled skill folder in bundled-skill dependency paths (f1eee1d); ours is kept, same mechanism. For Python MCP-runner findings the tree keeps #180's display path and shared unverified finding (450f922, 759774c) with our audit. This branch is cut per file. plugin_eval.py lands here whole, so it also holds the MCP staging policy (MCP execution commit), the frontmatter-hook refusals (hooks commit) and the member-skill cost hunks (context cost commit). models/result.py holds the mirrored error strings that follow content-relative finding paths (reports commit). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
… saw - Claude Code is_error flags (also as strings) and Codex failed statuses count as failed calls; codex.txt items pair by call id. - A server is proven only by a success the agent saw or a real init. - Argument grading skips refused calls, uses the server's input schema, and reports rules with no call. - Failure text is anchored: a status line needs its reason phrase (an HTTP/x.y line does not), and error words count only where they open a clause. - Tier 3 staging uses the Tier 1 policy and severities (also for a Codex plugin's MCP expansion rules); evaluate-plugin takes --policy; the refusal text never echoes a secret. - Reports show failed and unknown MCP calls with per-tool counts. Fixes proof H3, M30, M31, M32, M37, L27, L28; the --policy part of L25; the codex.txt part of L26; the Tier 3 parts of H8 and H9. L27 is partly fixed: regex time budgets, search-style patterns, substring contains and the call-weighted rate stay as documented. Overlap with #180: for M32 the tree keeps our anchored rule and adds #180's request-line and error-clause openings (3a46e21, 5610ad9). #180's "500: boom" test row is now "500: Internal Server Error", because a status line needs its reason phrase. This branch is cut per file. Most of this area's code is in plugin_signals.py (signals commit), collector.py (Harbor commit), plugin_eval.py (dependencies commit), tier3_report.py (lift commit) and cli.py (gate commit). This commit has mcp_proof.py and the tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
…l runs - The no-skill arm gets no skill_execution or skill_efficiency score. Lift compares both arms only on the dimensions both can score, and the headline shows that basis (pass@k and mixed metric contracts too). - One failed trial keeps the interval and the Integration verdict: the run is marked partial with "n of m pairs" and names the failed arm. - The Integration headline and its interval use the same weights, and the verdict uses the whole interval. - Small-sample intervals are widened; "adequate" needs 10 cases. - Integration-only runs file the members' lift as Integration, never as Skill Lift; multi-agent runs get one named block per agent. - evaluate-plugin, Markdown, HTML and BENCHMARK show one lift on one basis, and never a partial one as final. Fixes proof H5, M10, M12, M13, L18, L24; M4 except the measured-cost half; M14 except the census-merge half. M4 is partly fixed: a case missing from the Harbor job itself still drops the arm (the reason now names the arm). #180 removed the private _finite_number and _non_negative helpers; result_display.py and stats.py now call its shared finite_number(). This branch is cut per file. tier3_report.py lands here whole, so it also holds PR #83's Harbor report hunks, the gate commit's verdict helpers and the top argument failures (MCP execution). stats.py holds the measured context cost (context cost commit). markdown.py and plugin_sections.html.j2 hold small report hunks of most areas. The no-skill rule in the verifier is in the runtime security commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
…K honest - SARIF names the finding's own component (relative targets too), does not double cwd-relative or bundled-skill URIs, points a symlink finding at the link, and escapes message.markdown. - BENCHMARK redaction keeps ./ paths and closing tags. - Reports stop calling LSP, monitor, settings and output_style "unchecked"; Tier 1 checks them. - Coverage shows not-loaded rows as not loaded, says how many trials loaded, and does not count unstageable components as exercised. - In multi-agent runs the load-census merge keeps each agent's failure. - The mirrored error strings follow content-relative finding paths. Fixes proof M1, M2, M39, L2, L3, L33; L21 except the namespace part; the census-merge half of M14. L33 is partly fixed: every report scopes it, but the raw collector activation_coverage JSON still lists unstaged subagents and commands. Overlap with #180: its content-root and finding-path fixes for SARIF (d7dd1eb, 5ed62ab, 599deee) work at the scan layer, ours inside the SARIF reporter. Both are kept. Its Markdown fix for an empty plugin block (daf459a) is kept as is. This branch is cut per file. plugin_sections.py lands here whole, so it holds the report views of every area; sarif_reporter.py holds the canary results (runtime security). The census code in plugin_native.py is in the hooks commit, and the error-string mirror in models/result.py is in the dependencies commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
- Without --block-on-agent-eval, Tier 3 is advisory and BENCHMARK says so. With it, a FAIL verdict fails validate (exit 1), INCOMPLETE and skipped runs fail, and NEUTRAL does not. - A lift in the FAIL band warns, and with the flag a confirmed regression blocks; the BENCHMARK legend says what the code does. - Blocking Findings lists only CRITICAL and HIGH, and says how many more there are. - The CLI footer says INCOMPLETE for an INCOMPLETE run. - Tier 3 cards and dimension verdicts use one set of thresholds, and INCOMPLETE runs do not show a security % next to "no baseline". Fixes proof H1, M15, L4, L5 and the report half of L32. Overlap with #180: its fix that stops an INCOMPLETE Tier 3 verdict card from showing green (e65b531, 620e816) is merged with our gate states: one computed verdict and tone, used by both cards. The template lands in the Harbor commit. This branch is cut per file. cli.py lands here whole, so it also holds PR #83's --environment-kwarg plumbing, evaluate-plugin --policy (MCP execution), the dependency skip path and repo root (dependencies) and the content-relative error strings (reports); the golden CLI surface follows it. benchmark.py and reporting/cli.py hold lift, canary and coverage lines of other areas. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
Brings the docs in line with the fixes in this branch: plugin evaluation (manifests, inventory, hooks, MCP static policy and endpoints, dependencies, runtime security, MCP proof, signals, lift, coverage, context cost, verdicts), the CLI reference, reports, Tier 1 validation, benchmark rollout, and PR #83's Harbor 0.24 docs (Tier 3 live evaluation, agents and sandboxes, configuration, custom graders, eval datasets). CHANGELOG: one Unreleased line per user-visible fix, appended at the end, including the follow-ups from the merge with #180. Where #180 and this branch edited the same docs row, the rows are merged: the shell -c row, the floating-version row, the unreadable manifest paragraph (it still fails closed when nothing parses), and the reserved plugin MCP servers file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
Summary
Bug fixes from a code-quality review of #28. This PR is stacked on #28: its base is
naren/plugin-evaluation-all-tiers, and merging it lands the fixes on #28's branch.Every fix is its own signed-off commit with a regression test that fails without the fix. Please merge with a merge commit or rebase (not squash) to keep the per-fix history.
tier2 PLUGINinstead of passing as a MEDIUM advisory.argsno longer crashesvalidate.bash.exe -c,-lc/-ec/-xc, and"command": "bash -c …".@nextcloud/…@1.2.3or@headlessui/…are no longer HIGHmcp_command_floating_version../a/../bis no longer reported as leaving the hook's working directory.environment/plugin_mcp_servers.tomlis now reserved. Before, an authored copy in the evals source replaced the plugin's validated MCP servers and skipped the static checks and redaction..ENV/.Env.local(any letter case) are no longer copied into native plugin containers. Tier 1 already flagged them.Noneinstead of raising.skills/foo/skills/foo/package.json)..claude-plugin/plugin.json).manifest_unreadable, notmanifest_unsafewith a security failure. It is now read leniently, so its components are still checked.declared_dependenciescounts only component fields on both manifest paths.collect_harbor_results.validate ./validate SKILL.mdreports and SARIF use the resolved content root, so they no longer showTarget: .or a doubledSKILL.md/SKILL.mdURI.docs/plugin-evaluation.mdxis updated where it described the changed behavior (the shell-cand floating-version rows, the unreadable manifest paragraph, and the reserved MCP servers file).For reviewers
Behavior changes worth a look:
-ccheck:"args": {"-c": "x"}(a dict) used to get CRITICALmcp_command_dangerous_form, only because iterating the dict yields its keys. It now gets HIGHmcp_args_not_list, which still blocks.manifest_unreadableis no longer a security failure, so a policy severity override can lower it.pkg@main2is no longer HIGH but still gets MEDIUMmcp_unpinned_package.Known gaps, not addressed here:
evals/environment/mcp_servers.toml(the shared task environment) is still loaded into every arm without the MCP static checks. That applies when the evals source is the plugin's ownevals/.validateexit gate excludesadvisory_tier2results even when they are security failures. Tier 1's whole-tree check already rejects linked and hard-linked plugin files.Testing
uv run pytest -q -n auto). That includes the golden CLI surface; no snapshot was regenerated.uv run ruff check .passes, andgit diff --checkis clean.🤖 Generated with Claude Code