diff --git a/.env.example b/.env.example index cac03bf..30c59ea 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile index 6b1c5a3..c08e470 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ @@ -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" && \ @@ -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 \ @@ -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 diff --git a/README.md b/README.md index 4ee88ef..6450afb 100644 --- a/README.md +++ b/README.md @@ -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** @@ -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 | +
+Build arguments + +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 . +``` + +
+ ## 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] diff --git a/agentfield-package.yaml b/agentfield-package.yaml index c677a4d..fae8a33 100644 --- a/agentfield-package.yaml +++ b/agentfield-package.yaml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index b2c6ba2..6a48977 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,9 @@ 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: @@ -26,7 +29,8 @@ services: - 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} diff --git a/pyproject.toml b/pyproject.toml index 747af56..ae35577 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/src/sec_af/app.py b/src/sec_af/app.py index a6c574a..3c7c895 100644 --- a/src/sec_af/app.py +++ b/src/sec_af/app.py @@ -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( diff --git a/src/sec_af/config.py b/src/sec_af/config.py index 382798c..3f56f53 100644 --- a/src/sec_af/config.py +++ b/src/sec_af/config.py @@ -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 @@ -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( @@ -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")), ) @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py index adf903d..e241f78 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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 @@ -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() @@ -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: @@ -88,12 +91,14 @@ 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 @@ -101,6 +106,8 @@ def test_ai_integration_config_falls_back_to_harness_and_defaults(monkeypatch: p 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: @@ -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"