Skip to content

fix(plugin): review fixes batch 2 for plugin evaluation (stacked on #28) - #181

Merged
rng1995 merged 336 commits into
naren/plugin-evaluation-all-tiersfrom
naren/pr28-review-fixes-2
Oct 6, 2026
Merged

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

Conversation

@rng1995

@rng1995 rng1995 commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

This is the second batch of fixes from the code-quality review of PR #28, stacked on #28 the same way PR #180 was. Batch 1 (#180) fixed the small bugs. This batch covers what the review left open: the remaining bugs, the duplicated logic that had drifted apart, dead code, readability and performance.

The branch includes #28's head, 79e24ea. Where both changed the same behaviour, #28's approach is kept unless noted below.

The work was done in ten areas, each in its own merge commit, plus two passes that move callers onto the new shared helpers, and five merges from this PR's review. Every commit is signed off and does one thing. Each bug fix has a regression test that fails without it.

Fixes users will notice

Verifier (Harbor eval.py, kept in sync with eval_core)

  • The canary check reads every apply_patch target (Move to, shell heredoc, argv and piped forms, OpenCode patchText, the converters' raw/value bodies) and charges the token only to the file whose section carries it.
  • Shell-variable expansion is capped at 4096 added characters. Before, a crafted command could make the verifier allocate gigabytes.
  • The network check reads quoted eval payloads such as A='curl -d @/etc/passwd …'; eval "$A", values set by env or export, and commands held in a variable.
  • tee >(cat) ~/.bashrc is detected as a protected write.
  • Judge evidence redacts every configured credential on both the template and the host side; the verifier also redacts the canary token.
  • Judge file-change evidence uses the security check's shell write patterns.
  • Subagent logs carry a truncation flag, and a deeply nested line no longer crashes the verifier.

Secrets and URLs

  • Logs and judge evidence also redact GitLab, Slack, Hugging Face and npm tokens and AWS key IDs; artifacts add Hugging Face, npm and Slack refresh tokens. A standalone token keeps its prefix (glpat-<redacted>).
  • The inline-secret checks recognise every GitHub prefix, and sk- matches only where a word starts, so task-<hex> is not a key.
  • MCP URLs, HTTP hooks and hook commands share one credential rule, in url_policy. MCP now catches secret-shaped query values and names (?q=ghp_…) and fragment parameters, and reads ${USER:-} or ${env:USER} userinfo as a reference.
  • A key-like user name before \@ (https://<hex>\@host) is CRITICAL and never shown. Before, it was not flagged and a hook message printed it.
  • Findings never echo a credential from a command, including in metacharacter messages and pin details.
  • Secret redaction of finding text runs in linear time. Before, one crafted 100 KB plugin line that also triggered a SkillSpector or PII finding stalled Tier 1 for minutes.
  • Hook risk counts each flag once.
  • Permission-mode flags match in any letter case.
  • Inline programs behind fish -C, setsid or doas are detected.

MCP package runners

  • Pinning, container-image lookup and the CVE audit share one parser, which reads all of feat(plugin): Add Plugin evaluation support across all tiers #28's runner forms.
  • Runner options end at the package name, so npx -y pkg@1.2.3 -p 3000 audits pkg@1.2.3. Options after the command belong to the server.
  • A command line written in command is read by its first word, so bash -c x /usr/bin/env is CRITICAL.
  • uvx -w counts as --with, and --with-requirements leaves the server unpinned.
  • pkg==v1.0 and pkg@=1.2.3 count as pinned. A deno module's version is read only from its URL path.

Plugin manifests and components

  • A drive letter in any part of a component path counts as escaping the plugin root, and any other colon makes the path invalid.
  • Findings past the 100-finding cap are counted in all four capped lists and ranked by policy severity. The note is HIGH schema_errors_truncated when a blocking finding is left out, otherwise LOW schema_findings_truncated.
  • An SCP origin without a user (host.tld:owner/repo) gives the right repository slug, and the suggestion for unknown identity fits its cause.
  • Rules in folders the scans skip are listed and flagged HIGH. A declared folder inside one gets one finding, not one per file.

Tier 3 collection and plugin signals

  • The sum-of-parts arm saves every collected row with the expected case ids, so invalid-score diagnostics are kept. Its job_failure in the run results is redacted and bounded.
  • Canary summaries count every collected row, so a leak from a trial whose judge failed is counted.
  • A reward.json outside Harbor's verifier layouts is never scored.
  • Stale-output reset and the symlink checks also cover integration_lift.json.
  • The Codex session walk is bounded.
  • The validate plugin path records evaluated_source in the Tier 3 run's config and report.
  • evaluate-plugin stages the plugin's preferred manifest, the same one validate uses, even when a manifest file is passed.
  • Plugin signals read every apply_patch form, and a moved-away file no longer counts as a write. An MCP write tool counts when an output key names the artifact.
  • Every MCP tool spelling (fs__x, mcp_fs_x, OpenCode fs_x) counts as mcp__fs__x.
  • Plugin signals grade a trial at the component and call caps in under 1 s instead of about 16 s.

Reporting

  • The CLI, every report and a failed evaluate-plugin give one INCOMPLETE reason.
  • Legacy Integration runs label the sum-of-parts baseline correctly in the hook-census and canary tables.
  • There is one definition of "statically checked".
  • Markdown lists what the run did not evaluate.
  • The "+N more" counts come from totals.
  • SARIF places skill-relative paths in the right bundled skill, also when two skills share a name, and renders about 17× faster for a 300-component plugin.
  • BENCHMARK no longer overflows on very large scores.

Tier 2 and secure_fs

  • Windows ctypes types are cached, so memory no longer grows with every open.
  • Discovery uses its path budget in name order on both platforms, so path_count_limit errors are deterministic.
  • The JSON preflight is about 10× faster and counts each escape sequence as one character.
  • Skills that fail to parse still count against the profile byte budget.
  • Advisory skips show as skipped.
  • Whole-skill Tier 2 finding paths are no longer doubled.
  • Clustering normalizes each vector once, about 40× faster.

Kept in #28's form: the job-directory lookup, SARIF path placement, the repository-identity rule, and pins that pip-audit skips (MEDIUM dependency-not-audited).

Structure

  • One arm-collection pipeline: _collect_arm serves all three arms (B7).
  • New modules:
    • validators/url_policy.py, which owns the credential rules and redact_secrets
    • plugin_mcp.py and plugin_paths.py, split out of plugin_components
    • plugin_states.py, a dependency-free home for the coverage and dependency states
  • Shared helpers: RunnerInvocation/parse_mcp_runner, HostAllowlist/classify_host, PluginManifestFile, parse_manifest_text, lstat_walk, read_bounded, weighted_dimension_score, not_applicable_list, STATISTICS_BLOCKS, split_display_prefix, incomplete_reason, ComponentIndex, integration_reports_for, refuse_or_fall_back.
  • Dead code listed in the review is removed. Names that other modules import still work.

Testing

  • uv run pytest -q -n 4: 17,397 passed, 29 skipped (1,434 more tests than feat(plugin): Add Plugin evaluation support across all tiers #28's head)
  • ruff check . passes and git diff --check is clean.
  • Every non-merge commit is signed off.
  • The Windows-specific secure_fs changes (cached NT API, walker, handle metadata, final-path lookup, atomic writer) are unit-tested with stand-in handles and pass the Windows Tier 2 CI job.
  • .gitleaks.toml allowlists three earlier test fixtures, each for one commit, file and rule; the tests now assemble those values at run time. tests/test_ci_workflows.py pins the entries.
  • Two feat(plugin): Add Plugin evaluation support across all tiers #28 Harbor process-timing tests get budgets that hold on a loaded machine.
  • The refactor commits were checked by diffing old and new code on generated inputs, with identical results: plugin inventories (2,185), lockfiles (23,400) and dimension scores (180,000).

Known gaps, not addressed here

  • The plugin inventory is built three times in a Tier 1 run, and once more each for the dependency audit and Tier 3 (B5). Fixing that first needs a decision between strict and lenient manifest parsing.
  • The network check still misses quoted assignment values that contain a newline.
  • evaluate-plugin has no --evaluated-source-* options, because adding them would change the CLI surface.

🤖 Generated with Claude Code

rng1995 and others added 30 commits October 5, 2026 09:04
…t_known

_validate_folder_structure took manifest_present: bool | None, but its
only caller passes True or None, so the `manifest_present is False`
branch could never run. The parameter is now manifest_known: bool = False
("the caller already holds the manifest"), and the presence check reads
`not manifest_known and not self._find_skill_manifest(...)`. No behavior
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…idates

The verifier's _is_apply_patch_action spelled out its own namespace
suffixes ("__", ".", "/", ":"), while the judge evidence in the same file
splits tool names with _tool_name_candidates. It now uses that helper and
_APPLY_PATCH_TOOLS; the two agree on every name (checked exhaustively over
short combinations of the separators and tool names). The host copy in
eval_core/checks.py keeps its own test: atif_helpers, which holds
_tool_name_candidates, imports checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…the tests

parse_package_json() and parse_package_lock() only wrapped
read_npm_declarations(data, lockfile=...)[0], and only the tests called
them; the audit reads manifests through read_npm_declarations, which also
returns the total so a manifest past the package cap is never cut
silently. The wrappers become small helpers in the test module, and
read_npm_declarations documents which sections it reads. No behavior
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
The module defines PLUGIN_ROOT_LABEL ("<plugin-root>") and
MAX_MESSAGE_CHARS (300) but repeated both as literals: three finding
paths and the two recorded commands spelled out "<plugin-root>", and two
redacted reasons passed max_len=300. They now use the constants. No
behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
- plugin_native: DEFAULT_PLUGIN_LOAD, LOAD_CENSUS_PATH, COMPONENT_MODES,
  the NativePluginSource.plugin_slug property and texts_of, none of which
  anything read.
- plugin_native.component_support_matrix and parse_frontmatter_yaml were
  used only by tests; the tests now read HARNESS_ADAPTERS' component modes
  and plugin_components.parse_markdown(...).frontmatter directly.
- plugin_runtime.summarize_runtime_coverage only forwarded to
  summarize_coverage, which is now called directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
The pinning paragraph said a PyPI pin must be "a plain PEP 440 release",
which reads as if pre-releases were excluded. The shared matcher accepts
canonical PEP 440 versions, pre-, post-, dev-, and local releases
included; give examples and say that other spellings are not exact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
_extract_calls was one 60-line loop that read a step's tool calls,
mapped a Codex bare MCP name to its server, unwrapped exec wrappers,
classified each call, tracked the skill/command window and correlated
results. Those phases are now _step_tool_calls, _expanded_tool_calls,
_identify_call and _claimable_results, and the loop only sequences
them. No behavior change (checked against the previous implementation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Five builders in each copy (the verifier template and atif_helpers) wrote
out the same nested loop: every step, skip non-agent steps, then each
normalized tool call of that step. _agent_tool_calls(traj) now yields
(step index, step, tool call) in trajectory order, and
extract_tool_calls_as_dicts, _file_change_entries, _tool_call_refs,
_file_change_refs, and build_verified_facts use it. The tool history keeps
its own per-step walk, since it also reads each step's reasoning, results,
and message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
HarnessAdapter.workspace_skill_aliases took a names argument every adapter
ignored (noqa: ARG002), threaded through NativeTaskStaging and the Harbor
adapter, and Claude Code's version walked the plugin's skill directories
again (ClaudeCodeAdapter.staged_skills) after build had just done so.

ClaudeCodeAdapter.build now records the <plugin>:<skill dir> aliases on
NativeBundle.skill_aliases while it walks the staged skills, and
NativeTaskStaging.workspace_skill_aliases() returns them; the adapter
method and its unused parameter are gone. _declared_skill_dirs reads the
contained manifest's skills directly instead of building the staged
plugin.json a second time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…r validation

validate() read and parsed the selected manifest two or three times:
_load_yaml or _load_contained_json for the manifest checks, then
_best_effort_parse for the manifest declaration row, and, for
agent_plugin.yaml, _bundle_manifest_data for the inventory. The two
loaders were also near copies that differed only in wording, encoding,
and the lenient re-read of client manifests.

One _load_manifest now reads and parses the manifest once, driven by a
two-entry _MANIFEST_SYNTAXES table (encoding, lenient re-read, and the
wording of each load finding), and returns a _LoadedManifest. Its
"parsed" mapping is what the dropped re-parses returned, so the
declaration row and the bundle inventory see the same data as before:
an empty YAML mapping still reaches the inventory, and a selected
manifest read only leniently still shows no name in its row. "data" is
what the manifest checks continue with. Findings, messages, and the
security_failure rules are unchanged (also checked against the old
module on 1,400 random plugins).

One edge case differs: the row of an agent_plugin.yaml now comes from
the manifest check's read (UTF-8) rather than a UTF-8-with-BOM read, so
a file starting with two byte-order marks shows no name, as validation
reports. Tests pin the single read and that row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Three helpers formatted URLs for messages, and they disagreed:
- mcp_static.redacted_url had no length bound, printed path segments
  that look like tokens, and showed urllib's reading of the host, not the
  one a client connects to;
- _client_reading, used in the ambiguous-URL message, built its own
  unbounded scheme://host:port/path;
- endpoint_resolution._safe_url imported plugin_component_risk.safe_url
  lazily, although there is no import cycle.

url_policy now holds the one safe_url. It reads the URL the WHATWG way,
drops userinfo, query, and fragment, shows a secret-shaped path segment
as <redacted>, and bounds the result through report_text (the old
plugin_component_risk._bounded, which pcr keeps under that name).

Every MCP, hook, and endpoint message now uses safe_url. The ambiguous
MCP URL message names the WHATWG reading once, as the hook message does.
mcp_static's _URL_USERINFO_RE copy and redacted_url's body are deleted;
redacted_url stays as an alias because plugin_components imports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…ofiles

_json_sources yielded (name, origin, rel, config) tuples in which an
empty rel meant config was a problem string, and each of its four
consumers (hooks, LSP servers, monitors, apps) turned that case into the
same broken component. It now yields _JsonSource named tuples for
loadable sources only, and records a declaration it cannot load as the
broken component itself (still without a path), so consumers only see
configs.

The two identical apps loops become _add_apps(). openai_extension no
longer swaps self.profile to CODEX_PROFILE for the duration of its body:
_json_sources, _resolve_declared and _hook take the profile explicitly.

The inventories built by the plugin test suites serialize identically
before and after. A new test pins the Codex rules for
extensions["com.openai"] hooks and apps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Each copy found the last non-empty agent message three ways: the final
response text (_get_final_response in the template, get_final_response in
atif_helpers), its step index for the tool history, and its evidence ref.
The text and the ref now come from _final_response_index, which moves next
to the text getter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…_mcp_declarations

_resolve_endpoints rebuilt PluginInventory.all_mcp_declarations() by
hand: the effective servers of the selected manifest, then every MCP
component with declared_by. Use the inventory method. It also drops a
server it already listed for the same (name, file); such a server has
the same configuration, so the (name, url) de-duplication here, which
stays, would have dropped it anyway. Same targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
- Compute the plugin-run check once. "(eval_target_kind or 'skill') ==
  'plugin'" was evaluated four times, plus one bare comparison.
- Name the report-only sum-of-parts variant: _REPORT_ONLY_VARIANTS
  replaces four "sumofparts" literal comparisons in _run_agent_pair.
- Carry each job's Harbor agent import path in its job tuple instead of
  a one-key dict with a fallback lookup.
- Pass the task inputs that every arm stages the same way through one
  shared_task_inputs dict. The with-skill, without-skill and
  sum-of-parts emitter calls now spell out only what differs, and each
  still gets its own copy of the runtime environment.

No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
validate_tier2_llm_similarity_score and the confidence parsing in
parse_bounded_llm_verdict each spelled out the same "real number, finite,
within [0, 1]" check and message. _require_unit_interval(value, label) now
does it once; tests pin the rejected and accepted values for both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
_file_change_refs re-implemented the write test of _write_call_parts (a
write tool, or an exec tool whose command looks like a write), and the
command argument was looked up four ways, three of them spelling out
"command", "cmd", "code" again. _exec_command(args) now returns the
(key, command) the _BEHAVIOR_EXEC_COMMAND_KEYS name, argv lists joined, and
_write_call_parts, _tool_call_ref, and build_verified_facts use it;
_file_change_refs asks _write_call_parts. Both copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
mcp_command_inline_secret printed the very argument it had identified as
a secret ("command argument contains an inline credential:
'sk-...'"). The shell-metacharacter, floating-version, insecure-TLS, and
dangerous-form messages also printed raw tokens and command lines.
Finding messages are not redacted later, so these reached reports and
CI logs.

The secret-shaped argument is now named by its position (args[N],
value withheld). Every other token or command line in these messages
goes through _shown(): bounded and redacted with report_text, and
withheld entirely when it contains a secret shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Only ClaudeCodeAdapter set copies_plugin_tree, so four readers fetched it
with getattr(..., False). The base class now declares it False, and
plugin_eval's two plan checks share a _copies_tree(agent, decision)
helper under any() and all().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
_read_job_json repeated _read_json except for where the no-follow read
was anchored: at the Harbor job root instead of the file's directory.
Give _read_json an optional root and drop _read_job_json; usage
counters are still read anchored at the job root.

_trial_usage now accepts only the trial root names that saved trials
accept (_safe_trial_path_component), instead of its own "single path
component" check. A name such as "case:1" is saved as "unknown", so its
usage is no longer attributed either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Hook records keep a bounded copy of each command line as "target", and
findings quote command tokens. That text went through
redact_sensitive_text, which knows sk-, nvapi-, JWT, AKIA, and Bearer
values but not GitHub (ghp_), GitLab (glpat-), or Slack (xox*) tokens.
So the target of "gh auth login --with-token ghp_..." kept the token in
the plugin.hook_risk metadata and in reports.

report_text, which now formats all hook, MCP, and URL report text, first
replaces every match of the secret shapes the inline-credential checks
use, then applies redact_sensitive_text as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…ocking over all issues

Four places cut a finding list at MAX_PLUGIN_SCHEMA_FINDINGS (100), each
in its own way: the component findings and the bundle-reference schema
errors added a HIGH schema_errors_truncated finding, while the
native-manifest field problems and the unscanned-skill findings were cut
silently. The native-manifest check also decided "blocking" over the
reported slice only, so an error past the cap could leave the manifest
marked valid.

One _add_capped helper now adds the first 100 findings and, past that,
one HIGH schema_errors_truncated finding with the actual count, at all
four places; the two existing messages are unchanged. The
native-manifest check decides blocking over every issue. Today's field
validators cannot reach the cap with only warnings first, so this only
changes results for very large manifests, which now report the cut
instead of hiding it. Tests cover both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
… plans

plugin_eval imported plugin_native lazily in nine functions although
there is no import cycle, and typed the per-agent plans and the native
snapshot as Any. It now imports plugin_native once at module level and
types plans as dict[str, AgentLoadDecision], snapshots as
NativePluginSource | None, and coverage components as Component.

The CLI plugin helpers take a PluginEvalPackage and read native_source,
mcp_probe_targets, and package_path directly instead of through getattr
defaults, and the Harbor adapter types native_plugin as
NativeTaskStaging | None (a TYPE_CHECKING import) and reads the staged
command texts directly. The CLI tests' prepared-package fakes now carry
the two fields a real package always has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…ocks

The validator compared severities with "in (Severity.CRITICAL,
Severity.HIGH)" or "is Severity.HIGH" in three places: the blocking-MCP
check before the manifest success row, the native-manifest field check,
and _advisory_result. Use Severity.is_error(), which is that rule
(Finding always holds a Severity). No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…place

Every caller of _resolve_declared re-tested what its result already
guaranteed (declared is None or declared.rel is None or kind in
{escape, invalid, missing, unsafe}, which is just "not a file or
folder"), and the "wrong kind: invalid finding plus broken component"
block was written three times (skills, the commands map, JSON-config
sources).

_resolve_declared now takes the kinds a field accepts and reports a
path of the other kind as invalid itself. The new
_resolve_component() records the broken component for a path that
cannot be loaded and returns only a usable path and its kind
(_ResolvedPath); skills, rules, Markdown components and the commands
map use it. JSON-config sources keep their broken components without a
path, as before.

Findings (including the "commands" field name of a command source that
is a folder) and components are unchanged: the 2,185 inventories built
by the plugin test suites serialize identically. A new test pins the
wrong-kind cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Whether a plaintext http hook is HIGH depended on the message text
endpoint.reason == "loopback". EndpointClass now carries an is_loopback
field. classify_endpoint_host sets it where it picks the reason: true for
a loopback name, and otherwise from the address that decided the
classification (an embedded IPv4 address decides for an IPv6 literal).
The http-hook check reads the field. The result matches the reason test
for names, 127.0.0.0/8, ::1, and the mapped, 6to4, NAT64, and compatible
forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
_ArmStaging.native mapped each agent to a positional
(adapter id, component modes, staged types) tuple that every reader
unpacked by position. It now maps to a _NativeArm NamedTuple.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
… states

The inventory produces the Tier 3 coverage states, but the post-run
states and their rank ({"staged", "loaded", "exercised"}) were spelled as
literals here and again in plugin_runtime, plugin_native and the
reporting view. Define COVERAGE_STATE_RANK and EVALUATED_COVERAGE_STATES
next to COVERAGE_STATES, document how the states relate, and use them in
summarize_coverage. The other modules can import them instead of
redeclaring the vocabulary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Evidence entries were positional lists, [text, rank, paths] and
[text, rank, paths, head, body, tail, body_cut], read as entry[4] or
entry[6]. They are now the NamedTuples _Entry and _FileChange in both
copies; _demote_superseded_writes replaces an entry with its demoted copy.

NamedTuple rather than a dataclass: with "from __future__ import
annotations", the dataclass decorator looks the class's module up in
sys.modules, and the tests load the verifier template from its file
without registering it there. The template still imports and runs on
Python 3.9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
validate_contained_mcp_servers was called only from tests (about 60
calls in test_mcp_static.py), and it had drifted from production.
Production runs mcpServers through plugin_components.collect_mcp_declarations,
which reads path strings through the plugin-root reader, follows Claude
Code's load order, and validates each server with
validate_mcp_server_declaration.

The per-server tests now call validate_mcp_server_declaration directly
(56 mechanical rewrites). The three shape tests (scalar, path string,
array, absent, and empty mcpServers) now go through
collect_mcp_declarations with a real plugin root. So the test for a path
string reads an actual file instead of assuming it is clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
rng1995 and others added 27 commits October 6, 2026 03:13
… rank aliases

parsed_additional_manifests was a one-line alias of
PluginManifestLocation.parsed_additional that only a test imported, and
its test duplicated
test_parsed_additional_reads_each_client_manifest_like_its_client.
plugin_components also re-exported COVERAGE_STATE_RANK for that test
only, which invites importing coverage states through the heavy
plugin_components module that plugin_states exists to avoid.

Both aliases and the duplicate test are removed, and the coverage
vocabulary test imports its names from plugin_states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
HostAllowlist.from_entries normalized a whole '*.<name>' entry. IDNA
rejects the '*' label, so the NFKC fallback left a Unicode suffix such
as '.bücher.example', while every host is normalized to punycode
('sub.xn--bcher-kva.example'). A Unicode wildcard in hooks.allowed_urls
or mcp.allowed_private_hosts therefore matched nothing, where the hook
check before this branch matched it.

The suffix after '*.' is now normalized on its own, after look-alike
dots are read as '.', the way hosts are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…d plugin-file MCP rows

native_agents was set only on rule and hook/subagent/command rows. A
declared skill directory that only the native claude-code arm stages, and
an MCP server that launches from plugin files and starts only there,
carried no native_agents, so JSON consumers could not tell which agents
stage them and the load census repeated "staged natively for claude-code"
in their reasons. The docs say every row a native arm stages lists those
agents.

Both rows now record the native Claude Code arms that copy the plugin
tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
plugin_component_risk kept a copy of mcp_static._split_words with the
same docstring and the same behavior (shlex.split's defaults are
comments=False, posix=True), although it already imports from
mcp_static. Hook command stages and the MCP 'env -S' string now share
the one tokenizer, so a later fix cannot make them split the same text
differently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
… them

Hook census labels moved from a whole-value redaction to
plugin_signals._safe_text, which redacts only the first limit * 4 + 256
characters of the raw value. A token that window's end cut in two no
longer matched its pattern, and when an earlier redaction (a long JWT)
shortened the text, the token's head reached the 256-character hook_id in
clear: AKIAABCDEFGHIJKLMNO, a ghp_ prefix, or a second JWT's header and
payload. The census file is agent-writable, so the line can be forged.

parse_hook_census now redacts each hook_id and event whole, then makes the
label with _safe_text. A census line is at most MAX_HOOK_CENSUS_LINE_CHARS,
which bounds the value, and each distinct pair is still redacted once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
host_is_allowlisted had become a one-line wrapper around
HostAllowlist.of(...).allows(...) with no production caller: every
caller moved to HostAllowlist. It left two public spellings of one
decision, and host_name_is_allowlisted's docstring pointed readers to
the wrapper. The wrapper is gone, its test uses HostAllowlist.allows,
and the docstring points there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
… the reports give

When the Harbor run failed, evaluate-plugin exited with its own "Tier 3
plugin evaluation did not complete: <failure>" text, without the
INCOMPLETE prefix or the deferred components, although the provenance it
had just built carries both and validate and every report say
"INCOMPLETE: <failure>; <deferred> could not be resolved or evaluated at
Tier 3". Scripts keyed on the documented INCOMPLETE message missed the
failure.

The failure path now raises incomplete_reason(provenance), and falls back
to the failure alone, with the same prefix, when the provenance could not
be built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
The previous loop variable still holds the last sidecar while the next
one is read, so the docstring now says that memory does not grow with the
number of sidecars instead of claiming one sidecar at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…and-line credential messages

Readability follow-up to the two previous report_text and command
credential fixes. report_text's truncation is a plain if-chain instead
of one conditional expression, and the inline-credential messages are
built explicitly, so a credential flag in a command line written in
'command' reads "command line argument 'password' carries an inline
credential". A test pins those messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
The project runs no type checker, and no other test in the file carries
one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…ped helper

Follow-up to the skip_reason fix: the warnings and unverified findings
for pins pip-audit skipped move into _report_skipped_pins, which lists
at most MAX_UNVERIFIED_PER_SOURCE of them like the other unverified
declarations and counts the rest in a message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…it has no options

A command line whose first word is a path, such as
'./bin/serve --cache-dir /opt/cache/uvx', still read as one program
named by its last path segment (here uvx). A program path with spaces
has no option words, so _command_argv now requires that too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…on carries it

check_canary tested the token against a whole apply_patch patch and
charged it to every header path outside the workspace. A multi-file patch
that put the token in a workspace file then reported a critical,
score-impacting file_outside_workspace leak for an outside file that got
none of it, and so did the source of a move (Update File: /tmp/old.txt
with Move to: notes.txt), which the move removes. The PR's shell
apply_patch support extended this to apply_patch heredocs.

_canary_patch_sections splits a patch into per-file sections: an Add or
Update header up to the next file header, written to its own path or to
the Move to path that follows it; a deleted file and a move's source
receive nothing. The apply_patch tool tests the token per section. A shell
apply_patch charges a file only when the statement carries the canary and
the file's own section holds the token or an expansion ($, a backquote)
the shell may fill with it. An outside file whose section does not still
gets the verifier's read-back, as a moved decoy does.

The shared block stays byte-identical in the verifier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
The canary read a shell apply_patch's patch only from the command's own
argument or heredoc. A patch piped in (cat <<'EOF' | apply_patch, or
echo '...' | applypatch) that wrote the token outside the workspace gave
no file_outside_workspace sink, in the host checks and the verifier, while
check_security and the judge evidence did see the write.

When the apply_patch command's own words hold no patch header and the
statement is a pipeline, the patch is read from the pipeline's words,
which hold the heredoc body or the echoed text. Each file is then charged
by its own section, as for a patch given directly. printf text with
escaped newlines is still not read.

The shared block stays byte-identical in the verifier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
…e network check

The network check recorded only bare leading NAME=value words and
classified the command word as written, so these ran curl with an upload
and passed, in the host checks and the verifier (bash, sh and zsh run them
all):

- export, declare (-x), local, readonly or typeset A='curl -d @f URL',
  then eval "$A" or bash -c "$A";
- env A='curl -d @f URL' sh -c 'eval "$A"', whose value kept a stray
  quote;
- A='curl ...'; eval -- "$A", where -- became the payload's command;
- A='curl -d @f URL'; $A, and A=curl; $A -d @f URL.

Declaration builtins and env now assign through _network_assignment like a
bare assignment, eval drops a leading --, and an unquoted $VAR command word
is replaced by the words its value splits into before it is classified. A
declaration runs nothing itself, so its words are no longer read as a
wrapped client. A plain GET through any of these forms is still not a
finding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Bring #28's 19 new commits (Harbor 0.24.0, the Tier 3 collector and
plugin-signals rewrites, plugin evaluation fixes) into this branch and
resolve the 42 conflicting files.

Where both sides changed the same behaviour, #28's approach is kept
unless this branch covered a tested case it did not; this branch's
refactors are ported onto #28's code:
- validators: one MCP runner reader (parse_mcp_runner) covers #28's
  runner forms; URL credential rules live in url_policy, shared by MCP
  URLs, HTTP hooks and hook commands; #28's floating-version, shell -c,
  wrapper and credential rules are kept.
- plugin model: #28's repository-identity rule, with a remedy per
  cause; one agent-CLI flag walk that includes --allowedTools; the
  not-loaded state lives in plugin_states.
- verifier: eval.py and eval_core stay byte-identical; process
  substitution writes such as `tee >(cat) ~/.bashrc` are detected.
- Tier 3: one arm-collection pipeline carries #28's per-arm behaviour.
- reporting: one component index, one canary attribution rule, and
  per-agent Integration blocks in the result display.

Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The local streaming test allowed 0.5 s for the first output of a
command that exits after 1 s, and the bounded-process timeout test
killed its child after 0.1 s, before a loaded machine had started the
interpreter and written the diagnostic. Both failed under a parallel
full-suite run and passed alone.

The streamed command now sleeps 5 s with a 4 s wait for the partial
line, and the timeout test allows 5 s against a child that sleeps 30 s,
so each still proves what it tests.

Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gitleaks scans the full history of every pull request, and three
review-fix commits added high-entropy test fixtures that trip its jwt,
generic-api-key and private-key rules.

The tests now assemble those values at run time, so the tree carries
no literal Gitleaks can match, and .gitleaks.toml allowlists each
original commit for only its file and the one rule it tripped.
tests/test_ci_workflows.py pins the three entries.

Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SecurityValidator runs redact_secrets on whole SkillSpector snippets and
PII lines, and four of its patterns took quadratic time on one long run
of text, so a crafted 100 KB line that also triggered a finding stalled
Tier 1 for minutes (CWE-1333): the URL userinfo and query patterns tried
an optional scheme at every word of a run such as '-ab-ab...' and
retried a run of slashes from every slash, the query pattern re-read a
URL's path from every 'a://' inside it and scanned to the end of the
text from every '${' without a '}', and the credential flag and
assignment patterns retried every credential word of a long name
('--auth' or 'TOKEN' repeated), each time reading to the end of the run.
The userinfo pattern now starts only at the first slash of a run (the
scheme before it was always kept as written), the query step finds URL
starts once per scheme run and computes where every path and query ends
in one backward pass over the text's breaks, and the flag and assignment
patterns commit to a name's first credential word with an atomic group
and possessive repeats. Output is unchanged: no difference from the old
code on the 3,596 matching string literals in tests/, 31,215 generated
URL-ish strings, or 3.66 million token sequences; 100 KB inputs that
took 8 s to over 2 minutes now take under 0.05 s.

Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Windows Tier 2 job failed this test with a UnicodeDecodeError:
it read the Markdown report with Path.read_text() and no encoding, so
Windows decoded the UTF-8 report as cp1252. The reports are written as
UTF-8, so the test now reads all three (JSON, SARIF, Markdown) that
way.

Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rng1995
rng1995 merged commit 43f0a5b into naren/plugin-evaluation-all-tiers Oct 6, 2026
17 checks passed
@rng1995
rng1995 deleted the naren/pr28-review-fixes-2 branch October 6, 2026 19:05
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