From bce1211734d562101c595256ba89faa2fc182243 Mon Sep 17 00:00:00 2001 From: dbdefault Date: Tue, 10 Nov 2026 11:01:34 +0800 Subject: [PATCH 1/2] Distinguish max-cost gate exit code --- docs/REFERENCE.md | 6 ++++-- src/secfoo/cli.py | 20 ++++++++++++-------- tests/test_cli.py | 5 +++-- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 4dc7f6f..9a0bf8f 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -416,12 +416,14 @@ secfoo run --skill sast --skill secret-scanning --agent api --target . \ | Flag | Effect | |---|---| | `--fail-on critical\|high\|medium\|low` | Exit `2` if any run reports a finding at that severity or worse. Counts come from the report contract's `### [SEVERITY] Fn:` headings, not from prose. | -| `--max-cost ` | Exit `2` if the summed agent-reported spend for this invocation exceeds the cap. Evaluated after the runs complete. | +| `--max-cost ` | Exit `3` if the summed agent-reported spend for this invocation exceeds the cap. Evaluated after the runs complete. | | `--json` | Write one JSON document to stdout (per-skill status, severity counts, tokens, USD, gate verdicts, resolved exit code); progress and notes go to stderr. Also suppresses the interactive project-name prompt. | Exit codes: `0` clean, `1` a run failed or timed out (gate verdicts are still reported in the JSON but a partial scan never passes), `2` all runs -succeeded but a gate tripped. Config-file defaults: `[defaults].fail_on` +succeeded but the findings gate tripped, `3` all runs succeeded but the cost +gate tripped. If both gates trip, `3` takes precedence so CI can distinguish +budget overruns. Config-file defaults: `[defaults].fail_on` and `[defaults].max_cost_usd`. Cost provenance: `api`/`secfoo` take LiteLLM's `completion_cost`, `claude` diff --git a/src/secfoo/cli.py b/src/secfoo/cli.py index d4fcf61..a152ffe 100644 --- a/src/secfoo/cli.py +++ b/src/secfoo/cli.py @@ -124,6 +124,7 @@ class FailOn(str, Enum): # scan worked and the policy gate failed" without parsing output. EXIT_RUN_FAILED = 1 EXIT_GATE_FAILED = 2 +EXIT_COST_EXCEEDED = 3 _SEVERITY_ORDER = ("critical", "high", "medium", "low") @@ -425,9 +426,14 @@ def handle_complete(skill_name: str, result_status: str) -> None: # as passed but surface unpriced_runs so CI can decide otherwise. "passed": total_cost is None or total_cost <= max_cost, } - gate_failed = (gate_findings is not None and not gate_findings["passed"]) or ( - gate_cost is not None and not gate_cost["passed"] - ) + if any_failed: + exit_code = EXIT_RUN_FAILED + elif gate_cost is not None and not gate_cost["passed"]: + exit_code = EXIT_COST_EXCEEDED + elif gate_findings is not None and not gate_findings["passed"]: + exit_code = EXIT_GATE_FAILED + else: + exit_code = 0 if json_output: document = { @@ -459,7 +465,7 @@ def handle_complete(skill_name: str, result_status: str) -> None: "unpriced_runs": unpriced_runs, "gates": {"findings": gate_findings, "cost": gate_cost}, "any_run_failed": any_failed, - "exit_code": EXIT_RUN_FAILED if any_failed else (EXIT_GATE_FAILED if gate_failed else 0), + "exit_code": exit_code, } # Plain print, not rich: the document must be byte-exact JSON. print(json.dumps(document, indent=2)) @@ -502,10 +508,8 @@ def handle_complete(skill_name: str, result_status: str) -> None: ) console.print("Run [bold]secfoo serve[/] to view full reports in your browser.") - if any_failed: - raise typer.Exit(code=EXIT_RUN_FAILED) - if gate_failed: - raise typer.Exit(code=EXIT_GATE_FAILED) + if exit_code: + raise typer.Exit(code=exit_code) @app.command(name="list") diff --git a/tests/test_cli.py b/tests/test_cli.py index cb91f3c..5b03167 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -473,8 +473,9 @@ def test_max_cost_trips_when_summed_spend_exceeds_cap(monkeypatch): "secfoo.cli.execute_runs", lambda **kw: [_priced_outcome(cost=0.80, skill="sast"), _priced_outcome(cost=0.70, skill="sca")], ) - result = runner.invoke(app, ["run", "--skill", "sast", "--max-cost", "1.00"]) - assert result.exit_code == 2 + result = runner.invoke(app, ["run", "--skill", "sast", "--max-cost", "1.00", "--json"]) + assert result.exit_code == 3 + assert json.loads(result.stdout)["exit_code"] == 3 def test_max_cost_passes_under_cap(monkeypatch): From 080c374f19076fa5d5fab806d6f47cdaecb6561b Mon Sep 17 00:00:00 2001 From: dbdefault Date: Tue, 10 Nov 2026 11:02:18 +0800 Subject: [PATCH 2/2] Update run --help exit codes for the cost gate --- src/secfoo/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/secfoo/cli.py b/src/secfoo/cli.py index a152ffe..07f307a 100644 --- a/src/secfoo/cli.py +++ b/src/secfoo/cli.py @@ -286,7 +286,8 @@ def run( Multiple --skill flags run concurrently, not one after another. Exit codes: 0 success, 1 a run failed/timed out, 2 all runs succeeded - but a --fail-on / --max-cost gate tripped. + but the --fail-on gate tripped, 3 all runs succeeded but the --max-cost + gate tripped. """ for url in confluence: if not url.strip().lower().startswith(("http://", "https://")):