Skip to content

feat: add internal contributorbilling capture middleware for CLI billing [IANDT-238] - #671

Open
brettgurman-snyk wants to merge 18 commits into
bg/gaf-package-for-ac-clifrom
bg/contributor-capture-middleware
Open

feat: add internal contributorbilling capture middleware for CLI billing [IANDT-238]#671
brettgurman-snyk wants to merge 18 commits into
bg/gaf-package-for-ac-clifrom
bg/contributor-capture-middleware

Conversation

@brettgurman-snyk

@brettgurman-snyk brettgurman-snyk commented Jul 28, 2026

Copy link
Copy Markdown

Summary

Adds GAF HTTP capture middleware and EmitFromCapture for CLI Active Contributor billing. Middleware records billing entities from successful product API responses into a command-scoped capture session. At CLI teardown (IANDT-240), captured records become IANDT-237 ingest POSTs.

Depends on IANDT-237 (internal/contributorbilling emit/ingest + shared BillingEmitter). Does not go live end-to-end until IANDT-240 lands (#681 + CLI #7067): FinishCommand at tearDown closes the session and emits.

Lazy-open capture (current design)

The capture session is not opened at CLI startup. It opens lazily on the first billable product API request:

snyk monitor
  → analytics.SetCommand("monitor")          # normal instrumentation
  → first billable HTTP (e.g. PUT /v1/monitor/...)
       → middleware: URL match → capture flag → billable command?
       → EnsureCaptureSessionForConfig()     # session opens here
       → parse response → bag.Add(project UUID)
  → tearDown → FinishCommand (IANDT-240)     # close + EmitFromCapture

Not in this PR: BeginCommand, WithContributorBillingCapture(), or any cliv2 billing call at command start.

What it does

Capture (internal/contributorbilling/capture)

  • Process-scoped command session (EnsureCommandSession / CloseCommandSession / ActiveCapture)

  • gate.goIsBillableCommand, CaptureEnabledForBillableHTTP, EnsureCaptureSessionForConfig, CommandNameFromRawArgs

  • Parsers per supported CLI command / API flow:

    snyk monitor (OSS)

    • Registry PUT /v1/monitor/... or PUT /v1/monitor-dependencies/...
    • 2xx response: parse uri for project public ID
    • Captured entity: project UUID, capability oss

    snyk iac test --report (IaC legacy TS path)

    • Registry POST /v1/iac-cli-share-results
    • 2xx response: parse share-result map for project IDs
    • Captured entity: project UUID, capability iac

    snyk code test --report (Code native --report)

    • Test API: POST /orgs/{org}/tests with publish_report: true and SAST scan config
    • Follow-up on 2xx: GET /orgs/{org}/tests/{test} or GET .../components → project ID
    • Captured entity: project UUID, capability code

    Native OS --report (Test API, non-legacy monitor path)

    • Same Test API create/follow-up flow with publish_report: true without SAST config
    • Captured entity: project UUID, capability oss
  • Gated on contributor_billing_capture_enabled (FFS enable-entity-contributors-publish) and billable commands

Middleware (ContributorCaptureMiddleware)

  • Wired into the default GAF round tripper for all HTTP paths
  • URL-first gating — checks matched product API traffic before reading the capture flag, avoiding config/org/FFS recursion during auth lookups
  • Lazy session openEnsureCaptureSessionForConfig on first in-scope billable request when flag + command are eligible
  • networking.SetActiveCommandResolver + analytics.GetCommand() — active CLI command from analytics, with RAW_CMD_ARGS fallback
  • Guards: known Snyk host, 2xx only, 64 KiB body cap, safe response-body replay, panic recovery

Emit bridge

  • EmitFromCapture — groups deduped capture records by capability and calls EmitContributorBilling with git contributor collection at emit time

What this PR does not include

Safe to merge without billing going live: middleware only captures when the flag is on and a billable command is active; emit does not run until IANDT-240 FinishCommand at tearDown.

Test plan

Merge order

IANDT-237 (#664)IANDT-238 (#671)IANDT-240 (GAF #681 + CLI #7067) → E2E validation


Note

Medium Risk
Adds billing HTTP emits and global network middleware that parses product API traffic; failures are fire-and-forget but mis-capture or flag rollout could post wrong or unexpected contributor data. Scope is feature-flagged and ingest at tearDown remains out of this PR.

Overview
Introduces CLI Active Contributor billing in GAF: HTTP middleware records project/target entities from successful in-scope API calls into a lazy-opened command capture session, and EmitFromCapture / EmitContributorBilling fire-and-forget POSTs to entitlements-service ingest (with optional 90-day git log contributor collection). Gated by contributor_billing_capture_enabled (FFS enable-entity-contributors-publish) and billable commands (monitor, iac test --report, code test --report).

Capture parses Registry monitor/IaC share responses and Test API create/follow-up flows (OSS/Code/IaC capabilities). ContributorCaptureMiddleware sits on the default round tripper with URL-first gating to avoid FFS/auth recursion, 64 KiB body limits, and safe body replay for downstream consumers.

Also adds workflow.PostInvokeHook / WithPostInvokeHooks, analytics.GetCommand(), and SetActiveCommandResolver for command-aware capture. End-to-end billing at CLI teardown is not wired here (depends on IANDT-240 FinishCommand).

Reviewed by Cursor Bugbot for commit 148d2e3. Bugbot is set up for automated code reviews on this repo. Configure here.

@brettgurman-snyk
brettgurman-snyk requested review from a team as code owners July 28, 2026 17:46
@snyk-io

snyk-io Bot commented Jul 28, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@snyk-io

snyk-io Bot commented Jul 28, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues
Secrets 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@snyk-pr-review-bot

This comment has been minimized.

type Capability string

const (
CapabilityOSS Capability = "oss"

@rahultoora rahultoora Jul 29, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[question]: shouldnt this be:

CapabilityOS  Capability = "os"

i.e os not oss

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

oss matches pre-existing gaf work from 2024, as well as the pitch doc

not sure if it hugely matters as long as we're consistent

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

the capability is called open source so would prefer it be called that personally what does the additional s stand for?

Comment thread pkg/networking/middleware/contributor_capture.go Outdated
Comment thread pkg/networking/middleware/contributor_capture.go Outdated
return projectID.String()
}

func parseIaCShareResponse(body []byte) []string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion]: if my understanding is correct what this is doing is "any value in this object that happens to be a UUID string is a project ID". That's a heuristic tied to today's response shape.

Is it worth adding a comment here mentioning this or even looking for the specific key instead?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

there isnt a stable key name to match, so going with adding a comment

Comment thread pkg/networking/middleware/contributor_capture_test.go
Comment thread pkg/networking/middleware/contributor_capture.go Outdated
Comment thread pkg/networking/contributorcapture/parse.go Outdated
Comment thread pkg/networking/middleware/contributor_capture.go
@rahultoora

Copy link
Copy Markdown

overall good work have some non blocking comments. Have you got a jira ticket created for the code path?

Comment thread pkg/networking/contributorcapture/emit.go Outdated
Comment thread pkg/networking/contributorcapture/emit.go Outdated

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Do we want to be codeowners of this?

Comment thread pkg/networking/contributorcapture/capture.go Outdated
@brettgurman-snyk brettgurman-snyk changed the title feat: capture project IDs from product API responses for CLI billing [IANDT-165] feat: capture project IDs from product API responses for CLI billing [IANDT-238] Jul 29, 2026
Comment thread pkg/networking/middleware/contributor_capture.go Outdated
@snyk-pr-review-bot

This comment has been minimized.

@snyk-pr-review-bot

This comment has been minimized.

@snyk-pr-review-bot

This comment has been minimized.

Comment thread pkg/networking/middleware/contributor_capture.go Outdated
@snyk-pr-review-bot

This comment has been minimized.

@snyk-pr-review-bot

This comment has been minimized.

Comment thread pkg/networking/middleware/contributor_capture.go Outdated
@jonnyowenpowell

Copy link
Copy Markdown

This is very invisible to the product teams who own these flows/APIs. How do we plan to avoid silent breakages if their APIs change, they migrate onto new APIs, etc. ?

@snyk-pr-review-bot

This comment has been minimized.

@cursor cursor 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.

Automated verification — PR #671

Ran a 4-lens verification pass (semantic analysis, adversarial review, security scan, code review) against the full branch diff (main...db4f815).

Not a dependency-only change — no go.mod/go.sum changes; this adds substantial new functionality (49 files, ~5,678 lines: internal/contributorbilling/*, pkg/networking/middleware/contributor_capture.go, plus edits to public pkg/ packages). Per current policy, this automation does not auto-approve non-dependency changes, and will never leave the PR in CHANGES_REQUESTED — findings below are informational only and do not block merge through this review.

Critical

  • Request body truncated for real outgoing requests. readRequestBody (pkg/networking/middleware/contributor_capture.go) discards any request body content beyond the 64 KiB capture cap before the real request is forwarded via m.next.RoundTrip(req). Any POST /orgs/{org}/tests (code test --report / iac test --report) call with a body over ~64 KiB is corrupted, not just the billing telemetry. Confirmed independently by two review passes. Unlike the response-body path (readResponseBody), which correctly stitches the unread remainder back via stitchedReadCloser, there is no equivalent preservation on the request side, and there's no test coverage for this path.
  • Process-wide capture-session singleton is never closed by production code. commandSession (internal/contributorbilling/capture/session.go) is a single package-level instance; CloseCommandSession/OpenCommandSession are only called from tests in this diff. Once the contributor_billing_capture_enabled flag is on, the middleware's own EnsureCaptureSessionForConfig call opens a session that nothing closes — records accumulate indefinitely in a long-lived host process (IDE/MCP), and concurrent/unrelated billable commands in the same process can have their records merged into one bag (the repoPath of a second command is silently discarded once a session is already active). This also means the middleware is not fully inert independent of IANDT-240, contrary to the PR description's safety framing.
  • readResponseBody silently truncates on a read error without propagating it. On a partial/failed body read, the function swaps in only the partial bytes with no error signaled to the real caller and without closing the original body — a genuine transport error (TCP reset, TLS failure) can be misread by real product code as a truncated-but-valid response.

Should Fix

  • Breaking public API change without a breaking-change marker. Adding GetCommand() string to the exported analytics.Analytics interface (pkg/analytics/analytics.go) breaks any downstream (CLI/IDE/MCP) type that implements this interface directly. None of the branch's commits use feat!/BREAKING CHANGE:, so semantic-release won't bump the major version despite this being a load-bearing interface change per this repo's own AGENTS.md.
  • readRequestBody's error path doesn't restore/close req.Body, so a mid-stream read failure can send the real CreateTest request with a corrupted body.
  • pkg/utils/git/contributors.go: the commit-log scan breaks on the first commit older than the window, assuming strictly date-ordered traversal — go-git's default order is graph/topological, not author-date, so rebases/cherry-picks can cause a valid in-window commit to be skipped, silently undercounting a contributor for billing.
  • Contributor email (PII) is logged at Debug level in internal/contributorbilling/payload.go.

Suggestions

  • Capability/EntityType constants are duplicated as typed values in capture and untyped strings in internal/contributorbilling/constants.go, bridged by a manual switch — a single source of truth would remove the sync risk.
  • SkipReasonMissingCapability in internal/contributorbilling/types.go appears to be dead code (never produced).
  • No test exercises readRequestBody's truncation path, nor concurrent EnsureCommandSession calls with different repoPaths — both would have caught the Critical findings above.

Note on two discarded findings

An earlier pass flagged an apparent regression in pkg/auth/oauth2authenticator.go / pkg/auth/authHost.go (OAuth host-validation hardening) and a CircleCI context removal in .circleci/config.yml. I verified these against the actual merge-base diff (git diff $(git merge-base main HEAD) HEAD) and confirmed this PR branch makes zero changes to those files — the apparent diff was an artifact of the branch being behind current main (which has since merged unrelated security hardening), not something introduced by this PR. Excluded from the findings above; will resolve naturally on rebase/merge.

CI status at time of review

Most checks green (unit tests, Windows tests, security scans, secrets scan, Snyk code/license/security checks); CircleCI Build and test_and_release were still pending.


Verdict: no action taken automatically (not a dependency-only change). Findings are informational — this review is posted as a comment, not a change request.

Open in Web View Automation 

Sent by Cursor Automation: Automatic PR verification

PeterSchafer and others added 18 commits August 6, 2026 17:48
Co-authored-by: Cursor <cursoragent@cursor.com>
The multi-item timeout test can run overlapping HTTP handlers after the
client times out; use atomic.Int32 so go test -race passes in CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
…[IANDT-237]

Address AGENTS.md audit findings: fix WaitWithTimeout race, absolutize repo
paths at emit time, filter git contributors by author date, preserve ingest
URL path prefixes, optional Capability, ApplyFromConfiguration, and WaitBudget
for multi-item teardown.

Co-authored-by: Cursor <cursoragent@cursor.com>
Relocate contributor billing and the entitlements ingest client under
internal/contributorbilling/ per packaging review. Add pkg/clibilling as a
thin emit/wait facade; capture and middleware remain IANDT-238.

Co-authored-by: Cursor <cursoragent@cursor.com>
Fix import ordering so make lint passes in CI Build job.

Co-authored-by: Cursor <cursoragent@cursor.com>
…-237]

Before each ingest POST, collapse duplicate emails in the contributor
list using case-sensitive matching and keep the latest commit date.

Co-authored-by: Cursor <cursoragent@cursor.com>
Send contributors_entity_type and bare UUID contributors_entity_id, and
remove the temporary pkg/clibilling public facade from the emit package PR.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
HTTP capture middleware with command session, FFS gating, URL-first lazy
session open, analytics command resolver, and EmitFromCapture bridge.

Co-authored-by: Cursor <cursoragent@cursor.com>
@brettgurman-snyk
brettgurman-snyk force-pushed the bg/contributor-capture-middleware branch from db4f815 to 148d2e3 Compare August 6, 2026 16:16
@brettgurman-snyk
brettgurman-snyk changed the base branch from main to bg/gaf-package-for-ac-cli August 6, 2026 16:17
@snyk-pr-review-bot

Copy link
Copy Markdown

PR Reviewer Guide 🔍

🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Breaking Interface Change 🔴 [critical]

The Analytics interface is exported in pkg/ and is part of the framework's public API. Adding GetCommand() string to this interface is a breaking change for any downstream consumer that implements its own analytics backend. According to the stability checklist in AGENTS.md, changing an exported interface signature is breaking and requires a major version bump or a more additive approach (e.g. checking for an optional interface).

GetCommand() string
Systemic Risk: Process Singleton 🟠 [major]

The commandSession variable is a package-level global singleton. This design assumes only one billing-scoped interaction occurs per process at any time. As documented in AGENTS.md (Cater to multiple application types), GAF must support IDEs and MCP servers where multiple workflows may run concurrently. In such environments, simultaneous invocations will overwrite or read each other's capture state via this shared singleton, leading to billing data corruption or cross-talk between different users/orgs in the same process.

var commandSession session
Possible Performance Regression 🟡 [minor]

The new firePostInvokeHooks is called synchronously and inline within Invoke. Because Invoke is the primary entry point for all framework operations, any blocking or resource-intensive hook (like network I/O or heavy git log traversal) will delay the completion of every top-level workflow. While the documentation in types.go warns implementations should be lightweight, the framework doesn't provide a way to fire hooks asynchronously or bound their execution time.

e.firePostInvokeHooks(hookCtx, id, err)
📚 Repository Context Analyzed

This review considered 101 relevant code sections from 15 files (average relevance: 0.96)

🤖 Repository instructions applied (from AGENTS.md)

@NikkiL-Snyk
NikkiL-Snyk force-pushed the bg/gaf-package-for-ac-cli branch from 8ff205b to 2d4a94b Compare August 10, 2026 16:53
Comment on lines 38 to 79

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
iter, err := repo.Log(&git.LogOptions{})
if err != nil {
return nil, fmt.Errorf("read log: %w", err)
}
defer iter.Close()
authors := make(map[string]time.Time)
iter.ForEach(func(c *object.Commit) error{
when, email := c.Author.When, c.Author.Email
if when.Before(since) {
return nil
}
if prev, ok := authors[email]; ok && when.Before(prev) {
return nil
}
authors[email] = when
return nil
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

^ iterate through all commits on all branches, using authored date as cutoff and latest commit date value, and don't limit it to "now", i.e. count commits that are logged as being in the future

@NikkiL-Snyk
NikkiL-Snyk force-pushed the bg/gaf-package-for-ac-cli branch 5 times, most recently from 643ddf2 to 7b6cebb Compare August 11, 2026 16:40
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.

6 participants