Skip to content

Add pluggable LLM providers (anthropic, spark/vLLM) - #1

Open
markus-mohemian wants to merge 10 commits into
mainfrom
provider-abstraction
Open

Add pluggable LLM providers (anthropic, spark/vLLM)#1
markus-mohemian wants to merge 10 commits into
mainfrom
provider-abstraction

Conversation

@markus-mohemian

Copy link
Copy Markdown

Summary

Puts every LLM call in the security review behind a provider so the action can run against either Anthropic's Claude Code or a self-hosted Qwen3 on vLLM. provider defaults to anthropic, so existing workflows are unaffected.

How to test

  1. Unit tests — CI runs these (Test ClaudeCode Integration). Locally:
    pip install pytest -r claudecode/requirements.txt -r claudecode/requirements-anthropic.txt
    PYTHONPATH=$PWD pytest claudecode -q     # expect 261 passed
  2. The action itself — the Spark provider smoke test job on this PR runs action.yml end to end against scripts/fake-vllm-server.py. Check its log for audit + filtering both reached vLLM with the configured model and no Claude Code and no Anthropic SDK on the spark path, and look for the security review comment it leaves on this PR.
  3. Dry-run a provider against any PR — prints findings JSON to stdout, posts nothing:
    python3 scripts/fake-vllm-server.py --port 8000 --mode findings &
    GITHUB_TOKEN=$(gh auth token) GITHUB_REPOSITORY=owner/repo PR_NUMBER=123 \
    LLM_PROVIDER=spark ENABLE_CLAUDE_FILTERING=true \
    VLLM_BASE_URL=http://localhost:8000/v1 VLLM_MODEL=Qwen3-32B \
    REPO_PATH=$PWD PYTHONPATH=$PWD python claudecode/github_action_audit.py
    Swap --mode for empty, malformed, prose, toolong or flaky to check the failure paths; each is documented in the README table.
  4. Against the real DGX Spark — same command with VLLM_BASE_URL pointing at the box. Then in .github/workflows/spark-smoke.yml, change runs-on to the self-hosted runner, drop the "Start fake vLLM" step, and point the inputs at it.
  5. Backwards compatibility — omit provider entirely and confirm the run still invokes claude --output-format json --model claude-opus-4-1-20250805 --disallowed-tools 'Bash(ps:*)' and produces the same output shape.

The security review made two Anthropic-bound LLM calls: the audit itself
(Claude Code CLI, agentic with repository tools) and per-finding
false-positive filtering (Anthropic Messages API). Both now sit behind a
provider, so choosing a provider chooses every model call.

- claudecode/providers.py: provider protocol, factory and the Anthropic
  adapter, which delegates to the untouched SimpleClaudeRunner and
  ClaudeAPIClient.
- claudecode/spark.py: Spark provider talking to an OpenAI-compatible vLLM
  endpoint. Single stateless completion, no tool access, no agent loop.
  Model output is validated against the finding schema (required fields,
  severity whitelist, path traversal, length caps) before it can reach
  GitHub; malformed output fails the run rather than being dropped.
- claudecode/filter_prompts.py: the filtering prompts, extracted so both
  providers share them without importing the Anthropic SDK.
- prompts.py grows an `agentic` flag that swaps the tool-usage instructions
  for context-only ones. Default keeps the existing prompt byte-for-byte.
- The Anthropic SDK import is now lazy and moved to a separate requirements
  file, so provider=spark installs neither it nor Claude Code.
- filter_prompts.read_repo_file refuses to read outside REPO_PATH. The path
  comes from model output, which injected repository content can influence.

action.yml gains `provider` (default `anthropic`) plus vllm-* inputs that
fall back to VLLM_* environment variables, and validates configuration per
provider before any model call. Workflows that omit `provider` behave
exactly as before.
- action.yml rejects an unsupported provider in the install step, before
  Claude Code and the Anthropic SDK are installed, instead of minutes later.
- The gh install guard no longer risks SIGPIPE under pipefail.
- sast.yml skips the review step when CLAUDE_API_KEY is unset, so the fork's
  own workflow does not go red before a secret is configured.
- Cover main()'s PROMPT_TOO_LONG retry on the Spark path end to end: the
  retry must review a truncated diff, not silently review nothing.
The unit tests mock requests.post, so the HTTP layer was never exercised.
scripts/fake-vllm-server.py stands in for vLLM over real HTTP and can be put
into each failure mode (malformed findings, prose, context overflow, 500s).

Running it that way surfaced one real defect: when the reduced-diff retry is
also rejected as too long, main() printed the internal PROMPT_TOO_LONG
sentinel. It now says what to do about it.
Runs action.yml end to end against the fake vLLM server on a hosted runner:
the provider case statements, the conditional installs, VLLM_* supplied via
env rather than inputs, and PR commenting. Needs no DGX Spark and no secrets,
so it runs on every PR.

It then asserts what unit tests cannot: that both the audit and the filtering
call reached vLLM with the configured model, that neither the claude CLI nor
the anthropic SDK was installed, and that an unsupported provider is rejected.

The fake server now anchors its canned finding to the first real hunk in the
PR diff, so the review comment GitHub receives points at a line that actually
exists.
The first CI run went green while the audit had actually failed. Two bugs,
both in the test harness rather than the action:

- The fake server decided audit-vs-filter by looking for a marker string in
  the user prompt. That prompt contains the PR diff, and this PR adds a file
  containing the marker, so the audit call was answered with a filtering
  verdict and the action correctly rejected it as "missing the 'findings'
  key". It now keys off the system prompt, which the action authors and the
  repository under review cannot influence.
- The readiness probe was a POST to the completions endpoint, so it appeared
  in the log as an audit call and satisfied the assertion on its own. It is
  now a GET on /health.

The job also asserts findings-count == 1, so a failed audit can no longer
pass. Verified against this PR, whose diff is the case that broke it.
@@ -13,10 +13,14 @@ jobs:
permissions:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Security Issue: User input is interpolated into a SQL query

Severity: HIGH
Category: sql_injection
Tool: ClaudeCode AI Security Analysis

Exploit Scenario: Attacker submits 1' OR '1'='1 as the search parameter

Recommendation: Use parameterised queries

Against the real box (Qwen3.8-27B on a DGX Spark) the audit failed with
"vLLM response message contained no content". The cause: vLLM generates a
reasoning model's trace before any answer and charges it to max_tokens, so a
16384-token budget -- PROMPT_TOKEN_LIMIT, an Anthropic-era constant that was
never an output budget -- was spent thinking about a 40k-token diff without
ever reaching the JSON.

- The budget is now its own constant, defaulting to 32768, overridable with
  VLLM_MAX_TOKENS.
- VLLM_ENABLE_THINKING=false turns reasoning off via chat_template_kwargs.
  The same review then completes in under a minute instead of timing out.
- finish_reason "length" is now an error that names the cause and both ways
  out, rather than the useless "contained no content". A truncated report is
  never parsed as findings.

Verified end to end against the real model: PR #2 plants a SQL injection, a
command injection and a path traversal, and all three are found and survive
filtering.
A subscription OAuth token authenticates the Claude Code CLI but is rejected
by the Messages API with 401, so the two Anthropic call sites cannot share
one credential. Verified against the real CLI with a bogus-token control to
make sure a local login was not masking the result.

- Either credential now satisfies validation.
- With an OAuth token the audit runs and false-positive filtering falls back
  to the hard exclusion rules, with a warning. Degrading silently would make
  a noisier report look like a clean filter pass.
- action.yml gains claude-code-oauth-token, with the usual env fallback.

Verified end to end against PR #2: the anthropic provider finds all three
planted vulnerabilities using only an OAuth token.
Every provider now accumulates token usage into a shared UsageStats, and the
action renders provider, model, per-phase duration, LLM calls, token counts,
cost and finding counts to $GITHUB_STEP_SUMMARY. The same figures land in the
results JSON under run_stats for later steps to consume.

Cost is null rather than zero when a provider does not price its calls. A
self-hosted vLLM has no per-call cost, and rendering $0.00 would be a claim
rather than an absence, so the table says "not reported".

Assembling the summary is wrapped: a completed review must never be lost
because its decoration could not be built. That guard is not hypothetical --
it first surfaced as a crash that turned successful runs into exit code 2.

Verified by extracting the jq program out of action.yml and running it
against real output from both providers, plus the error and legacy-results
paths.
The smoke test could not have caught a broken summary: the fake vLLM
returned no usage block, so the table would have rendered all zeros and
still passed. The server now reports token counts, and the job reads back
$GITHUB_STEP_SUMMARY and asserts the rows are present, the token counts are
non-zero, and cost reads "not reported" rather than $0 for a local endpoint.

Checked with a negative control: a table with zeroed tokens is rejected.
The assertion added in the previous commit failed, and it was right to:
GITHUB_STEP_SUMMARY is per-step, so a later step cannot read back what the
composite action wrote. The table had rendered fine; the check was reading an
empty file of its own.

The action now renders to security-review-summary.md in the workspace and
feeds that into the step summary, which also makes the table available to
later steps and ships it with the uploaded artifacts.

Verified by extracting both step bodies from the YAML and running them
against real results, with four mutations to prove the assertions can fail:
zeroed tokens, wrong model, and a $0.00 cost each exit 1.
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