Issue
Summary
GitRepo.path_in_repo() and GitRepo.is_dirty(path) call normalize_path() without catching ValueError, so any filename that resolves outside the repo root lexically crashes aider with an unhandled traceback. The same normalize_path() call is already guarded with try/except ValueError in ignored_file_raw() (added for issue #1524), but these two sibling call sites were missed.
This matters because the path reaching these functions comes from LLM-generated edit blocks: when the model emits an edit header with an absolute filename (C:\project\module.py, /etc/hosts, ...), BaseCoder.allowed_to_edit() passes it straight into repo.path_in_repo(), and check_for_dirty_commit() passes it into repo.is_dirty(). One hallucinated absolute path in a SEARCH/REPLACE block then kills the whole session mid-edit-application instead of just skipping that hunk.
Affected code
aider/repo.py:
def normalize_path(self, path):
orig_path = path
res = self.normalized_path.get(orig_path)
if res:
return res
path = str(Path(PurePosixPath((Path(self.root) / path).relative_to(self.root))))
self.normalized_path[orig_path] = path
return path
(Path(self.root) / absolute_or_foreign_path) discards the root, and .relative_to(self.root) then raises:
ValueError: 'C:\\Windows\\win.ini' is not in the subpath of 'C:\\project'
Unguarded callers:
# repo.py:567-575
def path_in_repo(self, path):
...
normalized = self.normalize_path(path) # <-- unguarded
return normalized in tracked_files
# repo.py:598-601
def is_dirty(self, path=None):
if path and not self.path_in_repo(path): # <-- propagates
return False
Guarded counterpart (proof this class of input is expected):
# repo.py:542-563
def ignored_file_raw(self, fname):
if self.subtree_only:
try:
fname_path = Path(self.normalize_path(fname))
...
except ValueError:
# Issue #1524 ...
return True
...
try:
fname = self.normalize_path(fname)
except ValueError:
return True
LLM-controlled entry points:
aider/coders/base_coder.py:2191-2199 — allowed_to_edit(path) → self.repo.path_in_repo(path)
aider/coders/base_coder.py:2175-2181 — check_for_dirty_commit(path) → self.repo.is_dirty(path)
Minimal reproduction
Against current main (Python 3.12, Windows):
import subprocess, tempfile
from pathlib import Path
import git
from aider.io import InputOutput
from aider.repo import GitRepo
base = Path(tempfile.mkdtemp()).resolve()
subprocess.run(["git", "init"], cwd=base, capture_output=True)
repo = GitRepo(InputOutput(pretty=False, yes=True), None, str(base))
print(repo.path_in_repo(r"C:\Windows\win.ini"))
Result:
CRASH ValueError: 'C:\\Windows\\win.ini' is not in the subpath of
'C:\\Users\\...\\Temp\\aider_norm3_...'
End-to-end trigger: ask a model to make an edit where the response block header uses an absolute path (models do this occasionally, especially when discussing files outside the chat). With --yes-always there is no interactive step that would otherwise stop the flow before path_in_repo() runs.
Note that relative parent-paths (src/../file.py) do not trigger it (they remain lexically under root after the join), which is why this only shows up for absolute or aliased paths — easy to miss in review.
Expected behavior
A filename that isn't inside the repo should be treated like the existing #1524 handling: skip the edit with a warning ("skipping edits to outside the repo"), not crash the session.
Suggested fix
Wrap the normalize_path() calls in path_in_repo() (returning False) and mirror the guard used by ignored_file_raw(), or catch ValueError once in allowed_to_edit() around both checks. Happy to send a PR either way.
Version and model info
- Aider v0.86.3.dev53+g5dc9490bb (main @
5dc9490bb)
- Python 3.12.10, Windows 11 (repro is platform-independent)
- Bug present regardless of model; any edit format that uses filenames in blocks (diff / whole / udiff)
Issue
Summary
GitRepo.path_in_repo()andGitRepo.is_dirty(path)callnormalize_path()without catchingValueError, so any filename that resolves outside the repo root lexically crashes aider with an unhandled traceback. The samenormalize_path()call is already guarded withtry/except ValueErrorinignored_file_raw()(added for issue #1524), but these two sibling call sites were missed.This matters because the path reaching these functions comes from LLM-generated edit blocks: when the model emits an edit header with an absolute filename (
C:\project\module.py,/etc/hosts, ...),BaseCoder.allowed_to_edit()passes it straight intorepo.path_in_repo(), andcheck_for_dirty_commit()passes it intorepo.is_dirty(). One hallucinated absolute path in a SEARCH/REPLACE block then kills the whole session mid-edit-application instead of just skipping that hunk.Affected code
aider/repo.py:(Path(self.root) / absolute_or_foreign_path)discards the root, and.relative_to(self.root)then raises:Unguarded callers:
Guarded counterpart (proof this class of input is expected):
LLM-controlled entry points:
aider/coders/base_coder.py:2191-2199—allowed_to_edit(path)→self.repo.path_in_repo(path)aider/coders/base_coder.py:2175-2181—check_for_dirty_commit(path)→self.repo.is_dirty(path)Minimal reproduction
Against current
main(Python 3.12, Windows):Result:
End-to-end trigger: ask a model to make an edit where the response block header uses an absolute path (models do this occasionally, especially when discussing files outside the chat). With
--yes-alwaysthere is no interactive step that would otherwise stop the flow beforepath_in_repo()runs.Note that relative parent-paths (
src/../file.py) do not trigger it (they remain lexically under root after the join), which is why this only shows up for absolute or aliased paths — easy to miss in review.Expected behavior
A filename that isn't inside the repo should be treated like the existing #1524 handling: skip the edit with a warning ("skipping edits to outside the repo"), not crash the session.
Suggested fix
Wrap the
normalize_path()calls inpath_in_repo()(returningFalse) and mirror the guard used byignored_file_raw(), or catchValueErroronce inallowed_to_edit()around both checks. Happy to send a PR either way.Version and model info
5dc9490bb)