Commit a3a81a4

mo khan <mo@mokhan.ca>
2026-01-14 18:32:39
refactor: rename modes to permissions
1 parent 8f73d19
Changed files (4)
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