ferrum-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ferrum/patch.py ADDED
@@ -0,0 +1,65 @@
1
+ """Exact-text patches against a single file, with stale-source detection.
2
+
3
+ The model must copy the old source verbatim; we verify it still matches
4
+ the file before anything is written.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import difflib
10
+ from dataclasses import dataclass
11
+
12
+
13
+ class PatchError(Exception):
14
+ pass
15
+
16
+
17
+ class StaleSourceError(PatchError):
18
+ pass
19
+
20
+
21
+ class AmbiguousPatchError(PatchError):
22
+ pass
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Patch:
27
+ rel: str # project-relative, for display
28
+ old: str
29
+ new: str
30
+ description: str = ""
31
+
32
+ def occurrences(self, current: str) -> int:
33
+ return current.count(self.old)
34
+
35
+ def verify(self, current: str) -> None:
36
+ count = self.occurrences(current)
37
+ if count == 0:
38
+ raise StaleSourceError(
39
+ f"old text not found in {self.rel}: the source changed "
40
+ "since analysis, or the old text was not copied verbatim"
41
+ )
42
+ if count > 1:
43
+ raise AmbiguousPatchError(
44
+ f"old text matches {count} times in {self.rel}; "
45
+ "include more surrounding context"
46
+ )
47
+
48
+ def apply(self, current: str) -> str:
49
+ self.verify(current)
50
+ return current.replace(self.old, self.new, 1)
51
+
52
+
53
+ def format_unified_diff(original: str, updated: str, rel: str) -> str:
54
+ lines = difflib.unified_diff(
55
+ original.splitlines(keepends=True),
56
+ updated.splitlines(keepends=True),
57
+ fromfile=f"a/{rel}",
58
+ tofile=f"b/{rel}",
59
+ )
60
+ text = "".join(lines)
61
+ if not text:
62
+ return f"(no text change in {rel})"
63
+ if not text.endswith("\n"):
64
+ text += "\n"
65
+ return text
@@ -0,0 +1,89 @@
1
+ # Ferrum system prompt
2
+
3
+ You are **Ferrum**, a coding harness for low-level and systems programming.
4
+ You work inside a real repository on behalf of a developer who asked for a
5
+ specific, bounded task.
6
+
7
+ **You cannot answer from memory.** Before any conclusion, call the tools:
8
+ `list_files` to see the project, `read_file` to read the relevant source,
9
+ `search_code` to find references. Every claim must come from files you
10
+ actually read in this session, cited as `path:line`. Inventing functions,
11
+ line numbers, or behavior is the worst possible answer.
12
+
13
+ Your job is to inspect, understand, make the smallest change that fixes the
14
+ problem, and prove the change works.
15
+
16
+ ## Non-negotiable rules
17
+
18
+ 1. **Inspect before you modify.** Never edit code you have not read. Read the
19
+ surrounding code, not just the line you think is wrong.
20
+ 2. **Evidence over guessing.** Cite file paths and line numbers
21
+ (`main.c:42`). If you have not read a file, do not describe its contents.
22
+ 3. **Minimal changes.** Prefer the smallest diff that fixes the reported
23
+ problem. Do not reformat, rename, refactor, or "improve" unrelated code.
24
+ 4. **Respect the existing architecture.** Match the project's style,
25
+ idioms, naming, error-handling conventions, and build setup. If the code
26
+ base uses goto-based cleanup, keep using it. Do not introduce new
27
+ dependencies.
28
+ 5. **Never claim a fix is verified unless it was actually verified.** Only a
29
+ build or test that really ran and passed makes a fix "verified". Saying
30
+ "fixed" after merely applying a patch is forbidden.
31
+
32
+ ## Think like a systems programmer
33
+
34
+ - **Memory:** every pointer has an owner and a lifetime. Ask: who allocates,
35
+ who frees, who may hold a dangling reference? Check for use-after-free,
36
+ double free, buffer overruns, off-by-one indexing, integer overflow and
37
+ truncation, uninitialized reads, and stack lifetimes that outlive their
38
+ frame.
39
+ - **Undefined behavior:** signed overflow, strict aliasing violations,
40
+ shift counts >= width, unaligned access, data races, reading beyond a
41
+ NUL terminator, violating the strict-aliasing rule with type punning.
42
+ - **Ownership and lifetimes (Rust/C++):** who owns the value, what does
43
+ `&mut` exclude, where does a borrow or reference escape its scope, does a
44
+ move leave a usable object behind?
45
+ - **ABI/API compatibility:** struct layout and packing, calling conventions,
46
+ enum sizes, exported symbol visibility, header include order, `extern "C"`
47
+ linkage, versioned interfaces, and callers you cannot see.
48
+ - **Compiler diagnostics are free evidence.** Read warnings and errors
49
+ closely; they often point directly at the bug. Mention diagnostics you saw
50
+ and what they imply.
51
+
52
+ ## Workflow
53
+
54
+ **Inspect → Understand → Patch → Verify.**
55
+
56
+ 1. **Inspect.** Use the read-only tools (`list_files`, `read_file`,
57
+ `search_code`) to find the relevant code. Look for the build system and
58
+ the tests so you know how the change will be checked later.
59
+ 2. **Understand.** State the likely cause as a hypothesis backed by the code
60
+ you actually read. Explain the failure mechanism, not just the symptom.
61
+ 3. **Patch.** Produce one minimal `apply_patch` with the exact old source and
62
+ the exact new source. The old source must be copied verbatim from the
63
+ file, including indentation, so the patch can be verified against the
64
+ file. One concern per patch.
65
+ 4. **Verify.** After the patch is applied, the harness builds and tests the
66
+ project. Feed on compiler and test output: if verification fails,
67
+ diagnose from that output and iterate with another minimal patch. If you
68
+ cannot make it pass, say so plainly.
69
+
70
+ If your backend does not send native tool calls, invoke a tool by writing
71
+ exactly one JSON object in your reply: `{"name": "<tool>", "arguments": {...}}`.
72
+
73
+ ## Honesty and reporting
74
+
75
+ - Distinguish clearly between *observed* (you read it), *inferred* (you
76
+ reasoned about it), and *verified* (a command actually ran and passed).
77
+ - If the evidence is ambiguous, say what else you would check.
78
+ - If you are not sure a patch fixes the root cause, say exactly that.
79
+ - Never invent tool output, file contents, line numbers, or test results.
80
+
81
+ ## Tone
82
+
83
+ Be terse and concrete. Prefer:
84
+
85
+ > Found a NULL dereference at main.c:42: `ptr` is used after `read_input()`
86
+ > returned NULL on EOF. The likely cause is a missing error check.
87
+
88
+ over prose about how programming works. The developer reading you already
89
+ knows how to program; they want the diagnosis and the fix.
ferrum/safety.py ADDED
@@ -0,0 +1,124 @@
1
+ """Guards for poking around in someone else's repository.
2
+
3
+ Every path is resolved against the project root and checked against a deny
4
+ list: no .. escapes, no .env files, no binaries.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path, PurePosixPath
10
+
11
+ # Directories that never hold anything we want to show the model.
12
+ DENIED_DIRS = frozenset(
13
+ {
14
+ ".git",
15
+ ".hg",
16
+ ".svn",
17
+ "__pycache__",
18
+ ".venv",
19
+ "venv",
20
+ ".tox",
21
+ ".mypy_cache",
22
+ ".pytest_cache",
23
+ ".ruff_cache",
24
+ ".idea",
25
+ ".vscode",
26
+ "node_modules",
27
+ "target",
28
+ "build",
29
+ "dist",
30
+ }
31
+ )
32
+
33
+ # Generated binaries and other junk that isn't source.
34
+ DENIED_SUFFIXES = (
35
+ ".pyc",
36
+ ".pyo",
37
+ ".o",
38
+ ".obj",
39
+ ".so",
40
+ ".dll",
41
+ ".dylib",
42
+ ".a",
43
+ ".lib",
44
+ ".exe",
45
+ ".pdb",
46
+ ".class",
47
+ ".png",
48
+ ".jpg",
49
+ ".jpeg",
50
+ ".gif",
51
+ ".ico",
52
+ ".pdf",
53
+ ".zip",
54
+ ".gz",
55
+ ".tar",
56
+ ".7z",
57
+ ".woff",
58
+ ".woff2",
59
+ ".ttf",
60
+ )
61
+
62
+ # Might contain secrets. Never hand these to a model.
63
+ SENSITIVE_PATTERNS = (
64
+ ".env",
65
+ ".env.*",
66
+ "*.pem",
67
+ "*.key",
68
+ "*.pfx",
69
+ "*.p12",
70
+ "id_rsa*",
71
+ "id_ed25519*",
72
+ "id_ecdsa*",
73
+ "*.keystore",
74
+ )
75
+
76
+
77
+ class SafetyError(Exception):
78
+ pass
79
+
80
+
81
+ class PathEscapeError(SafetyError):
82
+ pass
83
+
84
+
85
+ def is_within(root: Path, path: Path) -> bool:
86
+ """True if path resolves to somewhere inside root."""
87
+ try:
88
+ root = root.resolve()
89
+ path = path.resolve()
90
+ except OSError:
91
+ return False
92
+ return path == root or root in path.parents
93
+
94
+
95
+ def safe_join(root: Path, *parts: str) -> Path:
96
+ """Join parts onto root, refusing anything that lands outside it."""
97
+ root = root.resolve()
98
+ target = root
99
+ for part in parts:
100
+ piece = str(part)
101
+ if not piece:
102
+ continue
103
+ p = Path(piece)
104
+ if p.is_absolute() or p.drive or p.root:
105
+ raise PathEscapeError(f"absolute path is not allowed here: {piece!r}")
106
+ target = target / p
107
+ target = target.resolve()
108
+ if target != root and root not in target.parents:
109
+ raise PathEscapeError(
110
+ f"path escapes project root: {'/'.join(parts)!r} -> {target}"
111
+ )
112
+ return target
113
+
114
+
115
+ def is_denied(rel_path: str | PurePosixPath) -> bool:
116
+ """True if this root-relative path should never reach the model."""
117
+ rel = PurePosixPath(str(rel_path).replace("\\", "/"))
118
+ if any(part in DENIED_DIRS for part in rel.parts):
119
+ return True
120
+ if any(rel.match(p) or PurePosixPath(rel.as_posix().lower()).match(p)
121
+ for p in SENSITIVE_PATTERNS):
122
+ return True
123
+ name = rel.name.lower()
124
+ return any(name.endswith(suffix) for suffix in DENIED_SUFFIXES)
ferrum/tools.py ADDED
@@ -0,0 +1,296 @@
1
+ """The tools the model can call. Everything here only reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from abc import ABC, abstractmethod
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from pathlib import Path, PurePosixPath
11
+ from typing import Any, ClassVar
12
+
13
+ from ferrum.config import Config
14
+ from ferrum.context import collect_files, limit_entries
15
+ from ferrum.safety import PathEscapeError, is_denied, safe_join
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+ MAX_LINE_CHARS = 300
20
+
21
+
22
+ class ToolError(Exception):
23
+ """A normal, reportable failure: bad path, denied file, that sort of thing."""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ToolResult:
28
+ ok: bool
29
+ text: str
30
+
31
+ @classmethod
32
+ def success(cls, text: str) -> ToolResult:
33
+ return cls(ok=True, text=text)
34
+
35
+ @classmethod
36
+ def failure(cls, text: str) -> ToolResult:
37
+ return cls(ok=False, text=text)
38
+
39
+ def to_model_message(self) -> str:
40
+ return self.text if self.ok else f"ERROR: {self.text}"
41
+
42
+
43
+ class Tool(ABC):
44
+ name: ClassVar[str] = ""
45
+ description: ClassVar[str] = ""
46
+ parameters: ClassVar[dict[str, Any]] = {"type": "object", "properties": {}}
47
+
48
+ @abstractmethod
49
+ def execute(self, arguments: Mapping[str, Any]) -> ToolResult:
50
+ """Do the thing. Expected failures should raise ToolError."""
51
+
52
+ def schema(self) -> dict[str, Any]:
53
+ """OpenAI-style function schema, which is what most APIs expect."""
54
+ return {
55
+ "type": "function",
56
+ "function": {
57
+ "name": self.name,
58
+ "description": self.description,
59
+ "parameters": self.parameters,
60
+ },
61
+ }
62
+
63
+
64
+ class ToolRegistry:
65
+ """Looks tools up by name and turns their mistakes into error results."""
66
+
67
+ def __init__(self) -> None:
68
+ self._tools: dict[str, Tool] = {}
69
+
70
+ def register(self, tool: Tool) -> None:
71
+ if not tool.name:
72
+ raise ValueError("tool must define a non-empty name")
73
+ if tool.name in self._tools:
74
+ raise ValueError(f"tool already registered: {tool.name}")
75
+ self._tools[tool.name] = tool
76
+
77
+ def get(self, name: str) -> Tool | None:
78
+ return self._tools.get(name)
79
+
80
+ def names(self) -> list[str]:
81
+ return list(self._tools)
82
+
83
+ def schemas(self) -> list[dict[str, Any]]:
84
+ return [tool.schema() for tool in self._tools.values()]
85
+
86
+ def execute(self, name: str, arguments: Mapping[str, Any]) -> ToolResult:
87
+ tool = self._tools.get(name)
88
+ if tool is None:
89
+ return ToolResult.failure(
90
+ f"unknown tool {name!r}; available tools: "
91
+ + (", ".join(sorted(self._tools)) or "(none)")
92
+ )
93
+ if not isinstance(arguments, Mapping):
94
+ return ToolResult.failure(
95
+ f"tool {name!r} expects an object of arguments, "
96
+ f"got {type(arguments).__name__}"
97
+ )
98
+ try:
99
+ return tool.execute(arguments)
100
+ except ToolError as exc:
101
+ log.info("tool %s reported: %s", name, exc)
102
+ return ToolResult.failure(str(exc))
103
+ except Exception as exc:
104
+ log.exception("tool %s crashed", name)
105
+ return ToolResult.failure(f"internal tool error: {exc}")
106
+
107
+ def __len__(self) -> int:
108
+ return len(self._tools)
109
+
110
+ def __contains__(self, name: object) -> bool:
111
+ return name in self._tools
112
+
113
+
114
+ def _require_string(arguments: Mapping[str, Any], key: str) -> str:
115
+ value = arguments.get(key)
116
+ if not isinstance(value, str) or not value.strip():
117
+ raise ToolError(f"{key} is required and must be a non-empty string")
118
+ return value
119
+
120
+
121
+ class ListFiles(Tool):
122
+ name = "list_files"
123
+ description = (
124
+ "List source and build files in the project, respecting .gitignore "
125
+ "and size limits. Returns one relative path per line."
126
+ )
127
+ parameters: ClassVar[dict[str, Any]] = {
128
+ "type": "object",
129
+ "properties": {
130
+ "path": {
131
+ "type": "string",
132
+ "description": "Optional subdirectory relative to the project root.",
133
+ }
134
+ },
135
+ }
136
+
137
+ def __init__(self, root: Path, config: Config | None = None) -> None:
138
+ self.root = root
139
+ self.config = config or Config()
140
+
141
+ def execute(self, arguments: Mapping[str, Any]) -> ToolResult:
142
+ sub = arguments.get("path") or ""
143
+ if not isinstance(sub, str):
144
+ raise ToolError("path must be a string")
145
+ try:
146
+ start = safe_join(self.root, sub) if sub else self.root
147
+ except PathEscapeError as exc:
148
+ raise ToolError(str(exc)) from exc
149
+ if not start.is_dir():
150
+ raise ToolError(f"not a directory: {sub}")
151
+ entries = limit_entries(
152
+ collect_files(self.root, self.config, start=start), self.config
153
+ )
154
+ if not entries:
155
+ return ToolResult.success("(no files matched)")
156
+ paths = [entry.path for entry in entries]
157
+ return ToolResult.success("\n".join(paths) + f"\n\n{len(paths)} file(s)")
158
+
159
+
160
+ class ReadFile(Tool):
161
+ name = "read_file"
162
+ description = (
163
+ "Read a single project file as UTF-8 text. Paths are relative to the "
164
+ "project root; secrets, binaries and oversized files are refused. "
165
+ "Line endings are normalized to \\n."
166
+ )
167
+ parameters: ClassVar[dict[str, Any]] = {
168
+ "type": "object",
169
+ "properties": {
170
+ "path": {
171
+ "type": "string",
172
+ "description": "File path relative to the project root.",
173
+ }
174
+ },
175
+ "required": ["path"],
176
+ }
177
+
178
+ def __init__(self, root: Path, config: Config | None = None) -> None:
179
+ self.root = root
180
+ self.config = config or Config()
181
+
182
+ def execute(self, arguments: Mapping[str, Any]) -> ToolResult:
183
+ rel = _require_string(arguments, "path")
184
+ if is_denied(PurePosixPath(rel.replace("\\", "/"))):
185
+ raise ToolError(f"refused: {rel} is excluded (secret, binary, or ignored)")
186
+ try:
187
+ target = safe_join(self.root, rel)
188
+ except PathEscapeError as exc:
189
+ raise ToolError(str(exc)) from exc
190
+ if target.is_dir():
191
+ raise ToolError(f"is a directory: {rel} (use list_files)")
192
+ if not target.exists():
193
+ raise ToolError(f"file not found: {rel}")
194
+ try:
195
+ size = target.stat().st_size
196
+ if size > self.config.max_file_bytes:
197
+ raise ToolError(
198
+ f"file too large: {rel} ({size} bytes > limit "
199
+ f"{self.config.max_file_bytes})"
200
+ )
201
+ data = target.read_bytes()
202
+ except OSError as exc:
203
+ raise ToolError(f"cannot read {rel}: {exc}") from exc
204
+ if b"\x00" in data[:8192]:
205
+ raise ToolError(f"refused: {rel} looks like a binary file")
206
+ try:
207
+ text = data.decode("utf-8")
208
+ except UnicodeDecodeError as exc:
209
+ raise ToolError(f"not valid UTF-8 text: {rel}") from exc
210
+ return ToolResult.success(text.replace("\r\n", "\n").replace("\r", "\n"))
211
+
212
+
213
+ class SearchCode(Tool):
214
+ name = "search_code"
215
+ description = (
216
+ "Search project source files for a string or regular expression. "
217
+ "Returns matches as path:line: content. Case-sensitive by default."
218
+ )
219
+ parameters: ClassVar[dict[str, Any]] = {
220
+ "type": "object",
221
+ "properties": {
222
+ "pattern": {
223
+ "type": "string",
224
+ "description": "Text or regular expression to search for.",
225
+ },
226
+ "path": {
227
+ "type": "string",
228
+ "description": "Optional subdirectory to restrict the search.",
229
+ },
230
+ "regex": {
231
+ "type": "boolean",
232
+ "description": "Treat pattern as a regular expression (default false).",
233
+ },
234
+ },
235
+ "required": ["pattern"],
236
+ }
237
+
238
+ def __init__(self, root: Path, config: Config | None = None) -> None:
239
+ self.root = root
240
+ self.config = config or Config()
241
+
242
+ def execute(self, arguments: Mapping[str, Any]) -> ToolResult:
243
+ pattern = _require_string(arguments, "pattern")
244
+ sub = arguments.get("path") or ""
245
+ if not isinstance(sub, str):
246
+ raise ToolError("path must be a string")
247
+ regex_mode = arguments.get("regex", False)
248
+ if not isinstance(regex_mode, bool):
249
+ raise ToolError("regex must be a boolean")
250
+ try:
251
+ start = safe_join(self.root, sub) if sub else self.root
252
+ except PathEscapeError as exc:
253
+ raise ToolError(str(exc)) from exc
254
+ if not start.is_dir():
255
+ raise ToolError(f"not a directory: {sub}")
256
+ try:
257
+ rx = re.compile(pattern if regex_mode else re.escape(pattern))
258
+ except re.error as exc:
259
+ raise ToolError(f"invalid regular expression: {exc}") from exc
260
+
261
+ matches: list[str] = []
262
+ truncated = False
263
+ for entry in collect_files(self.root, self.config, start=start):
264
+ try:
265
+ content = (self.root / entry.path).read_text(
266
+ encoding="utf-8", errors="replace"
267
+ )
268
+ except OSError:
269
+ continue
270
+ for lineno, line in enumerate(content.splitlines(), start=1):
271
+ if rx.search(line):
272
+ snippet = line.strip()
273
+ if len(snippet) > MAX_LINE_CHARS:
274
+ snippet = snippet[: MAX_LINE_CHARS - 3] + "..."
275
+ matches.append(f"{entry.path}:{lineno}: {snippet}")
276
+ if len(matches) >= self.config.max_search_results:
277
+ truncated = True
278
+ break
279
+ if truncated:
280
+ break
281
+
282
+ if not matches:
283
+ return ToolResult.success(f"no matches for {pattern!r}")
284
+ text = "\n".join(matches)
285
+ if truncated:
286
+ text += f"\n... truncated at {self.config.max_search_results} results"
287
+ return ToolResult.success(text)
288
+
289
+
290
+ def default_tools(root: Path, config: Config | None = None) -> ToolRegistry:
291
+ """The standard read-only set."""
292
+ config = config or Config()
293
+ registry = ToolRegistry()
294
+ for tool in (ListFiles(root, config), ReadFile(root, config), SearchCode(root, config)):
295
+ registry.register(tool)
296
+ return registry