elelem-tools 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module Tools
5
+ class Permissions
6
+ LOAD_PATHS = [
7
+ File.expand_path("permissions.json", __dir__),
8
+ "~/.agents/permissions.json",
9
+ ".agents/permissions.json"
10
+ ].freeze
11
+
12
+ # Enter, y or yes. Anything else refuses: no, a typo, or end of input (Ctrl+D, nil).
13
+ YES = ["", "y", "yes"].freeze
14
+
15
+ def initialize(rules: default_rules)
16
+ @rules = rules
17
+ end
18
+
19
+ def check(tool_name, args, input:)
20
+ policy = @rules[tool_name.to_sym] || :ask
21
+ raise "Permission denied: #{tool_name}" if policy == :deny
22
+ return nested(tool_name, args, input, policy) if Tools.nested?
23
+
24
+ # Remember whether a person just saw and approved this call. Only then
25
+ # may the command it builds skip its own prompt.
26
+ Tools.approved = false
27
+ return true if policy == :allow
28
+
29
+ prompt(tool_name, args, input).tap { Tools.approved = input.interactive? }
30
+ end
31
+
32
+ private
33
+
34
+ def nested(tool_name, args, input, policy)
35
+ policy == :allow || Tools.approved? || prompt(tool_name, args, input)
36
+ end
37
+
38
+ def load_config(path)
39
+ return {} unless File.exist?(path)
40
+
41
+ data = JSON.parse(File.read(path))
42
+ return {} unless data.is_a?(Hash)
43
+
44
+ data.transform_keys(&:to_sym).transform_values { |v| v.to_s.to_sym }
45
+ rescue JSON::ParserError
46
+ {}
47
+ end
48
+
49
+ def prompt(tool_name, args, input)
50
+ return true unless input.interactive?
51
+
52
+ answer = input.ask(" Allow #{tool_name}#{subject(tool_name, args)}? [Y/n] > ")
53
+ raise "User denied permission: #{tool_name}" unless YES.include?(answer&.strip&.downcase)
54
+
55
+ true
56
+ end
57
+
58
+ # The whole command, never cut, so nothing hidden gets approved.
59
+ def subject(tool_name, args)
60
+ return "" if args.nil? || args.empty?
61
+
62
+ command = args["command"] if tool_name.to_s == "execute"
63
+ text = command ? command.to_s : JSON.generate(args)
64
+ # Control and format characters (\r, ESC, bidi overrides) could redraw the prompt.
65
+ " `#{text.match?(/[\p{Cc}\p{Cf}]/) ? text.dump : text}`"
66
+ end
67
+
68
+ def default_rules
69
+ LOAD_PATHS.reduce({}) do |rules, path|
70
+ rules.merge(load_config(File.expand_path(path)))
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -1,23 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Elelem::Plugins.register(:shell) do |agent|
4
- strip_ansi = ->(text) do
5
- text
6
- .gsub(/^Script started.*?\n/, "")
7
- .gsub(/\nScript done.*$/, "")
8
- .gsub(/\e\].*?(?:\a|\e\\)/, "")
9
- .gsub(/\e\[[0-9;?]*[A-Za-z]/, "")
10
- .gsub(/\e[PX^_].*?\e\\/, "")
11
- .gsub(/\e./, "")
12
- .gsub(/[\b]/, "")
13
- .gsub(/\r/, "")
14
- end
3
+ Elelem.configure do |config|
4
+ config.setup(:shell) do |agent|
5
+ strip_ansi = lambda do |text|
6
+ text
7
+ .gsub(/^Script started.*?\n/, "")
8
+ .gsub(/\nScript done.*$/, "")
9
+ .gsub(/\e\].*?(?:\a|\e\\)/, "")
10
+ .gsub(/\e\[[0-9;?]*[A-Za-z]/, "")
11
+ .gsub(/\e[PX^_].*?\e\\/, "")
12
+ .gsub(/\e./, "")
13
+ .gsub(/[\b]/, "")
14
+ .gsub("\r", "")
15
+ end
15
16
 
16
- agent.commands.register("shell", description: "Start interactive shell") do
17
- transcript = Tempfile.create do |file|
18
- system("script", "-q", file.path, chdir: Dir.pwd)
19
- strip_ansi.call(File.read(file.path))
17
+ agent.commands.register("shell", description: "Start interactive shell") do
18
+ transcript = Tempfile.create do |file|
19
+ system("script", "-q", file.path, chdir: Dir.pwd)
20
+ strip_ansi.call(File.read(file.path))
21
+ end
22
+ agent.conversation.add(role: "user", content: transcript) unless transcript.strip.empty?
20
23
  end
21
- agent.conversation.add(role: "user", content: transcript) unless transcript.strip.empty?
22
24
  end
23
25
  end
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Elelem::Plugins.register(:task) do |agent|
4
- agent.toolbox.add("task",
5
- description: "Delegate subtask to focused agent (complex searches, multi-file analysis)",
6
- params: { prompt: { type: "string" } },
7
- required: ["prompt"]
8
- ) do |args|
9
- sub = agent.fork(system_prompt: "Research agent. Search, analyze, report. Be concise.")
10
- sub.turn(args["prompt"])
11
- { result: sub.conversation.last[:content] }
3
+ Elelem.configure do |config|
4
+ config.setup(:task) do |agent|
5
+ agent.toolbox.add("task",
6
+ description: "Delegate subtask to focused agent (complex searches, multi-file analysis)",
7
+ params: { prompt: { type: "string" } },
8
+ required: ["prompt"]) do |args|
9
+ sub = Elelem::Tools.fork(agent, template: "Research agent. Search, analyze, report. Be concise.")
10
+ sub.turn(args["prompt"])
11
+ { result: sub.conversation.last[:content] }
12
+ end
12
13
  end
13
14
  end
@@ -1,45 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Elelem::Plugins.register(:verify) do |agent|
4
- class Verifiers
5
- SYNTAX = {
6
- ".rb" => "ruby -c %{path}",
7
- ".erb" => "erb -x %{path} | ruby -c",
8
- ".py" => "python -m py_compile %{path}",
9
- ".go" => "go vet %{path}",
10
- ".rs" => "cargo check --quiet",
11
- ".ts" => "npx tsc --noEmit %{path}",
12
- ".js" => "node --check %{path}",
13
- }.freeze
14
-
15
- def self.for(path)
16
- return [] unless path
17
-
18
- cmds = []
19
- ext = File.extname(path)
20
- cmds << (SYNTAX[ext] % { path: path }) if SYNTAX[ext]
21
- cmds << test_runner
22
- cmds.compact
23
- end
3
+ require "shellwords"
24
4
 
25
- def self.test_runner
26
- %w[bin/test script/test].find { |s| File.executable?(s) }
27
- end
5
+ class Verifiers
6
+ SYNTAX = {
7
+ ".rb" => "ruby -c %{path}",
8
+ ".erb" => "erb -x %{path} | ruby -c",
9
+ ".py" => "python -m py_compile %{path}",
10
+ ".go" => "go vet %{path}",
11
+ ".rs" => "cargo check --quiet",
12
+ ".ts" => "npx tsc --noEmit %{path}",
13
+ ".js" => "node --check %{path}"
14
+ }.freeze
15
+
16
+ def self.for(path)
17
+ return [] unless path
18
+
19
+ cmds = []
20
+ ext = File.extname(path)
21
+ cmds << format(SYNTAX[ext], path: Shellwords.escape(path)) if SYNTAX[ext]
22
+ cmds << test_runner
23
+ cmds.compact
24
+ end
25
+
26
+ def self.test_runner
27
+ %w[bin/test script/test].find { |s| File.executable?(s) }
28
28
  end
29
+ end
30
+
31
+ Elelem.configure do |config|
32
+ config.setup(:verify) do |agent|
33
+ agent.toolbox.add("verify",
34
+ description: "Verify file syntax and run tests",
35
+ params: { path: { type: "string" } },
36
+ required: ["path"]) do |args|
37
+ path = args["path"]
38
+ Verifiers.for(path).each_with_object({ verified: [] }) do |cmd, memo|
39
+ agent.output.doing("execute", { "command" => cmd })
40
+ v = agent.toolbox.run("execute", { "command" => cmd })
41
+ break v.to_h.merge(path: path, command: cmd) unless v.ok?
42
+
43
+ memo[:verified] << cmd
44
+ end
45
+ end
29
46
 
30
- agent.toolbox.add("verify",
31
- description: "Verify file syntax and run tests",
32
- params: { path: { type: "string" } },
33
- required: ["path"]
34
- ) do |args|
35
- path = args["path"]
36
- Verifiers.for(path).inject({ verified: [] }) do |memo, cmd|
37
- agent.terminal.say agent.toolbox.header("execute", { "command" => cmd })
38
- v = agent.toolbox.run("execute", { "command" => cmd })
39
- break v.merge(path: path, command: cmd) if v[:exit_status] != 0
40
-
41
- memo[:verified] << cmd
42
- memo
47
+ agent.toolbox.after("write") do |_, result|
48
+ result[:verify] = agent.toolbox.run("verify", { "path" => result[:path] }).to_h unless result[:error]
43
49
  end
44
50
  end
45
51
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Elelem
4
4
  module Tools
5
- VERSION = "0.1.1"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/lib/elelem/tools.rb CHANGED
@@ -2,18 +2,23 @@
2
2
 
3
3
  require "elelem"
4
4
  require "pathname"
5
+ require "shellwords"
5
6
  require "tempfile"
6
7
 
7
8
  require_relative "tools/version"
8
9
  require_relative "tools/compact"
10
+ require_relative "tools/confirm"
9
11
  require_relative "tools/edit"
10
12
  require_relative "tools/eval"
13
+ require_relative "tools/exec"
14
+ require_relative "tools/fork"
11
15
  require_relative "tools/git"
12
16
  require_relative "tools/glob"
13
17
  require_relative "tools/grep"
14
18
  require_relative "tools/init"
15
19
  require_relative "tools/interview"
16
20
  require_relative "tools/list"
21
+ require_relative "tools/permissions"
17
22
  require_relative "tools/shell"
18
23
  require_relative "tools/task"
19
24
  require_relative "tools/verify"
@@ -0,0 +1,13 @@
1
+ {
2
+ "bootstrap-sha": "b15526735ac2f36abe2ac102d87a484ff3d3916b",
3
+ "packages": {
4
+ ".": {
5
+ "release-type": "ruby",
6
+ "package-name": "elelem-tools",
7
+ "version-file": "lib/elelem/tools/version.rb",
8
+ "bump-minor-pre-major": true,
9
+ "include-component-in-tag": false,
10
+ "release-as": "0.3.0"
11
+ }
12
+ }
13
+ }
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A pattern or path the model chooses must never become an option that runs
4
+ # a program or deletes files.
5
+ RSpec.describe "tool arguments that look like options" do
6
+ around { |example| Dir.mktmpdir { |dir| Dir.chdir(dir) { example.run } } }
7
+
8
+ before do
9
+ File.write("notes.txt", "hello\n")
10
+ File.write("x.sh", "#!/bin/sh\ntouch pwned\n")
11
+ File.chmod(0o755, "x.sh")
12
+ end
13
+
14
+ it "grep searches for a pattern like --pre=CMD instead of running CMD" do
15
+ run_tool("grep", { "pattern" => "--pre=./x.sh" })
16
+
17
+ expect(File).not_to exist("pwned")
18
+ end
19
+
20
+ it "grep finds a pattern that starts with -" do
21
+ File.write("flags.txt", "-v\n")
22
+
23
+ expect(run_tool("grep", { "pattern" => "-v" })[:content]).to include("flags.txt")
24
+ end
25
+
26
+ it "glob does not take a path like -delete as an action" do
27
+ run_tool("glob", { "pattern" => "zzz", "path" => "-delete" })
28
+
29
+ expect(File).to exist("notes.txt")
30
+ end
31
+
32
+ it "list takes a path like -R as a name" do
33
+ expect(run_tool("list", { "path" => "-R" })).not_to be_ok
34
+ end
35
+
36
+ describe "git" do
37
+ def executes_for(call)
38
+ seen = []
39
+ session.toolbox.before("execute") do |a|
40
+ seen << [a["command"], Elelem::Tools.nested?]
41
+ raise "stopped"
42
+ end
43
+ run_tool("git", call)
44
+ seen
45
+ end
46
+
47
+ it "asks for the exact command line of a read-only command, since config can make it run programs" do
48
+ expect(executes_for({ "command" => "status", "args" => ["--short"] })).to eq([["git status --short", false]])
49
+ end
50
+
51
+ it "asks for the exact command line of any other command" do
52
+ expect(executes_for({ "command" => "rebase", "args" => ["-xtouch pwned", "HEAD~1"] }))
53
+ .to eq([["git rebase -xtouch\\ pwned HEAD\\~1", false]])
54
+ end
55
+ end
56
+
57
+ it "git still runs ordinary commands" do
58
+ system("git", "init", "-q", ".")
59
+
60
+ expect(run_tool("git", { "command" => "status", "args" => ["--short"] })).to be_ok
61
+ end
62
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe "the edit tool" do
4
+ around { |example| Dir.mktmpdir { |dir| Dir.chdir(dir) { example.run } } }
5
+
6
+ before { File.write("a.txt", "one two two") }
7
+
8
+ it "replaces the one occurrence" do
9
+ expect(run_tool("edit", { "path" => "a.txt", "old" => "one", "new" => "1" })).to be_ok
10
+ expect(File.read("a.txt")).to eq("1 two two")
11
+ end
12
+
13
+ it "fails when old is missing" do
14
+ expect(run_tool("edit", { "path" => "a.txt", "old" => "three", "new" => "3" })[:error]).to eq("old not found")
15
+ expect(File.read("a.txt")).to eq("one two two")
16
+ end
17
+
18
+ it "fails when old matches more than once" do
19
+ expect(run_tool("edit", { "path" => "a.txt", "old" => "two", "new" => "2" })[:error]).to match(/more than once/)
20
+ end
21
+
22
+ it "counts overlapping matches" do
23
+ File.write("a.txt", "aaa")
24
+
25
+ expect(run_tool("edit", { "path" => "a.txt", "old" => "aa", "new" => "b" })[:error]).to match(/more than once/)
26
+ end
27
+
28
+ it "edits a file with bytes that are not UTF-8" do
29
+ File.binwrite("a.txt", "caf\xE9 one")
30
+ run_tool("edit", { "path" => "a.txt", "old" => "one", "new" => "1" })
31
+
32
+ expect(File.binread("a.txt")).to eq("caf\xE9 1".b)
33
+ end
34
+
35
+ it "takes new literally" do
36
+ run_tool("edit", { "path" => "a.txt", "old" => "one", "new" => "\\0 \\1" })
37
+
38
+ expect(File.read("a.txt")).to eq("\\0 \\1 two two")
39
+ end
40
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe "the grep and glob fallbacks" do
4
+ # A PATH with bash and the fallbacks, but no rg or fd.
5
+ around do |example|
6
+ original = ENV.fetch("PATH")
7
+ Dir.mktmpdir do |dir|
8
+ bin = File.join(dir, "bin")
9
+ Dir.mkdir(bin)
10
+ %w[bash grep find].each do |cmd|
11
+ found = ENV.fetch("PATH").split(":").map { |d| File.join(d, cmd) }.find { |f| File.executable?(f) }
12
+ File.symlink(found, File.join(bin, cmd))
13
+ end
14
+ File.write(File.join(dir, "notes.txt"), "needle\n")
15
+ ENV["PATH"] = bin
16
+ @bin = bin
17
+ Dir.chdir(dir) { example.run }
18
+ ensure
19
+ ENV["PATH"] = original
20
+ end
21
+ end
22
+
23
+ it "falls back when rg is there but cannot run" do
24
+ File.binwrite(File.join(@bin, "rg"), "\x7FELF\x00\x00")
25
+ File.chmod(0o755, File.join(@bin, "rg"))
26
+ result = run_tool("grep", { "pattern" => "needle" })
27
+
28
+ expect(result[:content]).to include("notes.txt")
29
+ end
30
+
31
+ it "does not fall back when rg runs and finds nothing" do
32
+ File.write(File.join(@bin, "rg"), "#!/bin/sh\nexit 1\n")
33
+ File.chmod(0o755, File.join(@bin, "rg"))
34
+ result = run_tool("grep", { "pattern" => "needle" })
35
+
36
+ expect([result[:exit_status], result[:content]]).to eq([1, "no matches"])
37
+ expect(result).to be_ok
38
+ end
39
+
40
+ it "greps with grep when rg is not installed" do
41
+ result = run_tool("grep", { "pattern" => "needle" })
42
+
43
+ expect(result[:exit_status]).to eq(0)
44
+ expect(result[:content]).to include("notes.txt")
45
+ end
46
+
47
+ it "globs with find when fd is not installed" do
48
+ result = run_tool("glob", { "pattern" => "*.txt" })
49
+
50
+ expect(result[:exit_status]).to eq(0)
51
+ expect(result[:content]).to include("notes.txt")
52
+ end
53
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe "the git tool" do
4
+ around { |example| Dir.mktmpdir { |dir| Dir.chdir(dir) { example.run } } }
5
+
6
+ it "reports a failing git command as an error" do
7
+ result = run_tool("git", { "command" => "log" })
8
+
9
+ expect(result).not_to be_ok
10
+ expect(result[:error]).to include(result[:exit_status].to_s)
11
+ end
12
+
13
+ it "passes a working git command through" do
14
+ system("git", "init", "-q", ".")
15
+
16
+ expect(run_tool("git", { "command" => "status" })).to be_ok
17
+ end
18
+
19
+ it "asks once, showing the command line it runs" do
20
+ home = Dir.home
21
+ ENV["HOME"] = Dir.pwd # ignore ~/.agents/permissions.json
22
+ system("git", "init", "-q", ".")
23
+ prompts = []
24
+ input = Class.new do
25
+ define_method(:interactive?) { true }
26
+ define_method(:ask) { |text| prompts << text and "y" }
27
+ end.new
28
+ session = Elelem::Config.build_session("demo", input: input, output: Elelem::NullOutput.new)
29
+
30
+ expect(session.toolbox.run("git", { "command" => "status" })).to be_ok
31
+ expect(prompts).to eq([" Allow execute `git status`? [Y/n] > "])
32
+ ensure
33
+ ENV["HOME"] = home
34
+ end
35
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Elelem::Tools::Permissions do
4
+ subject { described_class.new }
5
+
6
+ let(:input) { double(ask: nil, interactive?: false) }
7
+
8
+ describe "#check" do
9
+ context "with default allow policies" do
10
+ it "allows verify, whose commands are still checked as execute" do
11
+ expect(subject.check("verify", { "path" => "a.rb" }, input: input)).to be true
12
+ end
13
+
14
+ it "allows read without prompting" do
15
+ expect(subject.check("read", {}, input: input)).to be true
16
+ expect(input).not_to have_received(:ask)
17
+ end
18
+ end
19
+
20
+ context "with deny policy" do
21
+ subject { described_class.new(rules: { write: :deny }) }
22
+
23
+ it { expect { subject.check("write", {}, input: input) }.to raise_error(/Permission denied/) }
24
+ end
25
+
26
+ context "with ask policy on a non-interactive input" do
27
+ it "returns true without prompting" do
28
+ expect(subject.check("execute", {}, input: input)).to be true
29
+ expect(input).not_to have_received(:ask)
30
+ end
31
+ end
32
+
33
+ context "with ask policy on an interactive input" do
34
+ let(:input) { double(ask: answer, interactive?: true) }
35
+
36
+ context "when approved" do
37
+ let(:answer) { "y" }
38
+
39
+ it "prompts and returns true" do
40
+ expect(subject.check("execute", {}, input: input)).to be true
41
+ expect(input).to have_received(:ask)
42
+ end
43
+ end
44
+
45
+ context "when denied" do
46
+ let(:answer) { "n" }
47
+
48
+ it { expect { subject.check("execute", {}, input: input) }.to raise_error(/User denied permission/) }
49
+ end
50
+
51
+ context "at end of input" do
52
+ let(:answer) { nil }
53
+
54
+ it { expect { subject.check("execute", {}, input: input) }.to raise_error(/User denied permission/) }
55
+ end
56
+
57
+ context "when the answer is an empty line" do
58
+ let(:answer) { "" }
59
+
60
+ it { expect(subject.check("execute", {}, input: input)).to be true }
61
+ end
62
+
63
+ %w[y Y yes YES].each do |yes|
64
+ context "when the answer is #{yes}" do
65
+ let(:answer) { yes }
66
+
67
+ it { expect(subject.check("execute", {}, input: input)).to be true }
68
+ end
69
+ end
70
+
71
+ %w[no np nope x].each do |other|
72
+ context "when the answer is #{other}" do
73
+ let(:answer) { other }
74
+
75
+ it { expect { subject.check("execute", {}, input: input) }.to raise_error(/User denied permission/) }
76
+ end
77
+ end
78
+
79
+ context "with arguments" do
80
+ let(:answer) { "y" }
81
+
82
+ it "shows the whole command" do
83
+ subject.check("execute", { "command" => "rg -n foo ." }, input: input)
84
+
85
+ expect(input).to have_received(:ask).with(" Allow execute `rg -n foo .`? [Y/n] > ")
86
+ end
87
+
88
+ it "escapes newlines so no line is hidden" do
89
+ subject.check("execute", { "command" => "ls\nrm -rf x" }, input: input)
90
+
91
+ expect(input).to have_received(:ask).with(" Allow execute `\"ls\\nrm -rf x\"`? [Y/n] > ")
92
+ end
93
+
94
+ it "escapes characters that could redraw the prompt" do
95
+ subject.check("execute", { "command" => "rm -rf ~ \r Allow execute `ls" }, input: input)
96
+
97
+ expect(input).to have_received(:ask).with(" Allow execute `\"rm -rf ~ \\r Allow execute `ls\"`? [Y/n] > ")
98
+ end
99
+
100
+ it "shows other tools' arguments as JSON" do
101
+ subject.check("grep", { "pattern" => "foo" }, input: input)
102
+
103
+ expect(input).to have_received(:ask).with(" Allow grep `{\"pattern\":\"foo\"}`? [Y/n] > ")
104
+ end
105
+ end
106
+ end
107
+
108
+ context "inside a tool that runs its own command" do
109
+ let(:input) { double(interactive?: true) }
110
+ let(:answers) { [] }
111
+
112
+ before { allow(input).to receive(:ask) { answers.shift } }
113
+
114
+ def run_tool(permissions, name)
115
+ permissions.check(name, { "pattern" => "x" }, input: input)
116
+ Elelem::Tools.nested { permissions.check("execute", { "command" => "rg -n x ." }, input: input) }
117
+ end
118
+
119
+ it "does not ask again after the person approved the tool" do
120
+ answers.push("y")
121
+
122
+ expect(run_tool(subject, "grep")).to be true
123
+ expect(input).to have_received(:ask).once
124
+ end
125
+
126
+ it "still asks for the command when the tool was allowed without asking" do
127
+ answers.push("n")
128
+ allowed = described_class.new(rules: { git: :allow })
129
+
130
+ expect { run_tool(allowed, "git") }.to raise_error(/User denied permission: execute/)
131
+ end
132
+
133
+ it "still refuses a denied execute" do
134
+ answers.push("y")
135
+ denied = described_class.new(rules: { execute: :deny })
136
+
137
+ expect { run_tool(denied, "grep") }.to raise_error(/Permission denied: execute/)
138
+ end
139
+
140
+ it "forgets the approval at the next tool" do
141
+ answers.push("y", "n")
142
+ permissions = described_class.new(rules: { list: :allow })
143
+ run_tool(permissions, "grep")
144
+
145
+ expect { run_tool(permissions, "list") }.to raise_error(/User denied permission: execute/)
146
+ end
147
+ end
148
+
149
+ context "with a misspelled policy on an interactive input" do
150
+ subject { described_class.new(rules: { execute: :dney }) }
151
+
152
+ let(:input) { double(ask: "n", interactive?: true) }
153
+
154
+ it "asks" do
155
+ expect { subject.check("execute", {}, input: input) }.to raise_error(/User denied permission/)
156
+ expect(input).to have_received(:ask)
157
+ end
158
+ end
159
+ end
160
+
161
+ describe "loading a config" do
162
+ it "asks for a value that is not a string" do
163
+ Dir.mktmpdir do |dir|
164
+ path = File.join(dir, "permissions.json")
165
+ File.write(path, '{"execute": false}')
166
+ rules = subject.send(:load_config, path)
167
+ input = double(ask: "n", interactive?: true)
168
+ expect { described_class.new(rules: rules).check("execute", {}, input: input) }
169
+ .to raise_error(/User denied permission/)
170
+ end
171
+ end
172
+
173
+ it "ignores a file that is not an object" do
174
+ Dir.mktmpdir do |dir|
175
+ path = File.join(dir, "permissions.json")
176
+ File.write(path, "[]")
177
+ expect(subject.send(:load_config, path)).to eq({})
178
+ end
179
+ end
180
+ end
181
+ end