Commit 9a2cb59

mo khan <mo@mokhan.ca>
2026-09-07 03:09:16
feat: add permission-gated confirm plugin
Moved from elelem core (lib/elelem/{permissions,plugins/confirm}.rb, spec/elelem/permissions_spec.rb), which dropped the whole feature when it removed its spec suite. Elelem::Permissions is now Elelem::Tools::Permissions; LOAD_PATHS moved off the dead ~/.elelem convention onto ~/.agents/permissions.json and .agents/permissions.json, matching elelem-mcp's mcp.json. Claude-Session: https://claude.ai/code/session_01DUuj4amvRrPxDnHPHBkvz5
1 parent be099b9
lib/elelem/tools/confirm.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:confirm) do |agent|
+  permissions = Elelem::Tools::Permissions.new
+
+  agent.toolbox.before do |args, tool_name:|
+    permissions.check(tool_name, args, terminal: agent.terminal)
+  end
+end
lib/elelem/tools/permissions.json
@@ -0,0 +1,5 @@
+{
+  "execute": "ask",
+  "read": "allow",
+  "write": "allow"
+}
lib/elelem/tools/permissions.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Elelem
+  module Tools
+    class Permissions
+      LOAD_PATHS = [
+        File.expand_path("permissions.json", __dir__),
+        "~/.agents/permissions.json",
+        ".agents/permissions.json"
+      ].freeze
+
+      def initialize(rules: default_rules)
+        @rules = rules
+      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 terminal.interactive?
+
+        answer = terminal.ask("  Allow? [Y/n] > ")&.downcase
+        raise "User denied permission: #{tool_name}" if answer == "n"
+
+        true
+      end
+
+      def default_rules
+        LOAD_PATHS.reduce({}) do |rules, path|
+          rules.merge(load_config(File.expand_path(path)))
+        end
+      end
+    end
+  end
+end
lib/elelem/tools.rb
@@ -6,6 +6,7 @@ require "tempfile"
 
 require_relative "tools/version"
 require_relative "tools/compact"
+require_relative "tools/confirm"
 require_relative "tools/edit"
 require_relative "tools/eval"
 require_relative "tools/git"
@@ -14,6 +15,7 @@ require_relative "tools/grep"
 require_relative "tools/init"
 require_relative "tools/interview"
 require_relative "tools/list"
+require_relative "tools/permissions"
 require_relative "tools/shell"
 require_relative "tools/task"
 require_relative "tools/verify"
spec/elelem/tools/permissions_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Tools::Permissions do
+  subject { described_class.new }
+
+  let(:terminal) { double(ask: nil, interactive?: false) }
+
+  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
+      subject { described_class.new(rules: { write: :deny }) }
+
+      it { expect { subject.check("write", {}, terminal: terminal) }.to raise_error(/Permission denied/) }
+    end
+
+    context "with ask policy on a non-interactive terminal" do
+      it "returns true without prompting" do
+        expect(subject.check("execute", {}, terminal: terminal)).to be true
+        expect(terminal).not_to have_received(:ask)
+      end
+    end
+
+    context "with ask policy on an interactive terminal" do
+      let(:terminal) { double(ask: answer, interactive?: true) }
+
+      context "when approved" do
+        let(:answer) { "y" }
+
+        it "prompts and returns true" do
+          expect(subject.check("execute", {}, terminal: terminal)).to be true
+          expect(terminal).to have_received(:ask)
+        end
+      end
+
+      context "when denied" do
+        let(:answer) { "n" }
+
+        it { expect { subject.check("execute", {}, terminal: terminal) }.to raise_error(/User denied permission/) }
+      end
+    end
+  end
+end
spec/spec_helper.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require_relative "../lib/elelem/tools"
+
+RSpec.configure do |config|
+  config.disable_monkey_patching!
+
+  config.expect_with :rspec do |c|
+    c.syntax = :expect
+  end
+end
.rspec
@@ -0,0 +1,1 @@
+--require spec_helper
Gemfile
@@ -6,3 +6,4 @@ gemspec name: "elelem-tools"
 
 gem "irb"
 gem "rake", "~> 13.0"
+gem "rspec", "~> 3.0"
Gemfile.lock
@@ -12,6 +12,7 @@ GEM
     base64 (0.3.0)
     bigdecimal (4.1.2)
     date (3.5.1)
+    diff-lcs (1.6.2)
     digest (3.2.1)
     elelem (0.10.0)
       base64 (~> 0.1)
@@ -83,6 +84,19 @@ GEM
     reline (0.7.0)
       io-console (~> 0.5)
     resolv (0.7.2)
+    rspec (3.13.2)
+      rspec-core (~> 3.13.0)
+      rspec-expectations (~> 3.13.0)
+      rspec-mocks (~> 3.13.0)
+    rspec-core (3.13.6)
+      rspec-support (~> 3.13.0)
+    rspec-expectations (3.13.5)
+      diff-lcs (>= 1.2.0, < 2.0)
+      rspec-support (~> 3.13.0)
+    rspec-mocks (3.13.8)
+      diff-lcs (>= 1.2.0, < 2.0)
+      rspec-support (~> 3.13.0)
+    rspec-support (3.13.7)
     securerandom (0.4.1)
     shellwords (0.2.2)
     simpleidn (0.3.0)
@@ -101,12 +115,14 @@ DEPENDENCIES
   elelem-tools!
   irb
   rake (~> 13.0)
+  rspec (~> 3.0)
 
 CHECKSUMS
   base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
   bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
   bundler (4.0.20) sha256=7978a8ac648767f5e635bc522445b79e80a52b907a39a36c2d8085ed6bc762ae
   date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
+  diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
   digest (3.2.1) sha256=ab3312b4e272d7d5dc41c564c86a25861a1f34ac5153374199a0b74861395947
   elelem (0.10.0) sha256=de67f3a28351640da471e6e80e7a1d79e83e293cbc502a9e98c3c609cad2e237
   elelem-tools (0.1.1)
@@ -134,6 +150,11 @@ CHECKSUMS
   regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
   reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
   resolv (0.7.2) sha256=626d044d975ab2daac759bf898416f1b51e2cb8dcd6727c2b5b5b28b97ead2e1
+  rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587
+  rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
+  rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
+  rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47
+  rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c
   securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
   shellwords (0.2.2) sha256=b8695a791de2f71472de5abdc3f4332f6535a4177f55d8f99e7e44266cd32f94
   simpleidn (0.3.0) sha256=12ca730bed2f3db04d11e9bfd1bca3e11fb37f55b21eb2e9793fb5814bf54d03
Rakefile
@@ -1,4 +1,8 @@
 # frozen_string_literal: true
 
 require "bundler/gem_tasks"
-task default: %i[]
+require "rspec/core/rake_task"
+
+RSpec::Core::RakeTask.new(:spec)
+
+task default: %i[spec]