Commit a3a81a4
Changed files (4)
lib
elelem
spec
elelem
lib/elelem/agent.rb
@@ -9,7 +9,7 @@ module Elelem
MODES = %w[auto build plan verify].freeze
ENV_VARS = %w[ANTHROPIC_API_KEY OPENAI_API_KEY OPENAI_BASE_URL OLLAMA_HOST GOOGLE_CLOUD_PROJECT GOOGLE_CLOUD_REGION].freeze
- attr_reader :conversation, :client, :toolbox, :provider, :terminal
+ attr_reader :conversation, :client, :toolbox, :provider, :terminal, :permissions
def initialize(provider, model, toolbox, terminal: nil)
@conversation = Conversation.new
@@ -22,19 +22,18 @@ module Elelem
providers: PROVIDERS,
env_vars: ENV_VARS
)
+ @permissions = Set.new([:read])
end
def repl
- mode = Set.new([:read])
-
loop do
input = terminal.ask("User> ")
break if input.nil?
if input.start_with?("/")
- handle_command(input, mode)
+ handle_slash_command(input)
else
conversation.add(role: :user, content: input)
- result = execute_turn(conversation.history_for(mode), tools: toolbox.tools_for(mode))
+ result = execute_turn(conversation.history_for(permissions))
conversation.add(role: result[:role], content: result[:content])
end
end
@@ -42,55 +41,45 @@ module Elelem
private
- def handle_command(input, mode)
+ def handle_slash_command(input)
case input
when "/mode auto"
- mode.replace([:read, :write, :execute])
+ permissions.replace([:read, :write, :execute])
terminal.say " → Mode: auto (all tools enabled)"
when "/mode build"
- mode.replace([:read, :write])
+ permissions.replace([:read, :write])
terminal.say " → Mode: build (read + write)"
when "/mode plan"
- mode.replace([:read])
+ permissions.replace([:read])
terminal.say " → Mode: plan (read-only)"
when "/mode verify"
- mode.replace([:read, :execute])
+ permissions.replace([:read, :execute])
terminal.say " → Mode: verify (read + execute)"
when "/mode"
terminal.say " Usage: /mode [auto|build|plan|verify]"
terminal.say ""
terminal.say " Provider: #{provider}/#{client.model}"
- terminal.say " Mode: #{mode.to_a.inspect}"
- terminal.say " Tools: #{toolbox.tools_for(mode).map { |t| t.dig(:function, :name) }}"
+ terminal.say " Permissions: #{permissions.to_a.inspect}"
+ terminal.say " Tools: #{toolbox.tools_for(permissions).map { |t| t.dig(:function, :name) }}"
when "/exit" then exit
when "/clear"
conversation.clear
terminal.say " → Conversation cleared"
when "/context"
- terminal.say conversation.dump(mode)
+ terminal.say conversation.dump(permissions)
when "/shell"
transcript = start_shell
conversation.add(role: :user, content: transcript) unless transcript.strip.empty?
terminal.say " → Shell session captured"
when "/provider"
terminal.select("Provider?", PROVIDERS) do |selected_provider|
- models = models_for(selected_provider)
- if models.empty?
- terminal.say " ✗ No models available for #{selected_provider}"
- else
- terminal.select("Model?", models) do |m|
- switch_client(selected_provider, m)
- end
+ terminal.select("Model?", models_for(selected_provider)) do |m|
+ switch_client(selected_provider, m)
end
end
when "/model"
- models = models_for(provider)
- if models.empty?
- terminal.say " ✗ No models available for #{provider}"
- else
- terminal.select("Model?", models) do |m|
- switch_model(m)
- end
+ terminal.select("Model?", models_for(provider)) do |m|
+ switch_model(m)
end
when "/env"
terminal.say " Usage: /env VAR cmd..."
@@ -236,7 +225,8 @@ module Elelem
client.is_a?(Net::Llm::OpenAI)
end
- def execute_turn(messages, tools:)
+ def execute_turn(messages)
+ tools = toolbox.tools_for(permissions)
turn_context = []
errors = 0
@@ -269,7 +259,7 @@ module Elelem
tool_calls.each do |call|
name, args = call[:name], call[:arguments]
terminal.say "\nTool> #{name}(#{args})"
- result = toolbox.run_tool(name, args)
+ result = toolbox.run_tool(name, args, permissions: permissions)
terminal.say truncate_output(format_tool_call_result(result))
turn_context << { role: "tool", tool_call_id: call[:id], content: JSON.dump(result) }
errors += 1 if result[:error]
lib/elelem/conversation.rb
@@ -8,9 +8,9 @@ module Elelem
@items = items
end
- def history_for(mode)
+ def history_for(permissions)
history = @items.dup
- history[0] = { role: "system", content: system_prompt_for(mode) }
+ history[0] = { role: "system", content: system_prompt_for(permissions) }
history
end
@@ -30,8 +30,8 @@ module Elelem
@items = default_context
end
- def dump(mode)
- JSON.pretty_generate(history_for(mode))
+ def dump(permissions)
+ JSON.pretty_generate(history_for(permissions))
end
private
@@ -40,10 +40,10 @@ module Elelem
[{ role: "system", content: prompt }]
end
- def system_prompt_for(mode)
+ def system_prompt_for(permissions)
base = system_prompt
- case mode.sort
+ case permissions.sort
when [:read]
"#{base}\n\nYou may read files on the system."
when [:write]
lib/elelem/toolbox.rb
@@ -49,6 +49,7 @@ module Elelem
def initialize
@tools_by_name = {}
+ @tool_permissions = {}
@tools = { read: [], write: [], execute: [] }
add_tool(eval_tool(binding), :execute)
add_tool(EXEC_TOOL, :execute)
@@ -59,22 +60,31 @@ module Elelem
add_tool(WRITE_TOOL, :write)
end
- def add_tool(tool, mode)
- @tools[mode] << tool
+ def add_tool(tool, permission)
+ @tools[permission] << tool
@tools_by_name[tool.name] = tool
+ @tool_permissions[tool.name] = permission
end
def register_tool(name, description, properties = {}, required = [], mode: :execute, &block)
add_tool(Tool.build(name, description, properties, required, &block), mode)
end
- def tools_for(modes)
- Array(modes).map { |mode| tools[mode].map(&:to_h) }.flatten
+ def tools_for(permissions)
+ Array(permissions).map { |permission| tools[permission].map(&:to_h) }.flatten
end
- def run_tool(name, args)
+ def run_tool(name, args, permissions: [])
resolved_name = TOOL_ALIASES.fetch(name, name)
- @tools_by_name[resolved_name]&.call(args) || { error: "Unknown tool", name: name, args: args }
+ tool = @tools_by_name[resolved_name]
+ return { error: "Unknown tool", name: name, args: args } unless tool
+
+ tool_permission = @tool_permissions[resolved_name]
+ unless Array(permissions).include?(tool_permission)
+ return { error: "Tool '#{resolved_name}' not available in current mode", name: name }
+ end
+
+ tool.call(args)
rescue => error
{ error: error.message, name: name, args: args, backtrace: error.backtrace.first(5) }
end
spec/elelem/toolbox_spec.rb
@@ -49,6 +49,28 @@ RSpec.describe Elelem::Toolbox do
end
end
+ describe "#run_tool mode enforcement" do
+ it "allows tool execution when mode matches" do
+ result = subject.run_tool("read", { "path" => __FILE__ }, permissions: [:read])
+ expect(result[:content]).to include("RSpec.describe")
+ end
+
+ it "blocks tool execution when mode does not match" do
+ result = subject.run_tool("exec", { "cmd" => "echo hello" }, permissions: [:read])
+ expect(result[:error]).to include("not available in current mode")
+ end
+
+ it "resolves aliases and enforces mode" do
+ result = subject.run_tool("bash", { "cmd" => "echo hello" }, permissions: [:read])
+ expect(result[:error]).to include("not available in current mode")
+ end
+
+ it "returns unknown tool error for non-existent tools" do
+ result = subject.run_tool("nonexistent", {}, permissions: [:read])
+ expect(result[:error]).to include("Unknown tool")
+ end
+ end
+
describe "meta-programming with eval tool" do
it "allows LLM to register new tools dynamically" do
subject.run_tool("eval", {
@@ -57,7 +79,7 @@ RSpec.describe Elelem::Toolbox do
{ greeting: "Hello, " + args['name']+ "!" }
end
RUBY
- })
+ }, permissions: [:execute])
expect(subject.tools_for(:execute)).to include(hash_including({
type: "function",
@@ -80,25 +102,25 @@ RSpec.describe Elelem::Toolbox do
{ sum: args["a"] + args["b"] }
end
RUBY
- })
+ }, permissions: [:execute])
- result = subject.run_tool("add", { "a" => 5, "b" => 3 })
+ result = subject.run_tool("add", { "a" => 5, "b" => 3 }, permissions: [:execute])
expect(result[:sum]).to eq(8)
end
it "allows LLM to inspect tool schemas" do
- result = subject.run_tool("eval", { "ruby" => "tool_schema('read')" })
+ result = subject.run_tool("eval", { "ruby" => "tool_schema('read')" }, permissions: [:execute])
expect(result[:result]).to be_a(Hash)
expect(result[:result].dig(:function, :name)).to eq("read")
end
it "executes arbitrary Ruby code" do
- result = subject.run_tool("eval", { "ruby" => "2 + 2" })
+ result = subject.run_tool("eval", { "ruby" => "2 + 2" }, permissions: [:execute])
expect(result[:result]).to eq(4)
end
it "handles errors gracefully" do
- result = subject.run_tool("eval", { "ruby" => "undefined_variable" })
+ result = subject.run_tool("eval", { "ruby" => "undefined_variable" }, permissions: [:execute])
expect(result[:error]).to include("undefined")
expect(result[:backtrace]).to be_an(Array)
end