Skip to content

Commit f6fcda8

Browse files
Jakub ZikaJakub Zika
authored andcommitted
Enhance plan mode with safe-only tools and improved file operations
- Replace eca_plan_edit_file with eca_preview_file_change tool that simulates file changes without modification - Add shell command restrictions in plan mode to prevent destructive operations - Extract text matching logic into dedicated text-match namespace with robust content normalization for CRLF/whitespace differences - Improve file change error handling with specific messages for not-found, ambiguous matches, and new file creation scenarios - Add behavior parameter threading through tool system to enable mode-specific tool restrictions - Enhance file diff generation to support new file creation and improve existing file modification previews - Add test coverage for plan mode restrictions, text normalization, and preview functionality
1 parent 1d58e3d commit f6fcda8

File tree

9 files changed

+549
-83
lines changed

9 files changed

+549
-83
lines changed

src/eca/features/chat.clj

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@
216216
(if @approved?*
217217
(do
218218
(assert-chat-not-stopped! chat-ctx)
219-
(let [result (f.tools/call-tool! name arguments @db* config messenger)
219+
(let [result (f.tools/call-tool! name arguments @db* config messenger behavior)
220220
details (f.tools/tool-call-details-after-invocation name arguments details result)]
221221
(add-to-history! {:role "tool_call" :content (assoc tool-call
222222
:details details
@@ -355,7 +355,11 @@
355355
rules (f.rules/all config (:workspace-folders db))
356356
refined-contexts (f.context/raw-contexts->refined contexts db config)
357357
repo-map* (delay (f.index/repo-map db config {:as-string? true}))
358-
instructions (f.prompt/build-instructions refined-contexts rules repo-map* (or behavior (-> config :chat :defaultBehavior)) config)
358+
instructions (f.prompt/build-instructions refined-contexts
359+
rules
360+
repo-map*
361+
(or behavior (-> config :chat :defaultBehavior))
362+
config)
359363
chat-ctx {:chat-id chat-id
360364
:request-id request-id
361365
:contexts contexts

src/eca/features/tools.clj

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,15 @@
5555
(mapv #(assoc % :origin :native) (native-tools db config))
5656
(mapv #(assoc % :origin :mcp) (f.mcp/all-tools db))))))
5757

58-
(defn call-tool! [^String name ^Map arguments db config messenger]
58+
(defn call-tool! [^String name ^Map arguments db config messenger behavior]
5959
(logger/info logger-tag (format "Calling tool '%s' with args '%s'" name arguments))
6060
(let [arguments (update-keys arguments clojure.core/name)]
6161
(try
6262
(let [result (if-let [native-tool-handler (get-in (native-definitions db config) [name :handler])]
6363
(native-tool-handler arguments {:db db
6464
:config config
65-
:messenger messenger})
65+
:messenger messenger
66+
:behavior behavior})
6667
(f.mcp/call-tool! name arguments db))]
6768
(logger/debug logger-tag "Tool call result: " result)
6869
result)

src/eca/features/tools/filesystem.clj

Lines changed: 90 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
[babashka.fs :as fs]
44
[clojure.java.io :as io]
55
[clojure.java.shell :as shell]
6+
[eca.logger :as logger]
67
[clojure.string :as string]
78
[eca.diff :as diff]
9+
[eca.features.tools.text-match :as text-match]
810
[eca.features.tools.util :as tools.util]
911
[eca.shared :as shared]))
1012

@@ -203,40 +205,62 @@
203205
(format "Searching for '%s'" pattern)
204206
"Searching for files"))
205207

206-
(defn file-change-full-content [path original-content new-content all?]
207-
(let [original-full-content (slurp path)
208-
new-full-content (if all?
209-
(string/replace original-full-content original-content new-content)
210-
(string/replace-first original-full-content original-content new-content))]
211-
(when (string/includes? original-full-content original-content)
212-
{:original-full-content original-full-content
213-
:new-full-content new-full-content})))
208+
(defn ^:private handle-file-change-result
209+
"Convert file-change-full-content result to appropriate tool response"
210+
[result path success-message]
211+
(cond
212+
(:new-full-content result)
213+
(tools.util/single-text-content success-message)
214214

215-
(defn ^:private change-file [arguments {:keys [db]} diff?]
215+
(= (:error result) :not-found)
216+
(tools.util/single-text-content (format "Original content not found in %s" path) :error)
217+
218+
(= (:error result) :ambiguous)
219+
(tools.util/single-text-content
220+
(format "Ambiguous match - content appears %d times in %s. Provide more specific context to identify the exact location."
221+
(:match-count result) path) :error)
222+
223+
:else
224+
(tools.util/single-text-content (format "Failed to process %s" path) :error)))
225+
226+
(defn ^:private change-file [arguments {:keys [db]}]
216227
(or (tools.util/invalid-arguments arguments (concat (path-validations db)
217228
[["path" fs/readable? "File $path is not readable"]]))
218229
(let [path (get arguments "path")
219230
original-content (get arguments "original_content")
220231
new-content (get arguments "new_content")
221-
new-content (if diff?
222-
(str "<<<<<<< HEAD\n"
223-
original-content
224-
"\n=======\n"
225-
new-content
226-
"\n>>>>>>> eca\n")
227-
new-content)
228-
all? (boolean (get arguments "all_occurrences"))]
229-
(if-let [{:keys [new-full-content]} (file-change-full-content path original-content new-content all?)]
232+
all? (boolean (get arguments "all_occurrences"))
233+
result (text-match/apply-content-change-to-file path original-content new-content all?)]
234+
(if (:new-full-content result)
230235
(do
231-
(spit path new-full-content)
232-
(tools.util/single-text-content (format "Successfully replaced content in %s." path)))
233-
(tools.util/single-text-content (format "Original content not found in %s" path) :error)))))
236+
(spit path (:new-full-content result))
237+
(handle-file-change-result result path (format "Successfully replaced content in %s." path)))
238+
(handle-file-change-result result path nil)))))
234239

235240
(defn ^:private edit-file [arguments components]
236-
(change-file arguments components false))
241+
(change-file arguments components))
237242

238-
(defn ^:private plan-edit-file [arguments components]
239-
(change-file arguments components true))
243+
(defn ^:private preview-file-change [arguments {:keys [db]}]
244+
(or (tools.util/invalid-arguments arguments [["path" (partial allowed-path? db) (str "Access denied - path $path outside allowed directories: " (tools.util/workspace-roots-strs db))]])
245+
(let [path (get arguments "path")
246+
original-content (get arguments "original_content")
247+
new-content (get arguments "new_content")
248+
all? (boolean (get arguments "all_occurrences"))
249+
file-exists? (fs/exists? path)]
250+
(cond
251+
file-exists?
252+
(let [result (text-match/apply-content-change-to-file path original-content new-content all?)]
253+
(handle-file-change-result result path
254+
(format "Change simulation completed for %s. Original file unchanged - preview only." path)))
255+
256+
(and (not file-exists?) (= "" original-content))
257+
(tools.util/single-text-content (format "New file creation simulation completed for %s. File will be created - preview only." path))
258+
259+
:else
260+
(tools.util/single-text-content
261+
(format "Preview error for %s: For new files, original_content must be empty string (\"\"). Use markdown blocks during exploration, then eca_preview_file_change for final implementation only."
262+
path)
263+
:error)))))
240264

241265
(defn ^:private move-file [arguments {:keys [db]}]
242266
(let [workspace-dirs (tools.util/workspace-roots-strs db)]
@@ -284,7 +308,7 @@
284308
:required ["path" "content"]}
285309
:handler #'write-file
286310
;; TODO - add behaviors to config and define disabled tools there!
287-
:enabled-fn (fn [{:keys [behavior]}] (not= "plan" behavior))
311+
:enabled-fn #(not= "plan" (:behavior %))
288312
:summary-fn #'write-file-summary}
289313
"eca_edit_file"
290314
{:description (tools.util/read-tool-description "eca_edit_file")
@@ -299,30 +323,23 @@
299323
:description "Whether to replace all occurences of the file or just the first one (default)"}}
300324
:required ["path" "original_content" "new_content"]}
301325
:handler #'edit-file
302-
:enabled-fn (fn [{:keys [behavior]}] (not= "plan" behavior))
303-
:summary-fn (constantly "Editing file")}
304-
"eca_plan_edit_file"
305-
{:description (str "Plan a file change where user needs to apply or reject the change. "
306-
"Replace a specific string or content block in a file with new content. "
307-
"Finds the exact original content and replaces it with new content. "
308-
"Be extra careful to format the original-content exactly correctly, "
309-
"taking extra care with whitespace and newlines. "
310-
"Avoid replacing whole functions, methods, or classes, change only the needed code. "
311-
"In addition to replacing strings, this can also be used to prepend, append, or delete contents from a file.")
312-
:parameters {:type "object"
313-
:properties {"path" {:type "string"
314-
:description "The absolute file path to do the replace."}
315-
"original_content" {:type "string"
316-
:description "The exact content to find and replace"}
317-
"new_content" {:type "string"
318-
:description "The new content to replace the original content with"}
319-
"all_occurrences" {:type "boolean"
320-
:description "Whether to replace all occurences of the file or just the first one (default)"}}
321-
:required ["path" "original_content" "new_content"]}
322-
:handler #'plan-edit-file
323-
;; TODO improve plan behavior providing better tool for exit plan and present to user.
324-
:enabled-fn (constantly false) #_(fn [{:keys [behavior]}] (= "plan" behavior))
325-
:summary-fn (constantly "Planning edit")}
326+
:enabled-fn #(not= "plan" (:behavior %))
327+
:summary-fn (constantly "Editting file")}
328+
"eca_preview_file_change"
329+
{:description (tools.util/read-tool-description "eca_preview_file_change")
330+
:parameters {:type "object"
331+
:properties {"path" {:type "string"
332+
:description "The absolute file path to preview changes for."}
333+
"original_content" {:type "string"
334+
:description "The exact content to find in the file"}
335+
"new_content" {:type "string"
336+
:description "The content to show as replacement in the preview"}
337+
"all_occurrences" {:type "boolean"
338+
:description "Whether to preview replacing all occurrences or just the first one (default)"}}
339+
:required ["path" "original_content" "new_content"]}
340+
:handler #'preview-file-change
341+
:enabled-fn #(= "plan" (:behavior %))
342+
:summary-fn (constantly "Previewing change")}
326343
"eca_move_file"
327344
{:description (tools.util/read-tool-description "eca_move_file")
328345
:parameters {:type "object"
@@ -332,7 +349,7 @@
332349
:description "The new absolute file path to move to."}}
333350
:required ["source" "destination"]}
334351
:handler #'move-file
335-
:enabled-fn (fn [{:keys [behavior]}] (not= "plan" behavior))
352+
:enabled-fn #(not= "plan" (:behavior %))
336353
:summary-fn (constantly "Moving file")}
337354
"eca_grep"
338355
{:description (tools.util/read-tool-description "eca_grep")
@@ -353,19 +370,34 @@
353370
(let [path (get arguments "path")
354371
original-content (get arguments "original_content")
355372
new-content (get arguments "new_content")
356-
all? (get arguments "all_occurrences")]
357-
(when-let [{:keys [original-full-content
358-
new-full-content]} (and path (fs/exists? path) original-content new-content
359-
(file-change-full-content path original-content new-content all?))]
360-
(let [{:keys [added removed diff]} (diff/diff original-full-content new-full-content path)]
373+
all? (get arguments "all_occurrences")
374+
file-exists? (and path (fs/exists? path))]
375+
(cond
376+
(and file-exists? original-content new-content)
377+
(let [result (text-match/apply-content-change-to-file path original-content new-content all?)
378+
original-full-content (:original-full-content result)]
379+
(when original-full-content
380+
(if-let [new-full-content (:new-full-content result)]
381+
(let [{:keys [added removed diff]} (diff/diff original-full-content new-full-content path)]
382+
{:type :fileChange
383+
:path path
384+
:linesAdded added
385+
:linesRemoved removed
386+
:diff diff})
387+
(logger/warn "tool-call-details-before-invocation - NO DIFF GENERATED because match failed for path:" path))))
388+
389+
(and (not file-exists?) (= original-content "") new-content path)
390+
(let [{:keys [added removed diff]} (diff/diff "" new-content path)]
361391
{:type :fileChange
362392
:path path
363393
:linesAdded added
364394
:linesRemoved removed
365-
:diff diff}))))
395+
:diff diff})
396+
397+
:else nil)))
366398

367-
(defmethod tools.util/tool-call-details-before-invocation :eca_plan_edit_file [name arguments]
368-
(tools.util/tool-call-details-before-invocation :eca_edit_file name arguments))
399+
(defmethod tools.util/tool-call-details-before-invocation :eca_preview_file_change [_name arguments]
400+
(tools.util/tool-call-details-before-invocation :eca_edit_file arguments))
369401

370402
(defmethod tools.util/tool-call-details-before-invocation :eca_write_file [_name arguments]
371403
(let [path (get arguments "path")

src/eca/features/tools/shell.clj

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,55 @@
11
(ns eca.features.tools.shell
2-
(:require
3-
[babashka.fs :as fs]
4-
[babashka.process :as p]
5-
[clojure.string :as string]
6-
[eca.config :as config]
7-
[eca.features.tools.util :as tools.util]
8-
[eca.logger :as logger]
9-
[eca.shared :as shared :refer [multi-str]]))
2+
(:require [babashka.fs :as fs]
3+
[babashka.process :as p]
4+
[clojure.string :as string]
5+
[eca.config :as config]
6+
[eca.features.tools.util :as tools.util]
7+
[eca.logger :as logger]
8+
[eca.shared :as shared]))
109

1110
(set! *warn-on-reflection* true)
1211

1312
(def ^:private logger-tag "[TOOLS-SHELL]")
1413

15-
(defn ^:private shell-command [arguments {:keys [db config]}]
14+
;; Plan mode command restrictions
15+
;; TODO - We might see that this is not needed and prompt handles it.
16+
;; If we don't get 'Command bocked in plan model' error for some time, let's remove it.
17+
;; Maybe we should use allowed patterns only, especially for user's custom behaviors.
18+
(def ^:private plan-safe-commands-default
19+
#{"git" "ls" "find" "grep" "rg" "ag" "cat" "head" "tail"
20+
"pwd" "which" "file" "stat" "tree" "date" "whoami"
21+
"env" "echo" "wc" "du" "df"})
22+
23+
(def ^:private plan-forbidden-patterns-default
24+
[#">" ; output redirection
25+
#"\|\s*(tee|dd|xargs)" ; dangerous pipes
26+
#"\b(sed|awk|perl)\s+.*-i" ; in-place editing
27+
#"\b(rm|mv|cp|touch|mkdir)\b" ; file operations
28+
#"git\s+(add|commit|push)" ; git mutations
29+
#"npm\s+install" ; package installs
30+
#"-c\s+[\"'].*open.*[\"']w[\"']" ; programmatic writes
31+
#"bash.*-c.*>"]) ; nested shell redirects
32+
33+
(defn ^:private safe-for-plan-mode? [command-string]
34+
(let [cmd (-> command-string (string/split #"\s+") first)]
35+
(and (contains? plan-safe-commands-default cmd)
36+
(not (some #(re-find % command-string) plan-forbidden-patterns-default)))))
37+
38+
(defn ^:private shell-command [arguments {:keys [db config behavior]}]
1639
(let [command-args (get arguments "command")
1740
command (first (string/split command-args #"\s+"))
1841
user-work-dir (get arguments "working_directory")
19-
exclude-cmds (-> config :nativeTools :shell :excludeCommands set)]
42+
exclude-cmds (-> config :nativeTools :shell :excludeCommands set)
43+
plan-mode? (= "plan" behavior)]
2044
(or (tools.util/invalid-arguments arguments [["working_directory" #(or (nil? %)
2145
(fs/exists? %)) "working directory $working_directory does not exist"]
22-
["commmand" (constantly (not (contains? exclude-cmds command))) (format "Cannot run command '%s' because it is excluded by eca config."
23-
command)]])
46+
["commmand" (constantly (not (contains? exclude-cmds command)))
47+
(format "Command '%s' is excluded by configuration" command-args)]])
48+
;; Check plan mode restrictions
49+
(when (and plan-mode? (not (safe-for-plan-mode? command-args)))
50+
{:error true
51+
:contents [{:type :text
52+
:text "Command blocked in plan mode. Only read-only analysis commands are allowed."}]})
2453
(let [work-dir (or (some-> user-work-dir fs/canonicalize str)
2554
(some-> (:workspace-folders db)
2655
first

0 commit comments

Comments
 (0)