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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,6 @@ Developing this repo: `pip install -e ".[dev]"`.
PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) and
[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). Security issues go through
[SECURITY.md](SECURITY.md), not a public issue.

Choose a model for one assessment with `secfoo run --skill sast --agent claude --model <model-id>`,
or set a per-agent default in [configuration](docs/REFERENCE.md#configuration-secfooconfigtoml).
19 changes: 19 additions & 0 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,9 @@ exclude = ["vendor/", "some-cloned-repo/"]
fail_on = "high"
max_cost_usd = 2.00

[agents.claude]
model = "claude-sonnet-4-6"

[[mcp_servers]]
name = "Atlassian-Rovo-MCP"
command = "npx"
Expand All @@ -447,6 +450,22 @@ CLI flags always override `[defaults]`. See
[`src/secfoo/config.example.toml`](../src/secfoo/config.example.toml) for the
full reference, including remote HTTP/SSE servers.

`secfoo run --model <model-id>` overrides `[agents.<agent-id>].model` for
that invocation without changing any CLI global settings. Use the registered
agent ID (`agent` for Cursor, `agy` for Antigravity). Model IDs must be
non-empty strings. Claude, Codex, Gemini, Copilot and Cursor receive `--model`;
Droid receives `-m`. Antigravity and Devin currently warn and ignore model
selection because no supported model flag is established for these adapters.
The `api` agent uses the selected model ahead of `SECFOO_API_MODEL`, then its
existing built-in default. With no selection, CLI commands remain unchanged.

Each run stores the **requested** model (`runs.model`), not a provider-verified
model identity. It is NULL when no model was selected (including older runs).
A setting ignored by an unsupported adapter remains a requested label; its
warning explains that the CLI default was used. Run records do not infer the
CLI's global default or resolve provider aliases. Use model IDs supported by
your installed CLI version; an invalid ID is reported by that CLI.

How MCP servers reach each agent varies by what its CLI actually supports:

| Agent | Mechanism |
Expand Down
4 changes: 3 additions & 1 deletion src/secfoo/agents/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,8 @@ def is_available(self) -> bool:
def build_command(self, prompt: str, *, workdir: Path) -> list[str]:
return [] # required by the base class; there is no program to launch

supports_model = True

def run(self, prompt: str, *, workdir: Path, timeout: int | None = None, prev_commit: str | None = None) -> AgentResult: # Memory Bank: prev_commit triggers incremental diff scan.
started = time.monotonic()
_strip_key_whitespace()
Expand All @@ -268,7 +270,7 @@ def run(self, prompt: str, *, workdir: Path, timeout: int | None = None, prev_co
final = build_graph(litellm, StateGraph, START, END).invoke({
"prompt": prompt,
"workdir": str(workdir),
"model": os.environ.get("SECFOO_API_MODEL", DEFAULT_MODEL),
"model": self.model or os.environ.get("SECFOO_API_MODEL", DEFAULT_MODEL),
"timeout": timeout or self.default_timeout_seconds,
"prev_commit": prev_commit, # Memory Bank: None = full scan, SHA = incremental diff.
"scan_scope": None,
Expand Down
16 changes: 16 additions & 0 deletions src/secfoo/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import signal
import subprocess
import time
import warnings
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
Expand Down Expand Up @@ -95,6 +96,21 @@ class AgentAdapter(ABC):
# documents reading the prompt from stdin.
prompt_via_stdin: ClassVar[bool] = False

model: str | None = None
model_flag: ClassVar[str | None] = None
supports_model: ClassVar[bool] = False

def configure_model(self, model: str | None) -> None:
if model is not None and (not isinstance(model, str) or not model.strip()):
raise ValueError("model must be a non-empty string")
if model is not None and not (self.model_flag or self.supports_model):
warnings.warn(f"Agent {self.name!r} does not support model selection; ignoring model", stacklevel=2)
model = None
self.model = model

def model_arguments(self) -> list[str]:
return [self.model_flag, self.model] if self.model_flag and self.model is not None else []

def is_available(self) -> bool:
return shutil.which(self.binary) is not None

Expand Down
2 changes: 2 additions & 0 deletions src/secfoo/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class ClaudeAdapter(AgentAdapter):
name = "claude"
binary = "claude"
default_timeout_seconds = 1800
model_flag = "--model"
# SECFOO-42: with no prompt argument, `claude -p` reads the prompt from
# stdin. Passing it as argv breaks an npm install on Windows, where
# `claude` is a `.cmd` shim and cmd.exe cuts the argument at its first
Expand All @@ -78,6 +79,7 @@ def build_command(self, prompt: str, *, workdir: Path) -> list[str]:

cmd = [
self.binary,
*self.model_arguments(),
# No prompt here: it arrives on stdin (see prompt_via_stdin).
"-p",
"--output-format",
Expand Down
2 changes: 2 additions & 0 deletions src/secfoo/agents/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class CodexAdapter(AgentAdapter):
name = "codex"
binary = "codex"
default_timeout_seconds = 1800
model_flag = "--model"
# `codex exec -` reads the prompt from stdin; passing it as argv breaks
# on Windows, where `codex` is a `.cmd` shim and cmd.exe truncates the
# argument at its first newline.
Expand All @@ -68,6 +69,7 @@ def build_command(self, prompt: str, *, workdir: Path) -> list[str]:
return [
self.binary,
"exec",
*self.model_arguments(),
# Read-only filesystem: the assessment only reads the target.
"--sandbox",
"read-only",
Expand Down
3 changes: 2 additions & 1 deletion src/secfoo/agents/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,15 @@ class CopilotAdapter(AgentAdapter):
name = "copilot"
binary = "copilot"
default_timeout_seconds = 1800
model_flag = "--model"
# Piped stdin instead of `-p <prompt>`: on Windows `copilot` is a `.bat`
# shim, and cmd.exe cut a `-p` prompt off at its first newline
# (reproduced Sep 2026, CLI 1.0.89). With stdin piped and no `-p`, the
# CLI still runs one non-interactive turn and exits.
prompt_via_stdin = True

def build_command(self, prompt: str, *, workdir: Path) -> list[str]:
return [self.binary, "--output-format", "json", "--allow-all-tools"]
return [self.binary, *self.model_arguments(), "--output-format", "json", "--allow-all-tools"]

def extract_report(self, stdout: str) -> str:
report = None
Expand Down
3 changes: 2 additions & 1 deletion src/secfoo/agents/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class CursorAdapter(AgentAdapter):
name = "agent"
binary = "agent"
default_timeout_seconds = 900
model_flag = "--model"

def build_command(self, prompt: str, *, workdir: Path) -> list[str]:
return [self.binary, "-p", prompt]
return [self.binary, *self.model_arguments(), "-p", prompt]
2 changes: 2 additions & 0 deletions src/secfoo/agents/droid.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,13 @@ class DroidAdapter(AgentAdapter):
name = "droid"
binary = "droid"
default_timeout_seconds = 1800
model_flag = "-m"

def build_command(self, prompt: str, *, workdir: Path) -> list[str]:
return [
self.binary,
"exec",
*self.model_arguments(),
"--output-format",
"json",
"--cwd",
Expand Down
3 changes: 2 additions & 1 deletion src/secfoo/agents/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class GeminiAdapter(AgentAdapter):
name = "gemini"
binary = "gemini"
default_timeout_seconds = 1800
model_flag = "--model"

def build_command(self, prompt: str, *, workdir: Path) -> list[str]:
# Deliberately no --yolo: a read-only review never needs
Expand All @@ -31,7 +32,7 @@ def build_command(self, prompt: str, *, workdir: Path) -> list[str]:
# --skip-trust: secfoo always runs headless against arbitrary
# targets (cloned repos, user paths) — without this, Gemini CLI
# exits immediately in non-interactive mode on an untrusted cwd.
return [self.binary, "-p", prompt, "--output-format", "json", "--skip-trust"]
return [self.binary, *self.model_arguments(), "-p", prompt, "--output-format", "json", "--skip-trust"]

def extract_report(self, stdout: str) -> str:
try:
Expand Down
10 changes: 9 additions & 1 deletion src/secfoo/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ def run(
help="Which agent CLI runs the assessment. Falls back to [defaults].agent in "
"~/.secfoo/config.toml, then to claude.",
),
model: str | None = typer.Option(None, "--model", help="Model for this run; overrides [agents.<agent>].model."),
depth: Optional[DepthId] = typer.Option(
None,
"--depth",
Expand Down Expand Up @@ -306,7 +307,8 @@ def run(
app_id = Prompt.ask("Application ID (optional)", default="", console=console) or None

try:
file_defaults = load_config().defaults
file_config = load_config()
file_defaults = file_config.defaults
except ConfigError as exc:
err_console.print(f"[red]Error in {CONFIG_PATH}:[/] {exc}")
raise typer.Exit(code=1) from None
Expand All @@ -316,6 +318,11 @@ def run(

if agent is None:
agent = AgentId(file_defaults.agent) if file_defaults.agent else AgentId.CLAUDE
if model is not None and not model.strip():
err_console.print("[red]Error:[/] --model must be a non-empty string")
raise typer.Exit(code=1)
if model is None:
model = file_config.agent_models.get(agent.value)
if depth is None:
depth = DepthId(file_defaults.depth) if file_defaults.depth else DepthId.QUICK
if timeout is None:
Expand Down Expand Up @@ -378,6 +385,7 @@ def handle_complete(skill_name: str, result_status: str) -> None:
target=target,
confluence_urls=confluence,
agent_id=agent.value,
model=model,
depth=depth.value,
timeout=timeout,
on_skill_start=handle_start,
Expand Down
7 changes: 7 additions & 0 deletions src/secfoo/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ depth = "quick" # quick | standard
# cost noticeably on large trees.
# exclude = ["vendor/", "third_party/", "some-cloned-repo/"]

# Optional per-agent model defaults. --model overrides one run; no CLI global
# settings are changed. Use the registered agent ID (agent = Cursor).
# [agents.claude]
# model = "sonnet"
# [agents.agent]
# model = "your-cursor-model-id"

[memory]
# secfoo-memory: a hosted service of curated vulnerability patterns the
# agent can consult mid-assessment (known trigger conditions, remediation,
Expand Down
6 changes: 6 additions & 0 deletions src/secfoo/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ def _run_single_skill(
assessment_id: int | None = None,
exclude_paths: list[str] | None = None,
stack: Stack | None = None,
model: str | None = None,
) -> RunOutcome:
skill = load_skill(skill_id)
memory_enabled = memory.is_active_for_agent(agent_id)
Expand All @@ -431,6 +432,7 @@ def _run_single_skill(
agent_id=agent_id,
confluence_urls=confluence_urls,
assessment_id=assessment_id,
model=model,
)
started = time.monotonic()

Expand All @@ -448,6 +450,8 @@ def _run_single_skill(
on_skill_start(skill.name)

adapter = get_adapter(agent_id)
if model is not None:
adapter.configure_model(model)
# Memory Bank: look up the last successful commit so the adapter can diff and send only changed files.
prev_commit = repo.get_last_commit(project_id=project_id, skill_id=skill.id)
result = adapter.run(prompt, workdir=target_ctx.local_path, timeout=timeout, prev_commit=prev_commit)
Expand Down Expand Up @@ -538,6 +542,7 @@ def execute_runs(
target: str | None,
confluence_urls: list[str],
agent_id: str,
model: str | None = None,
depth: Literal["quick", "standard"] = "quick",
timeout: int | None = None,
repo: RunRepository | None = None,
Expand Down Expand Up @@ -637,6 +642,7 @@ def execute_runs(
skill_id=skill_id,
project_id=project_id,
agent_id=agent_id,
model=model,
target_ctx=target_ctx,
confluence_urls=confluence_urls,
depth=depth,
Expand Down
16 changes: 15 additions & 1 deletion src/secfoo/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class SecfooConfig:
# once a memory key exists (secfoo memory signup/login). On by default;
# without a key there is nothing to wire in either way.
memory_enabled: bool = True
agent_models: dict[str, str] = field(default_factory=dict)


def _parse_mcp_server(name: str, raw: dict) -> MCPServerConfig:
Expand Down Expand Up @@ -119,6 +120,19 @@ def load_config(path: Path | None = None) -> SecfooConfig:
max_cost_usd=max_cost_usd,
)

agents_raw = data.get("agents", {})
if not isinstance(agents_raw, dict):
raise ConfigError("[agents] must be a table")
agent_models = {}
for agent_id, options in agents_raw.items():
if not isinstance(options, dict):
raise ConfigError(f"agents.{agent_id} must be a table")
model = options.get("model")
if model is not None:
if not isinstance(model, str) or not model.strip():
raise ConfigError(f"agents.{agent_id}.model must be a non-empty string")
agent_models[agent_id] = model

servers = []
seen_names: set[str] = set()
for entry in data.get("mcp_servers", []):
Expand All @@ -137,4 +151,4 @@ def load_config(path: Path | None = None) -> SecfooConfig:
if not isinstance(memory_enabled, bool):
raise ConfigError("memory.enabled must be true or false")

return SecfooConfig(defaults=defaults, mcp_servers=servers, memory_enabled=memory_enabled)
return SecfooConfig(defaults=defaults, mcp_servers=servers, memory_enabled=memory_enabled, agent_models=agent_models)
1 change: 1 addition & 0 deletions src/secfoo/storage/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class RunRecord:
output_tokens: int | None = None
cost_usd: float | None = None
project_display_name: str | None = None
model: str | None = None
target_commit: str | None = None # Memory Bank: git HEAD SHA at scan time.
memory_enabled: int | None = None # 1/0: secfoo-memory tools wired in; NULL = unknown (older run).
# Memory Bank: paths an incremental scan covered; None = full scan.
Expand Down
6 changes: 4 additions & 2 deletions src/secfoo/storage/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def use_db_path(path: Path):
# adds them there too, idempotently, so upgrading never requires wiping a
# user's local store.
_RUNS_MIGRATIONS = {
"model": "ALTER TABLE runs ADD COLUMN model TEXT",
"assessment_id": "ALTER TABLE runs ADD COLUMN assessment_id INTEGER REFERENCES assessments(id)",
"critical_count": "ALTER TABLE runs ADD COLUMN critical_count INTEGER NOT NULL DEFAULT 0",
"high_count": "ALTER TABLE runs ADD COLUMN high_count INTEGER NOT NULL DEFAULT 0",
Expand Down Expand Up @@ -257,12 +258,13 @@ def create_run(
agent_id: str,
confluence_urls: list[str],
assessment_id: int | None = None,
model: str | None = None,
) -> str:
run_uuid = str(uuid.uuid4())
self._conn.execute(
"INSERT INTO runs (run_uuid, project_id, assessment_id, skill_id, skill_name, agent_id, "
"confluence_urls, status, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'running', ?)",
(run_uuid, project_id, assessment_id, skill_id, skill_name, agent_id, json.dumps(confluence_urls), _now()),
"confluence_urls, status, started_at, model) VALUES (?, ?, ?, ?, ?, ?, ?, 'running', ?, ?)",
(run_uuid, project_id, assessment_id, skill_id, skill_name, agent_id, json.dumps(confluence_urls), _now(), model),
)
self._conn.commit()
return run_uuid
Expand Down
1 change: 1 addition & 0 deletions src/secfoo/storage/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ CREATE TABLE IF NOT EXISTS runs (
skill_id TEXT NOT NULL,
skill_name TEXT NOT NULL,
agent_id TEXT NOT NULL,
model TEXT, -- requested model; NULL = CLI default/unknown
confluence_urls TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL CHECK (status IN
('pending','running','success','failed','timeout','binary_not_found')),
Expand Down
14 changes: 14 additions & 0 deletions tests/test_agents/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,17 @@ def test_reformat_is_attempted_only_once(tmp_path, monkeypatch):
assert result.status == "success"
assert result.raw_report == "no rating here"
assert len(fake.calls) == api.MAX_ATTEMPTS


def test_selected_model_overrides_api_environment_without_changing_it(tmp_path, monkeypatch):
import os

monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
monkeypatch.setenv("SECFOO_API_MODEL", "environment-model")
(tmp_path / "app.py").write_text("pass\n")
fake = _fake_litellm(monkeypatch)
adapter = ApiAdapter()
adapter.configure_model("selected-model")
assert adapter.run("Review this.", workdir=tmp_path).status == "success"
assert fake.calls[0]["model"] == "selected-model"
assert os.environ["SECFOO_API_MODEL"] == "environment-model"
Loading
Loading