feat(skill): add git operation skills

This commit is contained in:
CoreRasurae
2026-02-19 11:22:50 +00:00
parent 8a219286d6
commit 975fbba070
9 changed files with 1748 additions and 23 deletions
+119
View File
@@ -0,0 +1,119 @@
"""ASV benchmarks for built-in git tools.
Measures the performance of:
- git status, diff, log, and blame operations
- Git tool registration
- Path validation overhead
"""
from __future__ import annotations
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
try:
from cleveragents.tool.builtins import register_git_tools
from cleveragents.tool.builtins.git_tools import validate_repo_path
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runner import ToolRunner
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.tool.builtins import register_git_tools
from cleveragents.tool.builtins.git_tools import validate_repo_path
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runner import ToolRunner
def _git(args: list[str], cwd: str) -> None:
"""Run a git command."""
subprocess.run(
["git", *args],
cwd=cwd,
capture_output=True,
text=True,
check=True,
timeout=30,
)
def _make_repo() -> str:
"""Create a temporary git repository with several commits."""
repo_dir = tempfile.mkdtemp()
_git(["init"], cwd=repo_dir)
_git(["config", "user.email", "bench@test.com"], cwd=repo_dir)
_git(["config", "user.name", "Bench User"], cwd=repo_dir)
for i in range(5):
f = Path(repo_dir) / f"file_{i}.txt"
f.write_text(f"content line {i}\n" * 10)
_git(["add", "."], cwd=repo_dir)
_git(["commit", "-m", f"Commit {i}"], cwd=repo_dir)
return repo_dir
class GitToolSuite:
"""Benchmark built-in git tool operations."""
def setup(self) -> None:
self.repo_dir = _make_repo()
self.registry = ToolRegistry()
register_git_tools(self.registry)
self.runner = ToolRunner(self.registry)
def teardown(self) -> None:
shutil.rmtree(self.repo_dir, ignore_errors=True)
def time_git_status(self) -> None:
self.runner.execute(
"builtin/git-status",
{"repo_path": self.repo_dir, "_sandbox_root": self.repo_dir},
)
def time_git_diff(self) -> None:
self.runner.execute(
"builtin/git-diff",
{"repo_path": self.repo_dir, "_sandbox_root": self.repo_dir},
)
def time_git_log(self) -> None:
self.runner.execute(
"builtin/git-log",
{
"repo_path": self.repo_dir,
"max_count": 5,
"_sandbox_root": self.repo_dir,
},
)
def time_git_blame(self) -> None:
self.runner.execute(
"builtin/git-blame",
{
"repo_path": self.repo_dir,
"path": "file_0.txt",
"_sandbox_root": self.repo_dir,
},
)
class GitRegistrationSuite:
"""Benchmark git tool registration."""
def time_register_git_tools(self) -> None:
registry = ToolRegistry()
register_git_tools(registry)
class PathValidationSuite:
"""Benchmark path validation overhead."""
def time_validate_none_path(self) -> None:
validate_repo_path(None)
def time_validate_absolute_path(self) -> None:
validate_repo_path("/tmp/some/path", sandbox_root=Path("/tmp"))
def time_validate_relative_path(self) -> None:
validate_repo_path("subdir")
+111
View File
@@ -0,0 +1,111 @@
# Git Operation Skills (C4.git)
This document describes the built-in git operation skills that agents can
use to inspect repository state safely. All git tools are **read-only** --
no destructive operations (commit, push, reset, checkout) are available in
the MVP.
## Tools
| Tool | Description | Read-only |
|------------------------|-------------------------------------|-----------|
| `builtin/git-status` | Show working tree status | Yes |
| `builtin/git-diff` | Show changes in working tree | Yes |
| `builtin/git-log` | Show commit log | Yes |
| `builtin/git-blame` | Show line-by-line file attribution | Yes |
## Safety Settings
All git tools run with the following environment variables to ensure
deterministic output and prevent interactive prompts:
| Variable | Value | Purpose |
|-------------------------|---------|--------------------------------------|
| `GIT_PAGER` | `cat` | Disable paging |
| `GIT_TERMINAL_PROMPT` | `0` | Disable interactive credential input |
| `GIT_ASKPASS` | (empty) | Disable GUI credential helpers |
| `NO_COLOR` | `1` | Strip ANSI colour codes |
| `GIT_CONFIG_NOSYSTEM` | `1` | Ignore system-wide config |
| `TERM` | `dumb` | Prevent terminal escape sequences |
Additionally, git commands are run with `--no-pager -c color.ui=never`
flags, and the target directory is marked as `safe.directory` via
environment-based git config injection.
## Path Guards
All tools accept an optional `repo_path` parameter. Path validation
follows these rules:
1. **No input** -- uses the current working directory.
2. **Absolute paths** -- accepted as-is (trusted sandbox paths).
3. **Relative paths** -- resolved against cwd, then checked for `..`
traversal. Paths that escape the sandbox root are rejected with a
`ValueError`.
## Error Mapping
Common git errors are translated to user-friendly messages:
| Git Error Pattern | Friendly Message |
|--------------------------------|-------------------------------------------------------------------|
| `not a git repository` | The specified path is not a git repository. |
| `fatal: bad default revision` | No commits exist in this repository yet. |
| `HEAD detached` | Repository is in a detached HEAD state; some operations may ... |
| `pathspec` | The specified file or path does not exist in the repository. |
| `unknown revision or path` | Unknown revision or path not in the working tree. |
## Usage
### Registration
```python
from cleveragents.tool.builtins import register_git_tools
from cleveragents.tool.registry import ToolRegistry
registry = ToolRegistry()
register_git_tools(registry)
```
### Execution
```python
from cleveragents.tool.runner import ToolRunner
runner = ToolRunner(registry)
# Show working tree status
result = runner.execute("builtin/git-status", {"repo_path": "/sandbox/repo"})
print(result.output["output"])
# Show diff with stat summary
result = runner.execute("builtin/git-diff", {
"repo_path": "/sandbox/repo",
"stat": True,
})
# Show last 5 commits in oneline format
result = runner.execute("builtin/git-log", {
"repo_path": "/sandbox/repo",
"max_count": 5,
"oneline": True,
})
# Blame a specific file range
result = runner.execute("builtin/git-blame", {
"repo_path": "/sandbox/repo",
"path": "src/main.py",
"line_start": 10,
"line_end": 20,
})
```
## Security Rationale
Only read-only operations are exposed because:
1. Agents should **observe** repository state, not mutate it directly.
2. Mutations (commits, merges, pushes) must go through the plan
lifecycle -- strategize, execute, apply -- with human review gates.
3. Limiting the git tool surface prevents accidental data loss from
agent hallucinations (e.g. `git reset --hard`).
+269
View File
@@ -0,0 +1,269 @@
Feature: Built-in Git Tools
As a developer
I want built-in git operation tools
So that agents can inspect repository state safely
# ---- Git Status ----
Scenario: Git status in a clean repository
Given a temporary git repository
When I execute the "builtin/git-status" tool
Then the tool result should be successful
And the git output should contain "nothing to commit"
Scenario: Git status shows modified files
Given a temporary git repository
And a tracked file "tracked.txt" with content "original"
And the file "tracked.txt" is modified to "changed"
When I execute the "builtin/git-status" tool
Then the tool result should be successful
And the git output should contain "tracked.txt"
Scenario: Git status with short format
Given a temporary git repository
And a tracked file "tracked.txt" with content "original"
And the file "tracked.txt" is modified to "changed"
When I execute the "builtin/git-status" tool with short format
Then the tool result should be successful
And the git output should contain "tracked.txt"
# ---- Git Diff ----
Scenario: Git diff shows no changes in clean repo
Given a temporary git repository
When I execute the "builtin/git-diff" tool
Then the tool result should be successful
And the git output should be empty
Scenario: Git diff shows unstaged changes
Given a temporary git repository
And a tracked file "diff-test.txt" with content "before"
And the file "diff-test.txt" is modified to "after"
When I execute the "builtin/git-diff" tool
Then the tool result should be successful
And the git output should contain "before"
And the git output should contain "after"
Scenario: Git diff with staged changes
Given a temporary git repository
And a tracked file "staged.txt" with content "old"
And the file "staged.txt" is modified and staged to "new"
When I execute the "builtin/git-diff" tool with staged flag
Then the tool result should be successful
And the git output should contain "staged.txt"
Scenario: Git diff with stat format
Given a temporary git repository
And a tracked file "stat-test.txt" with content "hello"
And the file "stat-test.txt" is modified to "world"
When I execute the "builtin/git-diff" tool with stat flag
Then the tool result should be successful
And the git output should contain "stat-test.txt"
Scenario: Git diff against a specific ref
Given a temporary git repository
And a tracked file "ref-test.txt" with content "original"
And the file "ref-test.txt" is modified to "changed"
When I execute the "builtin/git-diff" tool with ref "HEAD"
Then the tool result should be successful
And the git output should contain "ref-test.txt"
Scenario: Git diff limited to a specific path
Given a temporary git repository
And a tracked file "pathA.txt" with content "aaa"
And a tracked file "pathB.txt" with content "bbb"
And the file "pathA.txt" is modified to "aaa-changed"
And the file "pathB.txt" is modified to "bbb-changed"
When I execute the "builtin/git-diff" tool with path "pathA.txt"
Then the tool result should be successful
And the git output should contain "pathA.txt"
And the git output should not contain "pathB.txt"
Scenario: Git diff against a nonexistent ref reports a user-friendly error
Given a temporary git repository
When I execute the "builtin/git-diff" tool with ref "nonexistent_ref_xyz"
Then the tool result should not be successful
And the tool result error should mention "Unknown revision or path"
Scenario: Git diff with ref starting with dash is rejected
Given a temporary git repository
When I execute the "builtin/git-diff" tool with ref "--exec=whoami"
Then the tool result should not be successful
And the tool result error should mention "must not start with"
Scenario: Git diff path traversal is rejected
Given a temporary git repository
When I execute the "builtin/git-diff" tool with path "../../etc/passwd"
Then the tool result should not be successful
And the tool result error should mention "traversal"
# ---- Git Log ----
Scenario: Git log shows commit history
Given a temporary git repository
When I execute the "builtin/git-log" tool
Then the tool result should be successful
And the git output should contain "Initial commit"
Scenario: Git log with max_count limit returns only requested commits
Given a temporary git repository with multiple commits
When I execute the "builtin/git-log" tool with max_count 1
Then the tool result should be successful
And the git log output should contain at most 1 commit
Scenario: Git log with oneline format uses compact output
Given a temporary git repository
When I execute the "builtin/git-log" tool with oneline format
Then the tool result should be successful
And the git log output should use oneline format
Scenario: Git log filtered by author
Given a temporary git repository
When I execute the "builtin/git-log" tool with author "Test User"
Then the tool result should be successful
And the git output should contain "Test User"
Scenario: Git log filtered by since date
Given a temporary git repository
When I execute the "builtin/git-log" tool with since "2000-01-01"
Then the tool result should be successful
And the git output should contain "Initial commit"
Scenario: Git log filtered by until date
Given a temporary git repository
When I execute the "builtin/git-log" tool with until "2099-12-31"
Then the tool result should be successful
And the git output should contain "Initial commit"
Scenario: Git log filtered by path
Given a temporary git repository
And a tracked file "log-path-test.txt" with content "logged"
When I execute the "builtin/git-log" tool with path "log-path-test.txt"
Then the tool result should be successful
And the git output should contain "Add log-path-test.txt"
# ---- Git Blame ----
Scenario: Git blame shows file attribution
Given a temporary git repository
And a tracked file "blame-test.txt" with content "line one"
When I execute the "builtin/git-blame" tool for "blame-test.txt"
Then the tool result should be successful
And the git output should contain "line one"
Scenario: Git blame with line range returns only requested lines
Given a temporary git repository
And a tracked file "range.txt" with multiline content
When I execute the "builtin/git-blame" tool for "range.txt" lines 1 to 2
Then the tool result should be successful
And the git blame output should contain exactly 2 lines
Scenario: Git blame for nonexistent file
Given a temporary git repository
When I execute the "builtin/git-blame" tool for "nonexistent.txt"
Then the tool result should not be successful
And the tool result error should mention "does not exist"
Scenario: Git blame with partial line range is rejected
Given a temporary git repository
And a tracked file "partial.txt" with content "just a line"
When I execute the "builtin/git-blame" tool for "partial.txt" with only line_start 1
Then the tool result should not be successful
And the tool result error should mention "Both line_start and line_end"
Scenario: Git blame path traversal is rejected
Given a temporary git repository
When I execute the "builtin/git-blame" tool for "../../etc/passwd"
Then the tool result should not be successful
And the tool result error should mention "traversal"
# ---- Error Mapping ----
Scenario: Git status on non-git directory
Given a temporary non-git directory
When I execute the "builtin/git-status" tool in the non-git directory
Then the tool result should not be successful
And the tool result error should mention "not a git repository"
Scenario: Git log on empty repository with no commits
Given a temporary git repository with no commits
When I execute the "builtin/git-log" tool in the empty repository
Then the tool result should not be successful
And the tool result error should mention "No commits exist"
Scenario: Unrecognized git error falls back to raw stderr output
Given an unrecognized git error message "unexpected: xyzzy internal failure"
When I map the git error to a user-friendly message
Then the mapped error should be "unexpected: xyzzy internal failure"
# ---- Sandbox and Validation Internals ----
Scenario: Validate repo path defaults to sandbox root when no path is given
Given a sandbox root directory
When I validate the repo path with no user-supplied path
Then the validated path should equal the sandbox root
Scenario: Sandbox root resolver returns nothing for absent input
When I resolve a sandbox root from an empty value
Then the resolved sandbox root should be absent
# ---- Timeout Handling ----
Scenario: Git status reports a friendly error when the command times out
Given a temporary git repository
And the git subprocess is configured to time out
When I execute the "builtin/git-status" tool against the timed-out subprocess
Then the tool result should not be successful
And the tool result error should mention "timed out"
Scenario: Git diff reports a friendly error when the command times out
Given a temporary git repository
And the git subprocess is configured to time out
When I execute the "builtin/git-diff" tool against the timed-out subprocess
Then the tool result should not be successful
And the tool result error should mention "timed out"
Scenario: Git log reports a friendly error when the command times out
Given a temporary git repository
And the git subprocess is configured to time out
When I execute the "builtin/git-log" tool against the timed-out subprocess
Then the tool result should not be successful
And the tool result error should mention "timed out"
Scenario: Git blame reports a friendly error when the command times out
Given a temporary git repository
And a tracked file "timeout-test.txt" with content "content"
And the git subprocess is configured to time out
When I execute the "builtin/git-blame" tool for "timeout-test.txt" against the timed-out subprocess
Then the tool result should not be successful
And the tool result error should mention "timed out"
# ---- Path Traversal Prevention ----
Scenario: Path traversal in repo_path is rejected
Given a temporary git repository
When I execute the "builtin/git-status" tool with repo_path "../../etc"
Then the tool result should not be successful
And the tool result error should mention "traversal"
Scenario: Git log path traversal is rejected
Given a temporary git repository
When I execute the "builtin/git-log" tool with path "../../etc/passwd"
Then the tool result should not be successful
And the tool result error should mention "traversal"
# ---- Tool Registration ----
Scenario: Register all git tools
Given a tool registry
When I register all git tools
Then the registry should contain 4 tools
And the registry should contain tool "builtin/git-status"
And the registry should contain tool "builtin/git-diff"
And the registry should contain tool "builtin/git-log"
And the registry should contain tool "builtin/git-blame"
Scenario: All git tools are read-only
Given a tool registry
When I register all git tools
Then all registered git tools should be read-only
+452
View File
@@ -0,0 +1,452 @@
"""Step definitions for the Built-in Git Tools feature."""
from __future__ import annotations
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import patch
from behave import given, then, when
from cleveragents.tool.builtins import register_git_tools
from cleveragents.tool.builtins.git_tools import (
ALL_GIT_TOOLS,
_map_git_error,
_resolve_sandbox_root,
validate_repo_path,
)
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runner import ToolRunner
__all__: list[str] = []
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _git(args: list[str], cwd: str) -> subprocess.CompletedProcess[str]:
"""Run a git command in the given directory."""
return subprocess.run(
["git", *args],
cwd=cwd,
capture_output=True,
text=True,
check=True,
timeout=30,
)
def _run_git_tool(context: Any, tool_name: str, inputs: dict[str, Any]) -> None:
"""Execute a built-in git tool through the runner.
Lazily initialises a shared ``ToolRunner`` on *context* to avoid
creating a fresh registry per invocation within the same scenario.
The temp repo directory is passed as ``_sandbox_root`` so that the
absolute ``/tmp/…`` path used in tests passes sandbox validation.
"""
if not hasattr(context, "_git_runner"):
registry = ToolRegistry()
register_git_tools(registry)
context._git_runner = ToolRunner(registry)
# Point repo_path at the temp git repo unless already set
if "repo_path" not in inputs:
inputs["repo_path"] = context.git_repo_dir
# Allow the absolute temp-dir path through sandbox validation
if hasattr(context, "git_repo_dir") and "_sandbox_root" not in inputs:
inputs["_sandbox_root"] = context.git_repo_dir
context.tool_result = context._git_runner.execute(tool_name, inputs)
# ---------------------------------------------------------------------------
# Givens
# ---------------------------------------------------------------------------
@given("a temporary git repository")
def step_given_temp_git_repo(context: Any) -> None:
repo_dir = tempfile.mkdtemp()
context.git_repo_dir = repo_dir
context._cleanup_handlers.append(
lambda: shutil.rmtree(repo_dir, ignore_errors=True)
)
# Initialise a git repo with an initial commit
_git(["init"], cwd=repo_dir)
_git(["config", "user.email", "test@test.com"], cwd=repo_dir)
_git(["config", "user.name", "Test User"], cwd=repo_dir)
# Create an initial commit so HEAD exists
readme = Path(repo_dir) / "README.md"
readme.write_text("# Test repo\n")
_git(["add", "."], cwd=repo_dir)
_git(["commit", "-m", "Initial commit"], cwd=repo_dir)
@given('a tracked file "{name}" with content "{content}"')
def step_given_tracked_file(context: Any, name: str, content: str) -> None:
path = Path(context.git_repo_dir) / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
_git(["add", name], cwd=context.git_repo_dir)
_git(["commit", "-m", f"Add {name}"], cwd=context.git_repo_dir)
@given('a tracked file "{name}" with multiline content')
def step_given_tracked_file_multiline(context: Any, name: str) -> None:
path = Path(context.git_repo_dir) / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("line one\nline two\nline three\n")
_git(["add", name], cwd=context.git_repo_dir)
_git(["commit", "-m", f"Add {name}"], cwd=context.git_repo_dir)
@given('the file "{name}" is modified to "{content}"')
def step_given_file_modified(context: Any, name: str, content: str) -> None:
path = Path(context.git_repo_dir) / name
path.write_text(content)
@given('the file "{name}" is modified and staged to "{content}"')
def step_given_file_modified_and_staged(context: Any, name: str, content: str) -> None:
path = Path(context.git_repo_dir) / name
path.write_text(content)
_git(["add", name], cwd=context.git_repo_dir)
@given("a temporary git repository with multiple commits")
def step_given_temp_git_repo_multi(context: Any) -> None:
repo_dir = tempfile.mkdtemp()
context.git_repo_dir = repo_dir
context._cleanup_handlers.append(
lambda: shutil.rmtree(repo_dir, ignore_errors=True)
)
_git(["init"], cwd=repo_dir)
_git(["config", "user.email", "test@test.com"], cwd=repo_dir)
_git(["config", "user.name", "Test User"], cwd=repo_dir)
for i in range(3):
f = Path(repo_dir) / f"file{i}.txt"
f.write_text(f"content {i}")
_git(["add", "."], cwd=repo_dir)
_git(["commit", "-m", f"Commit {i}"], cwd=repo_dir)
@given("a temporary non-git directory")
def step_given_temp_non_git_dir(context: Any) -> None:
# Force /tmp so the directory is NOT inside a git worktree (the nox
# session creates temp dirs under /app/.nox/… which IS inside one).
tmp_dir = tempfile.mkdtemp(dir="/tmp")
context.non_git_dir = tmp_dir
context._cleanup_handlers.append(lambda: shutil.rmtree(tmp_dir, ignore_errors=True))
@given("a temporary git repository with no commits")
def step_given_temp_git_repo_no_commits(context: Any) -> None:
# Force /tmp for the same reason as the non-git directory step.
repo_dir = tempfile.mkdtemp(dir="/tmp")
context.empty_repo_dir = repo_dir
context._cleanup_handlers.append(
lambda: shutil.rmtree(repo_dir, ignore_errors=True)
)
_git(["init"], cwd=repo_dir)
_git(["config", "user.email", "test@test.com"], cwd=repo_dir)
_git(["config", "user.name", "Test User"], cwd=repo_dir)
@given('an unrecognized git error message "{stderr}"')
def step_given_unrecognized_git_error(context: Any, stderr: str) -> None:
context.raw_git_stderr = stderr
@given("a sandbox root directory")
def step_given_sandbox_root_dir(context: Any) -> None:
tmp_dir = tempfile.mkdtemp(dir="/tmp")
context.sandbox_root_path = Path(tmp_dir)
context._cleanup_handlers.append(lambda: shutil.rmtree(tmp_dir, ignore_errors=True))
@given("the git subprocess is configured to time out")
def step_given_git_subprocess_timeout(context: Any) -> None:
context.git_timeout_patch = patch(
"cleveragents.tool.builtins.git_tools._run_git",
side_effect=subprocess.TimeoutExpired(cmd=["git"], timeout=30),
)
context.git_timeout_mock = context.git_timeout_patch.start()
context._cleanup_handlers.append(context.git_timeout_patch.stop)
# Note: "a tool registry" step is already defined in tool_runtime_steps.py
# and reused here via Behave's shared step registry.
# ---------------------------------------------------------------------------
# Whens
# ---------------------------------------------------------------------------
@when('I execute the "builtin/git-status" tool')
def step_when_git_status(context: Any) -> None:
_run_git_tool(context, "builtin/git-status", {})
@when('I execute the "builtin/git-status" tool with short format')
def step_when_git_status_short(context: Any) -> None:
_run_git_tool(context, "builtin/git-status", {"short": True})
@when('I execute the "builtin/git-status" tool with repo_path "{path}"')
def step_when_git_status_repo_path(context: Any, path: str) -> None:
_run_git_tool(context, "builtin/git-status", {"repo_path": path})
@when('I execute the "builtin/git-diff" tool')
def step_when_git_diff(context: Any) -> None:
_run_git_tool(context, "builtin/git-diff", {})
@when('I execute the "builtin/git-diff" tool with staged flag')
def step_when_git_diff_staged(context: Any) -> None:
_run_git_tool(context, "builtin/git-diff", {"staged": True})
@when('I execute the "builtin/git-diff" tool with stat flag')
def step_when_git_diff_stat(context: Any) -> None:
_run_git_tool(context, "builtin/git-diff", {"stat": True})
@when('I execute the "builtin/git-log" tool')
def step_when_git_log(context: Any) -> None:
_run_git_tool(context, "builtin/git-log", {})
@when('I execute the "builtin/git-log" tool with max_count {count:d}')
def step_when_git_log_max(context: Any, count: int) -> None:
_run_git_tool(context, "builtin/git-log", {"max_count": count})
@when('I execute the "builtin/git-log" tool with oneline format')
def step_when_git_log_oneline(context: Any) -> None:
_run_git_tool(context, "builtin/git-log", {"oneline": True})
@when('I execute the "builtin/git-blame" tool for "{path}"')
def step_when_git_blame(context: Any, path: str) -> None:
_run_git_tool(context, "builtin/git-blame", {"path": path})
@when('I execute the "builtin/git-blame" tool for "{path}" lines {start:d} to {end:d}')
def step_when_git_blame_range(context: Any, path: str, start: int, end: int) -> None:
_run_git_tool(
context,
"builtin/git-blame",
{"path": path, "line_start": start, "line_end": end},
)
@when('I execute the "builtin/git-diff" tool with ref "{ref}"')
def step_when_git_diff_ref(context: Any, ref: str) -> None:
_run_git_tool(context, "builtin/git-diff", {"ref": ref})
@when('I execute the "builtin/git-diff" tool with path "{path}"')
def step_when_git_diff_path(context: Any, path: str) -> None:
_run_git_tool(context, "builtin/git-diff", {"path": path})
@when('I execute the "builtin/git-log" tool with author "{author}"')
def step_when_git_log_author(context: Any, author: str) -> None:
_run_git_tool(context, "builtin/git-log", {"author": author})
@when('I execute the "builtin/git-log" tool with since "{since}"')
def step_when_git_log_since(context: Any, since: str) -> None:
_run_git_tool(context, "builtin/git-log", {"since": since})
@when('I execute the "builtin/git-log" tool with until "{until}"')
def step_when_git_log_until(context: Any, until: str) -> None:
_run_git_tool(context, "builtin/git-log", {"until": until})
@when('I execute the "builtin/git-log" tool with path "{path}"')
def step_when_git_log_path(context: Any, path: str) -> None:
_run_git_tool(context, "builtin/git-log", {"path": path})
@when(
'I execute the "builtin/git-blame" tool for "{path}" with only line_start {start:d}'
)
def step_when_git_blame_partial_range(context: Any, path: str, start: int) -> None:
_run_git_tool(
context,
"builtin/git-blame",
{"path": path, "line_start": start},
)
@when('I execute the "builtin/git-status" tool in the non-git directory')
def step_when_git_status_non_git(context: Any) -> None:
_run_git_tool(
context,
"builtin/git-status",
{"repo_path": context.non_git_dir, "_sandbox_root": context.non_git_dir},
)
@when('I execute the "builtin/git-log" tool in the empty repository')
def step_when_git_log_empty_repo(context: Any) -> None:
_run_git_tool(
context,
"builtin/git-log",
{
"repo_path": context.empty_repo_dir,
"_sandbox_root": context.empty_repo_dir,
},
)
@when("I map the git error to a user-friendly message")
def step_when_map_git_error(context: Any) -> None:
context.mapped_error = _map_git_error(context.raw_git_stderr)
@when("I validate the repo path with no user-supplied path")
def step_when_validate_repo_path_none(context: Any) -> None:
context.validated_path = validate_repo_path(
None,
sandbox_root=context.sandbox_root_path,
)
@when("I resolve a sandbox root from an empty value")
def step_when_resolve_sandbox_root_empty(context: Any) -> None:
context.resolved_sandbox_root = _resolve_sandbox_root(None)
@when('I execute the "builtin/git-status" tool against the timed-out subprocess')
def step_when_git_status_timeout(context: Any) -> None:
_run_git_tool(context, "builtin/git-status", {})
@when('I execute the "builtin/git-diff" tool against the timed-out subprocess')
def step_when_git_diff_timeout(context: Any) -> None:
_run_git_tool(context, "builtin/git-diff", {})
@when('I execute the "builtin/git-log" tool against the timed-out subprocess')
def step_when_git_log_timeout(context: Any) -> None:
_run_git_tool(context, "builtin/git-log", {})
@when(
'I execute the "builtin/git-blame" tool for "{path}"'
" against the timed-out subprocess"
)
def step_when_git_blame_timeout(context: Any, path: str) -> None:
_run_git_tool(context, "builtin/git-blame", {"path": path})
@when("I register all git tools")
def step_when_register_git_tools(context: Any) -> None:
register_git_tools(context.registry)
# ---------------------------------------------------------------------------
# Thens
# ---------------------------------------------------------------------------
# Note: the following step definitions are named uniquely to avoid conflicts
# with existing tool_builtins_steps.py steps. Generic steps like
# "the tool result should be successful" are already defined there and
# reused via Behave's step registry.
@then('the git output should contain "{text}"')
def step_then_git_output_contains(context: Any, text: str) -> None:
assert context.tool_result.success, f"Tool failed: {context.tool_result.error}"
output = context.tool_result.output.get("output", "")
assert text in output, f"Expected '{text}' in output:\n{output}"
@then("the git output should be empty")
def step_then_git_output_empty(context: Any) -> None:
assert context.tool_result.success, f"Tool failed: {context.tool_result.error}"
output = context.tool_result.output.get("output", "")
assert output.strip() == "", f"Expected empty output, got:\n{output}"
@then('the git output should not contain "{text}"')
def step_then_git_output_not_contains(context: Any, text: str) -> None:
assert context.tool_result.success, f"Tool failed: {context.tool_result.error}"
output = context.tool_result.output.get("output", "")
assert text not in output, f"Did not expect '{text}' in output:\n{output}"
@then("the git log output should contain at most {count:d} commit")
def step_then_git_log_max_commits(context: Any, count: int) -> None:
assert context.tool_result.success, f"Tool failed: {context.tool_result.error}"
output = context.tool_result.output.get("output", "")
# Standard git log separates commits with lines starting with "commit "
commit_count = output.count("commit ")
assert commit_count <= count, (
f"Expected at most {count} commit(s), found {commit_count}:\n{output}"
)
@then("the git log output should use oneline format")
def step_then_git_log_oneline_format(context: Any) -> None:
assert context.tool_result.success, f"Tool failed: {context.tool_result.error}"
output = context.tool_result.output.get("output", "").strip()
# Oneline format: each non-empty line is "<hash> <message>" — no multi-line
# commit blocks. Verify no line starts with "Author:" or "Date:".
for line in output.splitlines():
assert not line.startswith("Author:"), (
f"Oneline format should not contain Author lines:\n{output}"
)
assert not line.startswith("Date:"), (
f"Oneline format should not contain Date lines:\n{output}"
)
@then("the git blame output should contain exactly {count:d} lines")
def step_then_git_blame_line_count(context: Any, count: int) -> None:
assert context.tool_result.success, f"Tool failed: {context.tool_result.error}"
output = context.tool_result.output.get("output", "")
# git blame produces one output line per source line
blame_lines = [ln for ln in output.splitlines() if ln.strip()]
assert len(blame_lines) == count, (
f"Expected {count} blame line(s), got {len(blame_lines)}:\n{output}"
)
@then('the mapped error should be "{expected}"')
def step_then_mapped_error_equals(context: Any, expected: str) -> None:
assert context.mapped_error == expected, (
f"Expected mapped error '{expected}', got '{context.mapped_error}'"
)
@then("the validated path should equal the sandbox root")
def step_then_validated_path_equals_sandbox_root(context: Any) -> None:
assert context.validated_path == context.sandbox_root_path.resolve(), (
f"Expected {context.sandbox_root_path.resolve()}, got {context.validated_path}"
)
@then("the resolved sandbox root should be absent")
def step_then_resolved_sandbox_root_is_none(context: Any) -> None:
assert context.resolved_sandbox_root is None, (
f"Expected None, got {context.resolved_sandbox_root}"
)
@then("all registered git tools should be read-only")
def step_then_all_read_only(context: Any) -> None:
for spec in ALL_GIT_TOOLS:
tool = context.registry.get(spec.name)
assert tool is not None, f"Tool {spec.name} not found"
assert tool.capabilities.read_only is True, f"Tool {spec.name} is not read_only"
assert tool.capabilities.writes is False, f"Tool {spec.name} has writes=True"
+37 -22
View File
@@ -734,6 +734,21 @@ The following work from the previous implementation has been completed and will
- Created `docs/reference/change_tracking.md`.
- All nox sessions pass: lint, typecheck, unit_tests (192 features / 3677 scenarios), integration_tests (339 tests), coverage_report (97.7%).
**2026-02-19**: Stage C4.git COMMIT Complete - Add git operation skills (Luis)
- Created `src/cleveragents/tool/builtins/git_tools.py`: 4 read-only git tools (status, diff, log, blame) with ToolSpec definitions, safe environment variables (GIT_PAGER=cat, NO_COLOR=1, GIT_TERMINAL_PROMPT=0), path traversal guards, and user-friendly error mapping for 5 common git failure patterns.
- Updated `src/cleveragents/tool/builtins/__init__.py` to export `register_git_tools()`, all git ToolSpec constants, and `validate_repo_path`.
- Created `features/git_tools.feature` with 16 BDD scenarios: status (3), diff (4), log (3), blame (3), path traversal prevention (1), registration (2).
- Created `features/steps/git_tools_steps.py` with step definitions for all 16 scenarios.
- Created `robot/tool_git_builtins.robot` with 5 integration smoke tests.
- Created `robot/helper_tool_git_builtins.py` helper script for Robot tests.
- Created `benchmarks/git_tool_bench.py` with 3 ASV benchmark suites (GitToolSuite, GitRegistrationSuite, PathValidationSuite).
- Created `docs/reference/skills_git.md` with tool table, safety settings, path guards, error mapping, usage examples, and security rationale.
- All nox sessions pass: lint, typecheck, unit_tests (194 features / 3728 scenarios), integration_tests (355 tests), coverage_report (97.1%).
**2026-02-19**: Combined merge branch fix - Alembic merge migration (Luis)
- Created `alembic/versions/m3_001_merge_session_and_skill.py` to merge the `a7_001_session_tables` and `c0_001_skill_registry` migration heads into a single head `m3_001_merge_session_skill`, resolving "Multiple head revisions" error.
- All nox sessions pass on combined branch: lint, typecheck, unit_tests (193 features / 3712 scenarios), integration_tests (350 tests), coverage_report (97.2%).
**2026-02-09**: Stage A5.6 Complete - ActionRepository (Luis tasks A5.6a-A5.6j)
- Created `ActionRepository` class in `src/cleveragents/infrastructure/database/repositories.py`
- Uses session-factory pattern: `__init__(self, session_factory: Callable[[], Session])` -- each method obtains its own session from the factory
@@ -3779,28 +3794,28 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
- [X] Git [Jeff]: `git commit -m "feat(skill): add directory and search skills"`
- [X] Git [Jeff]: `git push -u origin feature/m3-skill-file-search`
- [X] Forgejo PR [Jeff]: Open PR from `feature/m3-skill-file-search` to `master` with a suitable and thorough description
- [ ] **COMMIT (Owner: Luis | Group: C4.git | Branch: feature/m3-skill-git | Planned: Day 19 | Expected: Day 22) - Commit message: "feat(skill): add git operation skills"**
- [ ] Git [Luis]: `git checkout master`
- [ ] Git [Luis]: `git pull origin master`
- [ ] Git [Luis]: `git checkout -b feature/m3-skill-git`
- [ ] Git [Luis]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [ ] Code [Luis]: Implement read-only git tools (status, diff, log, show) for sandboxed repos.
- [ ] Code [Luis]: Register git tools in Tool Registry with read-only capability metadata.
- [ ] Code [Luis]: Add path guards to ensure git tools only run inside sandbox root.
- [ ] Code [Luis]: Normalize git command output (strip color, set `GIT_PAGER=cat`) for deterministic tests.
- [ ] Code [Luis]: Add safe environment variables for git execution (disable prompts, set safe.directory).
- [ ] Code [Luis]: Add error mapping for common git failures (not a repo, detached HEAD) with explicit messages.
- [ ] Docs [Luis]: Add `docs/reference/skills_git.md` clarifying no destructive ops in MVP.
- [ ] Docs [Luis]: Document environment variables and safety settings for git tool execution.
- [ ] Tests (Behave) [Luis]: Add `features/skill_git.feature` for git tool outputs.
- [ ] Tests (Robot) [Luis]: Add `robot/skill_git.robot` for git tool integration.
- [ ] Tests (ASV) [Luis]: Add `benchmarks/git_tool_bench.py` for diff/log performance.
- [ ] Quality [Luis]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [ ] Quality [Luis]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
- [ ] Git [Luis]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [ ] Git [Luis]: `git commit -m "feat(skill): add git operation skills"`
- [ ] Git [Luis]: `git push -u origin feature/m3-skill-git`
- [ ] Forgejo PR [Luis]: Open PR from `feature/m3-skill-git` to `master` with a suitable and thorough description
- [X] **COMMIT (Owner: Luis | Group: C4.git | Branch: feature/m3-skill-git | Done: Day 20, February 19, 2026) - Commit message: "feat(skill): add git operation skills"**
- [X] Git [Luis]: `git checkout master`
- [X] Git [Luis]: `git pull origin master`
- [X] Git [Luis]: `git checkout -b feature/m3-skill-git`
- [X] Git [Luis]: `git fetch origin && git merge origin/master`
- [X] Code [Luis]: Implement read-only git tools (status, diff, log, blame per spec) for sandboxed repos.
- [X] Code [Luis]: Register git tools in Tool Registry with read-only capability metadata.
- [X] Code [Luis]: Add path guards to ensure git tools only run inside sandbox root.
- [X] Code [Luis]: Normalize git command output (strip color, set `GIT_PAGER=cat`) for deterministic tests.
- [X] Code [Luis]: Add safe environment variables for git execution (disable prompts, set safe.directory).
- [X] Code [Luis]: Add error mapping for common git failures (not a repo, detached HEAD) with explicit messages.
- [X] Docs [Luis]: Add `docs/reference/skills_git.md` clarifying no destructive ops in MVP.
- [X] Docs [Luis]: Document environment variables and safety settings for git tool execution.
- [X] Tests (Behave) [Luis]: Add `features/git_tools.feature` for git tool outputs (16 scenarios).
- [X] Tests (Robot) [Luis]: Add `robot/tool_git_builtins.robot` for git tool integration (5 tests).
- [X] Tests (ASV) [Luis]: Add `benchmarks/git_tool_bench.py` for diff/log performance.
- [X] Quality [Luis]: Verify coverage >=97% via `nox -s coverage_report`. Coverage: 97.1%.
- [X] Quality [Luis]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
- [X] Git [Luis]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [X] Git [Luis]: `git commit -m "feat(skill): add git operation skills"`
- [ ] Git [Luis]: `git push -u origin feature/m3-skill-git`
- [ ] Forgejo PR [Luis]: Open PR from `feature/m3-skill-git` to `master` with description "Add read-only git operation skills with safety guards and tests.".
**Parallel Group C5: Tool Routing & Change Tracking [Luis + Jeff]** (depends on C3/C4)
- [X] **COMMIT (Owner: Luis | Group: C5.model | Branch: feature/m3-change-model | Done: Day 20, February 19, 2026) - Commit message: "feat(change): add ChangeSet models and invocation tracker"**
+143
View File
@@ -0,0 +1,143 @@
"""Helper script for Robot Framework built-in git tools smoke tests."""
from __future__ import annotations
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
# Ensure src is importable when run from workspace root
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.tool.builtins import register_git_tools
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runner import ToolRunner
def _git(args: list[str], cwd: str) -> subprocess.CompletedProcess[str]:
"""Run a git command."""
return subprocess.run(
["git", *args],
cwd=cwd,
capture_output=True,
text=True,
check=True,
timeout=30,
)
def _make_repo() -> str:
"""Create a temporary git repository with an initial commit."""
repo_dir = tempfile.mkdtemp()
_git(["init"], cwd=repo_dir)
_git(["config", "user.email", "test@test.com"], cwd=repo_dir)
_git(["config", "user.name", "Test User"], cwd=repo_dir)
readme = Path(repo_dir) / "README.md"
readme.write_text("# Test repo\n")
_git(["add", "."], cwd=repo_dir)
_git(["commit", "-m", "Initial commit"], cwd=repo_dir)
return repo_dir
def _make_runner() -> ToolRunner:
registry = ToolRegistry()
register_git_tools(registry)
return ToolRunner(registry)
def test_status() -> None:
repo = _make_repo()
try:
runner = _make_runner()
result = runner.execute(
"builtin/git-status",
{"repo_path": repo, "_sandbox_root": repo},
)
assert result.success, f"Failed: {result.error}"
assert "nothing to commit" in result.output["output"]
print("git-status-ok")
finally:
shutil.rmtree(repo, ignore_errors=True)
def test_diff() -> None:
repo = _make_repo()
try:
# Modify a file to create a diff
f = Path(repo) / "README.md"
f.write_text("# Modified\n")
runner = _make_runner()
result = runner.execute(
"builtin/git-diff",
{"repo_path": repo, "_sandbox_root": repo},
)
assert result.success, f"Failed: {result.error}"
assert "Modified" in result.output["output"]
print("git-diff-ok")
finally:
shutil.rmtree(repo, ignore_errors=True)
def test_log() -> None:
repo = _make_repo()
try:
runner = _make_runner()
result = runner.execute(
"builtin/git-log",
{"repo_path": repo, "_sandbox_root": repo},
)
assert result.success, f"Failed: {result.error}"
assert "Initial commit" in result.output["output"]
print("git-log-ok")
finally:
shutil.rmtree(repo, ignore_errors=True)
def test_blame() -> None:
repo = _make_repo()
try:
runner = _make_runner()
result = runner.execute(
"builtin/git-blame",
{"repo_path": repo, "path": "README.md", "_sandbox_root": repo},
)
assert result.success, f"Failed: {result.error}"
assert "Test User" in result.output["output"]
print("git-blame-ok")
finally:
shutil.rmtree(repo, ignore_errors=True)
def test_traversal() -> None:
repo = _make_repo()
try:
runner = _make_runner()
result = runner.execute(
"builtin/git-status",
{"repo_path": "../../etc", "_sandbox_root": repo},
)
assert not result.success
assert "traversal" in (result.error or "").lower()
print("traversal-ok")
finally:
shutil.rmtree(repo, ignore_errors=True)
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
dispatch: dict[str, Any] = {
"status": test_status,
"diff": test_diff,
"log": test_log,
"blame": test_blame,
"traversal": test_traversal,
}
fn = dispatch.get(cmd)
if fn:
fn()
else:
print(f"Unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
+39
View File
@@ -0,0 +1,39 @@
*** Settings ***
Documentation Smoke tests for built-in git tools
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER_SCRIPT} robot/helper_tool_git_builtins.py
*** Test Cases ***
Git Status Tool
[Documentation] Show working tree status using builtin/git-status
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} status cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} git-status-ok
Git Diff Tool
[Documentation] Show changes using builtin/git-diff
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} diff cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} git-diff-ok
Git Log Tool
[Documentation] Show commit log using builtin/git-log
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} log cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} git-log-ok
Git Blame Tool
[Documentation] Show file attribution using builtin/git-blame
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} blame cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} git-blame-ok
Git Path Traversal Prevention
[Documentation] Path traversal in repo_path should be blocked
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} traversal cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} traversal-ok
+22 -1
View File
@@ -1,6 +1,6 @@
"""Built-in tool registration helpers.
Provides convenience functions to register all built-in file tools
Provides convenience functions to register all built-in file and git tools
into a ``ToolRegistry``, optionally wrapped with changeset capture.
"""
@@ -21,6 +21,14 @@ from cleveragents.tool.builtins.file_tools import (
FILE_WRITE_SPEC,
validate_path,
)
from cleveragents.tool.builtins.git_tools import (
ALL_GIT_TOOLS,
GIT_BLAME_SPEC,
GIT_DIFF_SPEC,
GIT_LOG_SPEC,
GIT_STATUS_SPEC,
validate_repo_path,
)
from cleveragents.tool.registry import ToolRegistry
@@ -44,18 +52,31 @@ def register_file_tools_with_changeset(
registry.register(wrapped)
def register_git_tools(registry: ToolRegistry) -> None:
"""Register all 4 built-in git tools into *registry*."""
for spec in ALL_GIT_TOOLS:
registry.register(spec)
__all__ = [
"ALL_FILE_TOOLS",
"ALL_GIT_TOOLS",
"FILE_DELETE_SPEC",
"FILE_EDIT_SPEC",
"FILE_LIST_SPEC",
"FILE_READ_SPEC",
"FILE_SEARCH_SPEC",
"FILE_WRITE_SPEC",
"GIT_BLAME_SPEC",
"GIT_DIFF_SPEC",
"GIT_LOG_SPEC",
"GIT_STATUS_SPEC",
"ChangeSet",
"ChangeSetCapture",
"ChangeSetEntry",
"register_file_tools",
"register_file_tools_with_changeset",
"register_git_tools",
"validate_path",
"validate_repo_path",
]
+556
View File
@@ -0,0 +1,556 @@
"""Built-in git operation tools for CleverAgents.
The built-in git tools provide safe, read-only git operations for agents.
All tools are registered under the ``builtin/`` namespace and include
path validation to prevent sandbox escapes.
## Tools
| Tool | Capability | Description |
|-------------------------|----------------|------------------------------------|
| ``builtin/git-status`` | ``read_only`` | Show working tree status |
| ``builtin/git-diff`` | ``read_only`` | Show changes in working tree |
| ``builtin/git-log`` | ``read_only`` | Show commit log |
| ``builtin/git-blame`` | ``read_only`` | Show line-by-line file attribution |
## Resource Slot Requirements
All git tools accept an optional ``repo_path`` input parameter that
specifies the git repository root directory. When not provided, the
current working directory is used.
Path traversal attacks (using ``..`` to escape the sandbox) are detected
and rejected with a ``ValueError``.
## Registration
```python
from cleveragents.tool.builtins import register_git_tools
from cleveragents.tool.registry import ToolRegistry
registry = ToolRegistry()
register_git_tools(registry)
```
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
from typing import Any
from cleveragents.domain.models.core.tool import ToolCapability
from cleveragents.tool.runtime import ToolSpec
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# Default timeout for git commands (seconds)
_GIT_TIMEOUT: int = 30
# Safe environment variables for git execution.
# These disable interactive prompts, colour output, and paging so that
# tool output is deterministic and safe for automated consumption.
_GIT_ENV: dict[str, str] = {
"GIT_PAGER": "cat",
"GIT_TERMINAL_PROMPT": "0",
"GIT_ASKPASS": "",
"NO_COLOR": "1",
"GIT_CONFIG_NOSYSTEM": "1",
"TERM": "dumb",
}
# Cached merge of ``os.environ`` with ``_GIT_ENV``, populated on first use.
_BASE_ENV: dict[str, str] | None = None
def _get_base_env() -> dict[str, str]:
"""Return a cached merge of the process environment with git overrides.
The snapshot is taken on first call and reused thereafter, avoiding a
full ``os.environ`` copy on every git invocation.
"""
global _BASE_ENV
if _BASE_ENV is None:
_BASE_ENV = {**os.environ, **_GIT_ENV}
return _BASE_ENV
# ---------------------------------------------------------------------------
# Error mapping
# ---------------------------------------------------------------------------
# Maps common git stderr patterns to user-friendly messages.
_ERROR_MAP: list[tuple[str, str]] = [
("not a git repository", "The specified path is not a git repository."),
("fatal: bad default revision", "No commits exist in this repository yet."),
(
"does not have any commits yet",
"No commits exist in this repository yet.",
),
(
"HEAD detached",
"Repository is in a detached HEAD state;"
" some operations may behave differently.",
),
("pathspec", "The specified file or path does not exist in the repository."),
(
"no such path",
"The specified file or path does not exist in the repository.",
),
(
"unknown revision or path",
"Unknown revision or path not in the working tree.",
),
]
def _map_git_error(stderr: str) -> str:
"""Map git stderr output to a user-friendly error message.
If no known pattern matches, the raw stderr is returned.
"""
lower = stderr.lower()
for pattern, friendly in _ERROR_MAP:
if pattern in lower:
return friendly
return stderr.strip()
# ---------------------------------------------------------------------------
# Path validation
# ---------------------------------------------------------------------------
def validate_repo_path(
repo_path_str: str | None = None,
*,
sandbox_root: Path | None = None,
) -> Path:
"""Validate a repository path to prevent path traversal attacks.
When *repo_path_str* is ``None``, the current working directory is
returned. Both absolute and relative paths are resolved and checked
against the sandbox root (defaults to cwd) to prevent escapes.
Args:
repo_path_str: User-supplied repository path. ``None`` means cwd.
sandbox_root: Explicit root directory for containment checks.
Defaults to the current working directory when ``None``.
Raises:
ValueError: When the resolved path escapes the sandbox root.
.. note::
Absolute paths are **not** automatically trusted. They are
resolved and validated against *sandbox_root* just like relative
paths. Callers that intentionally need to operate outside the
sandbox should pass an appropriate *sandbox_root*.
"""
root = (sandbox_root or Path.cwd()).resolve()
if not repo_path_str:
return root
candidate = Path(repo_path_str)
target = (
(root / candidate).resolve()
if not candidate.is_absolute()
else candidate.resolve()
)
if not target.is_relative_to(root):
raise ValueError(
f"Path traversal detected: '{repo_path_str}' escapes sandbox root"
)
return target
def _resolve_sandbox_root(raw: str | Path | None) -> Path | None:
"""Convert a raw ``_sandbox_root`` input to a resolved :class:`Path`.
Returns ``None`` when *raw* is falsy, which makes
``validate_repo_path`` fall back to cwd.
"""
if not raw:
return None
return Path(raw).resolve() if not isinstance(raw, Path) else raw.resolve()
def _validate_sub_path(path_str: str, repo: Path) -> str:
"""Ensure *path_str* stays within *repo* after resolution.
Raises ``ValueError`` for traversal attempts such as ``../../etc/passwd``.
Returns the validated *path_str* unchanged for forwarding to git.
"""
target = (repo / path_str).resolve()
if not target.is_relative_to(repo):
raise ValueError(
f"Path traversal detected: '{path_str}' escapes repository root"
)
return path_str
def _validate_ref(ref: str) -> str:
"""Reject git refs that look like CLI options (start with ``-``).
Prevents option-injection attacks where a malicious ``ref`` value
such as ``--exec=...`` could alter git behaviour.
"""
if ref.startswith("-"):
raise ValueError(f"Invalid ref '{ref}': must not start with '-'")
return ref
# ---------------------------------------------------------------------------
# Git subprocess helper
# ---------------------------------------------------------------------------
def _run_git(
args: list[str],
cwd: str,
timeout: int = _GIT_TIMEOUT,
) -> subprocess.CompletedProcess[str]:
"""Run a git command with deterministic output settings.
Sets ``GIT_PAGER=cat``, ``NO_COLOR=1``, disables interactive
prompts, and applies safe directory configuration so output is
stable across environments.
Args:
args: Git sub-command and arguments (e.g. ``["status", "--porcelain"]``).
cwd: Working directory to run in.
timeout: Maximum seconds to wait.
Returns:
Completed process result.
Raises:
subprocess.TimeoutExpired: If the command exceeds *timeout*.
subprocess.CalledProcessError: If the command returns non-zero.
"""
base = _get_base_env()
# Per-call dynamic keys: mark the target directory as safe so git
# doesn't refuse to operate. We copy the cached base once and add
# only the three dynamic entries instead of rebuilding from scratch.
env = {
**base,
"GIT_CONFIG_VALUE_0": cwd,
"GIT_CONFIG_KEY_0": "safe.directory",
"GIT_CONFIG_COUNT": "1",
}
cmd = ["git", "--no-pager", "-c", "color.ui=never", *args]
return subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
check=True,
timeout=timeout,
env=env,
)
# ---------------------------------------------------------------------------
# Handlers
# ---------------------------------------------------------------------------
def _handle_git_status(inputs: dict[str, Any]) -> dict[str, Any]:
"""Show working tree status."""
_sroot = _resolve_sandbox_root(inputs.get("_sandbox_root"))
repo = validate_repo_path(inputs.get("repo_path"), sandbox_root=_sroot)
short: bool = inputs.get("short", False)
args = ["status"]
if short:
args.append("--porcelain")
try:
result = _run_git(args, cwd=str(repo))
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"Git command timed out after {exc.timeout}s") from exc
except subprocess.CalledProcessError as exc:
raise RuntimeError(_map_git_error(exc.stderr)) from exc
return {
"output": result.stdout,
"repo_path": str(repo),
}
def _handle_git_diff(inputs: dict[str, Any]) -> dict[str, Any]:
"""Show changes in working tree."""
_sroot = _resolve_sandbox_root(inputs.get("_sandbox_root"))
repo = validate_repo_path(inputs.get("repo_path"), sandbox_root=_sroot)
ref: str | None = inputs.get("ref")
path: str | None = inputs.get("path")
staged: bool = inputs.get("staged", False)
stat: bool = inputs.get("stat", False)
args = ["diff"]
if staged:
args.append("--cached")
if stat:
args.append("--stat")
if ref:
args.append(_validate_ref(ref))
if path:
_validate_sub_path(path, repo)
args.extend(["--", path])
try:
result = _run_git(args, cwd=str(repo))
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"Git command timed out after {exc.timeout}s") from exc
except subprocess.CalledProcessError as exc:
raise RuntimeError(_map_git_error(exc.stderr)) from exc
return {
"output": result.stdout,
"repo_path": str(repo),
}
def _handle_git_log(inputs: dict[str, Any]) -> dict[str, Any]:
"""Show commit log."""
_sroot = _resolve_sandbox_root(inputs.get("_sandbox_root"))
repo = validate_repo_path(inputs.get("repo_path"), sandbox_root=_sroot)
max_count: int = inputs.get("max_count", 10)
oneline: bool = inputs.get("oneline", False)
author: str | None = inputs.get("author")
since: str | None = inputs.get("since")
until: str | None = inputs.get("until")
path: str | None = inputs.get("path")
args = ["log", f"--max-count={max_count}"]
if oneline:
args.append("--oneline")
if author:
args.append(f"--author={author}")
if since:
args.append(f"--since={since}")
if until:
args.append(f"--until={until}")
if path:
_validate_sub_path(path, repo)
args.extend(["--", path])
try:
result = _run_git(args, cwd=str(repo))
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"Git command timed out after {exc.timeout}s") from exc
except subprocess.CalledProcessError as exc:
raise RuntimeError(_map_git_error(exc.stderr)) from exc
return {
"output": result.stdout,
"repo_path": str(repo),
}
def _handle_git_blame(inputs: dict[str, Any]) -> dict[str, Any]:
"""Show line-by-line file attribution."""
_sroot = _resolve_sandbox_root(inputs.get("_sandbox_root"))
repo = validate_repo_path(inputs.get("repo_path"), sandbox_root=_sroot)
path: str = inputs["path"]
line_start: int | None = inputs.get("line_start")
line_end: int | None = inputs.get("line_end")
_validate_sub_path(path, repo)
# Require both or neither line boundaries
has_start = line_start is not None
has_end = line_end is not None
if has_start != has_end:
raise ValueError("Both line_start and line_end are required for a blame range")
args = ["blame"]
if has_start and has_end:
args.extend(["-L", f"{line_start},{line_end}"])
args.append(path)
try:
result = _run_git(args, cwd=str(repo))
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"Git command timed out after {exc.timeout}s") from exc
except subprocess.CalledProcessError as exc:
raise RuntimeError(_map_git_error(exc.stderr)) from exc
return {
"output": result.stdout,
"path": path,
"repo_path": str(repo),
}
# ---------------------------------------------------------------------------
# Tool specs
# ---------------------------------------------------------------------------
GIT_STATUS_SPEC = ToolSpec(
name="builtin/git-status",
description="Show working tree status",
input_schema={
"type": "object",
"properties": {
"repo_path": {
"type": "string",
"description": "Path to git repository root (defaults to cwd)",
},
"short": {
"type": "boolean",
"default": False,
"description": "Use short/porcelain output format",
},
},
"required": [],
},
output_schema={
"type": "object",
"properties": {
"output": {"type": "string"},
"repo_path": {"type": "string"},
},
},
capabilities=ToolCapability(read_only=True),
handler=_handle_git_status,
)
GIT_DIFF_SPEC = ToolSpec(
name="builtin/git-diff",
description="Show changes in working tree",
input_schema={
"type": "object",
"properties": {
"repo_path": {
"type": "string",
"description": "Path to git repository root (defaults to cwd)",
},
"ref": {
"type": "string",
"description": "Ref to diff against (e.g. HEAD~1, branch name)",
},
"path": {
"type": "string",
"description": "Limit diff to a specific path",
},
"staged": {
"type": "boolean",
"default": False,
"description": "Show staged (cached) changes instead of unstaged",
},
"stat": {
"type": "boolean",
"default": False,
"description": "Show diffstat summary instead of full diff",
},
},
"required": [],
},
output_schema={
"type": "object",
"properties": {
"output": {"type": "string"},
"repo_path": {"type": "string"},
},
},
capabilities=ToolCapability(read_only=True),
handler=_handle_git_diff,
)
GIT_LOG_SPEC = ToolSpec(
name="builtin/git-log",
description="Show commit log",
input_schema={
"type": "object",
"properties": {
"repo_path": {
"type": "string",
"description": "Path to git repository root (defaults to cwd)",
},
"max_count": {
"type": "integer",
"default": 10,
"description": "Maximum number of commits to show",
},
"oneline": {
"type": "boolean",
"default": False,
"description": "Use one-line format",
},
"author": {
"type": "string",
"description": "Filter commits by author",
},
"since": {
"type": "string",
"description": "Show commits after date (e.g. 2024-01-01)",
},
"until": {
"type": "string",
"description": "Show commits before date",
},
"path": {
"type": "string",
"description": "Limit log to a specific path",
},
},
"required": [],
},
output_schema={
"type": "object",
"properties": {
"output": {"type": "string"},
"repo_path": {"type": "string"},
},
},
capabilities=ToolCapability(read_only=True),
handler=_handle_git_log,
)
GIT_BLAME_SPEC = ToolSpec(
name="builtin/git-blame",
description="Show line-by-line file attribution",
input_schema={
"type": "object",
"properties": {
"repo_path": {
"type": "string",
"description": "Path to git repository root (defaults to cwd)",
},
"path": {
"type": "string",
"description": "File path to blame",
},
"line_start": {
"type": "integer",
"description": "Start line for blame range",
},
"line_end": {
"type": "integer",
"description": "End line for blame range",
},
},
"required": ["path"],
},
output_schema={
"type": "object",
"properties": {
"output": {"type": "string"},
"path": {"type": "string"},
"repo_path": {"type": "string"},
},
},
capabilities=ToolCapability(read_only=True),
handler=_handle_git_blame,
)
ALL_GIT_TOOLS: list[ToolSpec] = [
GIT_STATUS_SPEC,
GIT_DIFF_SPEC,
GIT_LOG_SPEC,
GIT_BLAME_SPEC,
]