6Filesystem tools — all operations are clamped to the working directory.
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.
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
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
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.
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
48- ``read_chunks`` clamps ``offset`` to ``[0, total_chars]`` and never
50- ``write_file`` chunk slicing is character-based (not byte-based)
51 to handle multi-byte UTF-8 correctly.
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)
63oc_chat/docs/conventions/CONTRACTS.md §3 Tool registry contract; §4 Tool return-
64value conventions (read_file → §4.2, write_file → §4.3).
68from collections.abc
import Iterator
69from pathlib
import Path
72from oc_diagnostics
import _dbg
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,
83 require_inside_sandbox,
86_HARDCODED_ROOT = Path(__file__).parent.parent.parent.resolve()
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)."
99_human_approved_write: contextvars.ContextVar[str |
None] = contextvars.ContextVar(
100 "oc_chat_human_approved_write", default=
None)
103@contextlib.contextmanager
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)
111 _human_approved_write.reset(token)
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."""
122 """The human-confirm-tier signal write_file returns (relayed by the react-loop;
123 failed closed headless by F70a)."""
125 "requires_confirmation":
True,
126 "gate":
"protected_write",
130 f
"Writing '{target}' touches the human-confirm protected-write tier "
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)."
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()}
148 """Resolve *path* through the shared write-side M8 gate; return the target or a block.
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):
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
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).
167 Returns the resolved absolute :class:`~pathlib.Path` on success, or a
168 ``{"error": ...}`` / ``{"requires_confirmation": ...}`` dict on denial / relay.
170 if is_protected_write_path(path):
171 return {
"error": _PROTECTED_WRITE_MSG}
174 except SandboxViolation:
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:
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):
190 elif is_human_confirm_write_path(target):
196 """Resolve *path* against the effective sandbox root; raise if it escapes.
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.
209 except SandboxViolation
as exc:
210 raise PermissionError(
211 f
"Access outside working directory is not allowed: {path}"
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():
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")
233 return Path(env).expanduser().resolve(strict=
False),
"OC_CHAT_SANDBOX_ROOT"
234 if ROOT != _HARDCODED_ROOT:
235 return ROOT,
"hardcoded default"
237 if worktree
is not None:
238 return worktree,
"worktree default"
239 return ROOT,
"hardcoded default"
243 """Determine the sandbox root for write-side filesystem operations."""
249 """Resolve *path* against the effective sandbox root WITHOUT clamping.
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`).
259 candidate = Path(path)
260 if not candidate.is_absolute():
261 candidate = abs_root / candidate
262 return abs_root, candidate.resolve(strict=
False)
266 """A boundary-truthful error for an out-of-sandbox *path* (F25), or ``None``.
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).
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.
284 Returns ``None`` for an in-bounds path (not a boundary case).
288 abs_candidate.relative_to(abs_root)
292 if is_protected_read_path(abs_candidate):
294 prefix =
"exists, but " if os.path.exists(abs_candidate)
else ""
295 return f
"{prefix}outside the sandbox (root: {abs_root}): {abs_candidate}"
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).
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.
306 return direct != resolved
310 """Intelligently discover file path with fallback mechanisms.
313 path: Original path requested by user/tool
316 Path object for the discovered file location
319 FileNotFoundError: If file cannot be found in any allowed location
321 func =
"_discover_file_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)
333 f
"oc_chat/docs/{path}",
334 f
"oc_chat/roadmap/{path}",
336 f
"oc_chat/{path}.md",
337 f
"oc_chat/docs/{path}.md",
338 f
"oc_chat/roadmap/{path}.md",
340 f
"roadmap/{path}.md",
344 base_name = Path(path).stem
345 fuzzy_alternatives = [
348 f
"ROADMAP-{base_name}",
349 f
"ROADMAP-{base_name}.md",
350 f
"{base_name.replace('_', '-')}",
351 f
"{base_name.replace('-', '_')}",
355 for alt
in alternatives:
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)
361 except PermissionError:
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:
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)
374 except PermissionError:
379 raise FileNotFoundError(f
"File not found: {path}")
383 """Intelligently discover directory path with fallback mechanisms.
386 path: Original directory path requested
389 Path object for the discovered directory
392 NotADirectoryError: If directory cannot be found in any allowed location
394 func =
"_discover_dir_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)
406 f
"oc_chat/docs/{path}",
407 f
"oc_chat/roadmap/{path}",
410 for alt
in alternatives:
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)
416 except PermissionError:
420 raise NotADirectoryError(f
"Directory not found: {path}")
423def list_dir(path: str =
".", max_depth: int = 2, exclude_hidden: bool =
True) -> dict:
424 """List files and subdirectories within the working directory.
426 Returns a dict keyed by relative path, each value being
427 ``{"dirs": [...], "files": [...]}``. ``max_depth=0`` lists only the
428 target directory itself.
432 max_depth = clamp_depth(max_depth)
433 _dbg(
"OC_CHAT.TOOLS", func, f
"path={path} max_depth={max_depth}", 3)
438 if direct_path.exists()
and not direct_path.is_dir():
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:
447 except (NotADirectoryError, PermissionError)
as e:
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)
461 for root, dirs, files
in os.walk(target_path):
462 if tool_cancel_requested():
465 dirs[:] = [d
for d
in dirs
if not d.startswith(
".")]
466 files[:] = [f
for f
in files
if not f.startswith(
".")]
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)]
473 depth = len(Path(root).relative_to(base).parts)
474 if depth > max_depth:
479 result[key] = {
"dirs": sorted(dirs),
"files": sorted(files)}
486 if root_key
in result:
487 result[root_key][
"resolved_path"] = str(target_path)
489 result[root_key][
"resolved_via_discovery"] =
True
490 result[root_key][
"requested_path"] = path
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.
498 Returns ``{"content": ..., "truncated": bool, "total_size": int, "offset": int,
499 "next_offset": int|None, "lines_count": int}`` or ``{"error": ...}``.
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)
507 max_bytes = clamp_bytes(max_bytes)
508 if is_protected_read_path(path):
509 return {
"error": _PROTECTED_READ_MSG}
512 except (FileNotFoundError, PermissionError)
as e:
519 return {
"error": f
"Path is a directory: {path}"}
520 if is_protected_read_path(p):
521 return {
"error": _PROTECTED_READ_MSG}
524 full_data = p.read_bytes()
525 total_size = len(full_data)
526 except Exception
as e:
527 return {
"error": str(e)}
530 if b
"\0" in full_data[:512]:
531 return {
"error":
"Binary file — cannot read as text."}
536 if offset > 0
and offset >= total_size:
537 return {
"error": f
"Offset {offset} exceeds file size {total_size}"}
539 data = full_data[offset:offset + max_bytes]
540 truncated = offset + len(data) < total_size
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
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
555 result: ReadFileResult = {
556 "content": redact_secrets(content),
557 "truncated": truncated,
558 "total_size": total_size,
560 "next_offset": next_offset,
561 "lines_count": lines_count,
567 result[
"resolved_via_discovery"] =
True
568 result[
"requested_path"] = path
573 """Normalize line endings to prevent accumulation of \r\n sequences."""
574 return content.replace(
'\r\n',
'\n').replace(
'\r',
'\n')
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.
580 Files whose basename starts with ``_tmp`` are automatically redirected
581 to ``logs/`` so that repository root stays tidy.
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)
589 from datetime
import datetime
600 if is_protected_write_path(path):
601 return {
"error": _PROTECTED_WRITE_MSG}
604 content_size = len(content.encode(
'utf-8'))
605 if content_size > 1_000_000:
606 return {
"error": f
"Content too large: {content_size} bytes (max 1,000,000)"}
610 if chunk_size > 0
and len(content) > chunk_size:
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]
618 "content": chunk_content,
619 "size": len(chunk_content)
621 except Exception
as e:
622 return {
"error": f
"Chunking failed: {str(e)}"}
625 if Path(path).name.startswith(
"_tmp"):
626 path = str(Path(
"logs") / Path(path).name)
634 if isinstance(gate, dict):
641 _suppress_chunk_meta = is_importable_write_path(target)
or is_human_confirm_write_path(target)
643 target.parent.mkdir(parents=
True, exist_ok=
True)
651 target.write_text(content, encoding=
"utf-8")
652 actual_path = str(target.relative_to(sandbox_root))
654 [{
"chunk_index": c[
"chunk_index"],
"size": c[
"size"]}
for c
in chunks]
655 if (chunks
and not _suppress_chunk_meta)
else []
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,
672 """1-based line numbers where each non-overlapping occurrence of *anchor* begins.
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.
678 lines: list[int] = []
679 step = max(1, len(anchor))
680 idx = text.find(anchor)
682 lines.append(text.count(
"\n", 0, idx) + 1)
683 idx = text.find(anchor, idx + step)
688 """Name the closest existing line to *anchor*'s first line + the first divergence.
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
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
707 return f
"old_string not found in {path} (file is empty)."
708 near = file_lines[best_i]
710 for expected_ch, file_ch
in zip(anchor_first, near):
711 if expected_ch != file_ch:
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)."
724 """A ±*radius*-line, line-numbered snippet of the applied change (ground-truth echo).
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).
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))
738 """Write *data* to *target* atomically: temp file in the SAME dir + ``os.replace``.
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.
745 target.parent.mkdir(parents=
True, exist_ok=
True)
746 fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), prefix=
".oc_edit_", suffix=
".tmp")
748 with os.fdopen(fd,
"wb")
as handle:
750 os.replace(tmp_name, target)
751 except BaseException:
752 with contextlib.suppress(OSError):
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.
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:
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).
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
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).
787 from datetime
import datetime
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."}
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}."}
803 if isinstance(gate, dict):
809 if not target.exists():
810 return {
"error": f
"File not found: {path}. edit_file modifies existing files; use write_file to create."}
812 return {
"error": f
"Path is a directory: {path}"}
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."}
822 original = raw.decode(
"utf-8")
823 except UnicodeDecodeError:
824 return {
"error":
"Non-UTF-8 file — cannot edit as text."}
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")
832 count = norm.count(anchor)
835 if count != expected_count:
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."
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")
851 except OSError
as exc:
852 return {
"error": f
"Atomic write failed (file unchanged): {exc}"}
854 actual_path = str(target.relative_to(sandbox_root))
856 _dbg(
"OC_CHAT.TOOLS", func, f
"edited {actual_path}: {count} replacement(s) at {match_lines}", 3)
858 "edited": actual_path,
860 "actual_path": actual_path,
861 "match_line": match_lines[0],
862 "match_lines": match_lines,
863 "replacements": count,
865 "size_bytes": len(out_bytes),
866 "newline":
"crlf" if newline ==
"\r\n" else "lf",
867 "timestamp": datetime.now().isoformat(),
872 """Return file metadata without reading content."""
874 if is_protected_read_path(path):
875 return {
"error": _PROTECTED_READ_MSG}
878 except (FileNotFoundError, PermissionError)
as e:
881 return {
"error": f
"Path is a directory: {path}"}
882 if is_protected_read_path(p):
883 return {
"error": _PROTECTED_READ_MSG}
890 with open(p,
'rb')
as f:
891 binary = b
'\0' in f.read(512)
894 _dbg(
"OC_CHAT.TOOLS", func, f
"VALIDATION_ERROR: binary-probe: {type(e).__name__}: {e}", 3)
901 with open(p,
'r', encoding=
'utf-8')
as f:
902 lines = sum(1
for _
in f)
903 except (OSError, UnicodeDecodeError)
as e:
905 _dbg(
"OC_CHAT.TOOLS", func, f
"VALIDATION_ERROR: line-count: {type(e).__name__}: {e}", 3)
915 chunks = (size + chunk_size - 1) // chunk_size
921 "readable_chunks": chunks,
922 "recommended_chunk_size": chunk_size,
923 "modified_time": stat.st_mtime,
924 "created_time": stat.st_ctime,
930 info[
"resolved_via_discovery"] =
True
931 info[
"requested_path"] = path
933 except Exception
as e:
934 return {
"error": str(e)}
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.
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.
950 chunk_size = max(1, clamp_bytes(chunk_size))
952 max_chunks = int(max_chunks)
953 except (TypeError, ValueError, OverflowError):
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}
959 if is_protected_read_path(p):
960 return {
"error": _PROTECTED_READ_MSG}
962 return {
"error": f
"File not found: {path}"}
964 return {
"error": f
"Path is a directory: {path}"}
967 full_data = p.read_bytes()
970 if b
"\0" in full_data[:512]:
971 return {
"error":
"Binary file — cannot read as text."}
974 content = full_data.decode(
"utf-8")
975 total_chars = len(content)
980 if offset > 0
and offset >= total_chars:
981 return {
"error": f
"Offset {offset} exceeds file length {total_chars}"}
987 for i
in range(max_chunks):
988 if pos >= total_chars:
992 chunk_end = min(pos + chunk_size, total_chars)
993 chunk_data = content[pos:chunk_end]
996 if chunk_end < total_chars:
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))
1004 candidates = [next_newline, next_para, next_sentence]
1005 candidates = [c
for c
in candidates
if c != -1]
1007 best_boundary = min(candidates)
1008 if best_boundary - chunk_end < 200:
1009 chunk_end = best_boundary + 1
1010 chunk_data = content[pos:chunk_end]
1015 "content": redact_secrets(chunk_data),
1017 "size": len(chunk_data),
1018 "lines": chunk_data.count(
'\n') + (1
if chunk_data
and not chunk_data.endswith(
'\n')
else 0)
1025 "total_size": len(full_data),
1026 "total_chars": total_chars,
1027 "total_chunks": len(chunks),
1028 "has_more": pos < total_chars,
1029 "next_offset": pos
if pos < total_chars
else None
1032 except UnicodeDecodeError:
1033 return {
"error":
"Non-UTF-8 file."}
1034 except Exception
as e:
1035 return {
"error": str(e)}
1039 """Search recursively for files matching a glob pattern."""
1042 matches: list[str] = []
1043 for p
in root.glob(f
"**/{pattern}"):
1044 if tool_cancel_requested():
1045 return {
"matches": sorted(matches),
"count": len(matches),
"cancelled":
True}
1047 if is_protected_read_path(p):
1050 return {
"matches": sorted(matches),
"count": len(matches)}
1054 """Read a file destined for the provider-bound prompt, with M8.0 WU-2b (F73)
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:
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).
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.
1075 raw = str(filepath).strip()
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}"
1084 except SandboxViolation
as exc:
1085 raise PermissionError(
1086 f
"Prompt-composition path outside the working directory is not allowed: {filepath}"
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}"
1093 content = p.read_text(encoding=
"utf-8").strip()
1095 raise FileNotFoundError(f
"prompt file missing or empty: {filepath}")
1096 return redact_secrets(content)
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,
1117 "List files and subdirectories within the working directory. "
1118 "Returns a dict keyed by relative path, each with 'dirs' and 'files' lists."
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)"},
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.",
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)"},
1143 "required": [
"path"],
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.",
1155 "path": {
"type":
"string",
"description":
"Destination path relative to working directory"},
1156 "content": {
"type":
"string",
"description":
"Text content to write"},
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)"},
1161 "required": [
"path",
"content"],
1168 "name":
"edit_file",
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."
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"},
1187 "required": [
"path",
"old_string",
"new_string"],
1194 "name":
"search_files",
1195 "description":
"Search recursively for files matching a glob pattern.",
1199 "root_dir": {
"type":
"string",
"description":
"Search root (relative, default '.')"},
1200 "pattern": {
"type":
"string",
"description":
"Glob pattern e.g. '*.py', '*.md'"},
1209 "name":
"file_info",
1210 "description":
"Get file metadata without reading content. Returns size, line count, binary detection, and chunking recommendations.",
1214 "path": {
"type":
"string",
"description":
"File path relative to working directory"},
1216 "required": [
"path"],
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.",
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."},
1233 "required": [
"path"],