Comparing changes

v0.6.0 v0.7.0
7 commits 12 files changed

Commits

c37d5af chore: release v0.7.0 mo khan 2026-01-14 21:03:59
a3a81a4 refactor: rename modes to permissions mo khan 2026-01-14 18:32:39
8f73d19 fix: bug in status parsing mo khan 2026-01-12 20:49:48
96307f8 fix: skip branch if nil mo khan 2026-01-12 20:49:12
lib/elelem/agent.rb
@@ -9,32 +9,26 @@ 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
       @provider = provider
       @toolbox = toolbox
       @client = build_client(provider, model)
-      @terminal = terminal || Terminal.new(
-        commands: COMMANDS,
-        modes: MODES,
-        providers: PROVIDERS,
-        env_vars: ENV_VARS
-      )
+      @terminal = terminal || default_terminal
+      @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 +36,54 @@ module Elelem
 
     private
 
-    def handle_command(input, mode)
+    def default_terminal
+      Terminal.new(
+        commands: COMMANDS,
+        env_vars: ENV_VARS,
+        modes: MODES,
+        providers: PROVIDERS
+      )
+    end
+
+    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 +229,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
 
@@ -244,7 +238,7 @@ module Elelem
         content = ""
         tool_calls = []
 
-        terminal.write "Thinking... "
+        terminal.waiting
         begin
           client.fetch(messages + turn_context, tools) do |chunk|
             case chunk[:type]
@@ -269,7 +263,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/git_context.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+module Elelem
+  class GitContext
+    MAX_DIFF_LINES = 100
+
+    def initialize(shell = Elelem.shell)
+      @shell = shell
+    end
+
+    def to_s
+      return "" unless git_repo?
+
+      parts = []
+      parts << "Branch: #{branch}" if branch
+      parts << status_section if status.any?
+      parts << diff_section if staged_diff.any? || unstaged_diff.any?
+      parts << recent_commits_section if recent_commits.any?
+      parts.join("\n\n")
+    end
+
+    private
+
+    def git_repo?
+      @shell.execute("git", args: ["rev-parse", "--git-dir"])["exit_status"].zero?
+    end
+
+    def branch
+      @branch ||= @shell.execute("git", args: ["branch", "--show-current"])["stdout"].strip.then { |b| b.empty? ? nil : b }
+    end
+
+    def status
+      @status ||= @shell.execute("git", args: ["status", "--porcelain"])["stdout"].lines.map(&:chomp)
+    end
+
+    def staged_diff
+      @staged_diff ||= @shell.execute("git", args: ["diff", "--cached", "--stat"])["stdout"].lines
+    end
+
+    def unstaged_diff
+      @unstaged_diff ||= @shell.execute("git", args: ["diff", "--stat"])["stdout"].lines
+    end
+
+    def recent_commits
+      @recent_commits ||= @shell.execute("git", args: ["log", "--oneline", "-5"])["stdout"].lines.map(&:strip)
+    end
+
+    def status_section
+      modified = status.select { |l| l[0] == "M" || l[1] == "M" }.map { |l| l[3..] }
+      added = status.select { |l| l[0] == "A" || l.start_with?("??") }.map { |l| l[3..] }
+      deleted = status.select { |l| l[0] == "D" || l[1] == "D" }.map { |l| l[3..] }
+
+      lines = []
+      lines << "Modified: #{modified.join(', ')}" if modified.any?
+      lines << "Added: #{added.join(', ')}" if added.any?
+      lines << "Deleted: #{deleted.join(', ')}" if deleted.any?
+      lines.any? ? "Working tree:\n#{lines.join("\n")}" : nil
+    end
+
+    def diff_section
+      lines = []
+      lines << "Staged:\n#{truncate(staged_diff)}" if staged_diff.any?
+      lines << "Unstaged:\n#{truncate(unstaged_diff)}" if unstaged_diff.any?
+      lines.join("\n\n")
+    end
+
+    def recent_commits_section
+      "Recent commits:\n#{recent_commits.join("\n")}"
+    end
+
+    def truncate(lines)
+      if lines.size > MAX_DIFF_LINES
+        lines.first(MAX_DIFF_LINES).join + "\n... (#{lines.size - MAX_DIFF_LINES} more lines)"
+      else
+        lines.join
+      end
+    end
+  end
+end
lib/elelem/system_prompt.erb
@@ -10,3 +10,7 @@ You are a trusted terminal agent. You act on behalf of the user - executing task
 ## System
 
 <%= `uname -s`.strip %> · <%= ENV['PWD'] %>
+
+## Git State
+
+<%= Elelem::GitContext.new.to_s %>
lib/elelem/terminal.rb
@@ -7,6 +7,7 @@ module Elelem
       @modes = modes
       @providers = providers
       @env_vars = env_vars
+      @spinner_thread = nil
       setup_completion
     end
 
@@ -15,13 +16,28 @@ module Elelem
     end
 
     def say(message)
+      stop_spinner
       $stdout.puts message
     end
 
     def write(message)
+      stop_spinner
       $stdout.print message
     end
 
+    def waiting
+      @spinner_thread = Thread.new do
+        frames = %w[| / - \\]
+        i = 0
+        loop do
+          $stdout.print "\r#{frames[i % frames.length]} "
+          $stdout.flush
+          i += 1
+          sleep 0.1
+        end
+      end
+    end
+
     def select(question, options, &block)
       CLI::UI::Prompt.ask(question) do |handler|
         options.each do |option|
@@ -32,6 +48,14 @@ module Elelem
 
     private
 
+    def stop_spinner
+      return unless @spinner_thread
+
+      @spinner_thread.kill
+      @spinner_thread = nil
+      $stdout.print "\r  \r"
+    end
+
     def setup_completion
       Reline.autocompletion = true
       Reline.completion_proc = ->(target, preposing) { complete(target, preposing) }
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
lib/elelem/version.rb
@@ -1,5 +1,5 @@
 # frozen_string_literal: true
 
 module Elelem
-  VERSION = "0.6.0"
+  VERSION = "0.7.0"
 end
\ No newline at end of file
lib/elelem.rb
@@ -17,6 +17,7 @@ require "timeout"
 require_relative "elelem/agent"
 require_relative "elelem/application"
 require_relative "elelem/conversation"
+require_relative "elelem/git_context"
 require_relative "elelem/terminal"
 require_relative "elelem/tool"
 require_relative "elelem/toolbox"
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
CHANGELOG.md
@@ -1,5 +1,22 @@
 ## [Unreleased]
 
+## [0.7.0] - 2026-01-14
+
+### Added
+- ASCII spinner animation while waiting for LLM responses
+- `Terminal#waiting` method with automatic cleanup on next output
+- Decision-making principles in system prompt (prefer reversible actions, ask when uncertain)
+- Mode enforcement tests
+
+### Changed
+- Renamed internal `mode` concept to `permissions` for clarity (read/write/execute are permissions, plan/build/verify are modes)
+- Refactored `Toolbox#run_tool` to accept `permissions:` parameter
+
+### Fixed
+- **Security**: Mode restrictions now enforced at execution time, not just schema time
+  - Previously, LLMs could call tools outside their mode by guessing tool names
+  - Now `run_tool` validates the tool is allowed for the current permission set
+
 ## [0.6.0] - 2026-01-12
 
 ### Added
elelem.gemspec
@@ -29,6 +29,7 @@ Gem::Specification.new do |spec|
     "lib/elelem/agent.rb",
     "lib/elelem/application.rb",
     "lib/elelem/conversation.rb",
+    "lib/elelem/git_context.rb",
     "lib/elelem/system_prompt.erb",
     "lib/elelem/terminal.rb",
     "lib/elelem/tool.rb",
Gemfile.lock
@@ -1,7 +1,7 @@
 PATH
   remote: .
   specs:
-    elelem (0.6.0)
+    elelem (0.7.0)
       cli-ui (~> 2.0)
       erb (~> 6.0)
       fileutils (~> 1.0)