Multi-harness support: skill adapters + pluggable scan engines (Hermes, Copilot CLI, Codex) - #31
Conversation
The scanner skill was usable only through Claude Code. Keep the repo-root skill directories as the single source of truth and add a stdlib-only renderer (scripts/render_skills.py) that applies declarative per-harness transforms (adapters/<name>/adapter.json) into dist/<name>/: - claude-code: identity transform; a golden test enforces byte-identity with the sources so the existing Claude path cannot silently regress - hermes / copilot / codex: SKILL_DIR token, subagent-dispatch wording (delegate_task / fleet / sequential self-execution), terminology overlays, frontmatter for Hermes toolsets, calibration-notice model gating (proceeds on any selected model - no enforcement) - missing find-strings fail the render loudly, so upstream prompt edits that break an adapter surface in tests, not mid-scan - install.sh gains --target claude-code|hermes|copilot|codex - docs/: ENGINES.md (usage), ADAPTER_GUIDE.md (adding a harness), engine-matrix.md (verified per-harness capabilities)
The headless runtime was bound to the Claude Agent SDK. Introduce a ScanEngine protocol (agent/engines/) so the same scan can be driven by other agent CLIs: - claude-code: existing SDK path, unchanged - __main__ still calls the module-level run_vulnhunt so existing monkeypatch seams keep working - hermes / copilot / codex: subprocess engines sharing the SDK path's pre-staging contract (results dir, git context, pre-resolved metadata kickoff, audit events) and judging success by the results directory, never stdout; per-engine timeout, binary, provider and extra-args knobs - hermes kickoff teaches the async-delegation wait protocol (top-level delegate_task runs in the background; poll action='list' until children complete) - without it a headless session exits and kills in-flight subagents mid-scan - config: [scan] engine selector + engine_* fields, validated against the registry; [anthropic] keeps working unchanged for the default engine
The batch/benchmark harness shelled out to the claude CLI directly. Parametrize it per engine so benchmark runs can compare harnesses and models on the ground-truth corpus: - VULNHUNT_HARNESS_ENGINE selects the scan engine; the claude-code argv is byte-identical to before (tests snapshot it) - VULNHUNT_HARNESS_JUDGE_ENGINE selects the judge independently, so a scan run under one engine can be judged under another (avoids self-preference bias); judges without a --system-prompt flag carry the system prompt inside the prompt text - CI: add a skill-renderer job that renders every adapter and runs the golden/drift tests on each PR
schenksj
left a comment
There was a problem hiding this comment.
First off — thank you for this. It's a genuinely thoughtful contribution: the three-layer split (skill rendering / headless engines / benchmark harness) is the right decomposition, the byte-identity golden test on claude-code is a clever invariant that protects the existing path far better than a review checklist would, and the PR description is unusually honest about what's verified versus what isn't. Documenting Copilot's flag surface as unverified rather than quietly shipping it is exactly the right call, and docs/ADAPTER_GUIDE.md means the next harness won't have to reverse-engineer your reasoning. I really enjoyed reading it.
I'm requesting changes for one correctness issue and a couple of one-liners, plus a duplication concern that I think is worth resolving before merge specifically because it multiplies the cost of fixing the correctness issue. None of it is structural — the design holds up.
Evidence of passing
The one thing I can't verify from here: the repo's own test workflow never ran on this PR. The only checks present are Mend License, Mend Security, Socket ×2, and CLA. The tests workflow (the pytest matrix from #27) is being held for maintainer approval since this is a fork PR from a first-time contributor — and that includes the new skill-renderer job this PR adds. A maintainer will need to approve the run before the claimed numbers are CI-backed.
I did reproduce them locally (Python 3.12, uv), and they hold up:
| Suite | PR claim | Reproduced |
|---|---|---|
| harness | 177 passed | ✅ 177 passed |
| vulnhunter-agent | 1147 passed | ✅ 1146 passed, 1 skipped |
| root renderer tests | 12 | ✅ 12 passed |
render_skills.py --adapter all |
clean | ✅ clean |
| vulnhunter-fix | 549 / 10 skipped | not run — untouched by the diff |
So the test claims are accurate. Thanks for putting real numbers in the description; it made this much faster to check.
Blocking
1. Non-zero engine exits are reported as successful scans. Details inline on hermes.py. Short version: the engines pre-create the results directory, and _find_results_dir judges success by that directory's existence — so it always finds the one the engine just made. The returncode != 0 and found is None guard is unreachable in production, and a crashed or OOM-killed engine returns an empty results dir as a clean scan. For a security scanner, "found nothing" and "died before looking" have to be distinguishable, so I'd like this one settled before merge.
2. COPY_IGNORE never fires (render_skills.py:224) — a one-line argument-shape bug that lets __pycache__/.venv/.pyc into rendered bundles. Inline, with a repro.
3. --check can't work as documented. This PR gitignores dist/, but check_render compares a fresh render against the committed dist/<name> — with nothing committed it always reports "not rendered yet" and exits 1. CI correctly doesn't use it, but the module docstring and the PR's validation section both cite it as a verification mechanism. Either point it at a supplied baseline or drop it; right now it's a promise the repo layout can't keep.
4. Please collapse the three subprocess engines before merge. Flagged inline on hermes.py:107. This is normally a "nice to have," but here hermes.py / copilot.py / codex.py contain the same ~90-line run_scan three times, which means issue #1 above has to be fixed three times and can regress independently in each. A SubprocessEngine base with _binary_name / _skill_path / _build_command / _build_kickoff hooks removes ~200 lines and makes the fix land once.
Non-blocking, worth a follow-up
Two independent engine registries. The agent has ENGINE_NAMES + get_engine; harness/local_harness/config.py has its own ENGINES dict; and the per-engine argv knowledge is duplicated between harness/scan.py/judge.py and the agent engine modules (hermes' file,delegation toolsets, codex's -s workspace-write, etc. appear in both places). Skill paths ~/.{claude,hermes,copilot,codex}/skills/vulnhunt are hardcoded in four files plus install.sh. Adding a fifth harness means editing 6+ locations, which works against the extensibility the PR is aiming for. One shared table (name → binary, skills dir) would fix it.
Reference engine is unexercised in production. __main__.py branches on engine.name == "claude-code" and calls run_vulnhunt directly, so ClaudeCodeEngine only ever runs in tests. The monkeypatch-seam rationale is sound and I'd keep the behavior for this PR — but it means the new protocol's reference implementation has no production consumer. Worth moving the test seam later so the abstraction is actually load-bearing.
Test coverage gaps (none blocking, but each is a small addition):
harness/scan.pydoesfrom .config import ENGINE— bound by value at import, soimportlib.reload(config)intest_default_engine_is_claude_codedoesn't propagate toscan.ENGINE. The tests don't notice because they always passengine=explicitly, leaving the env-var →scan_folder()default path untested.- Copilot and Codex have 2–3 tests each versus Hermes' 7 — no timeout, non-zero-exit, or extra-args coverage, despite being character-for-character the same code paths. (Folding them into a shared base per #4 would let one test class cover all three.)
totals_outis accepted and ignored by all three subprocess engines, so cost/token totals silently report zero for non-Claude runs. Worth a line indocs/engine-matrix.mdeven if implementing it is out of scope.- The timeout path's
proc.kill()reaches only the direct child; hermes/codex subagent processes are orphaned. Alsoengine_timeout_seconds = 0currently means "time out instantly" rather than "no timeout" and isn't validated — the timeout test depends on that reading. - Only 1 of hermes' 11 transforms sets
count. Literal find/replace over prompt prose without an expected count means a source edit that duplicates or partially rewords a phrase renders cleanly but transforms wrongly. Requiringcounton every non-optionalsubstitute would make that tripwire actually trip — which is the whole point of the drift tests, and they're a good idea worth strengthening.
Small dead-code items: ScanOutcome and _EFFECTIVE_TOOLS (copilot) are defined but never used — inline notes on both. _HERMES_SKILL_CANDIDATES is a one-element tuple named "candidates".
Thanks again for the care that went into this, and for offering to reshape the commits — the current slicing reads well, so I wouldn't change it. Happy to re-review quickly once the results-contract issue and the two one-liners are addressed; I don't think any of this is far from done.
Resolves schenksj's requested changes on PR capitalone#31. Blocking: 1. Non-zero/empty engine runs no longer pass as clean scans. The subprocess engines pre-create the results dir, so judging success by its existence reported a crashed/OOM/non-zero engine as "found nothing". Success is now contents-based: runner._results_dir_is_complete requires the skill's README.md (>100 bytes, matching the harness's has_valid_results). A non-zero exit is always a failure. The fix lands once in the new SubprocessEngine base, not three times. 2. COPY_IGNORE now actually fires. shutil.ignore_patterns needs an iterable of names, not a bare str; the arg-shape bug let __pycache__/*.pyc/.venv leak into rendered bundles. Centralized in render_skills.is_ignored() and covered by a regression test. 3. render_skills --check works against a supplied --baseline instead of the gitignored (never-committed) dist/, which always failed. Errors if --baseline is omitted; module docstring corrected. 4. hermes/copilot/codex collapse onto a SubprocessEngine base (agent/engines/_subprocess.py) with hooks (_binary_name, _skill_paths, _build_command, _build_kickoff). ~90 dup lines x3 -> one path. EngineError moved to engines/__init__ (re-exported from hermes for compat). Non-blocking follow-ups also addressed: - engine_extra_args: native TOML list (shlex.split fallback for strings) so `--allow-tool shell(ls,cat)` survives; the old .split(',') shredded it. - harness config no longer raises at import time; validation deferred to build_scan_command/build_judge_command/validate_engine so a typo'd env var can't break `import local_harness.config`. scan.py/judge.py read config.ENGINE/JUDGE_ENGINE at call time (the env-var default path is now tested). - engine_timeout_seconds <= 0 means "no timeout" (was "instant timeout"). - Dead code removed: ScanOutcome, copilot _EFFECTIVE_TOOLS. - Drift tripwire strengthened: every non-optional substitute must declare `count`; enforced by tests and applied to all adapters. - docs/engine-matrix.md documents the totals_out=0 (no cost/token totals), contents-based success, timeout, and orphaned-subprocess limitations. Tests: agent 1167 passed, harness 180 passed, root renderer 15 passed. Engine coverage extended: the shared contract (success/non-zero/timeout/ extra-args) is parametrized across all three subprocess engines.
…s contract Follow-ups from the second review pass: - _apply_drop now fails loudly like every other applicator: a missing `files` key or a pattern that matches nothing raises TransformError instead of an opaque crash / silent no-op. Restores the fail-loud invariant (latent until an adapter uses `drop`, but inconsistent). Covered by TestDropValidation. - docs/ENGINES.md described success as `*_VULNHUNT_RESULTS_*` + scan_manifest.json; realigned with the implementation (and engine-matrix / ADAPTER_GUIDE): subprocess success requires the README.md report, since the engines pre-create the directory.
What
Make VulnHunter harness-agnostic while leaving the Claude Code path byte-for-byte identical. The same scanner skill, batch harness, and headless runtime can now be driven by other agent CLIs, selected per run:
./install.sh(unchanged)./install.sh --target hermeshermes chat -Q -s vulnhunt./install.sh --target copilotcopilot -p …./install.sh --target codexcodex exec -C … -s workspace-writeDesign
Three layers, each independently extensible:
Skill rendering — the repo-root skill directories stay the single source of truth.
adapters/<name>/adapter.jsondeclares declarative transforms (substitutions, terminology overlays, frontmatter additions);scripts/render_skills.py(stdlib-only) rendersdist/<name>/. Theclaude-codeadapter is the identity transform andtests/test_render_skills.pyenforces byte-identity with the sources, so any source edit is a deliberate prompt change every adapter inherits. Missing find-strings fail the render loudly, so an upstream prompt edit that breaks an adapter shows up in tests, not mid-scan.Headless engines (
vulnhunter-agent/agent/engines/) — aScanEngineprotocol with the existing SDK path (claude-code) plus subprocess engines (hermes,copilot,codex). All engines share the SDK path's pre-staging contract (pre-created results dir, git context, pre-resolved-metadata kickoff, audit events) and judge success by the results-directory contract, never stdout. Theclaude-codepath is behaviorally untouched —__main__still calls the module-levelrun_vulnhunt, so existing test seams hold. Selected via[scan] enginein the agent TOML (defaultclaude-code).Benchmark harness engines —
VULNHUNT_HARNESS_ENGINEparametrizes batch scans;VULNHUNT_HARNESS_JUDGE_ENGINEparametrizes the judge independently (scan under one engine, judge under another to avoid self-preference bias). The claude argv is byte-identical to before (snapshot tests).Notable adaptation details:
delegate_taskin the background; a headless session that concludes early kills in-flight subagents mid-scan. The hermes kickoff/skill therefore teaches an explicit wait protocol (pollaction: "list"until every child completes). Found and fixed during live validation.claude-coderender keeps the upstream STOP gate byte-identically.Untouched by design:
scan_manifest.jsonschema, results-directory layout, publish/issues/audit/verify stages, ground-truth data, and thevulnhunter-fixpackage (its validators are already provider-neutral).How I validated
skill-rendererjob.--check --baseline <dir>verifies a fresh render matches a supplied baseline (dist/is gitignored and rendered fresh, so there is no committed tree to diff against);dist/claude-codebyte-identity to the sources is enforced bytests/test_render_skills.py.hermes skills list, headless-Qcontract verified,skill_viewresolves${HERMES_SKILL_DIR}, and a scoped Phase-1 recon run on a synthetic repo produced a methodology-conformantphase1_output.md(all planted sinks found, test dirs excluded, partition table correct). A full interactive scan of a real repo on a non-Opus model completed Phases 1–2 at the full 7×3+1 fan-out with the merged candidate inventory matching the methodology.codex execskill discovery verified against codex-cli 0.147.0.Caveats / follow-ups
VULNHUNT_HARNESS_ENGINEnow makes that a config choice).docs/ADAPTER_GUIDE.mddocuments how to add the next harness and the invariants to preserve.Thanks for considering this — happy to split or reshape the commits if maintainers prefer a different slicing.
Update — review remediation (commits
81dcb41,f15bb39)Addresses @schenksj's review. All 11 inline threads replied to and resolved.
Blocking
runner._results_dir_is_complete()requires the results dir to contain the skill'sREADME.mdreport (the engines pre-create the dir, so existence proves nothing); any non-zero exit is a failure. Lands once in the newSubprocessEnginebase.COPY_IGNOREfixed — correct(dir, [name])shape viarender_skills.is_ignored();__pycache__/*.pyc/.venvno longer leak into bundles. Regression-tested.--checkreworked to compare against a required--baseline(dist/ is gitignored); errors without one; docstring corrected.SubprocessEngine(agent/engines/_subprocess.py); ~270 duplicated lines removed;EngineErrormoved toengines/__init__.Non-blocking follow-ups also done:
engine_extra_argsas a native TOML list (shlex.splitfallback); harness config validates at use, not import;scan.py/judge.pyread the engine at call time (env-var default path now tested);engine_timeout_seconds <= 0= "no timeout"; dead code removed (ScanOutcome, copilot_EFFECTIVE_TOOLS); drift tripwire now requirescounton every non-optional substitute;_apply_dropfails loudly;docs/engine-matrix.md+ENGINES.mddocument the totals/timeout/success-contract limitations.Tests: vulnhunter-agent 1167 passed, harness 180 passed, root renderer 18 passed. Engine contract (success / empty-dir / non-zero / timeout / extra-args) parametrized across all three subprocess engines.