Skip to content

Add --block-on to select which blocking rules gate a CI scan - #149

Merged
Ibrahimrahhal merged 7 commits into
mainfrom
ci-blocking-rules
Aug 6, 2026
Merged

Add --block-on to select which blocking rules gate a CI scan#149
Ibrahimrahhal merged 7 commits into
mainfrom
ci-blocking-rules

Conversation

@Ibrahimrahhal

Copy link
Copy Markdown
Member

Description

--fail evaluated every active blocking rule on the company, so a pipeline had no way to opt into a specific gate, and any rule authored for CI also gated pull requests through the native integration. --block-on lets a pipeline name the rules it wants enforced.

corgea scan --block-on no-criticals
corgea scan --block-on no-criticals,no-malicious-deps

Blocking rules now carry a company-scoped slug and an "Applies To" target of either Pull Requests or CI (see the companion doghouse PR). --block-on passes the slugs to check_blocking_rules as a block_on query parameter and fails the scan only on those rules.

Unusable slugs are a hard error, not a silent pass. This is the main design decision worth understanding. If a slug is unknown, inactive, or scoped to pull requests, the backend returns a structured 400 and the CLI exits 1 naming the offending slugs per category:

Unknown blocking rule(s): typo-rule
Rule(s) not scoped to CI: pr-only-rule. Change 'Applies To' to CI in the web app, or remove them from --block-on.
Inactive rule(s): old-rule. Activate them in the web app, or remove them from --block-on.

The alternative — skipping slugs we cannot resolve — would let a typo or a renamed rule quietly disable a security gate while the build stayed green. Failing loudly is the safer default for something whose whole job is to block. Note this makes renaming a rule in the web app a breaking change for any pipeline naming its old slug, since the slug is derived from the name; that trade-off is called out in the skill docs.

normalize_block_on trims and de-duplicates the list and rejects empty entries locally, so a stray trailing comma is reported as a clear message rather than an opaque server error. --block-on is blast-only and mutually exclusive with --fail and --fail-on.

--fail is deprecated but unchanged. It still evaluates every active rule and still exits 1 the same way; it now prints a deprecation warning pointing at --block-on. Existing pipelines keep working. check_blocking_rules gained an Option<&str> parameter rather than a new function, and omitting it preserves the legacy all-rules behavior — that is what --fail and corgea ls pass.

BlockingIssue::triggered_by_slugs is Option with #[serde(default)], and the failure summary falls back to rule ids, so a newer CLI still works against a backend deployed before slugs exist.

Related Issues

Requires the companion doghouse PR (slug + applies_to model fields, migration 0048, and block_on resolution on check_blocking_rules). That must deploy before any release containing this change — against an older backend, block_on is ignored and the scan would be gated by every active rule instead of the named ones.

Type of Change

  • Bug fix
  • New feature
  • Breaking changes
  • Tests

Testing

Seven new unit tests in src/scanners/blast.rs covering slug normalization (absent flag, trimming, de-duplication, and each empty-entry shape), the triggered-slug summary (de-duplication across issues and the rule-id fallback), and BlockOnError::describe (all three categories, plus the bare-server-message fallback).

$ cargo test --bins
test result: ok. 73 passed; 0 failed; 0 ignored

$ cargo fmt --check
(clean)

Note these live in a #[cfg(test)] module in the binary target, so cargo test --lib does not pick them up; use --bins.

Not yet exercised end-to-end against a running backend — the error paths are covered at the unit level from the serialized 400 body rather than over the wire.

Documentation

skills/corgea/SKILL.md documents --block-on, the CI-only restriction, the hard-error behavior, the rename-changes-the-slug caveat, and the --fail deprecation.

Made with Cursor

--fail evaluated every active blocking rule, so a pipeline could not opt
into a specific gate and any rule meant for CI also blocked pull requests.

--block-on takes a comma-separated list of rule slugs and fails the scan
only on those. It is blast-only and mutually exclusive with --fail and
--fail-on. Unknown, inactive, and PR-scoped slugs are reported by name and
exit 1 rather than passing silently, so a typo cannot quietly disable the
gate. --fail still works but now warns that it is deprecated.

Co-authored-by: Cursor <cursoragent@cursor.com>
Resolves src/list.rs: main moved the check_blocking_rules call onto an
`id` binding that skips the enrichment for code-quality listings, while
this branch added the block_on parameter. Kept both, passing None so
`corgea ls` retains the legacy all-rules evaluation.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread src/scanners/blast.rs
utils::terminal::TerminalColor::Green
)
);
std::process::exit(1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Generate requested artifacts before exiting the gate

A blocking response exits here, but report generation starts at line 364 and SBOM generation at line 458. As a result, the newly documented command corgea scan --block-on no-criticals --out-format sarif --out-file results.sarif never writes results.sarif when the rule actually trips; --sbom is skipped too. Those failing CI runs are exactly where the diagnostic artifacts are needed, and --fail-on already avoids this by evaluating its exit after artifact generation. Store the blocking result, generate requested outputs, then exit 1 (while still failing immediately on API/configuration errors), and add a stubbed CLI test combining a blocking response with --out-file or --sbom.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I agree with this finding and think it should be addressed.

high: Generate requested artifacts before exiting the gate

The blocking branch exits before report and SBOM generation later in the function. Consequently, the newly documented combination of --block-on with SARIF produces no SARIF precisely when the gate detects violations. Preserve the blocking result, generate requested artifacts, and exit afterward; API and configuration errors can still fail immediately.

Proof or reproduction:

`corgea scan --block-on no-criticals --out-format sarif --out-file results.sarif` calls `std::process::exit(1)` in this branch, so execution never reaches the later out_file handling.

Comment thread src/utils/api.rs
Comment on lines +1234 to +1238
if status == reqwest::StatusCode::BAD_REQUEST {
if let Ok(block_on_error) = serde_json::from_str::<BlockOnError>(&response_text) {
return Err(block_on_error.describe().into());
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Restrict structured parsing to block-on errors

This function is also called with block_on == None by legacy --fail and corgea ls. Because every BlockOnError field has #[serde(default)] and Serde ignores unknown fields, any JSON-object 400 (for example {"detail":"invalid scan"}) deserializes successfully here and is rewritten as Invalid --block-on value. even when that flag was never used. That regresses existing error handling and can hide the real request failure. Only interpret this schema for a block_on request and only when at least one recognized field is present; otherwise preserve the ordinary status error.

Suggested change
if status == reqwest::StatusCode::BAD_REQUEST {
if let Ok(block_on_error) = serde_json::from_str::<BlockOnError>(&response_text) {
return Err(block_on_error.describe().into());
}
}
if block_on.is_some() && status == reqwest::StatusCode::BAD_REQUEST {
if let Ok(block_on_error) = serde_json::from_str::<BlockOnError>(&response_text) {
if block_on_error.message.is_some() || !block_on_error.is_empty() {
return Err(block_on_error.describe().into());
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I agree with this finding and think it should be addressed.

nitpick: Restrict structured parsing to block-on errors

All BlockOnError fields have defaults and unknown JSON fields are ignored, so unrelated JSON-object 400 responses deserialize as an empty BlockOnError. This rewrites errors from legacy --fail and list requests as Invalid --block-on value. even when --block-on was absent. Parse this schema only when block_on is present and at least one recognized field was returned.

Proof or reproduction:

A 400 body `{"detail":"invalid scan"}` successfully deserializes into default-valued BlockOnError and `describe()` returns `Invalid --block-on value.`.

Comment thread src/utils/api.rs
let query_params = vec![("page", page.to_string())];
let mut query_params = vec![("page", page.to_string())];
if let Some(block_on) = block_on {
query_params.push(("block_on", block_on.to_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.

[P2] Fail closed when the backend does not support block_on

The PR description confirms that an older backend ignores this query parameter, but the CLI has no capability check. That defeats the stated hard-error guarantee during a staggered rollout: --block-on typo-rule can receive a successful legacy all-rules response, and if no unrelated active rule trips, lines 358–361 print that no issues violated typo-rule and exit 0 instead of rejecting the unknown slug. Release ordering alone does not let the client distinguish support, especially for configured/custom API URLs. Have the new endpoint echo an explicit capability or the accepted slug set in every response and require it whenever block_on is supplied; if it is absent, exit with an unsupported-backend error. Add an HTTP-level test using a legacy 200 response to pin this fail-closed behavior.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I agree with this finding and think it should be addressed.

high: Fail closed when the backend ignores block_on

A legacy backend can ignore the new query parameter and return 200 using all active rules. The CLI then treats that response as validation of the requested slugs, so an unknown slug may print a success message and exit 0. This defeats the gate's documented hard-error behavior. Require an explicit capability or accepted-slug field in responses to block_on requests and reject responses without it.

Proof or reproduction:

Given `--block-on typo-rule`, a legacy response such as `{"block":false,"blocking_issues":[],"total_pages":1}` reaches the success branch and exits 0 even though `typo-rule` was never validated.

Comment thread src/scanners/blast.rs Outdated
@corgea-security corgea-security added the dennis-reviewed Dennis completed an automated review label Aug 5, 2026

@corgea-security corgea-security 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 review risk: 4/5.

The new CI gate can silently pass against an older backend and skips requested diagnostic artifacts when it blocks. Error handling and paginated reporting also need correction.

Critical or high-priority changes must be addressed.

Automatic approval was not submitted: automated review found critical or high-priority findings.

…lure

The --block-on message printed blocking_issues.len() and the triggered rule
slugs from page 1 only, but the endpoint pages at 20 issues. A blocked scan
with more than 20 issues reported "20 issue(s)" whatever the real total, and
a rule that only tripped on a later page was left out of the list of rules
blamed for the failure. The exit code was right; the explanation was not.

collect_blocking_issues walks pages 2..=total_pages and dedupes by issue id,
following the loop corgea ls already uses against the same endpoint. A page
that fails to load is warned about and skipped rather than aborting, so a
transient pagination error cannot downgrade a blocked scan to a pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread skills/corgea/SKILL.md Outdated
"--block-on no-criticals" reads as "block when there are no criticals",
the opposite of what it does. Name the example rules for the condition
that trips the gate: --block-on criticals,malicious-deps.

Applied to the --help text and the empty-slug error alongside the skill
docs, since all three carried the same examples, and recorded the naming
guidance next to the note that slugs derive from the rule name.

Co-authored-by: Cursor <cursoragent@cursor.com>
--block-on is a new backward-compatible flag and --fail is deprecated
rather than removed, so SemVer puts this at a minor bump. Cargo.toml is
the single source of truth: PyPI reads it via maturin and npm takes its
version from the release tag, so only the manifest and lockfile change.

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

@juangaitanv juangaitanv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚀

Ibrahimrahhal and others added 2 commits August 5, 2026 18:46
…fetches

The endpoint already returns the pre-pagination total in stats.blocked_issues,
which the CLI simply was not deserializing. Reading it gives an exact count
from the single request the gate already makes, so walking pages 2..=total_pages
was both unnecessary and unsound: each request re-paginates a list rebuilt from
querysets with no ORDER BY, so pages from separate requests need not line up.

The CLI never uses the issue records themselves, only their count and the rule
slugs, so the returned page remains sufficient to name the rules at fault.

Co-authored-by: Cursor <cursoragent@cursor.com>
The stub stopped reading at the header terminator, then replied with
Connection: close while the client was still writing its body. Small bodies
survived on socket buffering alone, but the streamed multipart uploads
(POST /start-scan and the chunk PATCH) did not, so the client got a
request-body error instead of a response. That is what made
scan_sbom_unwritable_path_errors_cleanly fail in CI while passing locally:
it asserts on the SBOM write error, and the scan died at upload instead.
Coverage instrumentation slows the client enough to widen the window.

read_http_request now follows Content-Length, or reads to the zero-length
chunk for chunked bodies, and carries a read timeout so a client that never
finishes cannot park the single-threaded stub. Adds unit tests for all
three cases, since the e2e symptom only reproduces under CI timing.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Ibrahimrahhal
Ibrahimrahhal merged commit 772806b into main Aug 6, 2026
17 checks passed
@Ibrahimrahhal
Ibrahimrahhal deleted the ci-blocking-rules branch August 6, 2026 07:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dennis-reviewed Dennis completed an automated review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants