oc_chat
Loading...
Searching...
No Matches
filesystem.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/tools/filesystem.py
4
5"""
6Filesystem tools — all operations are clamped to the working directory.
7
8Purpose
9-------
10Provides :func:`list_dir`, :func:`read_file`, :func:`write_file`,
11:func:`edit_file`, :func:`read_chunks`, and :func:`search_files` as both
12Python callables and OpenAI tool schemas. Every path is resolved relative
13to the project root and validated to prevent escaping the sandbox.
14
15Responsibilities
16----------------
17- :func:`_safe_path` — resolve and reject paths that escape ``ROOT``.
18- :func:`_enforce_write_gates` — the single shared write-side M8 gate
19 (sandbox clamp, hard-deny protected-write, human-confirm tier) both
20 :func:`write_file` and :func:`edit_file` route through.
21- :func:`list_dir` — recursive directory walk with depth limit and
22 hidden-file filter.
23- :func:`read_file`, :func:`read_chunks` — UTF-8 reads with offset
24 pagination and natural-boundary lookahead.
25- :func:`write_file` — whole-file overwrite/create with informational
26 chunk metadata.
27- :func:`edit_file` — fail-closed anchored partial edit (exact
28 ``old_string`` → ``new_string``); atomic, newline-preserving; the
29 primary modify path (plan 171 / F20).
30- :func:`search_files` — recursive grep over text files.
31- Module-level ``TOOLS`` (callables) and ``SCHEMAS`` (OpenAI shapes)
32 for auto-registration.
33
34Diagnostics
35-----------
36Domain: OC_CHAT.TOOLS
37Levels:
38 L2 — lifecycle events
39 L3 — semantic details
40 L4 — deep tracing
41
42Contracts
43---------
44- All paths are validated by :func:`_safe_path`; escapes raise
45 :class:`PermissionError` rather than silently allowing access.
46- Reads/writes use UTF-8 with ``errors="replace"``; binary files are
47 rejected.
48- ``read_chunks`` clamps ``offset`` to ``[0, total_chars]`` and never
49 reads past EOF.
50- ``write_file`` chunk slicing is character-based (not byte-based)
51 to handle multi-byte UTF-8 correctly.
52
53Dependencies
54------------
55- oc_diagnostics (structured debug logging via _dbg)
56- oc_chat.core.tool_results (ReadFileResult, WriteFileResult and ToolError typed return shapes)
57- oc_chat.tools._sandbox (sandbox clamp, protected read and write classes, human-confirm tier and secret redaction)
58- oc_chat.core.cancellation (lazy; cooperative Ctrl+C check in list_dir and search_files)
59- oc_chat.core.resource_caps (lazy; clamps the model-supplied depth, byte and chunk limits)
60
61See Also
62--------
63oc_chat/docs/conventions/CONTRACTS.md §3 Tool registry contract; §4 Tool return-
64value conventions (read_file → §4.2, write_file → §4.3).
65"""
66import contextlib
67import contextvars
68from collections.abc import Iterator
69from pathlib import Path
70import os
71
72from oc_diagnostics import _dbg
73
74from oc_chat.core.tool_results import ReadFileResult, ToolError, WriteFileResult
75from oc_chat.tools._sandbox import (
76 SandboxViolation,
77 get_session_sandbox_root_override,
78 is_human_confirm_write_path,
79 is_importable_write_path,
80 is_protected_read_path,
81 is_protected_write_path,
82 redact_secrets,
83 require_inside_sandbox,
84)
85
86_HARDCODED_ROOT = Path(__file__).parent.parent.parent.resolve() # project root
87ROOT = _HARDCODED_ROOT
88
89#: Generic denial for the protected-read (secret) class -- no existence or
90#: content disclosure (M8 F46; M8.0 WU-2).
91_PROTECTED_READ_MSG = "Reading this file is not permitted: it is in the protected-read (secret) class."
92_PROTECTED_WRITE_MSG = "Writing this file is not permitted: it is in the protected-write class (integrity / config-as-code / secret)."
93
94
95# ── Human-confirm protected-write channel (M8.0 WU-8 / F70 + F70a) ───────────
96#: The resolved write path an interactive human approved for one re-dispatch (F70).
97#: A ContextVar (NOT a tool argument) the model cannot set; the react-loop sets it
98#: after a y/N approval, scoped to the single re-run of write_file.
99_human_approved_write: contextvars.ContextVar[str | None] = contextvars.ContextVar(
100 "oc_chat_human_approved_write", default=None)
101
102
103@contextlib.contextmanager
104def human_confirmed_write(target: str) -> Iterator[None]:
105 """Authorize exactly *target* (a resolved path string) for one write_file
106 re-dispatch (F70). For the interactive react-loop only; reset on exit."""
107 token = _human_approved_write.set(target)
108 try:
109 yield
110 finally:
111 _human_approved_write.reset(token)
112
113
114def _plan_envelope_covers_write(target: str) -> bool:
115 """F70a forward-compat seam: does a signed ``PlanAutonomyEnvelope`` cover this
116 headless write? M8.0 has no envelope machinery (M8.3), so always ``False`` — a
117 headless config/importable write is denied, never silently auto-allowed."""
118 return False
119
120
121def _make_protected_write_block(target: str, reason: str) -> dict:
122 """The human-confirm-tier signal write_file returns (relayed by the react-loop;
123 failed closed headless by F70a)."""
124 return {
125 "requires_confirmation": True,
126 "gate": "protected_write",
127 "path": target,
128 "reason": reason,
129 "message": (
130 f"Writing '{target}' touches the human-confirm protected-write tier "
131 # OCT-LINT: disable=no_hardcoded_secrets reason="Fixed user-facing protected-write message; no credential material (operator-ruled 2026-09-28)"
132 f"({reason}): config-as-code or an importable-tree write (a write-then-import "
133 f"in-process code-execution risk). A human must approve it; headless runs "
134 f"fail closed (a signed PlanAutonomyEnvelope is M8.3)."
135 ),
136 }
137
138
139def deny_protected_write(result: dict, *, note: str = "") -> dict:
140 """Convert a protected-write block into a terminal ``error`` (headless / declined)."""
141 if isinstance(result, dict) and result.get("requires_confirmation"):
142 msg = result.get("message") or "This write requires human approval."
143 return {"error": (msg + " " + note).strip()}
144 return result
145
146
147def _enforce_write_gates(path: str) -> "Path | dict":
148 """Resolve *path* through the shared write-side M8 gate; return the target or a block.
149
150 The SINGLE enforcement path every write-class tool routes through
151 (:func:`write_file`, :func:`edit_file`) — one policy, never a parallel path.
152 In order (M8.0 WU-5 / WU-8; §5.1.2):
153
154 1. Hard-deny the protected-write class BEFORE the sandbox clamp (``.git``
155 internals, the protected-read secret class, ``*.pth`` / ``sitecustomize`` /
156 ``conftest`` import-exec hooks, the clean-exit marker).
157 2. Clamp to the effective sandbox root (a ``..`` / sibling-prefix / symlink
158 escape is refused).
159 3. Re-check the RESOLVED target (catches a ``..`` / symlink into a ``.git``
160 dir the literal string hid).
161 4. The human-confirm protected-write TIER (config-as-code + the importable
162 tree): the ``_human_approved_write`` ContextVar authorizes EXACTLY the
163 approved resolved target on a react-loop re-dispatch (a divergence fails
164 closed); otherwise a ``requires_confirmation`` block is returned (the
165 react-loop relays a y/N; headless fails closed, F70a).
166
167 Returns the resolved absolute :class:`~pathlib.Path` on success, or a
168 ``{"error": ...}`` / ``{"requires_confirmation": ...}`` dict on denial / relay.
169 """
170 if is_protected_write_path(path):
171 return {"error": _PROTECTED_WRITE_MSG}
172 try:
173 target = require_inside_sandbox(path, root=_sandbox_root())
174 except SandboxViolation:
175 # F25: name the boundary (root + resolved path + whether it exists on
176 # disk) instead of the generic escape message, so a write to a sibling
177 # subproject reads as "unreachable", not "absent". A secret target never
178 # reaches here (hard-denied above), so a None fallback is defensive only.
179 return {"error": _outside_sandbox_error(path) or "outside the sandbox"}
180 # Re-check the resolved target (catches `..`/symlink into a .git dir etc.).
181 if is_protected_write_path(target):
182 return {"error": _PROTECTED_WRITE_MSG}
183 granted = _human_approved_write.get()
184 if granted is not None:
185 # Only the EXACT approved target proceeds; a TOCTOU divergence fails closed.
186 if str(target) != granted:
187 return {"error": "The approved write path changed on re-dispatch; aborted for safety."}
188 elif is_importable_write_path(target):
189 return _make_protected_write_block(str(target), "importable-tree write")
190 elif is_human_confirm_write_path(target):
191 return _make_protected_write_block(str(target), "config-as-code")
192 return target
193
194
195def _safe_path(path: str) -> Path:
196 """Resolve *path* against the effective sandbox root; raise if it escapes.
197
198 Uses the effective sandbox root (:func:`_sandbox_root` — honouring the
199 session override / ``OC_CHAT_SANDBOX_ROOT`` / worktree) and the shared
200 :func:`require_inside_sandbox` ``relative_to`` check, NOT a string
201 ``startswith`` on the hardcoded package ``ROOT``. The old ``startswith``
202 let a sibling directory sharing the root's path prefix escape the sandbox
203 (e.g. ``<root>`` vs ``<root>_evil``), and pinned reads to the package root
204 while ``write_file`` already honoured the effective root — diverging the
205 read and write sandboxes. M8 F4/F4a/F69/F77; M8.0 WU-1.
206 """
207 try:
208 return require_inside_sandbox(path, _sandbox_root())
209 except SandboxViolation as exc:
210 raise PermissionError(
211 f"Access outside working directory is not allowed: {path}"
212 ) from exc
213
214
215def _find_worktree_root(start: Path | None = None) -> Path | None:
216 """Return nearest ancestor containing a Git ``.git`` dir or file."""
217 current = (start or Path.cwd()).resolve(strict=False)
218 if current.is_file():
219 current = current.parent
220 for candidate in (current, *current.parents):
221 if (candidate / ".git").exists():
222 return candidate
223 return None
224
225
226def _sandbox_root_info() -> tuple[Path, str]:
227 """Return the effective sandbox root and its source label."""
228 session_override = get_session_sandbox_root_override()
229 if session_override is not None:
230 return session_override, "session override"
231 env = os.environ.get("OC_CHAT_SANDBOX_ROOT")
232 if env:
233 return Path(env).expanduser().resolve(strict=False), "OC_CHAT_SANDBOX_ROOT"
234 if ROOT != _HARDCODED_ROOT:
235 return ROOT, "hardcoded default"
236 worktree = _find_worktree_root()
237 if worktree is not None:
238 return worktree, "worktree default"
239 return ROOT, "hardcoded default"
240
241
242def _sandbox_root() -> Path:
243 """Determine the sandbox root for write-side filesystem operations."""
244 root, _source = _sandbox_root_info()
245 return root
246
247
248def _resolve_unclamped(path: str) -> "tuple[Path, Path]":
249 """Resolve *path* against the effective sandbox root WITHOUT clamping.
250
251 Returns ``(abs_root, abs_candidate)`` — the resolved sandbox root and the
252 resolved absolute path *path* names, whether or not it stays inside the root.
253 A relative *path* is joined to the root (matching :func:`require_inside_sandbox`);
254 symlinks / ``..`` are resolved via ``Path.resolve(strict=False)`` so the file
255 need not exist. The counterpart of the clamped :func:`_safe_path`, used only to
256 build a truthful boundary error (:func:`_outside_sandbox_error`).
257 """
258 abs_root = _sandbox_root().resolve(strict=False)
259 candidate = Path(path)
260 if not candidate.is_absolute():
261 candidate = abs_root / candidate
262 return abs_root, candidate.resolve(strict=False)
263
264
265def _outside_sandbox_error(path: str) -> "str | None":
266 """A boundary-truthful error for an out-of-sandbox *path* (F25), or ``None``.
267
268 When *path* resolves OUTSIDE the effective sandbox root, return a message that
269 NAMES the root + the resolved absolute path and says whether it exists on disk,
270 so the model can tell "exists where you cannot reach" from "does not exist"
271 instead of reading every boundary denial as nonexistence (the F25 confusion that
272 produced the factually-wrong "the worktree lacks oc_diagnostics" report claim).
273
274 M8 basis (design foundation V1.1): the sandbox clamps REACH — every filesystem op
275 resolves through ``require_inside_sandbox(path, effective_root)`` (§5.1.2) — not
276 knowledge; the T1 tier has "no determined in-process adversary" and a git-recoverable
277 local blast radius (§3.2). SECRET existence/paths are withheld by the F46 protected-read
278 class, which every caller checks FIRST; defensively, this returns ``None`` (disclose
279 nothing, fall through to not-found) when the resolved out-of-bounds target is itself in
280 the protected-read class (a symlink-hidden secret). No content is read; the existence
281 probe is a bare ``os.path.exists`` on the ALREADY-RESOLVED path — no further
282 out-of-bounds symlink following.
283
284 Returns ``None`` for an in-bounds path (not a boundary case).
285 """
286 abs_root, abs_candidate = _resolve_unclamped(path)
287 try:
288 abs_candidate.relative_to(abs_root)
289 return None # in-bounds — not a boundary error
290 except ValueError:
291 pass
292 if is_protected_read_path(abs_candidate):
293 return None # never disclose a secret's existence/path (M8 F46)
294 prefix = "exists, but " if os.path.exists(abs_candidate) else ""
295 return f"{prefix}outside the sandbox (root: {abs_root}): {abs_candidate}"
296
297
298def _was_discovery_rescue(requested: str, resolved: Path) -> bool:
299 """``True`` when *resolved* differs from the literal in-sandbox resolution of
300 *requested* — i.e. the discovery fallback rescued a wrong/short path (F13).
301
302 Lets a read result SAY resolved-via-discovery with both paths (Change 2) so a
303 silent rescue stops masking a path error until write time.
304 """
305 _root, direct = _resolve_unclamped(requested)
306 return direct != resolved
307
308
309def _discover_file_path(path: str) -> Path:
310 """Intelligently discover file path with fallback mechanisms.
311
312 Args:
313 path: Original path requested by user/tool
314
315 Returns:
316 Path object for the discovered file location
317
318 Raises:
319 FileNotFoundError: If file cannot be found in any allowed location
320 """
321 func = "_discover_file_path"
322
323 # First try the direct path
324 direct_path = _safe_path(path)
325 if direct_path.exists() and not is_protected_read_path(direct_path):
326 _dbg("OC_CHAT.TOOLS", func, f"found at direct path: {direct_path}", 3)
327 return direct_path
328
329 # Try common alternative locations within project
330 alternatives = [
331 path, # Original path
332 f"oc_chat/{path}", # Within oc_chat module
333 f"oc_chat/docs/{path}", # In docs directory
334 f"oc_chat/roadmap/{path}", # In roadmap directory
335 f"{path}.md", # With .md extension
336 f"oc_chat/{path}.md", # In oc_chat with .md
337 f"oc_chat/docs/{path}.md", # In docs with .md
338 f"oc_chat/roadmap/{path}.md", # In roadmap with .md
339 f"roadmap/{path}", # Root roadmap directory (if exists)
340 f"roadmap/{path}.md", # Root roadmap directory with .md
341 ]
342
343 # Try fuzzy matching for common files
344 base_name = Path(path).stem
345 fuzzy_alternatives = [
346 base_name,
347 f"{base_name}.md",
348 f"ROADMAP-{base_name}",
349 f"ROADMAP-{base_name}.md",
350 f"{base_name.replace('_', '-')}", # Replace underscores with dashes
351 f"{base_name.replace('-', '_')}", # Replace dashes with underscores
352 ]
353
354 # Check all alternatives within allowed directories
355 for alt in alternatives:
356 try:
357 alt_path = _safe_path(alt)
358 if alt_path.exists() and not is_protected_read_path(alt_path):
359 _dbg("OC_CHAT.TOOLS", func, f"found at alternative: {alt_path}", 3)
360 return alt_path
361 except PermissionError:
362 # Skip paths that escape sandbox
363 continue
364
365 # Check fuzzy alternatives in key directories
366 key_dirs = ["", "oc_chat", "oc_chat/docs", "oc_chat/roadmap", "roadmap"]
367 for directory in key_dirs:
368 for fuzzy_alt in fuzzy_alternatives:
369 try:
370 alt_path = _safe_path(f"{directory}/{fuzzy_alt}")
371 if alt_path.exists() and not is_protected_read_path(alt_path):
372 _dbg("OC_CHAT.TOOLS", func, f"found via fuzzy match: {alt_path}", 3)
373 return alt_path
374 except PermissionError:
375 continue
376
377 # If nothing found, raise descriptive error
378 # M8.0 (F46c): a normalized not-found error — no attempt count (a structure leak).
379 raise FileNotFoundError(f"File not found: {path}")
380
381
382def _discover_dir_path(path: str) -> Path:
383 """Intelligently discover directory path with fallback mechanisms.
384
385 Args:
386 path: Original directory path requested
387
388 Returns:
389 Path object for the discovered directory
390
391 Raises:
392 NotADirectoryError: If directory cannot be found in any allowed location
393 """
394 func = "_discover_dir_path"
395
396 # First try the direct path
397 direct_path = _safe_path(path)
398 if direct_path.is_dir() and not is_protected_read_path(direct_path):
399 _dbg("OC_CHAT.TOOLS", func, f"found at direct path: {direct_path}", 3)
400 return direct_path
401
402 # Try common alternative locations
403 alternatives = [
404 path, # Original path
405 f"oc_chat/{path}", # Within oc_chat module
406 f"oc_chat/docs/{path}", # In docs directory
407 f"oc_chat/roadmap/{path}", # In roadmap directory
408 ]
409
410 for alt in alternatives:
411 try:
412 alt_path = _safe_path(alt)
413 if alt_path.is_dir() and not is_protected_read_path(alt_path):
414 _dbg("OC_CHAT.TOOLS", func, f"found at alternative: {alt_path}", 3)
415 return alt_path
416 except PermissionError:
417 continue
418
419 # If nothing found, raise descriptive error
420 raise NotADirectoryError(f"Directory not found: {path}") # F46c: no attempt count
421
422
423def list_dir(path: str = ".", max_depth: int = 2, exclude_hidden: bool = True) -> dict:
424 """List files and subdirectories within the working directory.
425
426 Returns a dict keyed by relative path, each value being
427 ``{"dirs": [...], "files": [...]}``. ``max_depth=0`` lists only the
428 target directory itself.
429 """
430 func = "list_dir"
431 from oc_chat.core.resource_caps import clamp_depth
432 max_depth = clamp_depth(max_depth) # M8.0 WU-9: bound the directory-walk depth
433 _dbg("OC_CHAT.TOOLS", func, f"path={path} max_depth={max_depth}", 3)
434
435 # First try direct path to check if it's a file vs directory
436 try:
437 direct_path = _safe_path(path)
438 if direct_path.exists() and not direct_path.is_dir():
439 # Path exists but is not a directory - this should raise NotADirectoryError
440 _dbg("OC_CHAT.TOOLS", func, f"path exists but is not directory: {path}", 2)
441 raise NotADirectoryError(f"Not a directory: {path}")
442 except PermissionError:
443 pass # Continue to discovery if direct path escapes sandbox
444
445 try:
446 target_path = _discover_dir_path(path)
447 except (NotADirectoryError, PermissionError) as e:
448 # F25: an out-of-sandbox dir is unreachable, not absent — name the
449 # boundary. (`_safe_path` inside discovery raises PermissionError on a
450 # sandbox escape; a genuinely-absent in-bounds path keeps not-found.)
451 boundary = _outside_sandbox_error(path)
452 if boundary is not None:
453 _dbg("OC_CHAT.TOOLS", func, f"outside-sandbox list: {boundary}", 2)
454 raise NotADirectoryError(boundary) from e
455 _dbg("OC_CHAT.TOOLS", func, f"directory discovery failed: {e}", 2)
456 raise
457
458 result = {}
459 base = target_path # Define base variable for depth calculation
460 from oc_chat.core.cancellation import tool_cancel_requested
461 for root, dirs, files in os.walk(target_path):
462 if tool_cancel_requested(): # cooperative Ctrl+C — return partial tree
463 break
464 if exclude_hidden:
465 dirs[:] = [d for d in dirs if not d.startswith(".")]
466 files[:] = [f for f in files if not f.startswith(".")]
467
468 # Protected-read class: omit secret files + never descend credential
469 # dirs (.ssh/.gnupg/.aws), regardless of exclude_hidden (M8 F46; WU-2).
470 dirs[:] = [d for d in dirs if not is_protected_read_path(Path(root) / d)]
471 files[:] = [f for f in files if not is_protected_read_path(Path(root) / f)]
472
473 depth = len(Path(root).relative_to(base).parts)
474 if depth > max_depth:
475 dirs.clear()
476 continue
477
478 key = str(Path(root).relative_to(_sandbox_root()))
479 result[key] = {"dirs": sorted(dirs), "files": sorted(files)}
480
481 # Change 2 (F13/F25): echo the RESOLVED absolute target + a discovery-rescue
482 # flag into the ROOT map entry (the return stays a path->{dirs,files} map, so
483 # the positional `/ls` consumer and existing tests are byte-compatible; the
484 # extra keys are additive metadata on the target's own entry).
485 root_key = str(target_path.relative_to(_sandbox_root()))
486 if root_key in result:
487 result[root_key]["resolved_path"] = str(target_path)
488 if _was_discovery_rescue(path, target_path):
489 result[root_key]["resolved_via_discovery"] = True
490 result[root_key]["requested_path"] = path
491
492 return result
493
494
495def read_file(path: str, max_bytes: int = 50_000, offset: int = 0) -> ReadFileResult | ToolError:
496 """Read a text file within the working directory with pagination support.
497
498 Returns ``{"content": ..., "truncated": bool, "total_size": int, "offset": int,
499 "next_offset": int|None, "lines_count": int}`` or ``{"error": ...}``.
500
501 Args:
502 path: File path relative to working directory
503 max_bytes: Maximum bytes to read per request (default 50_000 to avoid API truncation)
504 offset: Byte offset to start reading from (default 0)
505 """
506 from oc_chat.core.resource_caps import clamp_bytes
507 max_bytes = clamp_bytes(max_bytes) # M8.0 WU-9a: model arg cannot exceed 5 MB
508 if is_protected_read_path(path):
509 return {"error": _PROTECTED_READ_MSG}
510 try:
511 p = _discover_file_path(path)
512 except (FileNotFoundError, PermissionError) as e:
513 # F25: a path OUTSIDE the sandbox is "exists where you cannot reach",
514 # not "does not exist" — say so (a literal out-of-sandbox path raises
515 # PermissionError from _safe_path; a genuinely-absent in-bounds path
516 # raises FileNotFoundError and keeps the not-found message).
517 return {"error": _outside_sandbox_error(path) or str(e)}
518 if p.is_dir():
519 return {"error": f"Path is a directory: {path}"}
520 if is_protected_read_path(p):
521 return {"error": _PROTECTED_READ_MSG}
522
523 try:
524 full_data = p.read_bytes()
525 total_size = len(full_data)
526 except Exception as e:
527 return {"error": str(e)}
528
529 # Binary detection: null byte in first 512 bytes
530 if b"\0" in full_data[:512]:
531 return {"error": "Binary file — cannot read as text."}
532
533 # Handle offset and chunking. offset=0 on an empty file is fine —
534 # it returns empty content, untruncated. Only reject offsets that
535 # actually skip past the end of a non-empty file.
536 if offset > 0 and offset >= total_size:
537 return {"error": f"Offset {offset} exceeds file size {total_size}"}
538
539 data = full_data[offset:offset + max_bytes]
540 truncated = offset + len(data) < total_size
541
542 try:
543 content = data.decode("utf-8")
544 except UnicodeDecodeError:
545 content = data.decode("utf-8", errors="replace")
546 content = "[decoded with replacement characters; source is not valid UTF-8]\n" + content
547
548 # Add pagination info
549 lines_count = content.count('\n') + (1 if content and not content.endswith('\n') else 0)
550 next_offset = offset + len(data) if truncated else None
551
552 # Secret-pattern redaction of returned content (M8 F46/F51; WU-2). Applied
553 # after the pagination math, which is keyed to original-file byte offsets,
554 # not the redacted display string.
555 result: ReadFileResult = {
556 "content": redact_secrets(content),
557 "truncated": truncated,
558 "total_size": total_size,
559 "offset": offset,
560 "next_offset": next_offset,
561 "lines_count": lines_count,
562 # Change 2 (F13/F25): echo the RESOLVED absolute path so a discovery
563 # rescue is VISIBLE ground truth, not a silent wrong-path mask.
564 "path": str(p),
565 }
566 if _was_discovery_rescue(path, p):
567 result["resolved_via_discovery"] = True
568 result["requested_path"] = path
569 return result
570
571
572def _normalize_newlines(content: str) -> str:
573 """Normalize line endings to prevent accumulation of \r\n sequences."""
574 return content.replace('\r\n', '\n').replace('\r', '\n')
575
576
577def write_file(path: str, content: str, chunk_size: int = 0, max_chunks: int = 0) -> WriteFileResult | ToolError:
578 """Write (or overwrite) a text file within a working directory tree.
579
580 Files whose basename starts with ``_tmp`` are automatically redirected
581 to ``logs/`` so that repository root stays tidy.
582
583 Args:
584 path: Destination path relative to working directory
585 content: Text content to write
586 chunk_size: Target chunk size in bytes for large content (default 0 = no chunking)
587 max_chunks: Maximum number of chunks to return (default 0 = no chunking)
588 """
589 from datetime import datetime
590
591 # Normalize line endings to prevent blank line multiplication
592 content = _normalize_newlines(content)
593
594 original_path = path
595 redirected = False
596
597 # M8.0 WU-5 (§5.1.2/G-4): hard-deny the protected-write class BEFORE the
598 # sandbox clamp -- .git internals, secrets, *.pth / sitecustomize / conftest
599 # import-exec hooks, provider-endpoint config, the clean-exit marker.
600 if is_protected_write_path(path):
601 return {"error": _PROTECTED_WRITE_MSG}
602
603 # Content size validation
604 content_size = len(content.encode('utf-8'))
605 if content_size > 1_000_000: # 1MB limit
606 return {"error": f"Content too large: {content_size} bytes (max 1,000,000)"}
607
608 # Handle chunking for large content
609 chunks = []
610 if chunk_size > 0 and len(content) > chunk_size:
611 try:
612 for i in range(0, min(max_chunks, (len(content) + chunk_size - 1) // chunk_size)):
613 start = i * chunk_size
614 end = min(start + chunk_size, len(content))
615 chunk_content = content[start:end]
616 chunks.append({
617 "chunk_index": i,
618 "content": chunk_content,
619 "size": len(chunk_content)
620 })
621 except Exception as e:
622 return {"error": f"Chunking failed: {str(e)}"}
623
624 # Redirect temporary workspace files to logs/
625 if Path(path).name.startswith("_tmp"):
626 path = str(Path("logs") / Path(path).name)
627 redirected = True
628
629 # H.2a + M8.0 WU-5/WU-8: clamp to the sandbox root, re-check the resolved
630 # target for the hard-deny protected-write class, and apply the human-confirm
631 # protected-write TIER (config-as-code + importable-tree) — the SAME shared
632 # enforcement path edit_file routes through (no parallel policy).
633 gate = _enforce_write_gates(path)
634 if isinstance(gate, dict):
635 return gate
636 target = gate
637 sandbox_root = _sandbox_root()
638 # Suppress the informational chunk metadata for a human-confirm/importable
639 # target (only reachable here in an approved re-dispatch), matching pre-refactor
640 # behaviour where a protected human-confirm target is never chunk-tagged.
641 _suppress_chunk_meta = is_importable_write_path(target) or is_human_confirm_write_path(target)
642
643 target.parent.mkdir(parents=True, exist_ok=True)
644
645 # Always write the COMPLETE content to the target as ONE file. (The legacy
646 # "chunked" path recursively wrote separate ``*_chunk_N`` files, dropped the
647 # directory of a nested target — `target.stem` — and never wrote the target
648 # itself, yet reported the target as written: a real bug. ``chunk_size`` /
649 # ``max_chunks`` now only yield informational split metadata; the on-disk file is
650 # the complete target. A protected human-confirm target is never chunk-tagged.)
651 target.write_text(content, encoding="utf-8")
652 actual_path = str(target.relative_to(sandbox_root))
653 chunk_meta = (
654 [{"chunk_index": c["chunk_index"], "size": c["size"]} for c in chunks]
655 if (chunks and not _suppress_chunk_meta) else []
656 )
657 return {
658 "written": actual_path,
659 "original_path": original_path,
660 "redirected": redirected,
661 "actual_path": actual_path,
662 "size_bytes": content_size,
663 "timestamp": datetime.now().isoformat(),
664 "chunked": bool(chunk_meta),
665 "chunks": chunk_meta,
666 }
667
668
669# ── edit_file: anchored partial edits (plan 171; F20 cure class) ─────────────
670
671def _match_start_lines(text: str, anchor: str) -> list[int]:
672 """1-based line numbers where each non-overlapping occurrence of *anchor* begins.
673
674 Advances by ``len(anchor)`` so the count matches :meth:`str.count` /
675 :meth:`str.replace` (both non-overlapping), keeping the reported line numbers
676 consistent with the occurrence count the caller gated on.
677 """
678 lines: list[int] = []
679 step = max(1, len(anchor))
680 idx = text.find(anchor)
681 while idx != -1:
682 lines.append(text.count("\n", 0, idx) + 1)
683 idx = text.find(anchor, idx + step)
684 return lines
685
686
687def _edit_near_miss_message(text: str, anchor: str, path: str) -> str:
688 """Name the closest existing line to *anchor*'s first line + the first divergence.
689
690 A 0-match re-anchor aid (plan 171): the model gets the nearest line number, a
691 similarity score, and the column where its ``old_string`` first diverges from
692 the file, so it can copy an exact anchor from a fresh ``read_file`` instead of
693 guessing blindly.
694 """
695 import difflib
696 anchor_first = anchor.split("\n", 1)[0]
697 file_lines = text.split("\n")
698 matcher = difflib.SequenceMatcher()
699 matcher.set_seq2(anchor_first)
700 best_i, best_ratio = -1, -1.0
701 for i, line in enumerate(file_lines):
702 matcher.set_seq1(line)
703 ratio = matcher.ratio()
704 if ratio > best_ratio:
705 best_ratio, best_i = ratio, i
706 if best_i < 0:
707 return f"old_string not found in {path} (file is empty)."
708 near = file_lines[best_i]
709 col = 1
710 for expected_ch, file_ch in zip(anchor_first, near):
711 if expected_ch != file_ch:
712 break
713 col += 1
714 return (
715 f"old_string not found in {path}. Closest line is {best_i + 1} "
716 f"(similarity {best_ratio:.0%}); first divergence at column {col}.\n"
717 f" expected: {anchor_first!r}\n"
718 f" file : {near!r}\n"
719 f"Re-anchor old_string to match the file exactly (copy from a fresh read_file)."
720 )
721
722
723def _edit_context_snippet(edited_text: str, replacement: str, start_line: int, radius: int = 2) -> str:
724 """A ±*radius*-line, line-numbered snippet of the applied change (ground-truth echo).
725
726 Spans the replacement's own line range plus *radius* lines on each side of the
727 first edited line, so the caller sees WHAT changed and WHERE without a re-read
728 (F16/F25 direction; 172 generalizes this echo to reads).
729 """
730 lines = edited_text.split("\n")
731 span = replacement.count("\n")
732 lo = max(0, start_line - 1 - radius)
733 hi = min(len(lines), start_line + span + radius)
734 return "\n".join(f"{i + 1:>5} | {lines[i]}" for i in range(lo, hi))
735
736
737def _atomic_write_bytes(target: Path, data: bytes) -> None:
738 """Write *data* to *target* atomically: temp file in the SAME dir + ``os.replace``.
739
740 A same-directory temp keeps the final rename on one filesystem (so
741 :func:`os.replace` is atomic); on any failure the temp is unlinked, so a failed
742 edit never leaves a partial file — the target keeps its original bytes.
743 """
744 import tempfile
745 target.parent.mkdir(parents=True, exist_ok=True)
746 fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), prefix=".oc_edit_", suffix=".tmp")
747 try:
748 with os.fdopen(fd, "wb") as handle:
749 handle.write(data)
750 os.replace(tmp_name, target)
751 except BaseException:
752 with contextlib.suppress(OSError):
753 os.unlink(tmp_name)
754 raise
755
756
757def edit_file(path: str, old_string: str, new_string: str, expected_count: int = 1) -> dict:
758 """Replace an exact ``old_string`` with ``new_string`` in an EXISTING file.
759
760 The anchored-edit primary modify path (plan 171 / F20): the tool-call payload
761 is the anchor + replacement, not the whole file, so it does not hit the
762 large-write provider-truncation class. Fail-closed always — ``old_string`` must
763 occur exactly *expected_count* times (default 1) or nothing is written:
764
765 * 0 matches → an error naming the closest near-miss line (first divergence) so
766 the model can re-anchor.
767 * more than *expected_count* → an error listing the occurrence line numbers; no
768 write. (Set ``expected_count`` to the reported count to replace them all.)
769 * ``old_string == new_string`` or an empty ``old_string`` → error (this tool
770 never creates files; ``write_file`` owns creation).
771
772 Routes through the SAME :func:`_enforce_write_gates` policy as ``write_file``
773 (sandbox clamp, hard-deny protected-write, human-confirm tier). The write is
774 atomic (temp + ``os.replace``) and preserves the file's existing newline style
775 and UTF-8 encoding; binary files are refused. The result echoes the RESOLVED
776 absolute path + the match line number(s) + a ±2-line context snippet of the
777 applied change.
778
779 Args:
780 path: File path (relative to the working directory) to edit in place.
781 old_string: The exact text to find (anchor). Must match the file byte-for-byte
782 after newline normalization; include enough surrounding context to be unique.
783 new_string: The replacement text.
784 expected_count: Required number of occurrences of ``old_string`` (default 1).
785 """
786 func = "edit_file"
787 from datetime import datetime
788
789 # ── Argument validation (edit never creates; a no-op is an error) ───────
790 if old_string == "":
791 return {"error": "edit_file cannot create files: old_string is empty. Use write_file to create a file."}
792 if old_string == new_string:
793 return {"error": "edit_file is a no-op: old_string and new_string are identical."}
794 try:
795 expected_count = int(expected_count)
796 except (TypeError, ValueError):
797 return {"error": f"expected_count must be an integer, got {expected_count!r}."}
798 if expected_count < 1:
799 return {"error": f"expected_count must be >= 1, got {expected_count}."}
800
801 # ── The shared write-side M8 gate (identical policy to write_file) ──────
802 gate = _enforce_write_gates(path)
803 if isinstance(gate, dict):
804 return gate
805 target = gate
806 sandbox_root = _sandbox_root()
807
808 # ── The file must already exist (edit is not a create path) ─────────────
809 if not target.exists():
810 return {"error": f"File not found: {path}. edit_file modifies existing files; use write_file to create."}
811 if target.is_dir():
812 return {"error": f"Path is a directory: {path}"}
813
814 # ── Read + binary/encoding guards (parity with read_file) ───────────────
815 try:
816 raw = target.read_bytes()
817 except OSError as exc:
818 return {"error": str(exc)}
819 if b"\0" in raw[:512]:
820 return {"error": "Binary file — cannot edit as text."}
821 try:
822 original = raw.decode("utf-8")
823 except UnicodeDecodeError:
824 return {"error": "Non-UTF-8 file — cannot edit as text."}
825
826 # ── Detect the file's newline style; match/apply in normalized (\n) space ─
827 newline = "\r\n" if "\r\n" in original else "\n"
828 norm = original.replace("\r\n", "\n").replace("\r", "\n")
829 anchor = old_string.replace("\r\n", "\n").replace("\r", "\n")
830 replacement = new_string.replace("\r\n", "\n").replace("\r", "\n")
831
832 count = norm.count(anchor)
833 if count == 0:
834 return {"error": _edit_near_miss_message(norm, anchor, path)}
835 if count != expected_count:
836 found_lines = _match_start_lines(norm, anchor)
837 return {"error": (
838 f"Expected {expected_count} occurrence(s) of old_string in {path}, found {count} "
839 f"(at line(s) {', '.join(map(str, found_lines))}). No write performed. Add more "
840 f"surrounding context to old_string to anchor a single occurrence, or set "
841 f"expected_count={count} to replace all."
842 )}
843
844 # ── Apply (full replace; count == expected_count guaranteed) + atomic write ─
845 match_lines = _match_start_lines(norm, anchor)
846 edited_norm = norm.replace(anchor, replacement)
847 out_text = edited_norm.replace("\n", newline) if newline != "\n" else edited_norm
848 out_bytes = out_text.encode("utf-8")
849 try:
850 _atomic_write_bytes(target, out_bytes)
851 except OSError as exc:
852 return {"error": f"Atomic write failed (file unchanged): {exc}"}
853
854 actual_path = str(target.relative_to(sandbox_root))
855 context = _edit_context_snippet(edited_norm, replacement, match_lines[0])
856 _dbg("OC_CHAT.TOOLS", func, f"edited {actual_path}: {count} replacement(s) at {match_lines}", 3)
857 return {
858 "edited": actual_path,
859 "path": str(target), # RESOLVED absolute path — model-independent ground truth (F16/F25)
860 "actual_path": actual_path,
861 "match_line": match_lines[0],
862 "match_lines": match_lines,
863 "replacements": count,
864 "context": context,
865 "size_bytes": len(out_bytes),
866 "newline": "crlf" if newline == "\r\n" else "lf",
867 "timestamp": datetime.now().isoformat(),
868 }
869
870
871def file_info(path: str) -> dict:
872 """Return file metadata without reading content."""
873 func = "file_info"
874 if is_protected_read_path(path):
875 return {"error": _PROTECTED_READ_MSG}
876 try:
877 p = _discover_file_path(path)
878 except (FileNotFoundError, PermissionError) as e:
879 return {"error": _outside_sandbox_error(path) or str(e)} # F25: name the boundary
880 if p.is_dir():
881 return {"error": f"Path is a directory: {path}"}
882 if is_protected_read_path(p):
883 return {"error": _PROTECTED_READ_MSG}
884
885 try:
886 stat = p.stat()
887
888 # Detect if file is binary
889 try:
890 with open(p, 'rb') as f:
891 binary = b'\0' in f.read(512)
892 except OSError as e:
893 # VALIDATION_ERROR — unreadable; treat as binary so we don't try to count lines
894 _dbg("OC_CHAT.TOOLS", func, f"VALIDATION_ERROR: binary-probe: {type(e).__name__}: {e}", 3)
895 binary = True
896
897 # Estimate line count for text files
898 lines = None
899 if not binary:
900 try:
901 with open(p, 'r', encoding='utf-8') as f:
902 lines = sum(1 for _ in f)
903 except (OSError, UnicodeDecodeError) as e:
904 # VALIDATION_ERROR — encoding mismatch or read error; line count unavailable
905 _dbg("OC_CHAT.TOOLS", func, f"VALIDATION_ERROR: line-count: {type(e).__name__}: {e}", 3)
906 lines = None
907
908 # Estimate optimal chunk size
909 size = stat.st_size
910 if size < 10_000:
911 chunk_size = size
912 chunks = 1
913 else:
914 chunk_size = 50_000 # Match read_file default
915 chunks = (size + chunk_size - 1) // chunk_size
916
917 info = {
918 "size_bytes": size,
919 "lines": lines,
920 "binary": binary,
921 "readable_chunks": chunks,
922 "recommended_chunk_size": chunk_size,
923 "modified_time": stat.st_mtime,
924 "created_time": stat.st_ctime,
925 # Change 2 (F13/F25): the RESOLVED absolute path — a discovery rescue
926 # is visible, not silently masked.
927 "path": str(p),
928 }
929 if _was_discovery_rescue(path, p):
930 info["resolved_via_discovery"] = True
931 info["requested_path"] = path
932 return info
933 except Exception as e:
934 return {"error": str(e)}
935
936
937def read_chunks(path: str, chunk_size: int = 10_000, max_chunks: int = 10, offset: int = 0) -> dict:
938 """Read file in optimized chunks with natural boundaries.
939
940 Args:
941 path: File path relative to working directory
942 chunk_size: Target chunk size in characters (default 10000)
943 max_chunks: Maximum number of chunks to return (default 10)
944 offset: Character offset to start reading from (default 0);
945 pass the ``next_offset`` from a previous result to paginate.
946 """
947 # M8.0 WU-9 review fix (Appendix B): clamp the model-supplied chunk parameters so
948 # the total output (chunk_size * max_chunks) cannot exceed the 5 MB ceiling.
949 from oc_chat.core.resource_caps import MAX_BYTES, clamp_bytes
950 chunk_size = max(1, clamp_bytes(chunk_size))
951 try:
952 max_chunks = int(max_chunks)
953 except (TypeError, ValueError, OverflowError):
954 max_chunks = 1
955 max_chunks = max(1, min(max_chunks, MAX_BYTES // chunk_size))
956 if is_protected_read_path(path):
957 return {"error": _PROTECTED_READ_MSG}
958 p = _safe_path(path)
959 if is_protected_read_path(p):
960 return {"error": _PROTECTED_READ_MSG}
961 if not p.exists():
962 return {"error": f"File not found: {path}"}
963 if p.is_dir():
964 return {"error": f"Path is a directory: {path}"}
965
966 try:
967 full_data = p.read_bytes()
968
969 # Binary detection
970 if b"\0" in full_data[:512]:
971 return {"error": "Binary file — cannot read as text."}
972
973 # Decode once — all offsets below are character-based on this string
974 content = full_data.decode("utf-8")
975 total_chars = len(content)
976
977 # Clamp starting offset
978 if offset < 0:
979 offset = 0
980 if offset > 0 and offset >= total_chars:
981 return {"error": f"Offset {offset} exceeds file length {total_chars}"}
982
983 # Smart chunking - try to break at natural boundaries
984 chunks = []
985 pos = offset
986
987 for i in range(max_chunks):
988 if pos >= total_chars:
989 break
990
991 # Calculate tentative end of this chunk (character-based)
992 chunk_end = min(pos + chunk_size, total_chars)
993 chunk_data = content[pos:chunk_end]
994
995 # Try to extend to natural boundary if not at end
996 if chunk_end < total_chars:
997 # Look for newline, paragraph break, or sentence end
998 lookahead = min(total_chars, chunk_end + 1000)
999 next_newline = content.find('\n', chunk_end, lookahead)
1000 next_para = content.find('\n\n', chunk_end, lookahead)
1001 next_sentence = content.find('. ', chunk_end, min(total_chars, chunk_end + 500))
1002
1003 # Choose the closest natural boundary
1004 candidates = [next_newline, next_para, next_sentence]
1005 candidates = [c for c in candidates if c != -1]
1006 if candidates:
1007 best_boundary = min(candidates)
1008 if best_boundary - chunk_end < 200: # Don't extend too much
1009 chunk_end = best_boundary + 1
1010 chunk_data = content[pos:chunk_end]
1011
1012 chunks.append({
1013 # Secret-pattern redaction of returned content (M8 F46/F51;
1014 # WU-2). size/lines stay keyed to the original chunk text.
1015 "content": redact_secrets(chunk_data),
1016 "offset": pos,
1017 "size": len(chunk_data),
1018 "lines": chunk_data.count('\n') + (1 if chunk_data and not chunk_data.endswith('\n') else 0)
1019 })
1020
1021 pos = chunk_end
1022
1023 return {
1024 "chunks": chunks,
1025 "total_size": len(full_data), # byte size for backward compatibility
1026 "total_chars": total_chars, # character count
1027 "total_chunks": len(chunks),
1028 "has_more": pos < total_chars,
1029 "next_offset": pos if pos < total_chars else None
1030 }
1031
1032 except UnicodeDecodeError:
1033 return {"error": "Non-UTF-8 file."}
1034 except Exception as e:
1035 return {"error": str(e)}
1036
1037
1038def search_files(root_dir: str = ".", pattern: str = "*") -> dict:
1039 """Search recursively for files matching a glob pattern."""
1040 from oc_chat.core.cancellation import tool_cancel_requested
1041 root = _safe_path(root_dir)
1042 matches: list[str] = []
1043 for p in root.glob(f"**/{pattern}"):
1044 if tool_cancel_requested(): # cooperative Ctrl+C — return partial matches
1045 return {"matches": sorted(matches), "count": len(matches), "cancelled": True}
1046 if p.is_file():
1047 if is_protected_read_path(p):
1048 continue # omit protected-read (secret) files (M8 F46; WU-2)
1049 matches.append(str(p.relative_to(_sandbox_root())))
1050 return {"matches": sorted(matches), "count": len(matches)}
1051
1052
1053def read_prompt_file(filepath: str, *, allow_absolute: bool = False) -> str:
1054 """Read a file destined for the provider-bound prompt, with M8.0 WU-2b (F73)
1055 enforcement.
1056
1057 Prompt-composition reads (``system_prompt_file``, ``include_files``) happen
1058 before the tool gate and their content egresses to the model provider, so
1059 they must honour the same floor as the read tools:
1060
1061 * **No absolute paths** -- there is no out-of-band allowlist yet, so
1062 ``allow_absolute`` is effectively always ``False`` (the seam for a future
1063 OOB-allow; F73 "unless OOB-allowed").
1064 * **Clamp to the effective sandbox root** (``_sandbox_root()``); a ``..`` /
1065 sibling-prefix / symlink escape is refused.
1066 * **Deny the protected-read (secret) class** on the final resolved path.
1067 * **Redact secret patterns** from the returned content (defence in depth).
1068
1069 Returns the file's text, stripped + redacted. Raises :class:`PermissionError`
1070 on a policy denial (absolute, sandbox escape, or protected-read) and
1071 :class:`FileNotFoundError` when the file is missing or empty -- the callers
1072 fall back to the built-in default / skip the include, so a denied secret is
1073 never inserted into the prompt.
1074 """
1075 raw = str(filepath).strip()
1076 if not raw:
1077 raise FileNotFoundError("empty prompt-file path")
1078 if Path(raw).is_absolute() and not allow_absolute:
1079 raise PermissionError(
1080 f"Absolute prompt-composition paths are not allowed: {filepath}"
1081 )
1082 try:
1083 p = require_inside_sandbox(raw, _sandbox_root())
1084 except SandboxViolation as exc:
1085 raise PermissionError(
1086 f"Prompt-composition path outside the working directory is not allowed: {filepath}"
1087 ) from exc
1088 if is_protected_read_path(p):
1089 raise PermissionError(
1090 f"Reading this file for prompt composition is not permitted "
1091 f"(protected-read/secret class): {filepath}"
1092 )
1093 content = p.read_text(encoding="utf-8").strip()
1094 if not content:
1095 raise FileNotFoundError(f"prompt file missing or empty: {filepath}")
1096 return redact_secrets(content)
1097
1098
1099# ── OpenAI tool definitions ───────────────────────────────────────────────────
1100
1101TOOLS = {
1102 "list_dir": list_dir,
1103 "read_file": read_file,
1104 "write_file": write_file,
1105 "edit_file": edit_file,
1106 "search_files": search_files,
1107 "file_info": file_info,
1108 "read_chunks": read_chunks,
1109}
1110
1111SCHEMAS = [
1112 {
1113 "type": "function",
1114 "function": {
1115 "name": "list_dir",
1116 "description": (
1117 "List files and subdirectories within the working directory. "
1118 "Returns a dict keyed by relative path, each with 'dirs' and 'files' lists."
1119 ),
1120 "parameters": {
1121 "type": "object",
1122 "properties": {
1123 "path": {"type": "string", "description": "Directory to list (relative, default '.')"},
1124 "max_depth": {"type": "integer", "description": "Recursion depth (0 = flat listing, default 2)"},
1125 "exclude_hidden": {"type": "boolean", "description": "Exclude dot-files/dirs (default true)"},
1126 },
1127 "required": [],
1128 },
1129 },
1130 },
1131 {
1132 "type": "function",
1133 "function": {
1134 "name": "read_file",
1135 "description": "Read the text content of a file within the working directory with pagination support. Use offset for chunked reading of large files.",
1136 "parameters": {
1137 "type": "object",
1138 "properties": {
1139 "path": {"type": "string", "description": "File path relative to working directory"},
1140 "max_bytes": {"type": "integer", "description": "Maximum bytes to read per request (default 50000, reduced to avoid API truncation)"},
1141 "offset": {"type": "integer", "description": "Byte offset to start reading from (default 0)"},
1142 },
1143 "required": ["path"],
1144 },
1145 },
1146 },
1147 {
1148 "type": "function",
1149 "function": {
1150 "name": "write_file",
1151 "description": "Write (or overwrite) a text file within a working directory. The complete content is always written to one file. Files starting with '_tmp' are redirected to 'logs/'. Returns detailed metadata including the actual write location.",
1152 "parameters": {
1153 "type": "object",
1154 "properties": {
1155 "path": {"type": "string", "description": "Destination path relative to working directory"},
1156 "content": {"type": "string", "description": "Text content to write"},
1157 # OCT-LINT: disable=no_hardcoded_secrets reason="write_file tool-schema description of the chunk_size parameter; no credential material (operator-ruled 2026-09-28)"
1158 "chunk_size": {"type": "integer", "description": "Optional: target chunk size (chars) for INFORMATIONAL split metadata in the response; the file is still written whole (default 0 = none)"},
1159 "max_chunks": {"type": "integer", "description": "Optional: cap on the informational chunk-metadata entries (default 0 = none)"},
1160 },
1161 "required": ["path", "content"],
1162 },
1163 },
1164 },
1165 {
1166 "type": "function",
1167 "function": {
1168 "name": "edit_file",
1169 "description": (
1170 "Anchored partial edit: replace an exact old_string with new_string in an "
1171 "EXISTING file. Preferred over write_file for MODIFYING a file because the "
1172 "tool-call payload is only the anchor + replacement, not the whole file — this "
1173 "avoids the large-write truncation failure class. Fail-closed: old_string must "
1174 "occur exactly expected_count times (default 1) or nothing is written (0 matches "
1175 "reports the closest near-miss line; >expected_count reports the match line "
1176 "numbers). Never creates files (use write_file). Preserves the file's newline "
1177 "style; the result echoes the resolved path + match line + a context snippet."
1178 ),
1179 "parameters": {
1180 "type": "object",
1181 "properties": {
1182 "path": {"type": "string", "description": "File path (relative to the working directory) to edit in place"},
1183 "old_string": {"type": "string", "description": "Exact text to find (anchor); include enough surrounding context to be unique"},
1184 "new_string": {"type": "string", "description": "Replacement text"},
1185 "expected_count": {"type": "integer", "description": "Required number of occurrences of old_string (default 1); set to the reported count to replace all"},
1186 },
1187 "required": ["path", "old_string", "new_string"],
1188 },
1189 },
1190 },
1191 {
1192 "type": "function",
1193 "function": {
1194 "name": "search_files",
1195 "description": "Search recursively for files matching a glob pattern.",
1196 "parameters": {
1197 "type": "object",
1198 "properties": {
1199 "root_dir": {"type": "string", "description": "Search root (relative, default '.')"},
1200 "pattern": {"type": "string", "description": "Glob pattern e.g. '*.py', '*.md'"},
1201 },
1202 "required": [],
1203 },
1204 },
1205 },
1206 {
1207 "type": "function",
1208 "function": {
1209 "name": "file_info",
1210 "description": "Get file metadata without reading content. Returns size, line count, binary detection, and chunking recommendations.",
1211 "parameters": {
1212 "type": "object",
1213 "properties": {
1214 "path": {"type": "string", "description": "File path relative to working directory"},
1215 },
1216 "required": ["path"],
1217 },
1218 },
1219 },
1220 {
1221 "type": "function",
1222 "function": {
1223 "name": "read_chunks",
1224 "description": "Read file in optimized chunks with natural boundaries. Returns multiple chunks at once for efficient processing of large files.",
1225 "parameters": {
1226 "type": "object",
1227 "properties": {
1228 "path": {"type": "string", "description": "File path relative to working directory"},
1229 "chunk_size": {"type": "integer", "description": "Target chunk size in characters (default 10000)"},
1230 "max_chunks": {"type": "integer", "description": "Maximum number of chunks to return (default 10)"},
1231 "offset": {"type": "integer", "description": "Character offset to start reading from (default 0). Pass the next_offset from a previous result to paginate."},
1232 },
1233 "required": ["path"],
1234 },
1235 },
1236 },
1237]
dict deny_protected_write(dict result, *, str note="")
Path _discover_file_path(str path)
str _edit_context_snippet(str edited_text, str replacement, int start_line, int radius=2)
list[int] _match_start_lines(str text, str anchor)
bool _plan_envelope_covers_write(str target)
str _normalize_newlines(str content)
dict read_chunks(str path, int chunk_size=10_000, int max_chunks=10, int offset=0)
dict _make_protected_write_block(str target, str reason)
Path|None _find_worktree_root(Path|None start=None)
Iterator[None] human_confirmed_write(str target)
dict file_info(str path)
dict list_dir(str path=".", int max_depth=2, bool exclude_hidden=True)
Path _safe_path(str path)
dict edit_file(str path, str old_string, str new_string, int expected_count=1)
ReadFileResult|ToolError read_file(str path, int max_bytes=50_000, int offset=0)
bool _was_discovery_rescue(str requested, Path resolved)
str _edit_near_miss_message(str text, str anchor, str path)
"tuple[Path, Path]" _resolve_unclamped(str path)
"Path | dict" _enforce_write_gates(str path)
str read_prompt_file(str filepath, *, bool allow_absolute=False)
Path _discover_dir_path(str path)
None _atomic_write_bytes(Path target, bytes data)
dict search_files(str root_dir=".", str pattern="*")
tuple[Path, str] _sandbox_root_info()
WriteFileResult|ToolError write_file(str path, str content, int chunk_size=0, int max_chunks=0)
"str | None" _outside_sandbox_error(str path)