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