Skip to content

Commit 667c30c

Browse files
committed
Granular shell/git tool approve and remember per command
Closes #153
1 parent 04810b6 commit 667c30c

12 files changed

Lines changed: 563 additions & 22 deletions

File tree

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+
- Granular shell/git approve & remember by command + subcommand (e.g. `git checkout`) instead of whole tool; details carry breakdown, approval keys and remembered state. #153
6+
57
## 0.146.2
68

79
- Fix 400 on GitHub Copilot Claude adaptive-thinking models (e.g. Opus 4.8) when no variant is selected, by defaulting to adaptive thinking. #528

docs/config/tools.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,12 @@ Also check the `plan` agent which is safer.
355355

356356
__The `manualApproval` setting was deprecated and replaced by the `approval` one without breaking changes__
357357

358+
### Granular approve & remember for shell commands
359+
360+
When you approve a `eca__shell_command` or `eca__git` tool call choosing "approve and remember", ECA remembers the approved commands for the session instead of whitelisting the whole tool: the command string is parsed and each command in a chain (`&&`, `||`, `;`, `|`) produces a key — command + subcommand for multi-command tools (`git checkout`, `npm install`), the plain command otherwise (`rg`). A future shell call runs automatically only when all its commands were already remembered.
361+
362+
ECA fails closed: commands it cannot safely reason about always ask again, like command/process substitution (`$(...)`, backticks), subshells, heredocs, output redirections to files (except `/dev/null`), and wrapper commands that execute arbitrary code (`sudo`, `bash -c`, `xargs`, `env`, ...).
363+
358364
### Trust mode by default
359365

360366
Trust mode auto-accepts all tool calls in a chat (it never overrides `deny`). Editors expose a toggle for it, and you can make **new chats start trusted** for every editor via `chat.defaultTrust`:

docs/protocol.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1179,7 +1179,7 @@ interface ChatToolCallRejectedContent {
11791179

11801180
type ToolCallOrigin = 'mcp' | 'native' | 'server' | 'unknown';
11811181

1182-
type ToolCallDetails = FileChangeDetails | JsonOutputsDetails | SubagentDetails | TaskDetails;
1182+
type ToolCallDetails = FileChangeDetails | JsonOutputsDetails | SubagentDetails | TaskDetails | ShellCommandDetails;
11831183

11841184
interface FileChangeDetails {
11851185
type: 'fileChange';
@@ -1323,6 +1323,53 @@ interface TaskItem {
13231323
blockedBy?: number[];
13241324
}
13251325

1326+
/**
1327+
* Breakdown of a shell command tool call.
1328+
* Present when the server could safely parse the command; clients can use it
1329+
* to show what an "approve & remember" would remember.
1330+
*/
1331+
interface ShellCommandDetails {
1332+
type: 'shellCommand';
1333+
1334+
/**
1335+
* The individual commands extracted from the shell invocation.
1336+
* Chained commands (&&, ||, ;, |) are split into separate entries.
1337+
*/
1338+
commands: ShellCommandBreakdown[];
1339+
1340+
/**
1341+
* Whether the command runs in background as a job.
1342+
*/
1343+
background?: boolean;
1344+
}
1345+
1346+
interface ShellCommandBreakdown {
1347+
/**
1348+
* The command being executed, e.g. "ls", "git".
1349+
*/
1350+
command: string;
1351+
1352+
/**
1353+
* All arguments to the command, including flags.
1354+
* e.g. ["-la", "*.clj"] for "ls -la *.clj".
1355+
*/
1356+
args: string[];
1357+
1358+
/**
1359+
* The key that "approve & remember" would remember for this command,
1360+
* e.g. "git checkout" or "rg". Absent when this command can never be
1361+
* auto-approved (wrapper commands like sudo/xargs, file output
1362+
* redirections, dynamic command words).
1363+
*/
1364+
approvalKey?: string;
1365+
1366+
/**
1367+
* Whether approvalKey is already remembered for this session.
1368+
* Only present when approvalKey is present.
1369+
*/
1370+
remembered?: boolean;
1371+
}
1372+
13261373
/**
13271374
* Extra information about a chat
13281375
*/
@@ -1375,6 +1422,8 @@ interface ChatToolCallApproveParams {
13751422

13761423
/**
13771424
* The approach to save this tool call.
1425+
* For tools with granular approval (eca shell_command and git), 'session' remembers
1426+
* the specific approved commands (e.g. "git checkout") instead of the whole tool.
13781427
*/
13791428
save?: 'session';
13801429

src/eca/db.clj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@
146146
:stopping false
147147
:models {}
148148
:mcp-clients {}
149+
;; Approved tool calls remembered for this session (not cached):
150+
;; {tool-name {:remember-to-approve? boolean
151+
;; :remembered-command-keys #{string}}}
152+
:tool-calls {}
149153

150154
;; cacheable, bump db `version` when changing any below
151155
:chats {}

src/eca/features/chat.clj

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1788,8 +1788,14 @@
17881788
{:reason {:code :user-choice-allow
17891789
:text "Tool call allowed by user choice"}})
17901790
(when (= "session" save)
1791-
(let [tool-call-name (get-in @db* [:chats chat-id :tool-calls tool-call-id :name])]
1792-
(swap! db* assoc-in [:tool-calls tool-call-name :remember-to-approve?] true)))))))
1791+
(let [{tool-call-name :name arguments :arguments} (get-in @db* [:chats chat-id :tool-calls tool-call-id])]
1792+
(if-let [{keys' :keys} (f.tools/tool-approval-keys tool-call-name arguments)]
1793+
;; Granular remember (e.g. shell commands): remember only the
1794+
;; derived keys instead of whitelisting the whole tool. #153
1795+
(when (seq keys')
1796+
(swap! db* update-in [:tool-calls tool-call-name :remembered-command-keys]
1797+
(fnil into #{}) keys'))
1798+
(swap! db* assoc-in [:tool-calls tool-call-name :remember-to-approve?] true))))))))
17931799

17941800
(defn tool-call-reject
17951801
[{:keys [chat-id tool-call-id]} db* messenger config metrics]

src/eca/features/tools.clj

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -60,21 +60,30 @@
6060
([all-tools tool args db config agent-name]
6161
(approval all-tools tool args db config agent-name nil))
6262
([all-tools tool args db config agent-name {:keys [trust]}]
63-
(let [{:keys [server name require-approval-fn]} tool
64-
remember-to-approve? (get-in db [:tool-calls name :remember-to-approve?])
63+
(let [{:keys [server name require-approval-fn approval-keys-fn]} tool
64+
remembered? (if approval-keys-fn
65+
;; Granular remember: every derived key (e.g. "git checkout")
66+
;; must have been remembered, and the command must be fully
67+
;; understood (:complete?), otherwise still ask.
68+
(let [{keys' :keys complete? :complete?} (approval-keys-fn args)
69+
remembered-keys (get-in db [:tool-calls name :remembered-command-keys] #{})]
70+
(boolean (and complete?
71+
(seq keys')
72+
(every? remembered-keys keys'))))
73+
(boolean (get-in db [:tool-calls name :remember-to-approve?])))
6574
native-tools (filter #(= :native (:origin %)) all-tools)
6675
{:keys [allow ask deny byDefault]} (merge (get-in config [:toolCall :approval])
6776
(get-in config [:agent agent-name :toolCall :approval]))
6877
result (cond
69-
remember-to-approve?
78+
(some #(approval-matches? % (:name server) name args native-tools) deny)
79+
:deny
80+
81+
remembered?
7082
:allow
7183

7284
(and require-approval-fn (require-approval-fn args {:db db}))
7385
:ask
7486

75-
(some #(approval-matches? % (:name server) name args native-tools) deny)
76-
:deny
77-
7887
(some #(approval-matches? % (:name server) name args native-tools) ask)
7988
:ask
8089

@@ -130,6 +139,27 @@
130139
x))
131140
m))
132141

142+
(defn ^:private static-native-definitions
143+
"Native tool definitions that don't depend on chat/db/config."
144+
[]
145+
(merge {}
146+
f.tools.filesystem/definitions
147+
f.tools.shell/definitions
148+
f.tools.git/definitions
149+
f.tools.editor/definitions
150+
f.tools.chat/definitions
151+
f.tools.skill/definitions
152+
f.tools.task/definitions
153+
f.tools.background/definitions
154+
f.tools.ask-user/definitions))
155+
156+
(defn tool-approval-keys
157+
"Returns {:keys #{...} :complete? bool} when the native tool `tool-name`
158+
defines granular approval keys for these arguments, else nil."
159+
[tool-name arguments]
160+
(when-let [approval-keys-fn (get-in (static-native-definitions) [tool-name :approval-keys-fn])]
161+
(approval-keys-fn arguments)))
162+
133163
(defn ^:private native-definitions
134164
[chat-id agent-name db config]
135165
(into
@@ -142,16 +172,7 @@
142172
:readFileMaxLines (get-in config [:toolCall :readFile :maxLines])
143173
:fullModel (get-in db [:chats chat-id :model])
144174
:variant (get-in db [:chats chat-id :variant])}))]))
145-
(merge {}
146-
f.tools.filesystem/definitions
147-
f.tools.shell/definitions
148-
f.tools.git/definitions
149-
f.tools.editor/definitions
150-
f.tools.chat/definitions
151-
f.tools.skill/definitions
152-
f.tools.task/definitions
153-
f.tools.background/definitions
154-
f.tools.ask-user/definitions
175+
(merge (static-native-definitions)
155176
(f.tools.agent/definitions config db)
156177
(f.tools.custom/definitions config)
157178
(f.tools.fetch-rule/definitions config db chat-id agent-name))))

src/eca/features/tools/git.clj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
[clojure.string :as string]
55
[eca.cache :as cache]
66
[eca.features.tools.shell :as shell]
7+
[eca.features.tools.shell-parser :as shell-parser]
78
[eca.features.tools.util :as tools.util]
89
[eca.logger :as logger]
910
[eca.shared :as shared]))
@@ -14,6 +15,10 @@
1415

1516
(def ^:private default-timeout 120000)
1617

18+
(defmethod tools.util/tool-call-details-before-invocation :git
19+
[_name arguments _server {:keys [db]}]
20+
(shell/shell-command-details "git" (get arguments "command") db))
21+
1722
(defn ^:private git-command [arguments {:keys [db tool-call-id call-state-fn state-transition-fn]}]
1823
(let [command (get arguments "command")]
1924
(or (tools.util/invalid-arguments
@@ -102,6 +107,7 @@
102107
:description "Concise 2-3 word description of what is being done (e.g. \"staged changes\", \"all test files\")."}}
103108
:required ["command" "operation"]}
104109
:handler #'git-command
110+
:approval-keys-fn (fn [arguments] (shell-parser/approval-keys (get arguments "command")))
105111
:summary-fn #'git-command-summary}})
106112

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

src/eca/features/tools/shell.clj

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
[clojure.string :as string]
66
[eca.config :as config]
77
[eca.features.background-tasks :as bg]
8+
[eca.features.tools.shell-parser :as shell-parser]
89
[eca.features.tools.util :as tools.util]
910
[eca.logger :as logger]
1011
[eca.messenger :as messenger]
@@ -17,10 +18,29 @@
1718
(def ^:private default-timeout 60000)
1819
(def ^:private max-timeout (* 60000 10))
1920

21+
(defn shell-command-details
22+
"Builds the shellCommand tool call details for a command string, marking
23+
each command with whether its approval key is already remembered for
24+
`tool-name`. Returns nil when the command cannot be parsed."
25+
[tool-name command db]
26+
(when-let [parsed (shell-parser/parse command)]
27+
(let [remembered-keys (get-in db [:tool-calls tool-name :remembered-command-keys] #{})]
28+
(-> parsed
29+
(update :commands (fn [commands]
30+
(mapv (fn [{:keys [approvalKey] :as cmd}]
31+
(cond-> cmd
32+
approvalKey (assoc :remembered (contains? remembered-keys approvalKey))))
33+
commands)))
34+
(assoc :type :shellCommand)))))
35+
2036
(defmethod tools.util/tool-call-details-before-invocation :shell_command
21-
[_name arguments _server _ctx]
22-
(when (get arguments "background")
23-
{:background true}))
37+
[_name arguments _server {:keys [db]}]
38+
(let [background? (boolean (get arguments "background"))]
39+
(if-let [details (shell-command-details "shell_command" (get arguments "command") db)]
40+
(cond-> details
41+
background? (assoc :background true))
42+
(when background?
43+
{:background true}))))
2444

2545
(defn start-shell-process!
2646
"Start a shell process, returning the process object for deref/management.
@@ -262,6 +282,7 @@
262282
:required ["command"]}
263283
:handler #'shell-command
264284
:require-approval-fn (tools.util/require-approval-when-outside-workspace ["working_directory"])
285+
:approval-keys-fn (fn [arguments] (shell-parser/approval-keys (get arguments "command")))
265286
:summary-fn #'shell-command-summary}})
266287

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

0 commit comments

Comments
 (0)