Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <usd>` | Exit `2` if the summed agent-reported spend for this invocation exceeds the cap. Evaluated after the runs complete. |
| `--max-cost <usd>` | 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`
Expand Down
23 changes: 14 additions & 9 deletions src/secfoo/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -285,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://")):
Expand Down Expand Up @@ -425,9 +427,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 = {
Expand Down Expand Up @@ -459,7 +466,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))
Expand Down Expand Up @@ -502,10 +509,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")
Expand Down
5 changes: 3 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading