Skip to content

Commit f878d96

Browse files
authored
Merge pull request #95 from editor-code-assistant/improve-approval
Add toolCall approval
2 parents 5e73caa + 22591b1 commit f878d96

File tree

11 files changed

+311
-94
lines changed

11 files changed

+311
-94
lines changed

CHANGELOG.md

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

2121
- Fix command prompts to allow args with spaces between quotes.
2222
- Fix anthropic token renew when expires.
23+
- Considerably improve toolCall approval / permissions config.
24+
- Now with thave multiple optiosn to ask or allow tool calls, check config section.
2325

2426
## 0.38.1
2527

docs/configuration.md

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,77 @@ For MCP servers configuration, use the `mcpServers` config, example:
6868
}
6969
```
7070

71-
### Manual approval
72-
73-
By default, ECA auto approve any tool call from LLM, to configure that or for which tools, check `toolCall manualApproval` config or try the `plan` behavior before.
71+
### Tool approval / permissions
72+
73+
By default, ECA ask to call any tool, but that's can easily be configureed in many ways via the `toolCall approval` config
74+
75+
Check some examples:
76+
77+
=== "Allow any tools by default"
78+
79+
```javascript
80+
{
81+
"toolCall": {
82+
"approval": {
83+
"byDefault": "allow"
84+
}
85+
}
86+
}
87+
```
88+
89+
=== "Allow all but some tools"
90+
91+
```javascript
92+
{
93+
"toolCall": {
94+
"approval": {
95+
"byDefault": "allow",
96+
"ask": {
97+
"eca_editfile": {},
98+
"my-mcp__my_tool": {}
99+
}
100+
}
101+
}
102+
}
103+
```
104+
105+
=== "Ask all but all tools from some mcps"
106+
107+
```javascript
108+
{
109+
"toolCall": {
110+
"approval": {
111+
// "byDefault": "ask", not needed as it's eca default
112+
"allow": {
113+
"eca": {},
114+
"my-mcp": {}
115+
}
116+
}
117+
}
118+
}
119+
```
120+
121+
=== "Matching by a tool argument"
122+
123+
__`argsMatchers`__ is a map of argument name by list of [java regex](https://www.regexplanet.com/advanced/java/index.html).
124+
125+
```javascript
126+
{
127+
"toolCall": {
128+
"approval": {
129+
"byDefault": "allow",
130+
"allow": {
131+
"eca_shell_command": {"argsMatchers" {"command" [".*rm.*",
132+
".*mv.*"]}}
133+
}
134+
}
135+
}
136+
}
137+
```
138+
139+
Also check the `plan` behavior which is safer.
140+
141+
__The `manualApproval` setting was deprecated and replaced by the `approval` one without breaking changes__
74142

75143
## Custom command prompts
76144

@@ -181,7 +249,11 @@ There are 3 possible ways to configure rules following this order of priority:
181249
};
182250
disabledTools?: string[],
183251
toolCall?: {
184-
manualApproval?: boolean | string[], // manual approve all tools or the specified tools
252+
approval?: {
253+
byDefault: 'ask' | 'allow';
254+
allow?: {{key: string}: {argsMatchers?: {{[key]: string}: string[]}}},
255+
ask?: {{key: string}: {argsMatchers?: {{[key]: string}: string[]}}},
256+
};
185257
};
186258
mcpTimeoutSeconds?: number;
187259
lspTimeoutSeconds?: number;
@@ -225,7 +297,11 @@ There are 3 possible ways to configure rules following this order of priority:
225297
"editor": {"enabled": true}},
226298
"disabledTools": [],
227299
"toolCall": {
228-
"manualApproval": null,
300+
"approval": {
301+
"byDefault": "ask",
302+
"allow": {},
303+
"ask": {}
304+
}
229305
},
230306
"mcpTimeoutSeconds" : 60,
231307
"lspTimeoutSeconds" : 30,

docs/features.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ Behavior affect the prompt passed to LLM and the tools to include, the current s
2020
ECA leverage tools to give more power to the LLM, this is the best way to make LLMs have more context about your codebase and behave like an agent.
2121
It supports both MCP server tools + ECA native tools.
2222

23-
!!! warning "Automatic approval"
23+
!!! info "Approval / permissions"
2424

25-
By default, ECA auto approve any tool call from LLM, to configure that or for which tools, check `toolCall manualApproval` config or try the `plan` behavior.
25+
By default, ECA ask to approve any tool, you can easily configure that, check `toolCall approval` [config](./config.md) or try the `plan` behavior.
2626

2727
### Native tools
2828

integration-test/integration/fixture.clj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
(str "http://localhost:" llm-mock.server/port))
99

1010
(def default-init-options {:pureConfig true
11+
:toolCall {:approval {:byDefault "allow"}}
1112
:providers {"openai" {:url (str base-llm-mock-url "/openai")
1213
:key "foo-key"
1314
:keyEnv "FOO"}

src/eca/config.clj

Lines changed: 66 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
3. local config-file: searching from a local `.eca/config.json` file.
77
4. `initializatonOptions` sent in `initialize` request."
88
(:require
9-
[camel-snake-kebab.core :as csk]
109
[cheshire.core :as json]
1110
[cheshire.factory :as json.factory]
1211
[clojure.core.memoize :as memoize]
1312
[clojure.java.io :as io]
1413
[clojure.string :as string]
1514
[eca.logger :as logger]
16-
[eca.shared :as shared])
15+
[eca.shared :as shared]
16+
[camel-snake-kebab.core :as csk])
1717
(:import
1818
[java.io File]))
1919

@@ -57,6 +57,9 @@
5757
:excludeCommands []}
5858
:editor {:enabled true}}
5959
:disabledTools []
60+
:toolCall {:approval {:byDefault "ask"
61+
:allow {}
62+
:ask {}}}
6063
:mcpTimeoutSeconds 60
6164
:lspTimeoutSeconds 30
6265
:mcpServers {}
@@ -75,7 +78,7 @@
7578
(try
7679
(binding [json.factory/*json-factory* (json.factory/make-json-factory
7780
{:allow-comments true})]
78-
(json/parse-string raw-string true))
81+
(json/parse-string raw-string))
7982
(catch Exception e
8083
(logger/warn "Error parsing config json:" (.getMessage e)))))
8184

@@ -126,47 +129,67 @@
126129

127130
(def ollama-model-prefix "ollama/")
128131

129-
(defn ^:private normalize-providers [providers]
130-
(letfn [(norm-key [k]
131-
(csk/->kebab-case (string/replace-first (str k) ":" "")))]
132-
(reduce-kv (fn [m k v]
133-
(let [nk (norm-key k)]
134-
(if (contains? m nk)
135-
(update m nk #(deep-merge % v))
136-
(assoc m nk v))))
137-
{}
138-
providers)))
139-
140-
(defn ^:private normalize-provider-models [provider]
141-
(let [models (or (:models provider)
142-
(get provider "models"))
143-
models' (when models
144-
(into {}
145-
(map (fn [[k v]]
146-
[(if (or (keyword? k) (symbol? k))
147-
(string/replace-first (str k) ":" "")
148-
(str k))
149-
v])
150-
models)))
151-
provider' (dissoc provider "models")]
152-
(if models'
153-
(assoc provider' :models models')
154-
provider')))
155-
156-
(defn ^:private normalize-fields [config]
157-
(-> config
158-
(update-in [:providers]
159-
(fn [providers]
160-
(when providers
161-
(-> (normalize-providers providers)
162-
(update-vals normalize-provider-models)))))))
132+
(defn ^:private normalize-fields
133+
"Converts a deep nested map where keys are strings to keywords.
134+
normalization-rules follow the nest order, :ANY means any field name.
135+
:kebab-case means convert field names to kebab-case.
136+
:stringfy means convert field names to strings."
137+
[normalization-rules m]
138+
(let [kc-paths (set (:kebab-case normalization-rules))
139+
str-paths (set (:stringfy normalization-rules))
140+
; match a current path against a rule path with :ANY wildcard
141+
matches-path? (fn [rule-path cur-path]
142+
(and (= (count rule-path) (count cur-path))
143+
(every? true?
144+
(map (fn [rp cp]
145+
(or (= rp :ANY)
146+
(= rp cp)))
147+
rule-path cur-path))))
148+
applies? (fn [paths cur-path]
149+
(some #(matches-path? % cur-path) paths))
150+
normalize-map (fn normalize-map [cur-path m*]
151+
(cond
152+
(map? m*)
153+
(let [apply-kebab? (applies? kc-paths cur-path)
154+
apply-string? (applies? str-paths cur-path)]
155+
(into {}
156+
(map (fn [[k v]]
157+
(let [base-name (cond
158+
(keyword? k) (name k)
159+
(string? k) k
160+
:else (str k))
161+
kebabed (if apply-kebab?
162+
(csk/->kebab-case base-name)
163+
base-name)
164+
new-k (if apply-string?
165+
kebabed
166+
(keyword kebabed))
167+
new-v (normalize-map (conj cur-path new-k) v)]
168+
[new-k new-v])))
169+
m*))
170+
171+
(sequential? m*)
172+
(mapv #(normalize-map cur-path %) m*)
173+
174+
:else m*))]
175+
(normalize-map [] m)))
163176

164177
(defn all [db]
165178
(let [initialization-config @initialization-config*
166179
pure-config? (:pureConfig initialization-config)]
167-
(normalize-fields
168-
(deep-merge initial-config
169-
initialization-config
170-
(when-not pure-config? (config-from-envvar))
171-
(when-not pure-config? (config-from-global-file))
172-
(when-not pure-config? (config-from-local-file (:workspace-folders db)))))))
180+
(deep-merge initial-config
181+
(normalize-fields
182+
{:kebab-case
183+
[[:providers]]
184+
:stringfy
185+
[[:providers]
186+
[:providers :ANY :models]
187+
[:toolCall :approval :allow]
188+
[:toolCall :approval :allow :ANY :argsMatchers]
189+
[:toolCall :approval :ask]
190+
[:toolCall :approval :ask :ANY :argsMatchers]
191+
[:mcpServers]]}
192+
(deep-merge initialization-config
193+
(when-not pure-config? (config-from-envvar))
194+
(when-not pure-config? (config-from-global-file))
195+
(when-not pure-config? (config-from-local-file (:workspace-folders db))))))))

src/eca/features/tools.clj

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
f.tools.editor/definitions))))
3737

3838
(defn ^:private native-tools [db config]
39-
(vals (native-definitions db config)))
39+
(mapv #(assoc % :server "eca") (vals (native-definitions db config))))
4040

4141
(defn all-tools
4242
"Returns all available tools, including both native ECA tools
@@ -117,15 +117,73 @@
117117
(assoc :type :mcp)
118118
(update :tools #(mapv with-tool-status %)))))})))
119119

120-
(defn manual-approval? [all-tools name args db config]
120+
(defn legacy-manual-approval? [config tool-name]
121+
(let [manual-approval? (get-in config [:toolCall :manualApproval] nil)]
122+
(if (coll? manual-approval?)
123+
(some #(= tool-name (str %)) manual-approval?)
124+
manual-approval?)))
125+
126+
(defn ^:private approval-matches? [[server-or-full-tool-name config] tool-call-server tool-call-name args]
127+
(let [args-matchers (:argsMatchers config)
128+
[server-name tool-name] (if (string/includes? server-or-full-tool-name "__")
129+
(string/split server-or-full-tool-name #"__" 2)
130+
(if (string/starts-with? server-or-full-tool-name "eca_")
131+
["eca" server-or-full-tool-name]
132+
[server-or-full-tool-name nil]))]
133+
(cond
134+
;; specified server name in config
135+
(and (nil? tool-name)
136+
;; but the name doesn't match
137+
(not= tool-call-server server-name))
138+
false
139+
140+
;; tool or server not match
141+
(and tool-name
142+
(or (not= tool-call-server server-name)
143+
(not= tool-call-name tool-name)))
144+
false
145+
146+
(map? args-matchers)
147+
(some (fn [[arg-name matchers]]
148+
(when-let [arg (get args arg-name)]
149+
(some #(re-matches (re-pattern (str %)) (str arg))
150+
matchers)))
151+
args-matchers)
152+
153+
:else
154+
true)))
155+
156+
(defn manual-approval? [all-tools tool-call-name args db config]
121157
(boolean
122-
(let [require-approval-fn (:require-approval-fn (first (filter #(= name (:name %))
123-
all-tools)))
124-
manual-approval? (get-in config [:toolCall :manualApproval] nil)]
125-
(or (when require-approval-fn (require-approval-fn args {:db db}))
126-
(if (coll? manual-approval?)
127-
(some #(= name (str %)) manual-approval?)
128-
manual-approval?)))))
158+
(let [{:keys [server require-approval-fn]} (first (filter #(= tool-call-name (:name %))
159+
all-tools))
160+
{:keys [allow ask byDefault]} (get-in config [:toolCall :approval])]
161+
(cond
162+
(and require-approval-fn (require-approval-fn args {:db db}))
163+
true
164+
165+
(some #(approval-matches? % server tool-call-name args) ask)
166+
true
167+
168+
(some #(approval-matches? % server tool-call-name args) allow)
169+
false
170+
171+
(legacy-manual-approval? config tool-call-name)
172+
true
173+
174+
(= "ask" byDefault)
175+
true
176+
177+
(= "allow" byDefault)
178+
false
179+
180+
;; TODO suport :deny
181+
;; (= "deny" byDefault)
182+
;; false
183+
184+
;; A config error, default to manual approve
185+
:else
186+
true))))
129187

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

0 commit comments

Comments
 (0)