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
12 changes: 10 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,18 @@ OPENROUTER_API_KEY=
# Optional: API key required when the control plane enforces auth
AGENTFIELD_API_KEY=

# Optional model/provider overrides
HARNESS_PROVIDER=opencode
# AForge exec is the default. Set HARNESS_PROVIDER=opencode to roll back.
HARNESS_PROVIDER=aforge
# AForge headless command the SDK runs: `exec` (default) or `do`.
# Read by agentfield>=0.1.130; any other value fails the harness call.
AGENTFIELD_AFORGE_COMMAND=exec
HARNESS_MODEL=openrouter/moonshotai/kimi-k2.5
AI_MODEL=openrouter/moonshotai/kimi-k2.5
# SEC_AF_AFORGE_BIN=/absolute/path/to/aforge

# Build-time only: where `docker compose build` fetches the AForge CLI from.
# AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge
# AFORGE_VERSION=v0.1.0

# Optional: host path with repositories to scan/mirror into /workspaces
# Example: SCAN_REPOS_PATH=../repos
Expand Down
43 changes: 41 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,40 @@
# AForge CLI is fetched as a released, checksum-verified binary rather than
# copied out of a container image, so the build depends only on the public
# download host. Both ARGs are overridable (e.g. to point at a staging mirror).
ARG AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge
ARG AFORGE_VERSION=v0.1.0

FROM debian:bookworm-slim AS aforge

ARG AFORGE_BASE_URL
ARG AFORGE_VERSION
# Provided automatically by BuildKit; defaults to amd64 for legacy builders.
ARG TARGETARCH

RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl && \
rm -rf /var/lib/apt/lists/*

WORKDIR /out

# Download the gzipped release binary, decompress it, and verify the
# *decompressed* SHA-256 against the release checksums.txt before use.
RUN set -eux; \
arch="${TARGETARCH:-amd64}"; \
curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/aforge-linux-${arch}.gz" -o aforge.gz; \
gunzip -c aforge.gz > aforge; \
rm aforge.gz; \
curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/checksums.txt" -o checksums.txt; \
tr -d '\r' < checksums.txt \
| grep " aforge-linux-${arch}$" \
| sed 's/ aforge-linux-.*/ aforge/' > aforge.sha256; \
test -s aforge.sha256; \
sha256sum -c aforge.sha256; \
rm checksums.txt aforge.sha256; \
chmod +x aforge


FROM python:3.11-slim AS builder

ENV PYTHONDONTWRITEBYTECODE=1 \
Expand All @@ -14,7 +51,7 @@ COPY pyproject.toml README.md ./
COPY src/ src/

RUN pip install --no-cache-dir --prefix=/install \
"agentfield>=0.1.0" \
"agentfield>=0.1.130" \
"pydantic>=2.0" \
"httpx>=0.27" \
"python-dotenv>=1.0" && \
Expand All @@ -25,7 +62,8 @@ FROM python:3.11-slim AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
HARNESS_PROVIDER=opencode \
HARNESS_PROVIDER=aforge \
AGENTFIELD_AFORGE_COMMAND=exec \
HARNESS_MODEL=openrouter/minimax/minimax-m2.5 \
AI_MODEL=openrouter/minimax/minimax-m2.5 \
PORT=8080 \
Expand Down Expand Up @@ -53,6 +91,7 @@ RUN mkdir -p /home/secaf/.config/opencode && \
chown -R secaf:secaf /home/secaf/.config

COPY --from=builder /install /usr/local
COPY --from=aforge /out/aforge /usr/local/bin/aforge
COPY src/ /app/src/

USER secaf
Expand Down
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ The pipeline adapts at runtime based on what it discovers. An AI gate examines r

**6. Guided autonomy for coding agents**

SEC-AF runs on top of coding agents (Claude Code, OpenCode, Codex) via the AgentField harness. Rather than giving the agent a single massive prompt, each reasoner provides phase-aware guided autonomy: the agent receives a narrow task definition, a flat output schema (2-4 fields), and strategy-specific context. The agent has full autonomy within these boundaries — it can read files, trace code, and reason freely — but the harness constrains the _shape_ of its output. This prevents the common failure mode where autonomous agents go off-task or produce unstructured results.
SEC-AF runs on top of coding agents (AForge, Claude Code, OpenCode, Codex) via the AgentField harness. Rather than giving the agent a single massive prompt, each reasoner provides phase-aware guided autonomy: the agent receives a narrow task definition, a flat output schema (2-4 fields), and strategy-specific context. The agent has full autonomy within these boundaries — it can read files, trace code, and reason freely — but the harness constrains the _shape_ of its output. This prevents the common failure mode where autonomous agents go off-task or produce unstructured results.

**7. Composable reasoner DAG with full observability**

Expand Down Expand Up @@ -477,13 +477,46 @@ jobs:
| `SEC_AF_MAX_TURNS` | No | `50` | Max harness turns per call |
| `AGENTFIELD_API_KEY` | No | unset | API key for secured environments |
| `SEC_AF_WORKSPACES_DIR` | No | `/workspaces` | Directory for cloned repos (falls back to `~/.sec-af/workspaces` if not writable) |
| `HARNESS_PROVIDER` | No | `opencode` | Harness backend provider |
| `HARNESS_PROVIDER` | No | `aforge` | Harness backend provider; set `opencode` for rollback |
| `AGENTFIELD_AFORGE_COMMAND` | No | `exec` | AForge headless command the SDK runs: `exec` (default) or `do`. Read by agentfield>=0.1.130 |
| `SEC_AF_AFORGE_BIN` | No | `aforge` | Path to an AForge binary for the `aforge` provider (also honours `AFORGE_BIN`) |
| `SEC_AF_AI_MAX_RETRIES` | No | `3` | Retry count for model calls |

</details>

<details>
<summary><strong>Build arguments</strong></summary>

The image downloads the released AForge CLI at build time and verifies its
SHA-256 against the release `checksums.txt` before installing it.

| Build arg | Default | Description |
|---|---|---|
| `AFORGE_BASE_URL` | `https://agentfield.ai/downloads/aforge` | Root of the AForge download host |
| `AFORGE_VERSION` | `v0.1.0` | Released AForge version to install |

```bash
docker build -t sec-af .
docker build --build-arg AFORGE_VERSION=vX.Y.Z -t sec-af .
```

</details>

## Development Setup

### Harness selection

```bash
export OPENROUTER_API_KEY=sk-or-v1-...
export HARNESS_PROVIDER=aforge
python -m sec_af.app
```

The container ships the AForge CLI at `/usr/local/bin/aforge` and drives it
through `aforge exec` by default. To temporarily roll back without changing
code, set `HARNESS_PROVIDER=opencode`. Running outside the container requires
an `aforge` binary on `PATH` (or `SEC_AF_AFORGE_BIN` pointing at one).

```bash
python -m venv .venv && source .venv/bin/activate
pip install -e .[dev]
Expand Down
9 changes: 7 additions & 2 deletions agentfield-package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,13 @@ user_environment:
type: secret
scope: global
- name: HARNESS_PROVIDER
description: Coding-agent harness provider
default: opencode
description: Coding-agent harness provider (aforge | claude-code | codex | gemini | opencode)
default: aforge
- name: AGENTFIELD_AFORGE_COMMAND
description: AForge headless command the SDK runs (`exec` or `do`); agentfield>=0.1.130 reads it, default `exec`
default: exec
- name: SEC_AF_AFORGE_BIN
description: Optional path to the AForge binary (defaults to aforge on PATH)
- name: HARNESS_MODEL
description: Model the harness uses
default: openrouter/moonshotai/kimi-k2.5
Expand Down
6 changes: 5 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
AFORGE_BASE_URL: ${AFORGE_BASE_URL:-https://agentfield.ai/downloads/aforge}
AFORGE_VERSION: ${AFORGE_VERSION:-v0.1.0}
ports:
- "8003:8003"
environment:
- AGENTFIELD_SERVER=http://agentfield:8080
- AGENTFIELD_API_KEY=${AGENTFIELD_API_KEY:-}
- PORT=8003
- AGENT_CALLBACK_URL=http://sec-af:8003
- HARNESS_PROVIDER=opencode
- HARNESS_PROVIDER=${HARNESS_PROVIDER:-aforge}
- AGENTFIELD_AFORGE_COMMAND=${AGENTFIELD_AFORGE_COMMAND:-exec}
- HARNESS_MODEL=${HARNESS_MODEL:-openrouter/moonshotai/kimi-k2.5}
- AI_MODEL=${AI_MODEL:-openrouter/moonshotai/kimi-k2.5}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ license = "Apache-2.0"
requires-python = ">=3.11"
authors = [{ name = "AgentField", email = "hello@agentfield.dev" }]
dependencies = [
"agentfield>=0.1.0",
"agentfield>=0.1.130",
"pydantic>=2.0",
"httpx>=0.27",
]
Expand Down
1 change: 1 addition & 0 deletions src/sec_af/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
max_turns=_ai_config.max_turns,
env=_ai_config.provider_env(),
opencode_bin=_ai_config.opencode_bin,
aforge_bin=_ai_config.aforge_bin,
permission_mode="auto",
),
ai_config=AIConfig(
Expand Down
11 changes: 9 additions & 2 deletions src/sec_af/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ class AuditConfig(BaseModel):
exclude_paths: list[str] = Field(
default_factory=lambda: ["tests/", "vendor/", "node_modules/", ".git/"],
)
provider: str = "opencode"
provider: str = "aforge"
budget: BudgetConfig = Field(default_factory=BudgetConfig)

@classmethod
Expand All @@ -74,7 +74,7 @@ def from_input(cls, audit_input: AuditInput, repo_path: str) -> "AuditConfig":

class AIIntegrationConfig(BaseModel):
provider: str = Field(
default_factory=lambda: os.getenv("SEC_AF_PROVIDER", os.getenv("HARNESS_PROVIDER", "opencode"))
default_factory=lambda: os.getenv("SEC_AF_PROVIDER", os.getenv("HARNESS_PROVIDER", "aforge"))
)
harness_model: str = Field(
default_factory=lambda: os.getenv(
Expand All @@ -95,6 +95,12 @@ class AIIntegrationConfig(BaseModel):
)
max_backoff_seconds: float = Field(default_factory=lambda: float(os.getenv("SEC_AF_AI_MAX_BACKOFF_SECONDS", "8.0")))
opencode_bin: str = Field(default_factory=lambda: os.getenv("SEC_AF_OPENCODE_BIN", "opencode"))
aforge_bin: str = Field(
default_factory=lambda: os.getenv(
"SEC_AF_AFORGE_BIN",
os.getenv("AFORGE_BIN", "aforge"),
)
)
opencode_server: str | None = Field(
default_factory=lambda: os.getenv("SEC_AF_OPENCODE_SERVER", os.getenv("OPENCODE_SERVER")),
)
Expand All @@ -113,6 +119,7 @@ def provider_env(self) -> dict[str, str]:
"GH_TOKEN",
)
env: dict[str, str] = {key: value for key in env_keys if (value := os.getenv(key))}
env["AGENTFIELD_AFORGE_COMMAND"] = os.getenv("AGENTFIELD_AFORGE_COMMAND", "exec")
xdg = os.getenv("XDG_DATA_HOME") or os.path.join(tempfile.gettempdir(), "opencode-shared-data")
os.makedirs(xdg, exist_ok=True)
env["XDG_DATA_HOME"] = xdg
Expand Down
35 changes: 34 additions & 1 deletion tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Any, cast

import pytest
from agentfield import HarnessConfig

from sec_af.config import AIIntegrationConfig, AuditConfig, BudgetConfig, DepthProfile
from sec_af.schemas.input import AuditInput
Expand Down Expand Up @@ -62,6 +63,7 @@ def test_ai_integration_config_uses_sec_af_env_precedence(monkeypatch: pytest.Mo
monkeypatch.setenv("SEC_AF_AI_INITIAL_BACKOFF_SECONDS", "1.5")
monkeypatch.setenv("SEC_AF_AI_MAX_BACKOFF_SECONDS", "12")
monkeypatch.setenv("SEC_AF_OPENCODE_BIN", "/usr/local/bin/opencode")
monkeypatch.setenv("SEC_AF_AFORGE_BIN", "/usr/local/bin/aforge")

config = AIIntegrationConfig.from_env()

Expand All @@ -73,6 +75,7 @@ def test_ai_integration_config_uses_sec_af_env_precedence(monkeypatch: pytest.Mo
assert config.initial_backoff_seconds == 1.5
assert config.max_backoff_seconds == 12
assert config.opencode_bin == "/usr/local/bin/opencode"
assert config.aforge_bin == "/usr/local/bin/aforge"


def test_ai_integration_config_falls_back_to_harness_and_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
Expand All @@ -88,19 +91,23 @@ def test_ai_integration_config_falls_back_to_harness_and_defaults(monkeypatch: p
"SEC_AF_AI_INITIAL_BACKOFF_SECONDS",
"SEC_AF_AI_MAX_BACKOFF_SECONDS",
"SEC_AF_OPENCODE_BIN",
"SEC_AF_AFORGE_BIN",
"AFORGE_BIN",
):
monkeypatch.delenv(key, raising=False)

config = AIIntegrationConfig.from_env()

assert config.provider == "opencode"
assert config.provider == "aforge"
assert config.harness_model == "minimax/minimax-m2.5"
assert config.ai_model == "minimax/minimax-m2.5"
assert config.max_turns == 50
assert config.max_retries == 3
assert config.initial_backoff_seconds == 2.0
assert config.max_backoff_seconds == 8.0
assert config.opencode_bin == "opencode"
assert config.aforge_bin == "aforge"
assert config.provider_env()["AGENTFIELD_AFORGE_COMMAND"] == "exec"


def test_provider_env_only_includes_present_keys(monkeypatch: pytest.MonkeyPatch) -> None:
Expand All @@ -117,3 +124,29 @@ def test_provider_env_only_includes_present_keys(monkeypatch: pytest.MonkeyPatch
assert env["GITHUB_TOKEN"] == "test-gh"
assert "OPENROUTER_API_KEY" not in env
assert "XDG_DATA_HOME" in env


def test_audit_config_defaults_to_aforge_provider(sample_audit_input: AuditInput) -> None:
config = AuditConfig.from_input(sample_audit_input, repo_path="/tmp/sec-af-repo")

assert config.provider == "aforge"


def test_pinned_agentfield_sdk_exposes_the_aforge_surface(monkeypatch: pytest.MonkeyPatch) -> None:
"""The pinned AgentField SDK must accept the aforge harness settings app.py sends it."""
for key in ("SEC_AF_PROVIDER", "HARNESS_PROVIDER", "SEC_AF_AFORGE_BIN", "AFORGE_BIN"):
monkeypatch.delenv(key, raising=False)

config = AIIntegrationConfig.from_env()
harness_config = HarnessConfig(
provider=config.provider,
model=config.harness_model,
max_turns=config.max_turns,
env=config.provider_env(),
opencode_bin=config.opencode_bin,
aforge_bin=config.aforge_bin,
permission_mode="auto",
)

assert harness_config.provider == "aforge"
assert harness_config.aforge_bin == "aforge"