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.
- checksums.yaml +4 -4
- data/.github/workflows/release.yml +61 -0
- data/.release-please-manifest.json +3 -0
- data/.rspec +1 -0
- data/.rubocop.yml +62 -0
- data/CHANGELOG.md +27 -0
- data/Rakefile +5 -1
- data/lib/elelem/tools/compact.rb +8 -6
- data/lib/elelem/tools/confirm.rb +11 -0
- data/lib/elelem/tools/edit.rb +17 -11
- data/lib/elelem/tools/eval.rb +15 -14
- data/lib/elelem/tools/exec.rb +50 -0
- data/lib/elelem/tools/fork.rb +9 -0
- data/lib/elelem/tools/git.rb +14 -10
- data/lib/elelem/tools/glob.rb +14 -9
- data/lib/elelem/tools/grep.rb +22 -17
- data/lib/elelem/tools/init.rb +22 -20
- data/lib/elelem/tools/interview.rb +11 -10
- data/lib/elelem/tools/list.rb +11 -10
- data/lib/elelem/tools/permissions.json +7 -0
- data/lib/elelem/tools/permissions.rb +75 -0
- data/lib/elelem/tools/shell.rb +19 -17
- data/lib/elelem/tools/task.rb +10 -9
- data/lib/elelem/tools/verify.rb +43 -37
- data/lib/elelem/tools/version.rb +1 -1
- data/lib/elelem/tools.rb +5 -0
- data/release-please-config.json +13 -0
- data/spec/elelem/tools/arguments_spec.rb +62 -0
- data/spec/elelem/tools/edit_spec.rb +40 -0
- data/spec/elelem/tools/fallback_spec.rb +53 -0
- data/spec/elelem/tools/git_spec.rb +35 -0
- data/spec/elelem/tools/permissions_spec.rb +181 -0
- data/spec/elelem/tools/verify_spec.rb +36 -0
- data/spec/spec_helper.rb +13 -0
- data/spec/support/session.rb +11 -0
- metadata +47 -7
|
@@ -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
|
data/lib/elelem/tools/shell.rb
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
Elelem
|
|
4
|
-
|
|
5
|
-
text
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
data/lib/elelem/tools/task.rb
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
Elelem
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
data/lib/elelem/tools/verify.rb
CHANGED
|
@@ -1,45 +1,51 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
data/lib/elelem/tools/version.rb
CHANGED
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
|