Skip to content

Commit 1060f66

Browse files
committed
Allow tool calls outside workspace; validate required params
- Add centralized path gating - Remove in-handler workspace-only checks; approval now guards cross‑workspace paths. - Plan behavior: allow safe tools (read/grep/dir‑tree/preview) and pwd; improve deny rules for shell. - Validate required parameters before dispatch in tools/call-tool! (native and MCP) and return INVALID_ARGS on missing keys. - Update tests to reflect approval gating and new preview messaging.
1 parent 640562f commit 1060f66

File tree

8 files changed

+230
-139
lines changed

8 files changed

+230
-139
lines changed

src/eca/config.clj

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,15 @@
8080
:disabledTools ["eca_preview_file_change"]}
8181
"plan" {:systemPromptFile "prompts/plan_behavior.md"
8282
:disabledTools ["eca_edit_file" "eca_write_file" "eca_move_file"]
83-
:toolCall {:approval {:deny {"eca_shell_command"
84-
{:argsMatchers {"command" [".*>.*",
83+
:toolCall {:approval {:allow {"eca_shell_command"
84+
{:argsMatchers {"command" ["pwd"]}}
85+
"eca_preview_file_change" {}
86+
"eca_grep" {}
87+
"eca_read_file" {}
88+
"eca_directory_tree" {}}
89+
:deny {"eca_shell_command"
90+
{:argsMatchers {"command" ["[12&]?>>?\\s*(?!/dev/null($|\\s))\\S+"
91+
".*>.*",
8592
".*\\|\\s*(tee|dd|xargs).*",
8693
".*\\b(sed|awk|perl)\\s+.*-i.*",
8794
".*\\b(rm|mv|cp|touch|mkdir)\\b.*",

src/eca/features/tools.clj

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -80,19 +80,25 @@
8080
]
8181
(logger/info logger-tag (format "Calling tool '%s' with args '%s'" name arguments))
8282
(let [arguments (update-keys arguments clojure.core/name)
83-
db @db*]
83+
db @db*
84+
tool-meta (some #(when (= name (:name %)) %)
85+
(all-tools chat-id behavior db config))
86+
required-args-error (when-let [parameters (:parameters tool-meta)]
87+
(tools.util/required-params-error parameters arguments))]
8488
(try
85-
(let [result (if-let [native-tool-handler (get-in (native-definitions db config) [name :handler])]
86-
(native-tool-handler arguments {:db db
87-
:db* db*
88-
:config config
89-
:messenger messenger
90-
:behavior behavior
91-
:chat-id chat-id
92-
:tool-call-id tool-call-id
93-
:call-state-fn call-state-fn
94-
:state-transition-fn state-transition-fn})
95-
(f.mcp/call-tool! name arguments {:db db}))]
89+
(let [result (-> (if required-args-error
90+
required-args-error
91+
(if-let [native-tool-handler (get-in (native-definitions db config) [name :handler])]
92+
(native-tool-handler arguments {:db db
93+
:db* db*
94+
:config config
95+
:messenger messenger
96+
:behavior behavior
97+
:chat-id chat-id
98+
:tool-call-id tool-call-id
99+
:call-state-fn call-state-fn
100+
:state-transition-fn state-transition-fn})
101+
(f.mcp/call-tool! name arguments {:db db}))))]
96102
(logger/debug logger-tag "Tool call result: " result)
97103
(metrics/count-up! "tool-called" {:name name :error (:error result)} metrics)
98104
result)

src/eca/features/tools/filesystem.clj

Lines changed: 50 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,8 @@
1414

1515
(set! *warn-on-reflection* true)
1616

17-
(defn ^:private allowed-path? [db path]
18-
(some #(fs/starts-with? path (shared/uri->filename (:uri %)))
19-
(:workspace-folders db)))
20-
21-
(defn ^:private path-validations [db]
22-
[["path" fs/exists? "$path is not a valid path"]
23-
["path" (partial allowed-path? db) (str "Access denied - path $path outside allowed directories: " (tools.util/workspace-roots-strs db))]])
17+
(defn ^:private path-validations []
18+
[["path" fs/exists? "$path is not a valid path"]])
2419

2520
(def ^:private directory-tree-max-depth 10)
2621

@@ -35,7 +30,7 @@
3530

3631
(defn ^:private directory-tree [arguments {:keys [db config]}]
3732
(let [path (delay (fs/canonicalize (get arguments "path")))]
38-
(or (tools.util/invalid-arguments arguments (path-validations db))
33+
(or (tools.util/invalid-arguments arguments (path-validations))
3934
(let [max-depth (or (get arguments "max_depth") directory-tree-max-depth)
4035
dir-count* (atom 0)
4136
file-count* (atom 0)
@@ -69,8 +64,8 @@
6964
summary (format "%d directories, %d files" @dir-count* @file-count*)]
7065
(tools.util/single-text-content (str body "\n\n" summary)))))))
7166

72-
(defn ^:private read-file [arguments {:keys [db config]}]
73-
(or (tools.util/invalid-arguments arguments (concat (path-validations db)
67+
(defn ^:private read-file [arguments {:keys [config]}]
68+
(or (tools.util/invalid-arguments arguments (concat (path-validations)
7469
[["path" fs/readable? "File $path is not readable"]
7570
["path" (complement fs/directory?) "$path is a directory, not a file"]]))
7671
(let [line-offset (or (get arguments "line_offset") 0)
@@ -99,13 +94,12 @@
9994
(str "Reading file " (fs/file-name (fs/file path)))
10095
"Reading file"))
10196

102-
(defn ^:private write-file [arguments {:keys [db]}]
103-
(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))]])
104-
(let [path (get arguments "path")
105-
content (get arguments "content")]
106-
(fs/create-dirs (fs/parent (fs/path path)))
107-
(spit path content)
108-
(tools.util/single-text-content (format "Successfully wrote to %s" path)))))
97+
(defn ^:private write-file [arguments _]
98+
(let [path (get arguments "path")
99+
content (get arguments "content")]
100+
(fs/create-dirs (fs/parent (fs/path path)))
101+
(spit path content)
102+
(tools.util/single-text-content (format "Successfully wrote to %s" path))))
109103

110104
(defn ^:private write-file-summary [{:keys [args]}]
111105
(if-let [path (get args "path")]
@@ -178,8 +172,8 @@
178172
179173
Returns matching file paths, prioritizing by modification time when possible.
180174
Validates that the search path is within allowed workspace directories."
181-
[arguments {:keys [db]}]
182-
(or (tools.util/invalid-arguments arguments (concat (path-validations db)
175+
[arguments _]
176+
(or (tools.util/invalid-arguments arguments (concat (path-validations)
183177
[["path" fs/readable? "File $path is not readable"]
184178
["pattern" #(and % (not (string/blank? %))) "Invalid content regex pattern '$pattern'"]
185179
["include" #(or (nil? %) (not (string/blank? %))) "Invalid file pattern '$include'"]
@@ -245,8 +239,9 @@
245239
(text-match/apply-content-change-to-string file-content original-content new-content all? path)
246240
(smart-edit/apply-smart-edit file-content original-content new-content path)))
247241

242+
248243
(defn ^:private edit-file [arguments {:keys [db]}]
249-
(or (tools.util/invalid-arguments arguments (concat (path-validations db)
244+
(or (tools.util/invalid-arguments arguments (concat (path-validations)
250245
[["path" fs/readable? "File $path is not readable"]]))
251246
(let [path (get arguments "path")
252247
original-content (get arguments "original_content")
@@ -268,38 +263,34 @@
268263
(handle-file-change-result {:error :conflict} path nil)))))
269264
(handle-file-change-result result path nil)))))
270265

271-
(defn ^:private preview-file-change [arguments {:keys [db]}]
272-
(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))]])
273-
(let [path (get arguments "path")
274-
original-content (get arguments "original_content")
275-
new-content (get arguments "new_content")
276-
all? (boolean (get arguments "all_occurrences"))
277-
file-exists? (fs/exists? path)]
278-
(cond
279-
file-exists?
280-
(let [result (apply-file-edit-strategy (slurp path) original-content new-content all? path)]
281-
(handle-file-change-result result path
282-
(format "Change simulation completed for %s. Original file unchanged - preview only." path)))
283-
284-
(and (not file-exists?) (= "" original-content))
285-
(tools.util/single-text-content (format "New file creation simulation completed for %s. File will be created - preview only." path))
286-
287-
:else
288-
(tools.util/single-text-content
289-
(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."
290-
path)
291-
:error)))))
292-
293-
(defn ^:private move-file [arguments {:keys [db]}]
294-
(let [workspace-dirs (tools.util/workspace-roots-strs db)]
295-
(or (tools.util/invalid-arguments arguments [["source" fs/exists? "$source is not a valid path"]
296-
["source" (partial allowed-path? db) (str "Access denied - path $source outside allowed directories: " workspace-dirs)]
297-
["destination" (partial allowed-path? db) (str "Access denied - path $destination outside allowed directories: " workspace-dirs)]
298-
["destination" (complement fs/exists?) "Path $destination already exists"]])
299-
(let [source (get arguments "source")
300-
destination (get arguments "destination")]
301-
(fs/move source destination {:replace-existing false})
302-
(tools.util/single-text-content (format "Successfully moved %s to %s" source destination))))))
266+
(defn ^:private preview-file-change [arguments _]
267+
(let [path (get arguments "path")
268+
original-content (get arguments "original_content")
269+
new-content (get arguments "new_content")
270+
all? (boolean (get arguments "all_occurrences"))
271+
file-exists? (fs/exists? path)]
272+
(cond
273+
file-exists?
274+
(let [result (apply-file-edit-strategy (slurp path) original-content new-content all? path)]
275+
(handle-file-change-result result path
276+
(format "Change simulation completed for %s. Original file unchanged - preview only." path)))
277+
278+
(and (not file-exists?) (= "" original-content))
279+
(tools.util/single-text-content (format "New file creation simulation completed for %s. File will be created - preview only." path))
280+
281+
:else
282+
(tools.util/single-text-content
283+
(format "Preview error for %s: For new files, original_content must be empty string (\"\")."
284+
path)
285+
:error))))
286+
287+
(defn ^:private move-file [arguments _]
288+
(or (tools.util/invalid-arguments arguments [["source" fs/exists? "$source is not a valid path"]
289+
["destination" (complement fs/exists?) "Path $destination already exists"]])
290+
(let [source (get arguments "source")
291+
destination (get arguments "destination")]
292+
(fs/move source destination {:replace-existing false})
293+
(tools.util/single-text-content (format "Successfully moved %s to %s" source destination)))))
303294

304295
(def definitions
305296
{"eca_directory_tree"
@@ -311,6 +302,7 @@
311302
:description (format "Maximum depth to traverse (default: %s)" directory-tree-max-depth)}}
312303
:required ["path"]}
313304
:handler #'directory-tree
305+
:require-approval-fn (tools.util/require-approval-when-outside-workspace ["path"])
314306
:summary-fn (constantly "Listing file tree")}
315307
"eca_read_file"
316308
{:description (tools.util/read-tool-description "eca_read_file")
@@ -323,6 +315,7 @@
323315
:description "Maximum lines to read (default: configured in tools.readFile.maxLines, defaults to 2000)"}}
324316
:required ["path"]}
325317
:handler #'read-file
318+
:require-approval-fn (tools.util/require-approval-when-outside-workspace ["path"])
326319
:summary-fn #'read-file-summary}
327320
"eca_write_file"
328321
{:description (tools.util/read-tool-description "eca_write_file")
@@ -333,6 +326,7 @@
333326
:description "The complete content to write to the file"}}
334327
:required ["path" "content"]}
335328
:handler #'write-file
329+
:require-approval-fn (tools.util/require-approval-when-outside-workspace ["path"])
336330
:summary-fn #'write-file-summary}
337331
"eca_edit_file"
338332
{:description (tools.util/read-tool-description "eca_edit_file")
@@ -347,6 +341,7 @@
347341
:description "Whether to replace all occurrences of the file or just the first one (default)"}}
348342
:required ["path" "original_content" "new_content"]}
349343
:handler #'edit-file
344+
:require-approval-fn (tools.util/require-approval-when-outside-workspace ["path"])
350345
:summary-fn (constantly "Editing file")}
351346
"eca_preview_file_change"
352347
{:description (tools.util/read-tool-description "eca_preview_file_change")
@@ -361,6 +356,7 @@
361356
:description "Whether to preview replacing all occurrences or just the first one (default)"}}
362357
:required ["path" "original_content" "new_content"]}
363358
:handler #'preview-file-change
359+
:require-approval-fn (tools.util/require-approval-when-outside-workspace ["path"])
364360
:summary-fn (constantly "Previewing change")}
365361
"eca_move_file"
366362
{:description (tools.util/read-tool-description "eca_move_file")
@@ -371,6 +367,7 @@
371367
:description "The new absolute file path to move to."}}
372368
:required ["source" "destination"]}
373369
:handler #'move-file
370+
:require-approval-fn (tools.util/require-approval-when-outside-workspace ["source" "destination"])
374371
:summary-fn (constantly "Moving file")}
375372
"eca_grep"
376373
{:description (tools.util/read-tool-description "eca_grep")
@@ -385,6 +382,7 @@
385382
:description "Maximum number of results to return (default: 1000)"}}
386383
:required ["path" "pattern"]}
387384
:handler #'grep
385+
:require-approval-fn (tools.util/require-approval-when-outside-workspace ["path"])
388386
:summary-fn #'grep-summary}})
389387

390388
(defmethod tools.util/tool-call-details-before-invocation :eca_edit_file [_name arguments _server _ctx]

src/eca/features/tools/shell.clj

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,7 @@
106106
:description (format "Optional timeout in milliseconds (Default: %s)" default-timeout)}}
107107
:required ["command"]}
108108
:handler #'shell-command
109-
:require-approval-fn (fn [args {:keys [db]}]
110-
(when-let [wd (and args (get args "working_directory"))]
111-
(when-let [wd (and (fs/exists? wd) (str (fs/canonicalize wd)))]
112-
(let [workspace-roots (mapv (comp shared/uri->filename :uri) (:workspace-folders db))]
113-
(not-any? #(fs/starts-with? wd %) workspace-roots)))))
109+
:require-approval-fn (tools.util/require-approval-when-outside-workspace ["working_directory"])
114110
:summary-fn #'shell-command-summary}})
115111

116112
(defmethod tools.util/tool-call-destroy-resource! :eca_shell_command [name resource-kwd resource]

src/eca/features/tools/util.clj

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
(ns eca.features.tools.util
22
(:require
3+
[babashka.fs :as fs]
34
[cheshire.core :as json]
45
[clojure.java.io :as io]
56
[clojure.java.shell :as shell]
@@ -60,6 +61,31 @@
6061
(map #(shared/uri->filename (:uri %)))
6162
(string/join "\n")))
6263

64+
(defn workspace-root-paths
65+
"Returns a vector of workspace root absolute paths from `db`."
66+
[db]
67+
(mapv (comp shared/uri->filename :uri) (:workspace-folders db)))
68+
69+
(defn path-outside-workspace?
70+
"Returns true if `path` is outside any workspace root in `db`.
71+
Works for existing or non-existing paths by absolutizing."
72+
[db path]
73+
(let [p (when path (str (fs/absolutize path)))
74+
roots (workspace-root-paths db)]
75+
(and p (not-any? #(fs/starts-with? p %) roots))))
76+
77+
(defn require-approval-when-outside-workspace
78+
"Returns a function suitable for tool `:require-approval-fn` that triggers
79+
approval when any of the provided `path-keys` in args is outside the
80+
workspace roots."
81+
[path-keys]
82+
(fn [args {:keys [db]}]
83+
(when (seq path-keys)
84+
(some (fn [k]
85+
(when-let [p (get args k)]
86+
(path-outside-workspace? db p)))
87+
path-keys))))
88+
6389
(defn command-available? [command & args]
6490
(try
6591
(zero? (:exit (apply shell/sh (concat [command] args))))
@@ -78,3 +104,17 @@
78104
[tool-name]
79105
(-> (io/resource (str "prompts/tools/" tool-name ".md"))
80106
(slurp)))
107+
108+
(defn required-params-error
109+
"Given a tool `parameters` JSON schema (object) and an args map, return a
110+
single-text-content error when any required parameter is missing. Returns nil
111+
if all required parameters are present."
112+
[parameters args]
113+
(when-let [req (seq (:required parameters))]
114+
(let [args (update-keys args name)
115+
missing (->> req (map name) (filter #(nil? (get args %))) vec)]
116+
(when (seq missing)
117+
(single-text-content
118+
(format "INVALID_ARGS: missing required params: %s"
119+
(->> missing (map #(str "`" % "`")) (string/join ", ")))
120+
:error)))))

0 commit comments

Comments
 (0)