Skip to content

Commit 8ca016c

Browse files
committed
Require approval for eca_shell_command if running outside workspace folders.
Fixes #86
1 parent b41027e commit 8ca016c

File tree

6 files changed

+64
-23
lines changed

6 files changed

+64
-23
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Require approval for `eca_shell_command` if running outside workspace folders.
6+
57
## 0.36.5
68

79
- Fix pricing for models being case insensitive on its name when checking capabilities.

src/eca/features/chat.clj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@
177177
:origin (tool-name->origin name all-tools)
178178
:arguments-text arguments-text
179179
:id id
180-
:manual-approval (f.tools/manual-approval? name config)}
180+
:manual-approval (f.tools/manual-approval? all-tools name nil db config)}
181181
:summary (f.tools/tool-call-summary all-tools name nil))))
182182
:on-tools-called (fn [tool-calls]
183183
(assert-chat-not-stopped! chat-ctx)
@@ -191,7 +191,7 @@
191191
details (f.tools/tool-call-details-before-invocation name arguments)
192192
summary (f.tools/tool-call-summary all-tools name arguments)
193193
origin (tool-name->origin name all-tools)
194-
manual-approval? (f.tools/manual-approval? name config)]
194+
manual-approval? (f.tools/manual-approval? all-tools name arguments db config)]
195195
;; Inform UI the tool is about to run and store approval promise
196196
(send-content! chat-ctx :assistant
197197
(assoc-some

src/eca/features/tools.clj

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,15 @@
116116
(assoc :type :mcp)
117117
(update :tools #(mapv with-tool-status %)))))})))
118118

119-
(defn manual-approval? [name config]
119+
(defn manual-approval? [all-tools name args db config]
120120
(boolean
121-
(let [manual-approval? (get-in config [:toolCall :manualApproval] nil)]
122-
(if (coll? manual-approval?)
123-
(some #(= name (str %)) manual-approval?)
124-
manual-approval?))))
121+
(let [require-approval-fn (:require-approval-fn (first (filter #(= name (:name %))
122+
all-tools)))
123+
manual-approval? (get-in config [:toolCall :manualApproval] nil)]
124+
(or (when require-approval-fn (require-approval-fn args {:db db}))
125+
(if (coll? manual-approval?)
126+
(some #(= name (str %)) manual-approval?)
127+
manual-approval?)))))
125128

126129
(defn tool-call-summary [all-tools name args]
127130
(when-let [summary-fn (:summary-fn (first (filter #(= name (:name %))

src/eca/features/tools/filesystem.clj

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
[clojure.string :as string]
77
[eca.diff :as diff]
88
[eca.features.tools.util :as tools.util]
9-
[eca.shared :as shared]))
9+
[eca.shared :as shared :refer [multi-str]]))
1010

1111
(set! *warn-on-reflection* true)
1212

@@ -39,20 +39,20 @@
3939
is-last? (= index (dec (count filepaths)))
4040
current-prefix (if is-last? "└── " "├── ")
4141
next-prefix (if is-last? " " "")]
42-
42+
4343
;; Register file/directory in stats
4444
(if (fs/directory? absolute)
4545
(swap! (:dir-count stats) inc)
4646
(swap! (:file-count stats) inc))
47-
47+
4848
(let [current-line (str prefix current-prefix filename "\n")
4949
;; Only recurse if we haven't reached max depth
50-
subtree-output (if (and (fs/directory? absolute)
51-
(< current-depth max-depth))
52-
(tree-walk absolute
53-
(str prefix next-prefix)
54-
stats
55-
max-depth
50+
subtree-output (if (and (fs/directory? absolute)
51+
(< current-depth max-depth))
52+
(tree-walk absolute
53+
(str prefix next-prefix)
54+
stats
55+
max-depth
5656
(inc current-depth))
5757
"")]
5858
(recur (inc index)
@@ -297,12 +297,12 @@
297297
:handler #'write-file
298298
:summary-fn #'write-file-summary}
299299
"eca_edit_file"
300-
{:description (str "Replace a specific string or content block in a file with new content. "
301-
"Finds the exact original content and replaces it with new content. "
302-
"Be extra careful to format the original-content exactly correctly, "
303-
"taking extra care with whitespace and newlines. "
304-
"Avoid replacing whole functions, methods, or classes, change only the needed code. "
305-
"In addition to replacing strings, this can also be used to prepend, append, or delete contents from a file.")
300+
{:description (multi-str "You must use your `eca_read_file` tool to get the file’s exact contents before attempting an edit."
301+
"This tool will error if you attempt an edit without reading the file.When crafting the `orginal_content`, you must match the original content from the `eca_read_file` tool output exactly, including all indentation (spaces/tabs) and newlines."
302+
"Never include any part of the line number prefix in the `original_content` or `new_content`.The edit will FAIL if the `original_content` is not unique in the file. To resolve this, you must expand the `new_content` to include more surrounding lines of code or context to make it a unique block."
303+
"ALWAYS prefer making small, targeted edits to existing files. Avoid replacing entire functions or large blocks of code in a single step unless absolutely necessary. You can always call this tool multiple times for multiple edits."
304+
"To delete content, provide the content to be removed as the `original_content` and an empty string as the `new_content`."
305+
"To prepend or append content, the `new_content` must contain both the new content and the original content from `old_string`.")
306306
:parameters {:type "object"
307307
:properties {"path" {:type "string"
308308
:description "The absolute file path to do the replace."}

src/eca/features/tools/shell.clj

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060

6161
(def definitions
6262
{"eca_shell_command"
63-
{:description (multi-str "Execute an arbitrary shell command and return the output.
63+
{:description (multi-str "Executes an arbitrary shell command ensuring proper handling and security measures.
6464
1. Command Execution:
6565
- Always quote file paths that contain spaces with double quotes (e.g., cd \" path with spaces/file.txt \")
6666
- Examples of proper quoting:
@@ -71,6 +71,15 @@
7171
- After ensuring proper quoting, execute the command.
7272
- Capture the output of the command.
7373
- VERY IMPORTANT: You MUST avoid using search command `grep`. Instead use eca_grep to search. You MUST avoid read tools like `cat`, `head`, `tail`, and `ls`, and use eca_read_file or eca_directory_tree.
74+
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
75+
- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
76+
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.
77+
<good-example>
78+
pytest /foo/bar/tests
79+
</good-example>
80+
<bad-example>
81+
cd /foo/bar && pytest tests
82+
</bad-example>
7483
7584
# Committing changes with git
7685
@@ -186,4 +195,9 @@ Important:
186195
:description "The directory to run the command in. Default to the first workspace root."}}
187196
:required ["command"]}
188197
:handler #'shell-command
198+
:require-approval-fn (fn [args {:keys [db]}]
199+
(when-let [wd (and args (get args "working_directory"))]
200+
(when-let [wd (and (fs/exists? wd) (str (fs/canonicalize wd)))]
201+
(let [workspace-roots (mapv (comp shared/uri->filename :uri) (:workspace-folders db))]
202+
(not-any? #(fs/starts-with? wd %) workspace-roots)))))
189203
:summary-fn #'shell-command-summary}})

test/eca/features/tools/shell_test.clj

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,25 @@
7474
{:db {:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]}
7575
:config {:nativeTools {:shell {:enabled true
7676
:excludeCommands ["ls" "rm"]}}}}))))))
77+
78+
(deftest shell-require-approval-fn-test
79+
(let [approval-fn (get-in f.tools.shell/definitions ["eca_shell_command" :require-approval-fn])
80+
db {:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]}]
81+
(testing "returns nil when working_directory is not provided"
82+
(is (nil? (approval-fn nil {:db db})))
83+
(is (nil? (approval-fn {} {:db db}))))
84+
(testing "returns nil when working_directory does not exist"
85+
(with-redefs [fs/exists? (constantly false)]
86+
(is (nil? (approval-fn {"working_directory" (h/file-path "/project/foo/src")} {:db db})))))
87+
(testing "returns false when working_directory equals a workspace root"
88+
(with-redefs [fs/exists? (constantly true)
89+
fs/canonicalize identity]
90+
(is (false? (approval-fn {"working_directory" (h/file-path "/project/foo")} {:db db})))))
91+
(testing "returns false when working_directory is a subdirectory of a workspace root"
92+
(with-redefs [fs/exists? (constantly true)
93+
fs/canonicalize identity]
94+
(is (false? (approval-fn {"working_directory" (h/file-path "/project/foo/src")} {:db db})))))
95+
(testing "returns true when working_directory is outside any workspace root"
96+
(with-redefs [fs/exists? (constantly true)
97+
fs/canonicalize identity]
98+
(is (true? (approval-fn {"working_directory" (h/file-path "/other/place")} {:db db})))))))

0 commit comments

Comments
 (0)