Skip to content

D1: benchmark corpus + CI accuracy gate (closes #86) - #101

Merged
AndresL230 merged 14 commits into
mainfrom
claude/d1-benchmark-gate
May 13, 2026
Merged

AndresL230 merged 14 commits into
mainfrom
claude/d1-benchmark-gate

Conversation

@AndresL230

@AndresL230 AndresL230 commented May 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Stands up a labeled benchmark corpus + CI precision/recall gate for the scanner.

  • Adds benchmark/runner.ts, metrics.ts, schema.ts, report.ts, and an in-repo _smoke/ fixture.
  • Pins fixtures via .benchmark-fixtures-sha to recost-dev/extension_benchmark at a8d77b3 (5 hand-labeled fixtures: langchain-openai, openai-cookbook, stripe-sample, bedrock-raw-fetch, flask-mixed-providers).
  • Adds .github/workflows/benchmark.yml — clones the fixtures repo at the pinned SHA and runs the gate on every PR and main push.
  • Commits the initial benchmark/baseline.json. Initial baseline table populated in docs/accuracy/measurement.md.
  • Phase 1 also fixed two stdout-pollution bugs and the CLI directory-scan crash that blocked the runner — see commits 642539d and 77d658f.

Test plan

  • Verified locally: npm run benchmark -- --fixtures ../extension_benchmark runs all 5 fixtures and reports per-fixture + aggregate metrics. Exit 0 against the committed baseline.
  • Verified locally that a deliberate regression in src/scanner/endpoint-classification.ts (mis-classifying openai.com) trips the gate with exit 1 and a clear FAIL message.
  • All 10 unit tests in benchmark-metrics.test.ts pass (8 spec-mandated + 2 covering the methodsEquivalent dot-suffix branch).
  • All 6 schema validator tests pass.
  • CI benchmark workflow runs to completion green on this PR (will verify after push).

Closes #86.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added automated benchmark system to measure detection accuracy and track metrics across releases.
    • Implemented CI regression gate to prevent accuracy drops beyond 1 percentage point.
  • Bug Fixes

    • Removed debug logging statements from detection modules.
  • Documentation

    • Added benchmark setup and measurement guidance for contributors.
  • Tests

    • Added validation tests for benchmark schema and metrics computation.

Review Change Stack

AndresL230 and others added 14 commits May 13, 2026 02:22
Two unconditional console.log statements in scan-results.ts and local-waste-detector.ts
printed to stdout on every scan, which corrupts the --format json output stream and
makes JSON.parse fail. Other diagnostic logs in this codebase are correctly guarded
by RECOST_DEBUG_SCAN; these two were leftover debug traces. Deleting them rather than
gating because there's nothing useful here to debug.
…ripts

Adds the orchestrator that spawns the live CLI per fixture, parses its
JSON output, computes per-fixture metrics, aggregates, prints a console
report, optionally writes JSON and GITHUB_STEP_SUMMARY markdown, and
gates against benchmark/baseline.json (when present).

Notable implementation details:

- REPO_ROOT climbs two levels from __dirname because tsc emits the runner
  at <repo>/dist-test/benchmark/runner.js under rootDir: "." + outDir:
  "dist-test".
- detectedFromScan() dedupes scan results by file:line. The AST scanner
  emits one callSite per source location inside an endpoint group, but
  all callSites of a group share the group's methodSignature — including
  phantom callSites at the import line and the constructor line. Without
  dedupe the runner double-counts those phantoms and torches precision.
  Honest measurement of the phantoms remains (they show up as false
  positives), but each line is counted only once.
- methodsEquivalent() in metrics.ts is extended with a dot-suffix match
  so fixture authors who write "chat.completions.create" still match the
  AST scanner's full receiver chain "client.chat.completions.create".
- detectedFromScan() resolves callSite paths against the scan target dir
  (fixtureDir/src when present) so the resulting relative paths line up
  with the "src/..." paths used in expected.json.

Smoke run against benchmark/_smoke yields detection P/R 50%/100% and
finding P/R 100%/100% — the 50% precision reflects the two phantom
detections at the import + constructor lines, which is the honest signal.
@coderabbitai

coderabbitai Bot commented May 13, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR implements a complete benchmark infrastructure for measuring and gating scanner accuracy. It adds metric computation (precision/recall/provider attribution), a benchmark runner that executes the scanner against labeled fixtures, CI workflow integration, report formatting, and a minimal smoke fixture for testing.

Changes

Benchmark Infrastructure and CI Precision/Recall Gate

Layer / File(s) Summary
Benchmark fixture schema and validation
benchmark/schema.ts, src/test/benchmark-schema.test.ts
Defines ExpectedEndpoint, ExpectedFinding, ExpectedJson types with required sentinel fields; implements validation with per-field type checks (1-based lines, non-empty strings, true boolean literals) and optional field normalization; tests verify schema acceptance and rejection of malformed inputs.
Metric computation engine and matching logic
benchmark/metrics.ts, src/test/benchmark-metrics.test.ts
Implements computeMetrics to match expected endpoints/findings against detected results using file identity, provider/type equivalence, method equivalence (dot-suffix chains), and line-distance tolerance; aggregate sums per-fixture counts into global precision/recall and provider attribution accuracy; matchPairs performs one-to-one pairing; tests validate matching, FP/FN behavior, provider mismatches, and edge cases (empty inputs → perfect metrics).
Report formatting for console and Markdown
benchmark/report.ts
Exports formatConsoleReport and formatMarkdownReport that render MetricsReport as multi-line text or Markdown tables with current percentages, optional baseline comparison, and signed delta in percentage points.
Benchmark runner and main execution flow
benchmark/runner.ts
Orchestrates end-to-end execution: parses CLI args (--fixtures, --baseline, --update-baseline, --threshold, --smoke, --json-out), discovers fixture directories, runs compiled scanner via execFile, parses JSON output, deduplicates detections by file/line, computes and aggregates metrics, optionally updates baseline, prints console/Markdown reports, gates execution on metric drops, and writes JSON/GitHub step summary outputs.
Smoke fixture: minimal test data and helpers
benchmark/_smoke/FIXTURE.md, benchmark/_smoke/expected.json, benchmark/_smoke/src/openai-helper.ts
Hand-crafted minimal fixture with 2 OpenAI API calls (chat completion, embeddings in loop) and 1 expected batching finding; openai-helper.ts exports complete(prompt) and embedBatch(items) that invoke the OpenAI SDK for fixture execution.
CI workflow and fixture pinning
.benchmark-fixtures-sha, .github/workflows/benchmark.yml
GitHub Actions workflow triggered on push/PR to main checks out code, sets up Node 20, builds extension, reads pinned fixtures SHA from .benchmark-fixtures-sha, clones recost-dev/extension_benchmark at that SHA, runs benchmark with ./benchmark-fixtures, uploads benchmark/report.json as artifact regardless of outcome.
Benchmark documentation and baseline metrics
benchmark/README.md, benchmark/baseline.json, docs/accuracy/measurement.md
README documents quick-start commands, module layout, CI behavior, and contributor steps for adding fixtures; baseline.json contains measured baseline metrics (Detection precision/recall, Provider attribution accuracy, Finding precision/recall); measurement.md replaced "TBD" with measured baseline snapshot and documents the >1pp regression gate rule.
TypeScript configuration, package scripts, and debug cleanup
tsconfig.benchmark.json, tsconfig.scanner-tests.json, package.json, src/cli/filesystem-adapter.ts, src/scan-results.ts, src/scanner/local-waste-detector.ts
New tsconfig.benchmark.json compiles benchmark and test sources into dist-test; tsconfig.scanner-tests.json excludes src/test/benchmark-*.test.ts; package.json test:scanner script runs new benchmark tests; filesystem adapter refactored to destructure discoverFilesInDirectory result; removed debug console.log statements from mergeLocalWasteFindings and detectCacheFinding.

Sequence Diagrams

sequenceDiagram
  participant GitHub as GitHub Actions
  participant Checkout as Code Checkout
  participant Build as npm run build:ext
  participant Runner as benchmark/runner.ts
  participant Scanner as dist/cli/scan.js
  participant Fixtures as ./benchmark-fixtures
  GitHub->>Checkout: Clone repo
  Checkout->>Build: Setup + build extension
  Build->>Runner: Workflow runs benchmark
  Runner->>Runner: Discover fixture dirs
  loop For each fixture
    Runner->>Scanner: execFile with JSON output
    Scanner->>Fixtures: Scan fixture code
    Fixtures-->>Scanner: API calls detected
    Scanner-->>Runner: JSON results
    Runner->>Runner: Parse + deduplicate detections
  end
  Runner->>Runner: Load expected.json + computeMetrics
  Runner->>Runner: Aggregate + compare to baseline
  Runner->>GitHub: Upload report artifact
  alt Metric drop > threshold
    Runner->>GitHub: exit 1 (fail workflow)
  else No regression
    Runner->>GitHub: exit 0 (pass)
  end
Loading

🎯 4 (Complex) | ⏱️ ~60 minutes

🐰 A benchmark burrow built with care,
Where metrics hop through measured air,
Precision, recall, each endpoint caught,
Testing the accuracy we've sought.
With smoke and fixtures, CI gates stand tall—
Now we measure what matters most of all! 🎯

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main change: implementing a benchmark corpus and CI accuracy gate, directly matching the PR's primary objective.
Linked Issues check ✅ Passed All core coding requirements from issue #86 are met: 5+ labeled repos in corpus, npm run benchmark command, CI workflow, baseline committed, metrics published, and regression gate preventing drops >1pp.
Out of Scope Changes check ✅ Passed All changes are within scope of issue #86. Minor housekeeping (console.log removal, directory-scan fix, tsconfig updates) directly supports benchmark infrastructure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/d1-benchmark-gate

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/accuracy/measurement.md (1)

83-87: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update acceptance checklist state to reflect implemented D1 deliverables.

The checklist is still unchecked even though this section now includes measured baseline and CI gating details. Keeping it stale can mislead readers about completion status.

Suggested doc update
-- [ ] At least 5 repos in the corpus, hand-labeled.
-- [ ] `npm run benchmark` produces a metrics report.
-- [ ] CI workflow runs on every PR.
-- [ ] Baseline committed; current metrics published in this doc once measured.
-- [ ] Regression gate prevents merging PRs that drop precision/recall.
+- [x] At least 5 repos in the corpus, hand-labeled.
+- [x] `npm run benchmark` produces a metrics report.
+- [x] CI workflow runs on every PR.
+- [x] Baseline committed; current metrics published in this doc once measured.
+- [x] Regression gate prevents merging PRs that drop precision/recall.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/accuracy/measurement.md` around lines 83 - 87, Update the checklist so
each completed D1 deliverable item is marked checked: change the list entries "-
[ ] At least 5 repos in the corpus, hand-labeled.", "- [ ] `npm run benchmark`
produces a metrics report.", "- [ ] CI workflow runs on every PR.", "- [ ]
Baseline committed; current metrics published in this doc once measured.", and
"- [ ] Regression gate prevents merging PRs that drop precision/recall." to
checked form (e.g., "- [x] ...") and add a brief note or timestamp near the
checklist (or under the same section heading) noting the measured baseline and
CI gating are implemented to avoid stale state.
🧹 Nitpick comments (2)
benchmark/schema.ts (1)

84-90: ⚡ Quick win

Consider validating that required string fields are non-empty.

The validator checks that fixtureSlug is non-empty (line 65-66), but does not enforce the same constraint on file, provider, method, and type fields. Empty strings would pass validation but likely cause confusing downstream failures during metric computation or reporting.

✨ Proposed additional validation
 function validateEndpoint(value: unknown, fixturePath: string, index: number): ExpectedEndpoint {
   if (!value || typeof value !== "object") {
     throw new ExpectedJsonValidationError(fixturePath, `endpoints[${index}] must be an object`);
   }
   const e = value as Record<string, unknown>;
-  if (typeof e.file !== "string") throw new ExpectedJsonValidationError(fixturePath, `endpoints[${index}].file must be a string`);
+  if (typeof e.file !== "string" || e.file.length === 0) throw new ExpectedJsonValidationError(fixturePath, `endpoints[${index}].file must be a non-empty string`);
   if (typeof e.line !== "number" || !Number.isInteger(e.line) || e.line < 1) {
     throw new ExpectedJsonValidationError(fixturePath, `endpoints[${index}].line must be a 1-based integer`);
   }
-  if (typeof e.provider !== "string") throw new ExpectedJsonValidationError(fixturePath, `endpoints[${index}].provider must be a string`);
-  if (typeof e.method !== "string") throw new ExpectedJsonValidationError(fixturePath, `endpoints[${index}].method must be a string`);
+  if (typeof e.provider !== "string" || e.provider.length === 0) throw new ExpectedJsonValidationError(fixturePath, `endpoints[${index}].provider must be a non-empty string`);
+  if (typeof e.method !== "string" || e.method.length === 0) throw new ExpectedJsonValidationError(fixturePath, `endpoints[${index}].method must be a non-empty string`);
   if (e.must_detect !== true) throw new ExpectedJsonValidationError(fixturePath, `endpoints[${index}].must_detect must be true`);
 function validateFinding(value: unknown, fixturePath: string, index: number): ExpectedFinding {
   if (!value || typeof value !== "object") {
     throw new ExpectedJsonValidationError(fixturePath, `findings[${index}] must be an object`);
   }
   const f = value as Record<string, unknown>;
-  if (typeof f.file !== "string") throw new ExpectedJsonValidationError(fixturePath, `findings[${index}].file must be a string`);
+  if (typeof f.file !== "string" || f.file.length === 0) throw new ExpectedJsonValidationError(fixturePath, `findings[${index}].file must be a non-empty string`);
   if (typeof f.line !== "number" || !Number.isInteger(f.line) || f.line < 1) {
     throw new ExpectedJsonValidationError(fixturePath, `findings[${index}].line must be a 1-based integer`);
   }
-  if (typeof f.type !== "string") throw new ExpectedJsonValidationError(fixturePath, `findings[${index}].type must be a string`);
+  if (typeof f.type !== "string" || f.type.length === 0) throw new ExpectedJsonValidationError(fixturePath, `findings[${index}].type must be a non-empty string`);
   if (f.is_true_positive !== true) throw new ExpectedJsonValidationError(fixturePath, `findings[${index}].is_true_positive must be true`);

Also applies to: 107-112

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmark/schema.ts` around lines 84 - 90, The validator currently checks
types for endpoint fields but allows empty strings; update the validation in the
endpoint-checking block (where e.file, e.provider, e.method, e.type are
validated and ExpectedJsonValidationError is thrown) to also require non-empty
strings (e.g., typeof e.file === "string" && e.file.trim().length > 0), and
mirror the same non-empty checks in the second identical block (around the
107-112 area) so that file, provider, method and type cannot be empty; keep
error messages descriptive (use the existing fixturePath and
`endpoints[${index}]` context) when throwing ExpectedJsonValidationError.
.github/workflows/benchmark.yml (1)

1-16: ⚡ Quick win

Set explicit minimal workflow token permissions.

This workflow doesn’t declare permissions, so it inherits repo defaults. Add least-privilege permissions (e.g., contents: read) to reduce token blast radius.

Suggested patch
 name: benchmark
 
+permissions:
+  contents: read
+
 on:
   push:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/benchmark.yml around lines 1 - 16, The workflow currently
lacks an explicit permissions block; add a least-privilege permissions section
at the top-level of the workflow (e.g., under the root keys alongside
name/on/concurrency) to restrict the GITHUB_TOKEN scope—for example add
permissions: contents: read (and any other minimal permissions you need) to the
workflow named "benchmark" so the benchmark job uses a reduced-scope token
instead of inheriting repo defaults.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/benchmark.yml:
- Around line 33-42: The workflow reads an unvalidated SHA from
.benchmark-fixtures-sha and injects it directly into shell git commands in the
steps "Read fixtures SHA" and "Clone fixtures repo at pinned SHA", which allows
command injection; fix by validating the value before use (ensure it matches
/^[0-9a-fA-F]{40}$/) and then use a safe interpolation method (either export the
validated value into an env: variable and reference $PINNED_SHA in the shell, or
quote the parameter when using ${{ steps.sha.outputs.value }}), and fail the job
if validation fails so only a proper 40‑char hex SHA is ever passed to git
fetch/checkout.

In `@benchmark/runner.ts`:
- Around line 67-72: The --threshold parsing currently allows negative numbers
which breaks the gate logic in computeDrops; update the parsing code (the branch
handling "--threshold" that sets args.thresholdPp) to validate that the parsed
Number n is finite AND non-negative (n >= 0), and if not, print an invalid
--threshold message and exit with code 2; apply the same validation change to
the other threshold parsing block referenced around the 237-249 region so both
places enforce non-negative values.
- Around line 145-157: The dedupe key used in the loop (constructed as
`${relFile}:${cs.line}`) is too coarse and can collapse distinct endpoints on
the same line; update the key creation in the block that builds endpoints
(inside the for-loops over result.endpoints and e.callSites where seenEndpoints,
endpoints, relFile, cs, and e are used) to include a more specific discriminator
such as the call-site column (cs.column or cs.col if your AST uses that name)
and/or the endpoint identity (e.methodSignature or e.method) — e.g. compose the
key with `${relFile}:${cs.line}:${cs.column ?? cs.col ?? (e.methodSignature ??
e.method ?? "")}` so distinct calls on the same line are not deduplicated
incorrectly.

---

Outside diff comments:
In `@docs/accuracy/measurement.md`:
- Around line 83-87: Update the checklist so each completed D1 deliverable item
is marked checked: change the list entries "- [ ] At least 5 repos in the
corpus, hand-labeled.", "- [ ] `npm run benchmark` produces a metrics report.",
"- [ ] CI workflow runs on every PR.", "- [ ] Baseline committed; current
metrics published in this doc once measured.", and "- [ ] Regression gate
prevents merging PRs that drop precision/recall." to checked form (e.g., "- [x]
...") and add a brief note or timestamp near the checklist (or under the same
section heading) noting the measured baseline and CI gating are implemented to
avoid stale state.

---

Nitpick comments:
In @.github/workflows/benchmark.yml:
- Around line 1-16: The workflow currently lacks an explicit permissions block;
add a least-privilege permissions section at the top-level of the workflow
(e.g., under the root keys alongside name/on/concurrency) to restrict the
GITHUB_TOKEN scope—for example add permissions: contents: read (and any other
minimal permissions you need) to the workflow named "benchmark" so the benchmark
job uses a reduced-scope token instead of inheriting repo defaults.

In `@benchmark/schema.ts`:
- Around line 84-90: The validator currently checks types for endpoint fields
but allows empty strings; update the validation in the endpoint-checking block
(where e.file, e.provider, e.method, e.type are validated and
ExpectedJsonValidationError is thrown) to also require non-empty strings (e.g.,
typeof e.file === "string" && e.file.trim().length > 0), and mirror the same
non-empty checks in the second identical block (around the 107-112 area) so that
file, provider, method and type cannot be empty; keep error messages descriptive
(use the existing fixturePath and `endpoints[${index}]` context) when throwing
ExpectedJsonValidationError.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 39008be2-64c4-4040-adcc-c15af1cbce02

📥 Commits

Reviewing files that changed from the base of the PR and between 533462e and ad157f1.

📒 Files selected for processing (20)
  • .benchmark-fixtures-sha
  • .github/workflows/benchmark.yml
  • benchmark/README.md
  • benchmark/_smoke/FIXTURE.md
  • benchmark/_smoke/expected.json
  • benchmark/_smoke/src/openai-helper.ts
  • benchmark/baseline.json
  • benchmark/metrics.ts
  • benchmark/report.ts
  • benchmark/runner.ts
  • benchmark/schema.ts
  • docs/accuracy/measurement.md
  • package.json
  • src/cli/filesystem-adapter.ts
  • src/scan-results.ts
  • src/scanner/local-waste-detector.ts
  • src/test/benchmark-metrics.test.ts
  • src/test/benchmark-schema.test.ts
  • tsconfig.benchmark.json
  • tsconfig.scanner-tests.json
💤 Files with no reviewable changes (2)
  • src/scan-results.ts
  • src/scanner/local-waste-detector.ts

Comment on lines +33 to +42
- name: Read fixtures SHA
id: sha
run: echo "value=$(cat .benchmark-fixtures-sha)" >> "$GITHUB_OUTPUT"

- name: Clone fixtures repo at pinned SHA
run: |
git clone --depth 1 https://github.com/recost-dev/extension_benchmark.git benchmark-fixtures
cd benchmark-fixtures
git fetch --depth 1 origin ${{ steps.sha.outputs.value }}
git checkout ${{ steps.sha.outputs.value }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

git ls-files | grep -E "(benchmark\.yml|\.benchmark-fixtures-sha)" | head -20

Repository: recost-dev/extension

Length of output: 120


🏁 Script executed:

fd -t f "benchmark.yml" -o "\.benchmark-fixtures-sha"

Repository: recost-dev/extension

Length of output: 232


🏁 Script executed:

cat -n .github/workflows/benchmark.yml | sed -n '30,45p'

Repository: recost-dev/extension

Length of output: 810


🏁 Script executed:

cat -n .github/workflows/benchmark.yml | head -20

Repository: recost-dev/extension

Length of output: 561


🏁 Script executed:

cat .benchmark-fixtures-sha

Repository: recost-dev/extension

Length of output: 105


🏁 Script executed:

git log --oneline -n 5 -- .benchmark-fixtures-sha

Repository: recost-dev/extension

Length of output: 110


🏁 Script executed:

cat -n .github/workflows/benchmark.yml | sed -n '37,42p'

Repository: recost-dev/extension

Length of output: 426


🌐 Web query:

GitHub Actions variables shell injection escaping security

💡 Result:

GitHub Actions “variables” (contexts/expressions) can become a shell-injection risk when they’re interpolated directly into a run: (shell) script, because the ${{ ... }} expressions are evaluated by GitHub Actions first and substituted into the generated temporary shell script; untrusted input can then break out of quoting and execute attacker-controlled commands. [1][2] What to watch for 1) Inline ${{ ... }} inside run: (or script:) blocks Example pattern: run: some-command ${{ github.event.issue.title }}. The GitHub Docs explicitly show that a vulnerable workflow can be exploited when ${{ ... }} output is substituted into an inline shell script before execution. [1] 2) Untrusted sources GitHub advises treating attacker-influenced values such as issue/PR titles and bodies as untrusted input, and notes that expression evaluation can lead to script injections when used inside blocks like run. [2][3] Is escaping/quoting enough? - Naive escaping is error-prone because ${{ ... }} is replaced before the shell runs; the substituted value may still contain characters that break the script’s structure (e.g., quotes). GitHub recommends not using ${{ ... }} in run for this reason, and instead using safer patterns. [2][1] Defensive patterns that are recommended (with grounding) 1) Don’t use ${{ ... }} directly in run:; use environment variables instead GitHub’s blog recommends that ${{ }} in the run section is almost always dangerous because it macro-expands to arbitrary text in the shell command body. It recommends using ${{ }} in env: instead, then referencing the variable from the shell (example: "$TITLE" bash syntax). [2] 2) Prefer the “intermediate environment variable” approach (inline scripts) GitHub’s Secure use reference (security hardening) states that for inline scripts, the preferred approach is to set the expression result into an intermediate environment variable, so it “doesn’t interact with the script generation process,” and also advises double-quoting shell variables to avoid word-splitting. [3] 3) Consider using an action instead of inline shell script for parsing/processing GitHub states that using an action (e.g., JavaScript) that receives the context value as an argument is not vulnerable to the injection attack described, because the value is passed as an argument rather than used to generate a shell script. [3] 4) If you must serialize to JSON (to preserve structure), be careful with quoting boundaries Community guidance shows that placing ${{ toJSON(...) }} inside shell quoting can break parsing if the JSON contains characters that terminate or conflict with the surrounding quotes (e.g., single quotes or backticks/quotes depending on the quoting style). One workaround is to pass the serialized output via an environment variable, and then read/echo it from the shell using shell-safe quoting. [4] Practical “safe” template - Use env: to bring the untrusted value in, then reference it in run: as a normal shell variable (and quote it): - Set env: TITLE: ${{ github.event.pull_request.title }} - In run:, use echo "$TITLE" (double quotes) [2][3] - If you want, you can also validate/sanitize the value before using it as part of a command or argument. (GitHub’s hardening guidance emphasizes that you should ensure untrusted values do not flow into places where they could be interpreted as executable code.) [1][3] Bottom line - GitHub Actions “expression variables” are not automatically shell-escaped when interpolated into run:; they’re substituted into the shell script before execution, so untrusted content can lead to command/script injection. [1][2] - The most reliable mitigation is to move ${{ ... }} out of run: into env: (or into an action argument) and then handle the value as a quoted shell variable (double-quoted) rather than raw substituted text. [2][3]

Citations:


Validate and quote the pinned SHA before using it in shell commands.

At line 35, the SHA is read from .benchmark-fixtures-sha and interpolated into shell commands at lines 41–42 without validation or quoting. Since this workflow runs on pull requests, a malicious PR can modify the file to inject shell commands. GitHub Actions ${{ ... }} expressions are macro-expanded before shell execution, so untrusted input can break out and execute arbitrary code.

Restrict the SHA to the expected 40-character hex format and either quote the interpolation or move it to an env: block (GitHub's recommended pattern). The suggested patch below adds validation and quotes; consider alternatively using env: for stronger isolation.

Suggested hardening patch
       - name: Read fixtures SHA
         id: sha
-        run: echo "value=$(cat .benchmark-fixtures-sha)" >> "$GITHUB_OUTPUT"
+        run: |
+          SHA="$(tr -d '\n\r' < .benchmark-fixtures-sha)"
+          if ! [[ "$SHA" =~ ^[0-9a-f]{40}$ ]]; then
+            echo "Invalid fixtures SHA: '$SHA'" >&2
+            exit 1
+          fi
+          echo "value=$SHA" >> "$GITHUB_OUTPUT"
@@
       - name: Clone fixtures repo at pinned SHA
         run: |
           git clone --depth 1 https://github.com/recost-dev/extension_benchmark.git benchmark-fixtures
           cd benchmark-fixtures
-          git fetch --depth 1 origin ${{ steps.sha.outputs.value }}
-          git checkout ${{ steps.sha.outputs.value }}
+          git fetch --depth 1 origin "${{ steps.sha.outputs.value }}"
+          git checkout "${{ steps.sha.outputs.value }}"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Read fixtures SHA
id: sha
run: echo "value=$(cat .benchmark-fixtures-sha)" >> "$GITHUB_OUTPUT"
- name: Clone fixtures repo at pinned SHA
run: |
git clone --depth 1 https://github.com/recost-dev/extension_benchmark.git benchmark-fixtures
cd benchmark-fixtures
git fetch --depth 1 origin ${{ steps.sha.outputs.value }}
git checkout ${{ steps.sha.outputs.value }}
- name: Read fixtures SHA
id: sha
run: |
SHA="$(tr -d '\n\r' < .benchmark-fixtures-sha)"
if ! [[ "$SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "Invalid fixtures SHA: '$SHA'" >&2
exit 1
fi
echo "value=$SHA" >> "$GITHUB_OUTPUT"
- name: Clone fixtures repo at pinned SHA
run: |
git clone --depth 1 https://github.com/recost-dev/extension_benchmark.git benchmark-fixtures
cd benchmark-fixtures
git fetch --depth 1 origin "${{ steps.sha.outputs.value }}"
git checkout "${{ steps.sha.outputs.value }}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/benchmark.yml around lines 33 - 42, The workflow reads an
unvalidated SHA from .benchmark-fixtures-sha and injects it directly into shell
git commands in the steps "Read fixtures SHA" and "Clone fixtures repo at pinned
SHA", which allows command injection; fix by validating the value before use
(ensure it matches /^[0-9a-fA-F]{40}$/) and then use a safe interpolation method
(either export the validated value into an env: variable and reference
$PINNED_SHA in the shell, or quote the parameter when using ${{
steps.sha.outputs.value }}), and fail the job if validation fails so only a
proper 40‑char hex SHA is ever passed to git fetch/checkout.

Comment thread benchmark/runner.ts
Comment on lines +67 to +72
else if (a === "--threshold") {
const raw = requireValue(argv, ++i, a);
const n = Number(raw);
if (!Number.isFinite(n)) { console.error(`Invalid --threshold value: ${raw}`); process.exit(2); }
args.thresholdPp = n;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate --threshold as non-negative to keep gate semantics correct.

--threshold currently accepts negative numbers. With a negative value, the drop check in computeDrops becomes invalid and can fail/pass the gate incorrectly.

Suggested fix
 else if (a === "--threshold") {
   const raw = requireValue(argv, ++i, a);
   const n = Number(raw);
-  if (!Number.isFinite(n)) { console.error(`Invalid --threshold value: ${raw}`); process.exit(2); }
+  if (!Number.isFinite(n) || n < 0) {
+    console.error(`Invalid --threshold value: ${raw} (must be a non-negative number)`);
+    process.exit(2);
+  }
   args.thresholdPp = n;
 }

Also applies to: 237-249

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmark/runner.ts` around lines 67 - 72, The --threshold parsing currently
allows negative numbers which breaks the gate logic in computeDrops; update the
parsing code (the branch handling "--threshold" that sets args.thresholdPp) to
validate that the parsed Number n is finite AND non-negative (n >= 0), and if
not, print an invalid --threshold message and exit with code 2; apply the same
validation change to the other threshold parsing block referenced around the
237-249 region so both places enforce non-negative values.

Comment thread benchmark/runner.ts
Comment on lines +145 to +157
for (const e of result.endpoints) {
for (const cs of e.callSites) {
const relFile = path.relative(fixtureDir, path.resolve(scanRoot, cs.file)).replace(/\\/g, "/");
const key = `${relFile}:${cs.line}`;
if (seenEndpoints.has(key)) continue;
seenEndpoints.add(key);
endpoints.push({
file: relFile,
line: cs.line,
provider: e.provider ?? "unknown",
method: e.methodSignature ?? e.method ?? "",
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Endpoint dedupe key is too broad and can drop valid detections on same line.

Deduping by file:line alone can merge different endpoint calls that share a line, which undercounts detections and distorts benchmark metrics.

Suggested fix
-      const key = `${relFile}:${cs.line}`;
+      const method = e.methodSignature ?? e.method ?? "";
+      const provider = e.provider ?? "unknown";
+      const key = `${relFile}:${cs.line}:${provider}:${method}`;
       if (seenEndpoints.has(key)) continue;
       seenEndpoints.add(key);
       endpoints.push({
         file: relFile,
         line: cs.line,
-        provider: e.provider ?? "unknown",
-        method: e.methodSignature ?? e.method ?? "",
+        provider,
+        method,
       });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmark/runner.ts` around lines 145 - 157, The dedupe key used in the loop
(constructed as `${relFile}:${cs.line}`) is too coarse and can collapse distinct
endpoints on the same line; update the key creation in the block that builds
endpoints (inside the for-loops over result.endpoints and e.callSites where
seenEndpoints, endpoints, relFile, cs, and e are used) to include a more
specific discriminator such as the call-site column (cs.column or cs.col if your
AST uses that name) and/or the endpoint identity (e.methodSignature or e.method)
— e.g. compose the key with `${relFile}:${cs.line}:${cs.column ?? cs.col ??
(e.methodSignature ?? e.method ?? "")}` so distinct calls on the same line are
not deduplicated incorrectly.

@AndresL230
AndresL230 merged commit 226c035 into main May 13, 2026
2 of 3 checks passed
AndresL230 pushed a commit that referenced this pull request May 13, 2026
- workflow: validate fixtures SHA as 40-char hex and pass through env var instead
  of inline ${{ }} interpolation — prevents shell injection from a PR that edits
  .benchmark-fixtures-sha
- workflow: add explicit `permissions: contents: read` (least privilege)
- runner: reject negative --threshold (a negative value silently disabled the
  gate via `< -(-n)` always-false comparison)
- runner: dedupe endpoints by file:line:provider:method instead of file:line, so
  distinct calls on the same line (e.g. Promise.all([a.create(), b.create()]))
  aren't merged
- schema: require non-empty strings for file/provider/method/type — empty values
  passed type-only validation but produced silent zero-match downstream
- docs/accuracy/measurement.md: flip the D1 acceptance checklist to checked now
  that the corpus, runner, workflow, baseline, and gate are all live
@AndresL230
AndresL230 deleted the claude/d1-benchmark-gate branch May 22, 2026 22:50
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.

[Measurement] Labeled benchmark corpus + CI precision/recall gate (D1)

1 participant