Comparing changes
v0.6.0
→
v0.7.0
7 commits
12 files changed
Commits
Changed files (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)