Skip to content

fix(plugin): review fixes for plugin evaluation (stacked on #28) - #180

Merged
rng1995 merged 28 commits into
naren/plugin-evaluation-all-tiersfrom
naren/pr28-review-fixes
Oct 5, 2026
Merged

rng1995 merged 28 commits into
naren/plugin-evaluation-all-tiersfrom
naren/pr28-review-fixes

Conversation

@rng1995

@rng1995 rng1995 commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

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.

Area Fix
Tier 2 plugin dedup A plugin with one bundled skill no longer crashes context dedup: its per-skill LLM budget was 100, above the validator's cap of 50.
Unsafe content in a bundled skill (for example a hard link) now blocks standalone tier2 PLUGIN instead of passing as a MEDIUM advisory.
Advisory capping keeps each capped finding once and keeps plain warnings.
MCP and hook static checks A shell-command MCP server with non-list args no longer crashes validate.
The shell inline-program check now also catches bash.exe -c, -lc/-ec/-xc, and "command": "bash -c …".
Exactly pinned scoped packages such as @nextcloud/…@1.2.3 or @headlessui/… are no longer HIGH mcp_command_floating_version.
./a/../b is no longer reported as leaving the hook's working directory.
Tier 3 plugin staging environment/plugin_mcp_servers.toml is 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.
A missing Claude Code harness log returns None instead of raising.
Dependency and schema validators npm and container findings in bundled skills are no longer double-prefixed (skills/foo/skills/foo/package.json).
Python MCP-runner findings name the manifest folder (.claude-plugin/plugin.json).
An undecodable or oversize selected manifest is HIGH manifest_unreadable, not manifest_unsafe with a security failure. It is now read leniently, so its components are still checked.
declared_dependencies counts only component fields on both manifest paths.
Harbor results, reporting, CLI An integer usage counter or reward too large for a float no longer aborts collect_harbor_results.
The HTML Tier 3 dashboard no longer shows INCOMPLETE in green.
Markdown no longer raises on an empty plugin block.
validate . / validate SKILL.md reports and SARIF use the resolved content root, so they no longer show Target: . or a doubled SKILL.md/SKILL.md URI.
Status-like numbers inside successful MCP answers (for example "#451 - Crash") no longer count as failed calls.

docs/plugin-evaluation.mdx is updated where it described the changed behavior (the shell -c and floating-version rows, the unreadable manifest paragraph, and the reserved MCP servers file).

For reviewers

Behavior changes worth a look:

  • Shell -c check: "args": {"-c": "x"} (a dict) used to get CRITICAL mcp_command_dangerous_form, only because iterating the dict yields its keys. It now gets HIGH mcp_args_not_list, which still blocks.
  • Unreadable manifest: manifest_unreadable is no longer a security failure, so a policy severity override can lower it.
  • Verdict card colour: the dashboard Verdict card now follows the Tier 3 tier-card tone for every state, so a neutral verdict is warning-coloured.
  • Floating-version marker: the marker must now be attached to a package or image name. pkg@main2 is no longer HIGH but still gets MEDIUM mcp_unpinned_package.

Known gaps, not addressed here:

  • An authored 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 own evals/.
  • The validate exit gate excludes advisory_tier2 results even when they are security failures. Tier 1's whole-tree check already rejects linked and hard-linked plugin files.
  • The dependency audit and Tier 3 coverage still read an unreadable selected manifest strictly.

Testing

  • Full suite on this branch: 13,061 passed, 26 skipped, 0 failed (uv run pytest -q -n auto). That includes the golden CLI surface; no snapshot was regenerated.
  • uv run ruff check . passes, and git diff --check is clean.
  • Each new regression test was confirmed to fail with its source change reverted.

🤖 Generated with Claude Code

rng1995 and others added 19 commits October 4, 2026 23:28
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 rng1995 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the batch-1 fixes. All 9 inline comments are below; the first four are correctness issues to address before merging.

  1. Blocking false positive: the shell -c check scans the script's own arguments (-config, -recursive → CRITICAL).
  2. Policy can hide unchecked manifests: an unreadable selected manifest whose contents couldn't be parsed can now be downgraded by policy.
  3. Missed MCP failures: errors like status 404: Not Found in the middle of a line are counted as successful calls.
  4. Collection can abort: a harness-log lstat error 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]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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"]} → CRITICAL mcp_command_dangerous_form
  • ["run.sh", "--watch", "-recursive"] → CRITICAL mcp_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 False

Option values such as -o pipefail or --rcfile x need care; a test with script arguments after the script path would have caught this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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_unreadable returns None for a manifest over the 8 MiB lenient bound, or one that isn't JSON even when read leniently;
  • the agent_plugin.yaml branch 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]*[:\-])"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 Found
  • Error calling tool: 404: not found
  • GET /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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, so src/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:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/skillevaluator/cli.py
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()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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],

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 '')) }}">

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[simplification] Use a lookup instead of the nested conditional for the tone mapping.

Suggested change
<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, '') }}">

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 620e816, as suggested. The partial-run test still renders the card as warning, and a complete run renders success.

rng1995 and others added 7 commits October 5, 2026 05:10
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>
rng1995 and others added 2 commits October 5, 2026 06:26
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 rng1995 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

@rng1995
rng1995 merged commit 0f22819 into naren/plugin-evaluation-all-tiers Oct 5, 2026
15 of 16 checks passed
@rng1995
rng1995 deleted the naren/pr28-review-fixes branch October 5, 2026 13:37
chrisknvidia added a commit that referenced this pull request Oct 6, 2026
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>
chrisknvidia added a commit that referenced this pull request Oct 6, 2026
… 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>
chrisknvidia added a commit that referenced this pull request Oct 6, 2026
- 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>
chrisknvidia added a commit that referenced this pull request Oct 6, 2026
- ${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>
chrisknvidia added a commit that referenced this pull request Oct 6, 2026
…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>
chrisknvidia added a commit that referenced this pull request Oct 6, 2026
… 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>
chrisknvidia added a commit that referenced this pull request Oct 6, 2026
…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>
chrisknvidia added a commit that referenced this pull request Oct 6, 2026
…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>
chrisknvidia added a commit that referenced this pull request Oct 6, 2026
- 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>
chrisknvidia added a commit that referenced this pull request Oct 6, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant