From 3288e3397df1c6510d31c7b7c35c6fc7bdc62889 Mon Sep 17 00:00:00 2001 From: Binay Date: Tue, 18 Aug 2026 15:46:35 -0400 Subject: [PATCH] feat(executors): add Aider adapter behind the Executor protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #53. Ports @adity982's implementation from PR #55. That PR could not be rebased: it predates the umbra_core -> signetry_core rename (#90), so every file it touched had moved. The adapter design is theirs — the flag set, deriving the result from the repository diff rather than from Aider's own claim, and redacting the prompt from the replay command. This port updates the package path, the env-var prefix (UMBRA_ -> SIGNETRY_), and adds the safety tests. Registered as `aider`. Fail-closed on two independent conditions: SIGNETRY_ENABLE_AIDER=true AND the CLI actually responding to --version. Commit authority stays with the pipeline: --no-auto-commits and --no-dirty-commits mean Aider edits the working tree but never creates a commit, so the change stays governable by admission. --no-suggest-shell-commands keeps it from proposing shell execution, and --dry-run is used for read-only (reason) runs. model_identity reports model_resolved as "unavailable" rather than back-filling the requested model, because Aider does not echo the provider model it resolved — claiming otherwise would put an unverified value on a receipt. 13 tests: fail-closed gating (flag missing, CLI missing), protocol + registry membership, diff capture, the safety-flag contract (asserting no commit is created in a real temp repo), --dry-run on read_only, prompt redaction, honest "unavailable" on a failed run, the disabled result, model pass-through, and that registration does not let aider be auto-selected ahead of a configured preference. Also updates the pre-existing registry test, which pinned the exact executor set, and tidies a line wrap left in README by the Kotlin change. --- CONTRIBUTORS.md | 1 + README.md | 12 +- signetry_core/__init__.py | 2 + signetry_core/executors/__init__.py | 2 + signetry_core/executors/aider.py | 170 ++++++++++++++++++++++++++++ signetry_core/executors/registry.py | 2 + tests/test_executors.py | 152 ++++++++++++++++++++++++- 7 files changed, 334 insertions(+), 7 deletions(-) create mode 100644 signetry_core/executors/aider.py diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 3893daf..c1dd1ff 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -19,4 +19,5 @@ owner. +- **ADITYA** ([@adity982](https://github.com/adity982)) — Aider executor adapter behind the Executor protocol ([#55](https://github.com/Signetry/core/pull/55), ported in [#96](https://github.com/Signetry/core/pull/96)) - **Advait Varhade** ([@AdvaitVarhade](https://github.com/AdvaitVarhade)) — SSRF (CWE-918) detection rule for JavaScript/Node ([#73](https://github.com/Signetry/core/pull/73)); SSRF URL-argument resolution for keyword + positional calls, plus httpx/aiohttp coverage ([#89](https://github.com/Signetry/core/pull/89)) diff --git a/README.md b/README.md index 46a664e..cfe86e4 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ one admission pipeline and adapted behind a single interface: Executor (protocol) ├── CodexExecutor → codex exec (disposable checkout, no push/merge) ├── ClaudeCodeExecutor → claude -p (--bare: no CLAUDE.md auto-read, push/merge tools denied) + ├── AiderExecutor → aider --message (--no-auto-commits: edits the tree, never commits) └── → one adapter, no pipeline change ``` @@ -83,10 +84,9 @@ check and nothing merges without a signed receipt. `auto_merge` is always false. `signetry-core` also ships a **layered SAST detection engine**: a deterministic, offline floor (Python AST taint + cross-file/interprocedural taint, plus rules and line-based taint for Go, Java, PHP, Ruby, C#, and rules for Kotlin) covering the -OWASP set — SQL/command/ -code injection, unsafe deserialization, path traversal, XSS, weak crypto, insecure -randomness, SSRF, SSTI, JWT-none, NoSQL, XXE, hardcoded secrets, TLS-off, debug -mode. Optional, non-fatal layers add Semgrep, tree-sitter AST, and advisory LLM +OWASP set — SQL/command/code injection, unsafe deserialization, path traversal, +XSS, weak crypto, insecure randomness, SSRF, SSTI, JWT-none, NoSQL, XXE, +hardcoded secrets, TLS-off, debug mode. Optional, non-fatal layers add Semgrep, tree-sitter AST, and advisory LLM triage (which can only reduce noise — never strengthen or self-approve). ```bash @@ -144,7 +144,7 @@ agent = resolve_available(["claude-code", "codex-cli"]) agent = get_executor("claude-code") result = agent.propose("bump the vulnerable dependency", repo_path=checkout) -print(result.executor) # "claude-code" | "codex-cli" | "unavailable" +print(result.executor) # "claude-code" | "codex-cli" | "aider" | "unavailable" print(result.diff) # recomputed from git on the final tree print(result.model_identity) # honest provenance for the receipt ``` @@ -153,6 +153,8 @@ Enable agents via environment flags (off by default, fail-closed): - `SIGNETRY_ENABLE_CODEX_CLI=true` (+ `codex login`) - `SIGNETRY_ENABLE_CLAUDE_CODE=true` (+ authenticated `claude` CLI) +- `SIGNETRY_ENABLE_AIDER=true` (+ an Aider model provider; optionally + `SIGNETRY_AIDER_MODEL=`) ## The admission pipeline diff --git a/signetry_core/__init__.py b/signetry_core/__init__.py index b15778d..2af202b 100644 --- a/signetry_core/__init__.py +++ b/signetry_core/__init__.py @@ -1,4 +1,5 @@ """signetry-core — an agent-agnostic change-control plane for coding agents.""" +from .executors.aider import AiderExecutor from .executors.base import ExecutionResult, Executor from .executors.claude_code import ClaudeCodeExecutor from .executors.codex import CodexExecutor @@ -72,6 +73,7 @@ "Executor", "ExecutionResult", "CodexExecutor", + "AiderExecutor", "ClaudeCodeExecutor", "NullExecutor", "available_executors", diff --git a/signetry_core/executors/__init__.py b/signetry_core/executors/__init__.py index 06b7312..3f00e52 100644 --- a/signetry_core/executors/__init__.py +++ b/signetry_core/executors/__init__.py @@ -1,3 +1,4 @@ +from .aider import AiderExecutor from .base import ExecutionResult, Executor from .claude_code import ClaudeCodeExecutor from .codex import CodexExecutor @@ -8,6 +9,7 @@ "Executor", "ExecutionResult", "CodexExecutor", + "AiderExecutor", "ClaudeCodeExecutor", "NullExecutor", "available_executors", diff --git a/signetry_core/executors/aider.py b/signetry_core/executors/aider.py new file mode 100644 index 0000000..c82d850 --- /dev/null +++ b/signetry_core/executors/aider.py @@ -0,0 +1,170 @@ +"""Aider CLI executor — adapts ``aider --message`` to the Executor protocol. + +Ported from @adity982's implementation in PR #55, which predated the +``umbra_core`` → ``signetry_core`` rename and so could no longer be rebased. The +adapter design (flag set, diff-derived result, prompt redaction) is theirs; this +port updates the package path, the env-var prefix, and the model allowlist. + +Aider is run against a checkout with commit and push authority deliberately +withheld: ``--no-auto-commits`` / ``--no-dirty-commits`` mean Aider edits the +working tree but never creates a commit, so the change stays governable by the +admission pipeline. The result is derived from the repository diff, not from +Aider's own claim of success. +""" +from __future__ import annotations + +import logging +import os +import subprocess +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from ._shared import ( + Runner, + bounded_prompt, + changed_files, + reason_prompt, + sanitize_paths, + unified_diff, +) +from .base import ExecutionResult + +logger = logging.getLogger("signetry.executor.aider") + + +class AiderExecutor: + """Draft changes with Aider while withholding commit and push authority.""" + + name = "aider" + + def __init__( + self, + runner: Runner = subprocess.run, + model: str | None = None, + ) -> None: + self.runner = runner + configured = model if model is not None else os.getenv("SIGNETRY_AIDER_MODEL") + self.model = (configured or "").strip() or None + + # --- capability --------------------------------------------------------- + def available(self) -> bool: + """Fail closed: opt-in via env AND the CLI must actually respond.""" + if os.getenv("SIGNETRY_ENABLE_AIDER", "false").lower() != "true": + return False + return self._cli_version() is not None + + def _cli_version(self) -> str | None: + try: + result = self.runner( + ["aider", "--version"], + text=True, + capture_output=True, + timeout=15, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + if result is None: + return None + output = ( + (getattr(result, "stdout", "") or "") + (getattr(result, "stderr", "") or "") + ).strip() + return output.splitlines()[0].strip() if output else None + + def model_identity(self) -> dict[str, Any]: + """Report what we know, and mark what we cannot verify as unavailable. + + Aider does not echo the resolved provider model, so ``model_resolved`` is + honestly ``unavailable`` rather than being back-filled from the request. + """ + return { + "executor": self.name, + "cli_version": self._cli_version() or "unavailable", + "model_configured": self.model or "aider-default", + "model_resolved": "unavailable", + "model_evidence": "cli-argument" if self.model else "aider-default", + } + + # --- execution ---------------------------------------------------------- + def propose(self, prompt: str, repo_path: Path, *, read_only: bool = False) -> ExecutionResult: + if not self.available(): + return ExecutionResult.disabled( + prompt, + self.name, + "Aider is disabled. Set SIGNETRY_ENABLE_AIDER=true and configure an " + "Aider model provider.", + ) + if repo_path is None or not repo_path.is_dir(): + raise RuntimeError("A checked-out repository is required for AiderExecutor.propose()") + + cli_prompt = reason_prompt(prompt, "Aider") if read_only else bounded_prompt(prompt, "Aider") + command = [ + "aider", + "--message", + cli_prompt, + "--yes-always", + # Commit authority stays with the pipeline, not the agent: Aider edits + # the working tree and never commits, so the change remains governable. + "--no-auto-commits", + "--no-dirty-commits", + "--no-gitignore", + "--no-suggest-shell-commands", + ] + if read_only: + command.append("--dry-run") + if self.model: + command.extend(["--model", self.model]) + + # The prompt can contain the mission verbatim; keep it out of the replay + # command recorded on the receipt. + redacted_command = ( + command[:2] + [""] + command[3:] + ) + try: + completed = self.runner( + command, + text=True, + capture_output=True, + timeout=900, + check=False, + cwd=str(repo_path), + ) + except (OSError, subprocess.SubprocessError) as exc: + return ExecutionResult.failed( + prompt, + self.name, + str(exc)[:300], + command=redacted_command, + ) + + returncode = getattr(completed, "returncode", 1) + stdout = getattr(completed, "stdout", "") or "" + stderr = getattr(completed, "stderr", "") or "" + # Ground truth is the repository diff, not Aider's own summary. + diff = unified_diff(repo_path) + files = changed_files(repo_path) + summary = stdout.strip() + if not summary: + if returncode == 0: + summary = ( + "Aider completed; see the diff below." if diff + else "Aider ran and produced no changes." + ) + else: + summary = f"Aider failed (exit {returncode})." + + return ExecutionResult( + prompt=prompt, + summary=sanitize_paths(summary, repo_path), + diff=diff, + tests_passed=returncode == 0, + files=files, + # Honesty rule: only claim the executor produced output when it did. + executor=self.name if returncode == 0 else "unavailable", + created_at=datetime.now(UTC).isoformat(), + command=redacted_command, + stdout=sanitize_paths(stdout[-12000:], repo_path), + error=sanitize_paths(stderr[-4000:], repo_path) or None, + model_identity=self.model_identity(), + ) diff --git a/signetry_core/executors/registry.py b/signetry_core/executors/registry.py index 72beec9..dff6300 100644 --- a/signetry_core/executors/registry.py +++ b/signetry_core/executors/registry.py @@ -9,6 +9,7 @@ import subprocess from typing import Callable +from .aider import AiderExecutor from .base import Executor from .claude_code import ClaudeCodeExecutor from .codex import CodexExecutor @@ -20,6 +21,7 @@ _REGISTRY: dict[str, Callable[[Runner], Executor]] = { "codex-cli": lambda runner: CodexExecutor(runner=runner), "claude-code": lambda runner: ClaudeCodeExecutor(runner=runner), + "aider": lambda runner: AiderExecutor(runner=runner), "none": lambda runner: NullExecutor(), # govern an existing working-tree change } diff --git a/tests/test_executors.py b/tests/test_executors.py index 3847542..8eed07b 100644 --- a/tests/test_executors.py +++ b/tests/test_executors.py @@ -11,6 +11,7 @@ import pytest from signetry_core import ( + AiderExecutor, ClaudeCodeExecutor, CodexExecutor, ExecutionResult, @@ -42,7 +43,9 @@ def __call__(self, command, *args, **kwargs): self.calls.append(list(command)) key = f"{command[0]}:{command[1] if len(command) > 1 else ''}" # Simulate the agent editing a file inside the checkout on the exec/print call. - if self._edit_file is not None and command[0] in {"codex", "claude"} and command[1] in {"exec", "-p"}: + if (self._edit_file is not None + and command[0] in {"codex", "claude", "aider"} + and command[1] in {"exec", "-p", "--message"}): self._edit_file.write_text(self._edit_text) return self.responses.get(key, FakeCompleted()) @@ -66,9 +69,10 @@ def test_both_executors_satisfy_protocol(): def test_registry_lists_and_resolves(): - assert set(available_executors()) == {"codex-cli", "claude-code", "none"} + assert set(available_executors()) == {"codex-cli", "claude-code", "aider", "none"} assert isinstance(get_executor("codex-cli"), CodexExecutor) assert isinstance(get_executor("claude-code"), ClaudeCodeExecutor) + assert isinstance(get_executor("aider"), AiderExecutor) assert isinstance(get_executor("none"), NullExecutor) @@ -264,3 +268,147 @@ def test_execution_result_failed_factory(): assert res.executor == "unavailable" assert res.tests_passed is False assert "boom" in (res.error or "") + + +# --- aider (#53) ------------------------------------------------------------ + + +def test_aider_unavailable_without_flag(monkeypatch): + monkeypatch.delenv("SIGNETRY_ENABLE_AIDER", raising=False) + runner = FakeRunner({"aider:--version": FakeCompleted(stdout="aider 0.86.1")}) + # Fail closed: the CLI responding is not enough, the opt-in must be set too. + assert AiderExecutor(runner=runner).available() is False + + +def test_aider_available_with_flag_and_version(monkeypatch): + monkeypatch.setenv("SIGNETRY_ENABLE_AIDER", "true") + runner = FakeRunner({"aider:--version": FakeCompleted(stdout="aider 0.86.1")}) + assert AiderExecutor(runner=runner).available() is True + + +def test_aider_unavailable_when_cli_missing(monkeypatch): + monkeypatch.setenv("SIGNETRY_ENABLE_AIDER", "true") + + def boom(*_a, **_k): + raise OSError("aider not found") + + assert AiderExecutor(runner=boom).available() is False + + +def test_aider_satisfies_protocol_and_is_registered(): + assert isinstance(AiderExecutor(), Executor) + assert "aider" in available_executors() + assert get_executor("aider").name == "aider" + + +def test_aider_propose_captures_diff(monkeypatch, git_repo): + monkeypatch.setenv("SIGNETRY_ENABLE_AIDER", "true") + runner = FakeRunner( + { + "aider:--version": FakeCompleted(stdout="aider 0.86.1"), + "aider:--message": FakeCompleted(returncode=0, stdout="Applied edit to app.py"), + }, + edit_file=git_repo / "app.py", + edit_text="x = 2\n", + ) + res = AiderExecutor(runner=runner).propose("bump x", git_repo) + assert res.executor == "aider" + assert res.tests_passed is True + assert "app.py" in res.files + assert "x = 2" in res.diff + assert res.model_identity["executor"] == "aider" + + +def test_aider_withholds_commit_and_shell_authority(monkeypatch, git_repo): + """The security contract: Aider edits the tree but never commits, and cannot + be steered into suggesting shell commands.""" + monkeypatch.setenv("SIGNETRY_ENABLE_AIDER", "true") + runner = FakeRunner( + { + "aider:--version": FakeCompleted(stdout="aider 0.86.1"), + "aider:--message": FakeCompleted(returncode=0, stdout="ok"), + } + ) + AiderExecutor(runner=runner).propose("do a thing", git_repo) + invocation = next(c for c in runner.calls if c[1] == "--message") + for flag in ("--no-auto-commits", "--no-dirty-commits", "--no-suggest-shell-commands"): + assert flag in invocation, f"missing safety flag {flag}" + # No commit was created in the repo. + log = subprocess.run(["git", "log", "--oneline"], cwd=git_repo, + capture_output=True, text=True, check=True).stdout + assert log.strip().count("\n") == 0 + + +def test_aider_read_only_uses_dry_run(monkeypatch, git_repo): + monkeypatch.setenv("SIGNETRY_ENABLE_AIDER", "true") + runner = FakeRunner( + { + "aider:--version": FakeCompleted(stdout="aider 0.86.1"), + "aider:--message": FakeCompleted(returncode=0, stdout="ok"), + } + ) + AiderExecutor(runner=runner).propose("explain", git_repo, read_only=True) + invocation = next(c for c in runner.calls if c[1] == "--message") + assert "--dry-run" in invocation + + +def test_aider_redacts_prompt_from_command_replay(monkeypatch, git_repo): + monkeypatch.setenv("SIGNETRY_ENABLE_AIDER", "true") + runner = FakeRunner( + { + "aider:--version": FakeCompleted(stdout="aider 0.86.1"), + "aider:--message": FakeCompleted(returncode=0, stdout="ok"), + } + ) + res = AiderExecutor(runner=runner).propose("secret mission text", git_repo) + assert res.command is not None + assert "secret mission text" not in " ".join(res.command) + assert any("redacted" in part for part in res.command) + + +def test_aider_failed_run_reports_unavailable(monkeypatch, git_repo): + monkeypatch.setenv("SIGNETRY_ENABLE_AIDER", "true") + runner = FakeRunner( + { + "aider:--version": FakeCompleted(stdout="aider 0.86.1"), + "aider:--message": FakeCompleted(returncode=1, stderr="provider auth failed"), + } + ) + res = AiderExecutor(runner=runner).propose("x", git_repo) + # Honesty rule: a failed run is never recorded as the executor producing output. + assert res.executor == "unavailable" + assert res.tests_passed is False + + +def test_aider_disabled_result_is_honest(monkeypatch, git_repo): + monkeypatch.delenv("SIGNETRY_ENABLE_AIDER", raising=False) + res = AiderExecutor(runner=FakeRunner({})).propose("x", git_repo) + assert res.diff == "" + assert res.tests_passed is None + assert "SIGNETRY_ENABLE_AIDER" in res.summary + + +def test_aider_model_passed_through(monkeypatch, git_repo): + monkeypatch.setenv("SIGNETRY_ENABLE_AIDER", "true") + monkeypatch.setenv("SIGNETRY_AIDER_MODEL", "gpt-5.6-terra") + runner = FakeRunner( + { + "aider:--version": FakeCompleted(stdout="aider 0.86.1"), + "aider:--message": FakeCompleted(returncode=0, stdout="ok"), + } + ) + res = AiderExecutor(runner=runner).propose("x", git_repo) + invocation = next(c for c in runner.calls if c[1] == "--message") + assert "--model" in invocation and "gpt-5.6-terra" in invocation + assert res.model_identity["model_configured"] == "gpt-5.6-terra" + # Aider does not echo the resolved provider model, so we must not claim one. + assert res.model_identity["model_resolved"] == "unavailable" + + +def test_aider_not_auto_selected_over_configured_preference(monkeypatch): + """resolve_available honors preference order; aider being registered must not + silently take priority.""" + monkeypatch.delenv("SIGNETRY_ENABLE_AIDER", raising=False) + monkeypatch.delenv("SIGNETRY_ENABLE_CODEX_CLI", raising=False) + monkeypatch.delenv("SIGNETRY_ENABLE_CLAUDE_CODE", raising=False) + assert resolve_available(runner=FakeRunner({})) is None