feat(tools): wire up dormant file_read/write/edit/patch builtins for general agents (#268)#306
Conversation
…general agents (closes #268) Forge shipped four fully-implemented, unit-tested file builtins that the runtime never registered — reachable only from tests (effectively dead code). A general (non-code-agent) agent got grep/glob/tree + file_create but no general file read-back, edit, or overwrite. Register file_read/file_write/file_edit/file_patch in the runtime for general agents, confined to the same searchRoot the search tools use so read/edit and grep/glob share one #235 PathValidator confinement boundary. - builtins/register.go: new FileTools() / RegisterFileTools() helper. - runner.go: registerGeneralFileTools() registers the four after the search tools. Skipped when the code-agent skill is active — its project-scoped code_agent_* tools are the specialized file surface, so registering the general file_* builtins too would show the LLM two overlapping file surfaces (skill tools win; names differ so there is no registry collision either way). Tests: - RegisterFileTools registers the four named tools. - Each general file tool rejects a ../../etc/passwd traversal (guards the #235-class cli_execute escape through the newly-wired surface). - registerGeneralFileTools: general agent gets all four; code-agent active skips them. Docs: tools-and-builtins.md documents the general file surface and the general-vs-code-agent distinction (two non-colliding surfaces). Note: mutating tools inherit the #235 path confinement that file_create / cli_execute already rely on; #223 (action-level approval / dry-run) is still open and will layer over all mutating tools via BeforeToolExec when it lands.
initializ-mk
left a comment
There was a problem hiding this comment.
Review: wire up dormant file_read/write/edit/patch builtins
Small, well-scoped, correctly reasoned. The FileTools()/RegisterFileTools() helper mirrors the existing CodeAgentSearchTools pattern exactly, the confinement boundary is shared (not re-derived), and CI is fully green. Two things I verified as positives:
- Emergent #304 coverage: because the mutating tools now flow through
BeforeToolExec, the platformdenied_command_patternsguard matches against their JSON args (including decoded values, after the #304 fix). So an operator can already write a pattern blockingfile_writeto sensitive paths — the "all mutating tools covered uniformly at BeforeToolExec" posture the PR body gestures at for #223 is partially real today for the command-deny axis. - #223 deferral is honest and correct: path confinement is the current boundary, consistent with
file_createalready being a default write tool. No pretense that destructive-action approval exists.
Three minors, all inline. Verdict below.
Verdict
Merge-ready. All three findings are minor — finding 1 (the double hasSkill computation) is the one I'd actually fold in since it's a correctness-adjacent consistency fix, not just perf; findings 2–3 are test quality and a docs line. The core wiring is exactly right.
|
|
||
| // Register the general file read/write/edit/patch builtins (#268), | ||
| // confined to the same searchRoot as the search tools. | ||
| r.registerGeneralFileTools(reg, searchRoot, r.hasSkill("code-agent")) |
There was a problem hiding this comment.
Minor: hasSkill("code-agent") is computed twice, and the two calls MUST agree.
This is the second call — line ~786 already computed it to scope searchRoot to workspace/. Each call re-runs discoverSkillFiles() + ParseFileWithMetadata over every skill file on disk (redundant I/O in the registration path). More importantly, these are two reads of the same fact that must stay consistent: if they ever diverged (a caching change, a race with hot-reload), you'd get file tools confined to workspace/ but registered as if code-agent were inactive, or vice versa.
Fix: hoist once — codeAgentActive := r.hasSkill("code-agent") above the search-tool registration — and pass it to both the searchRoot decision and registerGeneralFileTools. One computation, provably consistent.
| "file_read": {"path": "../../../../../../etc/passwd"}, | ||
| "file_write": {"path": "../../../../../../etc/passwd", "content": "x"}, | ||
| "file_edit": {"path": "../../../../../../etc/passwd", "old_text": "a", "new_text": "b"}, | ||
| "file_patch": {"path": "../../../../../../etc/passwd", "operations": []map[string]any{ |
There was a problem hiding this comment.
Minor (test quality): the file_patch case passes by accident of evaluation order.
"action": "write" isn't a valid action (the enum is add/update/delete/move). This case passes only because file_patch resolves op.Path BEFORE the action switch, so the traversal error fires before the invalid action is ever evaluated — right thing tested, wrong reason.
Use a valid action (update), and separately add a move case with a traversal in new_path: file_patch resolves NewPath too (file_patch.go ~107), and that destination-confinement path currently has no test. A move that reads a confined source but writes ../../etc/cron.d/x is exactly the escape worth pinning.
|
|
||
| | Surface | Tools | Scope | When | | ||
| |---------|-------|-------|------| | ||
| | General builtins (#268) | `file_read` / `file_write` / `file_edit` / `file_patch` | `WorkDir` (or `workspace/` when a code-agent workspace exists) | Every agent, **except** when the code-agent skill is active | |
There was a problem hiding this comment.
Minor (docs): the scope cell says "WorkDir (or workspace/ when a code-agent workspace exists)" — but the paragraph below says the general file_* builtins are skipped when the code-agent skill is active. So the general builtins are never actually scoped to workspace/; that parenthetical describes a state that can't occur for them (it's true for the search tools, which register in both cases — looks like a copy from that row). Drop the parenthetical from this row to avoid implying a code-agent-active general-file-tool exists.
…#306 review) - runner.go: compute hasSkill("code-agent") ONCE into codeAgentActive and pass it to both the searchRoot scoping and registerGeneralFileTools — removes redundant discoverSkillFiles() I/O and guarantees the two reads of the same fact can't diverge (searchRoot=workspace/ <=> file tools skipped). - file_patch confinement test: use a VALID action (update) so the rejection is the path-confinement check, not an unknown-action error firing first (the old 'write' action passed by accident of evaluation order). Add a dedicated move case that pins destination confinement — a move with a confined source but an escaping new_path must be rejected (file_patch resolves new_path too; that path had no test). - docs: drop the misleading '(or workspace/ ...)' parenthetical from the general-file-tools scope cell — general file_* are skipped under code-agent, so they are never scoped to workspace/.
|
Thanks — all three folded in ( 1 (hasSkill computed twice) — hoisted. 2 (file_patch test passed by accident) — fixed + extended. The traversal case now uses a valid action ( 3 (docs scope cell) — dropped the parenthetical. The general-file-tools row now just says Full |
initializ-mk
left a comment
There was a problem hiding this comment.
Re-review of e3505ce — all three findings resolved, CI green
Finding 1 (double hasSkill) — hoisted exactly as suggested. codeAgentActive := r.hasSkill("code-agent") computed once above the search-tool block, passed to both the searchRoot scoping and registerGeneralFileTools. The comment states the invariant flagged in review — "they must agree (searchRoot=workspace/ ⇔ general file tools skipped)" — so the consistency guarantee is now documented, not incidental. Redundant discoverSkillFiles() I/O gone.
Finding 2 (test quality) — both halves fixed. The file_patch case now uses the valid update action, so the rejection genuinely exercises path confinement rather than an unknown-action error firing first. The new file_patch move rejects escaping new_path subtest pins the destination-confinement path — a move with a confined source but an escaping new_path, asserting the error names both new_path and the confinement string. Exactly the gap closed, and specific enough to catch a regression that only checked the source path.
Finding 3 (docs) — parenthetical dropped. The general-builtins scope cell now reads just WorkDir; the workspace/ note stays only where it's true (the search tools, registered in both cases).
Verdict
All findings resolved — merge-ready (all 10 checks green). Clean cycle: the correctness-adjacent finding got the hoist plus an invariant comment, and the test that passed by accident now passes for the right reason with the move-destination escape explicitly pinned.
Closes #268.
Problem
Forge shipped four fully-implemented, unit-tested file builtins that the runtime never registered —
file_read,file_write,file_edit,file_patch. They were reachable only throughCodeAgent*Tools()constructors that nothing in the runtime called (onlyRegisterCodeAgentSearchToolswas wired), so they were effectively dead code.Consequence: a general (non-code-agent) agent got
grep_search/glob_search/directory_tree+file_create, but no general file read-back, edit, or overwrite. Real editing existed only inside thecode-agentskill via its project-scopedcode_agent_*SKILL.md tools.Change
Register
file_read/file_write/file_edit/file_patchfor general agents, confined to the samesearchRootthe search tools use, so read/edit and grep/glob share onePathValidator(#235) confinement boundary.builtins/register.go— newFileTools()/RegisterFileTools()helper (mirrors the existingCodeAgentSearchToolspattern).runner.go—registerGeneralFileTools()registers the four right after the search tools.Code-agent precedence
The general
file_*builtins are skipped when thecode-agentskill is active — its project-scopedcode_agent_*tools (registered fromSKILL.md) are the specialized file surface, so registering the general builtins too would show the LLM two overlapping file surfaces. Skill tools win. (The names differ —file_*vscode_agent_*— so there's no registry collision either way; this is a surface-clarity choice, not conflict avoidance.)Acceptance criteria
file_read/file_write/file_edit/file_patchregistered by the runtime for general agents, path-confined to the working root.../../etc/passwd-style traversal (guards the [ASI02] cli_execute workDir path confinement — reads escape to /etc/passwd (GAP-PATH) #235-classcli_executeescape through the newly-wired surface).code-agentskill is active (generalfile_*skipped;code_agent_*unaffected).code_agent_*tools.On #223 (destructive-action approval)
The issue asks the mutating tools to honor the destructive-action approval / dry-run posture. #223 is still open — there is no such posture yet. The safety boundary that exists today is path confinement (#235), which these tools already enforce via
PathValidator— the same boundaryfile_createandcli_executerely on. When #223 lands it applies to all mutating tools uniformly atBeforeToolExec(like the platform command guard in #304), covering these automatically. Registering them now under the existing confinement is consistent withfile_createalready being a default write tool.Tests
TestRegisterFileTools_RegistersTheFour— the four are registered (no longer dead code).TestFileTools_ConfinePathTraversal— each rejects../../…/etc/passwdwith a confinement error.TestRegisterGeneralFileTools— general agent registers all four; code-agent active skips them.Full
forge-core/tools/...+forge-cli/runtimesuites pass; lint clean.Docs
docs/core-concepts/tools-and-builtins.md— renamed the section to File & Search Tools, documents that the file surface is registered by default, and adds a table contrasting the generalfile_*builtins with the code-agent skill'scode_agent_*tools (two non-colliding surfaces).