From 0c798faf56c0a216be096e426e8b61a348aeba7b Mon Sep 17 00:00:00 2001 From: Jakub Zika Date: Tue, 28 Apr 2026 18:00:27 +0200 Subject: [PATCH] Support regex patterns for shell_command in markdown agent tools Add `tool(regex)` syntax to markdown agent allow/ask/deny lists to restrict approvals by argument pattern. Currently limited to `eca__shell_command` (matched against its `command` arg); on other tools the pattern is ignored with a warning. Repeated entries for the same tool are merged into one argsMatchers list. For broader argsMatchers use (other tools, multiple args), use JSON `toolCall` config --- CHANGELOG.md | 2 ++ docs/config/agents.md | 14 +++++++++++++ src/eca/features/agents.clj | 35 ++++++++++++++++++++++++++++--- test/eca/features/agents_test.clj | 23 ++++++++++++++++++++ 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7f9f4bc0..3bb4a0e42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ - Bugfix: avoid `Divide by zero` crash in chat auto-compact when models.dev reports `0` for a model's context/output limits (e.g. `openai/chatgpt-image-latest`); such limits are now normalized to `nil` and `auto-compact?` skips models without a known positive context window. - Bugfix: image edit follow-up turns no longer fail on the OpenAI Responses API when prior generations are replayed; generated images are now persisted under a dedicated `image_generation_call` history role and replayed as a user-role `input_image` data URL across providers. +- Support regex patterns in markdown agent tool entries (e.g. `eca__shell_command(npm run .*)`) for fine-grained tool approval, currently limited to `eca__shell_command`. + ## 0.130.1 - Add configurable skill paths and recursive directory loading for configured rules, commands, and skills; local skills are also discovered from `.agents/skills`. #423 diff --git a/docs/config/agents.md b/docs/config/agents.md index 3f23a8e4d..a8a2d494c 100644 --- a/docs/config/agents.md +++ b/docs/config/agents.md @@ -124,6 +124,20 @@ Subagents can be configured in config or markdown and support/require these fiel You should run sleep 1 and return "I slept 1 second" ``` + !!! info "Pattern-based tool approval in markdown" + + You can append a regex pattern in parentheses after a tool name to restrict approval to calls matching the pattern. Currently only `eca__shell_command` supports this — the pattern is matched against its `command` argument. Multiple entries for the same tool are automatically merged. + + ```yaml + tools: + allow: + - eca__shell_command(npm run .*) + - eca__shell_command(git diff(\s+.*)?) + - eca__read_file + ``` + + This is equivalent to `argsMatchers` in JSON config. Patterns on tools other than `eca__shell_command` are currently ignored. + !!! info "Tool call approval" For more complex tool call approval, use toolCall via config diff --git a/src/eca/features/agents.clj b/src/eca/features/agents.clj index 1895e7d8a..ee2c64a9e 100644 --- a/src/eca/features/agents.clj +++ b/src/eca/features/agents.clj @@ -14,10 +14,39 @@ (def ^:private logger-tag "[AGENTS-MD]") +(def ^:private tool-arg-name + "Maps tool names to the argument name used for regex pattern matching in argsMatchers. + Only tools that support pattern-based approval need an entry here." + {"eca__shell_command" "command"}) + +(defn ^:private parse-tool-entry + "Parses a tool entry string into [tool-name config]. + Plain names like 'eca__read_file' -> ['eca__read_file' {}] + Pattern entries like 'eca__shell_command(npm run .*)' -> ['eca__shell_command' {:argsMatchers {'command' ['npm run .*']}}]" + [entry] + (let [s (str entry)] + (if-let [[_ tool-name pattern] (re-matches #"(.+?)\((.+)\)" s)] + (if-let [arg-name (get tool-arg-name tool-name)] + [tool-name {:argsMatchers {arg-name [pattern]}}] + (do (logger/warn logger-tag (format "Tool '%s' has pattern '%s' but no arg-name mapping in tool-arg-name; pattern will be ignored" tool-name pattern)) + [tool-name {}])) + [s {}]))) + (defn ^:private tools-list->approval-map - [tool-names] - (when (seq tool-names) - (into {} (map (fn [name] [(str name) {}]) tool-names)))) + [tool-entries] + (when (seq tool-entries) + (reduce + (fn [acc entry] + (let [[tool-name config] (parse-tool-entry entry)] + (if (contains? acc tool-name) + ;; Merge argsMatchers patterns for repeated tool entries + (update-in acc [tool-name :argsMatchers] + (fn [existing new-matchers] + (merge-with into existing new-matchers)) + (:argsMatchers config)) + (assoc acc tool-name config)))) + {} + tool-entries))) (defn ^:private md->agent-config [{:keys [description mode model steps tools body inherit]}] diff --git a/test/eca/features/agents_test.clj b/test/eca/features/agents_test.clj index 0509ed1af..8c615034f 100644 --- a/test/eca/features/agents_test.clj +++ b/test/eca/features/agents_test.clj @@ -84,6 +84,29 @@ :deny {"foo" {}}}}} config)))) + (testing "tool entries with regex patterns" + (let [parsed {:tools {"byDefault" "ask" + "allow" ["eca__shell_command(npm run .*)" + "eca__shell_command(git commit .*)" + "eca__read_file"]}} + config (#'agents/md->agent-config parsed)] + (is (match? {:toolCall {:approval {:byDefault "ask" + :allow {"eca__shell_command" {:argsMatchers {"command" ["npm run .*" "git commit .*"]}} + "eca__read_file" {}}}}} + config)))) + + (testing "tool entry with pattern for unknown tool arg (no tool-arg-name entry)" + (let [parsed {:tools {"allow" ["eca__read_file(/tmp/.*)"]}} + config (#'agents/md->agent-config parsed)] + (is (match? {:toolCall {:approval {:allow {"eca__read_file" {}}}}} + config)))) + + (testing "single tool with pattern" + (let [parsed {:tools {"allow" ["eca__shell_command(git diff(\\s+.*)?)"]}} + config (#'agents/md->agent-config parsed)] + (is (match? {:toolCall {:approval {:allow {"eca__shell_command" {:argsMatchers {"command" ["git diff(\\s+.*)?"]}}}}}} + config)))) + (testing "minimal agent with only description and body" (let [parsed (shared/parse-md "---\ndescription: Simple agent\nmode: subagent\n---\n\nDo stuff") config (#'agents/md->agent-config parsed)]