Commit ab77b45
Changed files (15)
lib
spec
lib/elelem/plugins/confirm.rb
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-Elelem::Plugins.register(:confirm) do |agent|
- agent.toolbox.before("execute") do |args|
- next unless $stdin.tty?
-
- cmd = args["command"]
- answer = agent.terminal.ask(" Allow? [Y/n] > ")&.downcase
- raise "User denied permission to execute: #{cmd}" if answer == "n"
- end
-end
lib/elelem/plugins/git.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:git) do |agent|
+ allowed = %w[status diff log show branch checkout add reset stash].freeze
+
+ agent.toolbox.add("git",
+ description: "Run git command",
+ params: { command: { type: "string" }, args: { type: "array" } },
+ required: ["command"]
+ ) do |a|
+ cmd = a["command"]
+ next { error: "not allowed: #{cmd}" } unless allowed.include?(cmd)
+
+ agent.toolbox.exec("git", cmd, *(a["args"] || []))
+ end
+
+ agent.toolbox.after("git") do |_, result|
+ agent.terminal.say " ! #{result[:error]}" if result[:error]
+ end
+end
lib/elelem/plugins/glob.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:glob) do |agent|
+ agent.toolbox.add("glob",
+ description: "Find files matching pattern",
+ params: { pattern: { type: "string" }, path: { type: "string" } },
+ required: ["pattern"]
+ ) do |a|
+ path = a["path"] || "."
+ result = agent.toolbox.exec("fd", "--glob", a["pattern"], path)
+ result[:ok] ? result : agent.toolbox.exec("find", path, "-name", a["pattern"])
+ end
+end
lib/elelem/plugins/grep.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:grep) do |agent|
+ agent.toolbox.add("grep",
+ description: "Search file contents",
+ params: { pattern: { type: "string" }, path: { type: "string" }, glob: { type: "string" } },
+ required: ["pattern"]
+ ) do |a|
+ path = a["path"] || "."
+ glob = a["glob"]
+ rg_args = ["rg", "-n", a["pattern"], path]
+ rg_args += ["-g", glob] if glob
+ result = agent.toolbox.exec(*rg_args)
+ next result if result[:ok]
+
+ grep_args = ["grep", "-rn"]
+ grep_args += ["--include", glob] if glob
+ grep_args += [a["pattern"], path]
+ agent.toolbox.exec(*grep_args)
+ end
+end
lib/elelem/plugins/list.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:list) do |agent|
+ agent.toolbox.add("list",
+ description: "List directory contents",
+ params: { path: { type: "string" }, recursive: { type: "boolean" } },
+ required: [],
+ aliases: ["ls"]
+ ) do |a|
+ path = a["path"] || "."
+ flags = a["recursive"] ? "-laR" : "-la"
+ agent.toolbox.exec("ls", flags, path)
+ end
+end
lib/elelem/plugins/permissions.json
@@ -0,0 +1,6 @@
+{
+ "read": "allow",
+ "write": "ask",
+ "edit": "ask",
+ "execute": "ask"
+}
lib/elelem/plugins/write.rb
@@ -12,6 +12,17 @@ Elelem::Plugins.register(:write) do |agent|
{ bytes: path.write(a["content"]), path: a["path"] }
end
+ agent.toolbox.before("write") do |args|
+ path = Pathname.new(args["path"]).expand_path
+ next unless path.exist? && $stdin.tty?
+
+ Tempfile.create(["elelem", File.extname(path)]) do |t|
+ t.write(args["content"])
+ t.flush
+ system("diff", "--color=always", "-u", path.to_s, t.path)
+ end
+ end
+
agent.toolbox.after("write") do |_, result|
if result[:error]
agent.terminal.say " ! #{result[:error]}"
lib/elelem/plugins/zz_confirm.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:confirm) do |agent|
+ permissions = Elelem::Permissions.new
+
+ agent.toolbox.tools.each_key do |tool_name|
+ agent.toolbox.before(tool_name) do |args|
+ permissions.check(tool_name, args, terminal: agent.terminal)
+ end
+ end
+end
lib/elelem/permissions.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Elelem
+ class Permissions
+ LOAD_PATHS = [
+ File.expand_path("plugins/permissions.json", __dir__),
+ "~/.elelem/permissions.json",
+ ".elelem/permissions.json"
+ ].freeze
+
+ def initialize
+ @rules = LOAD_PATHS.reduce({}) do |rules, path|
+ rules.merge(load_config(File.expand_path(path)))
+ end
+ end
+
+ def check(tool_name, args, terminal:)
+ policy = @rules[tool_name.to_sym] || :ask
+ case policy
+ when :allow then true
+ when :deny then raise "Permission denied: #{tool_name}"
+ when :ask then prompt(tool_name, args, terminal)
+ end
+ end
+
+ private
+
+ def load_config(path)
+ return {} unless File.exist?(path)
+
+ JSON.parse(File.read(path)).transform_keys(&:to_sym).transform_values(&:to_sym)
+ rescue JSON::ParserError
+ {}
+ end
+
+ def prompt(tool_name, args, terminal)
+ return true unless $stdin.tty?
+
+ answer = terminal.ask(" Allow #{tool_name}? [Y/n] > ")&.downcase
+ raise "User denied permission: #{tool_name}" if answer == "n"
+
+ true
+ end
+ end
+end
lib/elelem/toolbox.rb
@@ -44,6 +44,11 @@ module Elelem
failure(error: e.message, name: name, args: args)
end
+ def exec(*args)
+ command = args.flatten.map { |a| Shellwords.escape(a.to_s) }.join(" ")
+ run("execute", { "command" => command })
+ end
+
def to_a
tools.values.map(&:to_h)
end
lib/elelem.rb
@@ -23,6 +23,7 @@ require_relative "elelem/commands"
require_relative "elelem/conversation"
require_relative "elelem/mcp"
require_relative "elelem/net"
+require_relative "elelem/permissions"
require_relative "elelem/plugins"
require_relative "elelem/system_prompt"
require_relative "elelem/terminal"
spec/elelem/permissions_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Permissions do
+ subject { described_class.new }
+
+ let(:terminal) { double(ask: nil) }
+
+ describe "#check" do
+ context "with default allow policies" do
+ it "allows read without prompting" do
+ expect(subject.check("read", {}, terminal: terminal)).to be true
+ expect(terminal).not_to have_received(:ask)
+ end
+ end
+
+ context "with deny policy" do
+ it "raises an error" do
+ permissions = described_class.new
+ permissions.instance_variable_set(:@rules, { write: :deny })
+ expect { permissions.check("write", {}, terminal: terminal) }.to raise_error(/Permission denied/)
+ end
+ end
+
+ context "with ask policy in non-TTY mode" do
+ before { allow($stdin).to receive(:tty?).and_return(false) }
+
+ it "returns true without prompting" do
+ expect(subject.check("execute", {}, terminal: terminal)).to be true
+ end
+ end
+ end
+end
spec/elelem/toolbox_spec.rb
@@ -48,4 +48,16 @@ RSpec.describe Elelem::Toolbox do
expect(result[:error]).to include("unknown tool")
end
end
+
+ describe "#exec" do
+ it "escapes arguments and runs execute" do
+ result = subject.exec("echo", "hello world")
+ expect(result[:output]).to include("hello world")
+ end
+
+ it "handles arrays of arguments" do
+ result = subject.exec("echo", ["a", "b"])
+ expect(result[:output]).to include("a")
+ end
+ end
end
elelem.gemspec
@@ -27,6 +27,8 @@ Gem::Specification.new do |spec|
"exe/elelem",
"lib/elelem.rb",
"lib/elelem/agent.rb",
+ "lib/elelem/commands.rb",
+ "lib/elelem/conversation.rb",
"lib/elelem/mcp.rb",
"lib/elelem/mcp/oauth.rb",
"lib/elelem/mcp/token_storage.rb",
@@ -34,15 +36,24 @@ Gem::Specification.new do |spec|
"lib/elelem/net/claude.rb",
"lib/elelem/net/ollama.rb",
"lib/elelem/net/openai.rb",
+ "lib/elelem/permissions.rb",
"lib/elelem/plugins.rb",
- "lib/elelem/plugins/confirm.rb",
+ "lib/elelem/plugins/builtins.rb",
"lib/elelem/plugins/edit.rb",
"lib/elelem/plugins/eval.rb",
"lib/elelem/plugins/execute.rb",
+ "lib/elelem/plugins/git.rb",
+ "lib/elelem/plugins/glob.rb",
+ "lib/elelem/plugins/grep.rb",
+ "lib/elelem/plugins/list.rb",
"lib/elelem/plugins/mcp.rb",
+ "lib/elelem/plugins/permissions.json",
"lib/elelem/plugins/read.rb",
+ "lib/elelem/plugins/task.rb",
+ "lib/elelem/plugins/tools.rb",
"lib/elelem/plugins/verify.rb",
"lib/elelem/plugins/write.rb",
+ "lib/elelem/plugins/zz_confirm.rb",
"lib/elelem/system_prompt.rb",
"lib/elelem/templates/system_prompt.erb",
"lib/elelem/terminal.rb",
@@ -67,6 +78,7 @@ Gem::Specification.new do |spec|
spec.add_dependency "pathname", "~> 0.1"
spec.add_dependency "reline", "~> 0.6"
spec.add_dependency "securerandom", "~> 0.1"
+ spec.add_dependency "shellwords", "~> 0.2"
spec.add_dependency "stringio", "~> 3.0"
spec.add_dependency "tempfile", "~> 0.3"
spec.add_dependency "uri", "~> 1.0"
Gemfile.lock
@@ -15,6 +15,7 @@ PATH
pathname (~> 0.1)
reline (~> 0.6)
securerandom (~> 0.1)
+ shellwords (~> 0.2)
stringio (~> 3.0)
tempfile (~> 0.3)
uri (~> 1.0)
@@ -83,6 +84,7 @@ GEM
rspec-support (~> 3.13.0)
rspec-support (3.13.6)
securerandom (0.4.1)
+ shellwords (0.2.2)
simpleidn (0.2.3)
stringio (3.2.0)
tempfile (0.3.1)