Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: CI

on:
push:
pull_request:

jobs:
test:
name: ${{ matrix.os }} / Python ${{ matrix.python-version }} / ${{ matrix.installer }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- macos-latest
- windows-latest
python-version:
- "3.10"
installer:
- pip
- uv

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies with pip
if: matrix.installer == 'pip'
run: |
python -m pip install --upgrade pip
python -m pip install -e . pytest ruff

- name: Install uv and sync environment
if: matrix.installer == 'uv'
run: |
python -m pip install --upgrade pip
python -m pip install uv
uv sync --group dev

- name: Lint with pip
if: matrix.installer == 'pip'
run: python -m ruff check .

- name: Run tests with pip
if: matrix.installer == 'pip'
run: python -m pytest -q

- name: Lint with uv
if: matrix.installer == 'uv'
run: uv run python -m ruff check .

- name: Run tests with uv
if: matrix.installer == 'uv'
run: uv run python -m pytest -q
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ Risky tools such as shell commands and file writes are gated by approval.
- `--approval ask`
prompts before risky actions (default and recommended)
- `--approval auto`
allows risky actions automatically (convenient but riskier)
allows risky actions automatically, including arbitrary command execution and file writes by the model; use only with trusted prompts and trusted repositories
- `--approval never`
denies risky actions

Expand Down
22 changes: 19 additions & 3 deletions mini_coding_agent.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import argparse
import json
import os
import re
import shutil
import subprocess
Expand Down Expand Up @@ -734,11 +733,23 @@ def reset(self):
self.session["memory"] = {"task": "", "files": [], "notes": []}
self.session_store.save(self.session)

def path_is_within_root(self, resolved):
probe = resolved
while not probe.exists() and probe.parent != probe:
probe = probe.parent
for candidate in (probe, *probe.parents):
try:
if candidate.samefile(self.root):
return True
except OSError:
continue
return False

def path(self, raw_path):
path = Path(raw_path)
path = path if path.is_absolute() else self.root / path
resolved = path.resolve()
if os.path.commonpath([str(self.root), str(resolved)]) != str(self.root):
if not self.path_is_within_root(resolved):
raise ValueError(f"path escapes workspace: {raw_path}")
return resolved

Expand Down Expand Up @@ -956,7 +967,12 @@ def build_arg_parser():
parser.add_argument("--host", default="http://127.0.0.1:11434", help="Ollama server URL.")
parser.add_argument("--ollama-timeout", type=int, default=300, help="Ollama request timeout in seconds.")
parser.add_argument("--resume", default=None, help="Session id to resume or 'latest'.")
parser.add_argument("--approval", choices=("ask", "auto", "never"), default="ask", help="Approval policy for risky tools.")
parser.add_argument(
"--approval",
choices=("ask", "auto", "never"),
default="ask",
help="Approval policy for risky tools; auto grants the model arbitrary command execution and file writes.",
)
parser.add_argument("--max-steps", type=int, default=6, help="Maximum tool/model iterations per request.")
parser.add_argument("--max-new-tokens", type=int, default=512, help="Maximum model output tokens per step.")
parser.add_argument("--temperature", type=float, default=0.2, help="Sampling temperature sent to Ollama.")
Expand Down
36 changes: 36 additions & 0 deletions tests/test_mini_coding_agent.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import pytest
from unittest.mock import patch

from mini_coding_agent import (
Expand Down Expand Up @@ -188,6 +189,41 @@ def test_list_files_hides_internal_agent_state(tmp_path):
assert "[F] hello.txt" in result


def test_path_rejects_parent_escape(tmp_path):
agent = build_agent(tmp_path, [])

with pytest.raises(ValueError, match="path escapes workspace"):
agent.path("../outside.txt")


def test_path_rejects_symlink_escape(tmp_path):
agent = build_agent(tmp_path, [])
outside = tmp_path.parent / f"{tmp_path.name}-outside"
outside.mkdir()
link = tmp_path / "outside-link"
try:
link.symlink_to(outside, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("symlink creation is not available in this environment")

with pytest.raises(ValueError, match="path escapes workspace"):
agent.path("outside-link/secret.txt")


def test_path_accepts_case_variant_on_case_insensitive_filesystems(tmp_path):
project_root = tmp_path / "Proj"
project_root.mkdir()
agent = build_agent(project_root, [])
variant = project_root.parent / project_root.name.lower() / "README.md"

if not variant.exists():
pytest.skip("case-sensitive filesystem")

resolved = agent.path(str(variant))

assert resolved.samefile(project_root / "README.md")


def test_repeated_identical_tool_call_is_rejected(tmp_path):
agent = build_agent(tmp_path, [])
agent.record({"role": "tool", "name": "list_files", "args": {}, "content": "(empty)", "created_at": "1"})
Expand Down
Loading