elelem-tools 0.2.0 → 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/.rubocop.yml +62 -0
- data/CHANGELOG.md +27 -0
- data/lib/elelem/tools/compact.rb +1 -1
- data/lib/elelem/tools/edit.rb +12 -8
- data/lib/elelem/tools/eval.rb +4 -5
- data/lib/elelem/tools/exec.rb +41 -1
- data/lib/elelem/tools/fork.rb +2 -2
- data/lib/elelem/tools/git.rb +7 -5
- data/lib/elelem/tools/glob.rb +9 -6
- data/lib/elelem/tools/grep.rb +14 -11
- data/lib/elelem/tools/init.rb +2 -2
- data/lib/elelem/tools/interview.rb +5 -6
- data/lib/elelem/tools/list.rb +5 -6
- data/lib/elelem/tools/permissions.json +2 -0
- data/lib/elelem/tools/permissions.rb +32 -8
- data/lib/elelem/tools/shell.rb +2 -2
- data/lib/elelem/tools/task.rb +4 -5
- data/lib/elelem/tools/verify.rb +13 -9
- data/lib/elelem/tools/version.rb +1 -1
- 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 +133 -0
- data/spec/elelem/tools/verify_spec.rb +36 -0
- data/spec/spec_helper.rb +2 -0
- data/spec/support/session.rb +11 -0
- metadata +26 -22
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4b0f5e3a9dd3ff1d691507d3eaffb33f083d57eb607ce2be6af72862820d56ad
|
|
4
|
+
data.tar.gz: 918f507fd626ed799f2469412ed990061ba51739363b3296456151691418a0de
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3f1fcbc49703bb6fbf87d96593fe5b7e5be04833c9e8af3f4cc27f9a9fc395fbb5b912b57a00eef9d82b9e5bdcf425d1f58424f9d26d384084eaa7ca9f7c7dc5
|
|
7
|
+
data.tar.gz: fc5bff90cd435be07a865803f5f269a50b2b98ba4950934031ce19d9131a1ed7e3ee30a4908784221f87e956062a9bf590ef7b4e3afa25f52fe80a24627d0d0c
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions: {}
|
|
9
|
+
|
|
10
|
+
concurrency:
|
|
11
|
+
group: release
|
|
12
|
+
cancel-in-progress: false
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
test:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
21
|
+
with:
|
|
22
|
+
persist-credentials: false
|
|
23
|
+
- uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1.299.0
|
|
24
|
+
with:
|
|
25
|
+
ruby-version: "4.0"
|
|
26
|
+
bundler-cache: true
|
|
27
|
+
- run: bin/test
|
|
28
|
+
- run: bin/lint
|
|
29
|
+
|
|
30
|
+
release-please:
|
|
31
|
+
needs: test
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
permissions:
|
|
34
|
+
contents: write
|
|
35
|
+
issues: write
|
|
36
|
+
pull-requests: write
|
|
37
|
+
outputs:
|
|
38
|
+
release_created: ${{ steps.release.outputs.release_created }}
|
|
39
|
+
steps:
|
|
40
|
+
- id: release
|
|
41
|
+
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
|
|
42
|
+
with:
|
|
43
|
+
config-file: release-please-config.json
|
|
44
|
+
manifest-file: .release-please-manifest.json
|
|
45
|
+
|
|
46
|
+
publish:
|
|
47
|
+
needs: release-please
|
|
48
|
+
if: needs.release-please.outputs.release_created == 'true'
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
permissions:
|
|
51
|
+
contents: write
|
|
52
|
+
id-token: write
|
|
53
|
+
steps:
|
|
54
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
55
|
+
with:
|
|
56
|
+
persist-credentials: false
|
|
57
|
+
- uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1.299.0
|
|
58
|
+
with:
|
|
59
|
+
ruby-version: "4.0"
|
|
60
|
+
bundler-cache: true
|
|
61
|
+
- uses: rubygems/release-gem@7f9650160c1a4e7989fdc9855807bdbd421d8b6b # v1.4.1
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
NewCops: enable
|
|
3
|
+
TargetRubyVersion: 4.0
|
|
4
|
+
SuggestExtensions: false
|
|
5
|
+
Exclude:
|
|
6
|
+
- "pkg/**/*"
|
|
7
|
+
- "bin/**/*"
|
|
8
|
+
- "vendor/**/*"
|
|
9
|
+
- "fixtures/**/*"
|
|
10
|
+
|
|
11
|
+
Metrics:
|
|
12
|
+
Enabled: false
|
|
13
|
+
|
|
14
|
+
Naming/PredicateMethod:
|
|
15
|
+
AllowedMethods:
|
|
16
|
+
- call
|
|
17
|
+
- run
|
|
18
|
+
- summarize
|
|
19
|
+
- prompt
|
|
20
|
+
|
|
21
|
+
Naming/MethodParameterName:
|
|
22
|
+
AllowedNames:
|
|
23
|
+
- me
|
|
24
|
+
- id
|
|
25
|
+
- as
|
|
26
|
+
- io
|
|
27
|
+
|
|
28
|
+
Style/GlobalVars:
|
|
29
|
+
Exclude:
|
|
30
|
+
- "spec/**/*"
|
|
31
|
+
|
|
32
|
+
Lint/EmptyBlock:
|
|
33
|
+
Exclude:
|
|
34
|
+
- "spec/**/*"
|
|
35
|
+
|
|
36
|
+
Lint/UselessDefaultValueArgument:
|
|
37
|
+
Enabled: false
|
|
38
|
+
|
|
39
|
+
Lint/RedundantRequireStatement:
|
|
40
|
+
Enabled: false
|
|
41
|
+
|
|
42
|
+
Lint/UnusedBlockArgument:
|
|
43
|
+
IgnoreEmptyBlocks: true
|
|
44
|
+
AllowUnusedKeywordArguments: true
|
|
45
|
+
|
|
46
|
+
Style/Documentation:
|
|
47
|
+
Enabled: false
|
|
48
|
+
|
|
49
|
+
Style/FormatStringToken:
|
|
50
|
+
Enabled: false
|
|
51
|
+
|
|
52
|
+
Style/FrozenStringLiteralComment:
|
|
53
|
+
Enabled: true
|
|
54
|
+
|
|
55
|
+
Style/StringLiterals:
|
|
56
|
+
EnforcedStyle: double_quotes
|
|
57
|
+
|
|
58
|
+
Style/StringLiteralsInInterpolation:
|
|
59
|
+
EnforcedStyle: double_quotes
|
|
60
|
+
|
|
61
|
+
Layout/LineLength:
|
|
62
|
+
Max: 120
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.3.0](https://github.com/elelem-sh/tools/compare/v0.2.0...v0.3.0) (2026-11-15)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* a built command skips its prompt only after a person approved the tool that built it (elelem-sh/elelem.sh[#3](https://github.com/elelem-sh/tools/issues/3)) ([0f47899](https://github.com/elelem-sh/tools/commit/0f4789942fe36b780beab507f2d406286852ca1e))
|
|
9
|
+
* a command a tool builds itself does not ask again after the tool was approved (elelem-sh/elelem.sh[#3](https://github.com/elelem-sh/tools/issues/3)) ([097b5b9](https://github.com/elelem-sh/tools/commit/097b5b98411857bd2b94ea439a41ce175ac889cd))
|
|
10
|
+
* a failed verify check is an error, so the model sees ok: false ([acddd22](https://github.com/elelem-sh/tools/commit/acddd22fe00653bf5f09110dc4d84434f17e8f37))
|
|
11
|
+
* a failing git command is an error, and grep with no matches is not (elelem-sh/elelem.sh[#20](https://github.com/elelem-sh/tools/issues/20)) ([fedc829](https://github.com/elelem-sh/tools/commit/fedc8295646dfed601cc2e9120434738b9542ac1))
|
|
12
|
+
* ask before running a tool whose permission policy is misspelled ([4adf5f6](https://github.com/elelem-sh/tools/commit/4adf5f6d22230dca701cdd72b8ee45efc346655a))
|
|
13
|
+
* edit counts overlapping matches and edits files that are not UTF-8 (elelem-sh/elelem.sh[#21](https://github.com/elelem-sh/tools/issues/21)) ([c3c2f73](https://github.com/elelem-sh/tools/commit/c3c2f737358d9df67f4fd75a4c952c70ab960b60))
|
|
14
|
+
* edit fails when old is missing or repeated, and takes new literally (elelem-sh/elelem.sh[#21](https://github.com/elelem-sh/tools/issues/21)) ([c2fd4eb](https://github.com/elelem-sh/tools/commit/c2fd4eb9f78ce1c13717c9e04ac7d08e5256bb2a))
|
|
15
|
+
* end of input at a permission prompt refuses (elelem-sh/elelem.sh[#2](https://github.com/elelem-sh/tools/issues/2)) ([dd75f4b](https://github.com/elelem-sh/tools/commit/dd75f4b2d5749b6972f2cfc7e24718654c4f809d))
|
|
16
|
+
* git always asks for its exact command line, since repository config can make any git command run a program ([d50005a](https://github.com/elelem-sh/tools/commit/d50005a9be0d56f1561887f7abe55ee4c3be23bb))
|
|
17
|
+
* git asks once, at execute, for its exact command line ([4dc6bd9](https://github.com/elelem-sh/tools/commit/4dc6bd975c7fa3441af6baee50d72e8662a176eb))
|
|
18
|
+
* grep and glob also fall back when rg or fd cannot run (elelem-sh/elelem.sh[#20](https://github.com/elelem-sh/tools/issues/20)) ([8a5a5d7](https://github.com/elelem-sh/tools/commit/8a5a5d7924dcfebd310efbb8cbcb841e02de48c3))
|
|
19
|
+
* grep and glob fall back only when rg or fd is not installed (elelem-sh/elelem.sh[#20](https://github.com/elelem-sh/tools/issues/20)) ([43180e3](https://github.com/elelem-sh/tools/commit/43180e30477eeeec5388a953376c2b53f14b865b))
|
|
20
|
+
* grep, glob, list and git never take a model-chosen value as an option that runs a program ([0aca202](https://github.com/elelem-sh/tools/commit/0aca2029521e1793c0e631dfa4df24c7eafebe46))
|
|
21
|
+
* only Enter, y or yes allow at a permission prompt (elelem-sh/elelem.sh[#2](https://github.com/elelem-sh/tools/issues/2)) ([d853f3b](https://github.com/elelem-sh/tools/commit/d853f3bf714142ce1f1303744815baa1ab44af97))
|
|
22
|
+
* only read-only git commands share the git approval; any other git command asks for its exact command line ([bbc1a0f](https://github.com/elelem-sh/tools/commit/bbc1a0ff5bc66b9c95b145653942f768df90a1a1))
|
|
23
|
+
* permission prompts escape characters that could redraw them (elelem-sh/elelem.sh[#2](https://github.com/elelem-sh/tools/issues/2)) ([fa46378](https://github.com/elelem-sh/tools/commit/fa463789635d446fced2b84dc6358d51c9389ed5))
|
|
24
|
+
* permission prompts show the command, and verify is allowed by default (elelem-sh/elelem.sh[#3](https://github.com/elelem-sh/tools/issues/3)) ([97c3437](https://github.com/elelem-sh/tools/commit/97c3437b6ec494c9b27d61e6ab11ee2632d5d3ed))
|
|
25
|
+
* require elelem ~> 0.12, the floor builtins/skills actually need ([48b5818](https://github.com/elelem-sh/tools/commit/48b5818282b37877d72c55aeb79ec20ded25c1b4))
|
|
26
|
+
* require elelem 0.13.1, verify each written file, and grep with no matches is not an error ([4dfab39](https://github.com/elelem-sh/tools/commit/4dfab39bb738f3e41e3d2cb28093a22204409911))
|
|
27
|
+
* verify escapes the path before passing it to the shell ([ed52cf8](https://github.com/elelem-sh/tools/commit/ed52cf8f4682225fff4d0a1380f6c44fb59dd614))
|
data/lib/elelem/tools/compact.rb
CHANGED
|
@@ -6,7 +6,7 @@ Elelem.configure do |config|
|
|
|
6
6
|
response = agent.turn("Summarize: accomplishments, state, next steps. Brief.")
|
|
7
7
|
agent.conversation.clear!
|
|
8
8
|
agent.conversation.add(role: "user", content: "Context: #{response}")
|
|
9
|
-
agent.output.say "
|
|
9
|
+
agent.output.say " -> compacted"
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
end
|
data/lib/elelem/tools/edit.rb
CHANGED
|
@@ -3,15 +3,19 @@
|
|
|
3
3
|
Elelem.configure do |config|
|
|
4
4
|
config.setup(:edit) do |agent|
|
|
5
5
|
agent.toolbox.add("edit",
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
description: "Replace the one occurrence of old with new in a file",
|
|
7
|
+
params: { path: { type: "string" }, old: { type: "string" }, new: { type: "string" } },
|
|
8
|
+
required: %w[path old new]) do |args|
|
|
9
|
+
content = Pathname.new(args["path"]).expand_path.read
|
|
10
|
+
first = content.index(args["old"]) unless args["old"].empty?
|
|
11
|
+
next { error: "old not found" } unless first
|
|
12
|
+
# index also finds overlapping matches, and copes with invalid bytes.
|
|
13
|
+
next { error: "old matches more than once; include more context" } if content.index(args["old"], first + 1)
|
|
14
|
+
|
|
15
|
+
# The block form takes new literally, so \0 and \1 are not expanded.
|
|
12
16
|
agent.toolbox
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
.run("write", { "path" => args["path"], "content" => content.sub(args["old"]) { args["new"] } })
|
|
18
|
+
.merge(replaced: args["old"], with: args["new"])
|
|
15
19
|
end
|
|
16
20
|
end
|
|
17
21
|
end
|
data/lib/elelem/tools/eval.rb
CHANGED
|
@@ -12,11 +12,10 @@ Elelem.configure do |config|
|
|
|
12
12
|
DESC
|
|
13
13
|
|
|
14
14
|
agent.toolbox.add("eval",
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
{ result: binding.eval(args["ruby"]) }
|
|
15
|
+
description: description,
|
|
16
|
+
params: { ruby: { type: "string" } },
|
|
17
|
+
required: ["ruby"]) do |args|
|
|
18
|
+
{ result: binding.eval(args["ruby"]) } # rubocop:disable Security/Eval
|
|
20
19
|
end
|
|
21
20
|
end
|
|
22
21
|
end
|
data/lib/elelem/tools/exec.rb
CHANGED
|
@@ -2,9 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
module Elelem
|
|
4
4
|
module Tools
|
|
5
|
+
# glob, grep and list run their command through execute. When a person was
|
|
6
|
+
# just asked about the outer call, which shows the same arguments, the
|
|
7
|
+
# command it builds does not ask again. When the outer call was allowed
|
|
8
|
+
# without asking, the command still asks. A deny on execute always applies.
|
|
9
|
+
# git is not one of them: repository config can make any git command run a
|
|
10
|
+
# program, so git is allowed by default and execute asks for its exact
|
|
11
|
+
# command line, once.
|
|
5
12
|
def self.exec(toolbox, *args)
|
|
6
13
|
command = args.flatten.map { |a| Shellwords.escape(a.to_s) }.join(" ")
|
|
7
|
-
toolbox.run("execute", { "command" => command })
|
|
14
|
+
nested { toolbox.run("execute", { "command" => command }) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.nested
|
|
18
|
+
outer = Thread.current[:elelem_tools_nested]
|
|
19
|
+
Thread.current[:elelem_tools_nested] = true
|
|
20
|
+
yield
|
|
21
|
+
ensure
|
|
22
|
+
Thread.current[:elelem_tools_nested] = outer
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.nested?
|
|
26
|
+
Thread.current[:elelem_tools_nested] == true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.approved?
|
|
30
|
+
Thread.current[:elelem_tools_approved] == true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.approved=(value)
|
|
34
|
+
Thread.current[:elelem_tools_approved] = value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# rg and grep exit 1 when nothing matches. That is an answer, not a failure.
|
|
38
|
+
def self.no_matches(result)
|
|
39
|
+
return result unless result[:exit_status] == 1 && result[:content].to_s.strip.empty?
|
|
40
|
+
|
|
41
|
+
result.to_h.except(:ok, :error).merge(content: "no matches")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# bash exits 127 when the command is not installed, 126 when it cannot run
|
|
45
|
+
# it. rg exits 1 when nothing matches, which is not a reason to fall back.
|
|
46
|
+
def self.missing?(result)
|
|
47
|
+
[126, 127].include?(result[:exit_status])
|
|
8
48
|
end
|
|
9
49
|
end
|
|
10
50
|
end
|
data/lib/elelem/tools/fork.rb
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Elelem
|
|
4
4
|
module Tools
|
|
5
|
-
def self.fork(agent,
|
|
6
|
-
Elelem::Agent.new(agent.provider, toolbox: agent.toolbox, output: agent.output,
|
|
5
|
+
def self.fork(agent, template:)
|
|
6
|
+
Elelem::Agent.new(agent.provider, toolbox: agent.toolbox, output: agent.output, template: template)
|
|
7
7
|
end
|
|
8
8
|
end
|
|
9
9
|
end
|
data/lib/elelem/tools/git.rb
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
Elelem.configure do |config|
|
|
4
4
|
config.setup(:git) do |agent|
|
|
5
5
|
agent.toolbox.add("git",
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
description: "Run git command",
|
|
7
|
+
params: { command: { type: "string" }, args: { type: "array", items: { type: "string" } } },
|
|
8
|
+
required: ["command"]) do |args|
|
|
9
|
+
# Not Tools.exec: config (aliases, hooks, filters, textconv, signing) can
|
|
10
|
+
# make any git command run a program, so execute asks for the exact line.
|
|
11
|
+
line = ["git", args["command"], *(args["args"] || [])].map { |a| Shellwords.escape(a.to_s) }.join(" ")
|
|
12
|
+
agent.toolbox.run("execute", { "command" => line })
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
agent.toolbox.after("git") do |_, result|
|
data/lib/elelem/tools/glob.rb
CHANGED
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
Elelem.configure do |config|
|
|
4
4
|
config.setup(:glob) do |agent|
|
|
5
5
|
agent.toolbox.add("glob",
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
) do |args|
|
|
6
|
+
description: "Find files matching pattern",
|
|
7
|
+
params: { pattern: { type: "string" }, path: { type: "string" } },
|
|
8
|
+
required: ["pattern"]) do |args|
|
|
10
9
|
path = args["path"].to_s.empty? ? "." : args["path"]
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
# find has no --, so a path like -delete becomes ./-delete.
|
|
11
|
+
path = "./#{path}" if path.start_with?("-")
|
|
12
|
+
result = Elelem::Tools.exec(agent.toolbox, "fd", "--glob", "--", args["pattern"], path)
|
|
13
|
+
next result unless Elelem::Tools.missing?(result)
|
|
14
|
+
|
|
15
|
+
Elelem::Tools.exec(agent.toolbox, "find", path, "-name", args["pattern"])
|
|
13
16
|
end
|
|
14
17
|
end
|
|
15
18
|
end
|
data/lib/elelem/tools/grep.rb
CHANGED
|
@@ -3,22 +3,25 @@
|
|
|
3
3
|
Elelem.configure do |config|
|
|
4
4
|
config.setup(:grep) do |agent|
|
|
5
5
|
agent.toolbox.add("grep",
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
) do |args|
|
|
6
|
+
description: "Search file contents",
|
|
7
|
+
params: { pattern: { type: "string" }, path: { type: "string" }, glob: { type: "string" } },
|
|
8
|
+
required: ["pattern"]) do |args|
|
|
10
9
|
path = args["path"].to_s.empty? ? "." : args["path"]
|
|
11
10
|
glob = args["glob"]
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
# -e and -- keep a pattern or path starting with - from being read as an
|
|
13
|
+
# option, such as rg --pre=CMD, which runs a program on every file.
|
|
14
|
+
rg_args = ["rg", "-n"]
|
|
14
15
|
rg_args += ["-g", glob] if glob
|
|
16
|
+
rg_args += ["-e", args["pattern"], "--", path]
|
|
15
17
|
result = Elelem::Tools.exec(agent.toolbox, *rg_args)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
if Elelem::Tools.missing?(result)
|
|
19
|
+
grep_args = ["grep", "-rn"]
|
|
20
|
+
grep_args += ["--include", glob] if glob
|
|
21
|
+
grep_args += ["-e", args["pattern"], "--", path]
|
|
22
|
+
result = Elelem::Tools.exec(agent.toolbox, *grep_args)
|
|
23
|
+
end
|
|
24
|
+
Elelem::Tools.no_matches(result)
|
|
22
25
|
end
|
|
23
26
|
end
|
|
24
27
|
end
|
data/lib/elelem/tools/init.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Elelem.configure do |config|
|
|
4
4
|
config.setup(:init) do |agent|
|
|
5
5
|
agent.commands.register("init", description: "Generate AGENTS.md") do
|
|
6
|
-
|
|
6
|
+
template = <<~PROMPT
|
|
7
7
|
AGENTS.md generator. Analyze codebase and write AGENTS.md to project root.
|
|
8
8
|
|
|
9
9
|
# AGENTS.md Spec (https://agents.md/)
|
|
@@ -25,7 +25,7 @@ Elelem.configure do |config|
|
|
|
25
25
|
Keep it minimal. No fluff.
|
|
26
26
|
PROMPT
|
|
27
27
|
|
|
28
|
-
Elelem::Tools.fork(agent,
|
|
28
|
+
Elelem::Tools.fork(agent, template: template).turn("Generate AGENTS.md for this project")
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
end
|
|
@@ -3,12 +3,11 @@
|
|
|
3
3
|
Elelem.configure do |config|
|
|
4
4
|
config.setup(:interview) do |agent|
|
|
5
5
|
agent.toolbox.add("interview",
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
) do |args|
|
|
6
|
+
description: "Ask the user a question and wait for their response",
|
|
7
|
+
params: {
|
|
8
|
+
question: { type: "string", description: "The question to ask the user" }
|
|
9
|
+
},
|
|
10
|
+
required: ["question"]) do |args|
|
|
12
11
|
agent.output.say(args["question"], as: :markdown)
|
|
13
12
|
{ answer: agent.input.ask("> ") }
|
|
14
13
|
end
|
data/lib/elelem/tools/list.rb
CHANGED
|
@@ -3,14 +3,13 @@
|
|
|
3
3
|
Elelem.configure do |config|
|
|
4
4
|
config.setup(:list) do |agent|
|
|
5
5
|
agent.toolbox.add("list",
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
) do |args|
|
|
6
|
+
description: "List directory contents",
|
|
7
|
+
params: { path: { type: "string" }, recursive: { type: "boolean" } },
|
|
8
|
+
required: [],
|
|
9
|
+
aliases: ["ls"]) do |args|
|
|
11
10
|
path = args["path"] && !args["path"].empty? ? args["path"] : "."
|
|
12
11
|
flags = args["recursive"] ? "-laR" : "-la"
|
|
13
|
-
Elelem::Tools.exec(agent.toolbox, "ls", flags, path)
|
|
12
|
+
Elelem::Tools.exec(agent.toolbox, "ls", flags, "--", path)
|
|
14
13
|
end
|
|
15
14
|
end
|
|
16
15
|
end
|
|
@@ -9,25 +9,39 @@ module Elelem
|
|
|
9
9
|
".agents/permissions.json"
|
|
10
10
|
].freeze
|
|
11
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
|
+
|
|
12
15
|
def initialize(rules: default_rules)
|
|
13
16
|
@rules = rules
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
def check(tool_name, args, input:)
|
|
17
20
|
policy = @rules[tool_name.to_sym] || :ask
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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? }
|
|
23
30
|
end
|
|
24
31
|
|
|
25
32
|
private
|
|
26
33
|
|
|
34
|
+
def nested(tool_name, args, input, policy)
|
|
35
|
+
policy == :allow || Tools.approved? || prompt(tool_name, args, input)
|
|
36
|
+
end
|
|
37
|
+
|
|
27
38
|
def load_config(path)
|
|
28
39
|
return {} unless File.exist?(path)
|
|
29
40
|
|
|
30
|
-
JSON.parse(File.read(path))
|
|
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 }
|
|
31
45
|
rescue JSON::ParserError
|
|
32
46
|
{}
|
|
33
47
|
end
|
|
@@ -35,12 +49,22 @@ module Elelem
|
|
|
35
49
|
def prompt(tool_name, args, input)
|
|
36
50
|
return true unless input.interactive?
|
|
37
51
|
|
|
38
|
-
answer = input.ask(" Allow? [Y/n] > ")
|
|
39
|
-
raise "User denied permission: #{tool_name}"
|
|
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)
|
|
40
54
|
|
|
41
55
|
true
|
|
42
56
|
end
|
|
43
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
|
+
|
|
44
68
|
def default_rules
|
|
45
69
|
LOAD_PATHS.reduce({}) do |rules, path|
|
|
46
70
|
rules.merge(load_config(File.expand_path(path)))
|
data/lib/elelem/tools/shell.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Elelem.configure do |config|
|
|
4
4
|
config.setup(:shell) do |agent|
|
|
5
|
-
strip_ansi =
|
|
5
|
+
strip_ansi = lambda do |text|
|
|
6
6
|
text
|
|
7
7
|
.gsub(/^Script started.*?\n/, "")
|
|
8
8
|
.gsub(/\nScript done.*$/, "")
|
|
@@ -11,7 +11,7 @@ Elelem.configure do |config|
|
|
|
11
11
|
.gsub(/\e[PX^_].*?\e\\/, "")
|
|
12
12
|
.gsub(/\e./, "")
|
|
13
13
|
.gsub(/[\b]/, "")
|
|
14
|
-
.gsub(
|
|
14
|
+
.gsub("\r", "")
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
agent.commands.register("shell", description: "Start interactive shell") do
|
data/lib/elelem/tools/task.rb
CHANGED
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
Elelem.configure do |config|
|
|
4
4
|
config.setup(:task) do |agent|
|
|
5
5
|
agent.toolbox.add("task",
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
sub = Elelem::Tools.fork(agent, system_prompt: "Research agent. Search, analyze, report. Be concise.")
|
|
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.")
|
|
11
10
|
sub.turn(args["prompt"])
|
|
12
11
|
{ result: sub.conversation.last[:content] }
|
|
13
12
|
end
|
data/lib/elelem/tools/verify.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
3
5
|
class Verifiers
|
|
4
6
|
SYNTAX = {
|
|
5
7
|
".rb" => "ruby -c %{path}",
|
|
@@ -8,7 +10,7 @@ class Verifiers
|
|
|
8
10
|
".go" => "go vet %{path}",
|
|
9
11
|
".rs" => "cargo check --quiet",
|
|
10
12
|
".ts" => "npx tsc --noEmit %{path}",
|
|
11
|
-
".js" => "node --check %{path}"
|
|
13
|
+
".js" => "node --check %{path}"
|
|
12
14
|
}.freeze
|
|
13
15
|
|
|
14
16
|
def self.for(path)
|
|
@@ -16,7 +18,7 @@ class Verifiers
|
|
|
16
18
|
|
|
17
19
|
cmds = []
|
|
18
20
|
ext = File.extname(path)
|
|
19
|
-
cmds << (SYNTAX[ext]
|
|
21
|
+
cmds << format(SYNTAX[ext], path: Shellwords.escape(path)) if SYNTAX[ext]
|
|
20
22
|
cmds << test_runner
|
|
21
23
|
cmds.compact
|
|
22
24
|
end
|
|
@@ -29,19 +31,21 @@ end
|
|
|
29
31
|
Elelem.configure do |config|
|
|
30
32
|
config.setup(:verify) do |agent|
|
|
31
33
|
agent.toolbox.add("verify",
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
) do |args|
|
|
34
|
+
description: "Verify file syntax and run tests",
|
|
35
|
+
params: { path: { type: "string" } },
|
|
36
|
+
required: ["path"]) do |args|
|
|
36
37
|
path = args["path"]
|
|
37
|
-
Verifiers.for(path).
|
|
38
|
+
Verifiers.for(path).each_with_object({ verified: [] }) do |cmd, memo|
|
|
38
39
|
agent.output.doing("execute", { "command" => cmd })
|
|
39
40
|
v = agent.toolbox.run("execute", { "command" => cmd })
|
|
40
|
-
break v.merge(path: path, command: cmd)
|
|
41
|
+
break v.to_h.merge(path: path, command: cmd) unless v.ok?
|
|
41
42
|
|
|
42
43
|
memo[:verified] << cmd
|
|
43
|
-
memo
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
|
+
|
|
47
|
+
agent.toolbox.after("write") do |_, result|
|
|
48
|
+
result[:verify] = agent.toolbox.run("verify", { "path" => result[:path] }).to_h unless result[:error]
|
|
49
|
+
end
|
|
46
50
|
end
|
|
47
51
|
end
|
data/lib/elelem/tools/version.rb
CHANGED
|
@@ -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
|
|
@@ -7,6 +7,10 @@ RSpec.describe Elelem::Tools::Permissions do
|
|
|
7
7
|
|
|
8
8
|
describe "#check" do
|
|
9
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
|
+
|
|
10
14
|
it "allows read without prompting" do
|
|
11
15
|
expect(subject.check("read", {}, input: input)).to be true
|
|
12
16
|
expect(input).not_to have_received(:ask)
|
|
@@ -43,6 +47,135 @@ RSpec.describe Elelem::Tools::Permissions do
|
|
|
43
47
|
|
|
44
48
|
it { expect { subject.check("execute", {}, input: input) }.to raise_error(/User denied permission/) }
|
|
45
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
|
|
46
179
|
end
|
|
47
180
|
end
|
|
48
181
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe "the verify tool" do
|
|
4
|
+
around { |example| Dir.mktmpdir { |dir| Dir.chdir(dir) { example.run } } }
|
|
5
|
+
|
|
6
|
+
it "fails when a check fails" do
|
|
7
|
+
File.write("bad.rb", "def oops")
|
|
8
|
+
result = run_tool("verify", { "path" => "bad.rb" })
|
|
9
|
+
|
|
10
|
+
expect(result).not_to be_ok
|
|
11
|
+
expect(result[:error]).to include(result[:exit_status].to_s)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it "checks a written file once, in the write's own result" do
|
|
15
|
+
checks = []
|
|
16
|
+
session.toolbox.before("execute") { |args| checks << args["command"] }
|
|
17
|
+
result = run_tool("write", { "path" => "bad.rb", "content" => "def oops" })
|
|
18
|
+
|
|
19
|
+
expect(result[:verify]).to include(command: "ruby -c bad.rb", ok: false)
|
|
20
|
+
expect(checks).to eq(["ruby -c bad.rb"])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "passes when every check passes" do
|
|
24
|
+
File.write("good.rb", "1")
|
|
25
|
+
|
|
26
|
+
expect(run_tool("verify", { "path" => "good.rb" })[:verified]).to eq(["ruby -c good.rb"])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "passes the path to the shell as one word" do
|
|
30
|
+
path = "a b; touch pwned.rb"
|
|
31
|
+
File.write(path, "1")
|
|
32
|
+
|
|
33
|
+
expect(run_tool("verify", { "path" => path })).to be_ok
|
|
34
|
+
expect(File).not_to exist("pwned.rb")
|
|
35
|
+
end
|
|
36
|
+
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../lib/elelem/tools"
|
|
4
|
+
require_relative "support/session"
|
|
4
5
|
|
|
5
6
|
RSpec.configure do |config|
|
|
6
7
|
config.disable_monkey_patching!
|
|
8
|
+
config.include SessionHelper
|
|
7
9
|
|
|
8
10
|
config.expect_with :rspec do |c|
|
|
9
11
|
c.syntax = :expect
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SessionHelper
|
|
4
|
+
def session
|
|
5
|
+
@session ||= Elelem::Config.build_session("demo", input: Elelem::NullInput.new, output: Elelem::NullOutput.new)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def run_tool(name, args)
|
|
9
|
+
session.toolbox.run(name, args)
|
|
10
|
+
end
|
|
11
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: elelem-tools
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- mo khan
|
|
@@ -13,30 +13,22 @@ dependencies:
|
|
|
13
13
|
name: elelem
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
|
-
- - "
|
|
16
|
+
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version:
|
|
19
|
-
|
|
20
|
-
prerelease: false
|
|
21
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
-
requirements:
|
|
23
|
-
- - "~>"
|
|
18
|
+
version: 0.13.1
|
|
19
|
+
- - "<"
|
|
24
20
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '
|
|
26
|
-
- !ruby/object:Gem::Dependency
|
|
27
|
-
name: elelem-builtins
|
|
28
|
-
requirement: !ruby/object:Gem::Requirement
|
|
29
|
-
requirements:
|
|
30
|
-
- - "~>"
|
|
31
|
-
- !ruby/object:Gem::Version
|
|
32
|
-
version: '0.1'
|
|
21
|
+
version: '1'
|
|
33
22
|
type: :runtime
|
|
34
23
|
prerelease: false
|
|
35
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
25
|
requirements:
|
|
37
|
-
- - "
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: 0.13.1
|
|
29
|
+
- - "<"
|
|
38
30
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '
|
|
31
|
+
version: '1'
|
|
40
32
|
- !ruby/object:Gem::Dependency
|
|
41
33
|
name: json
|
|
42
34
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -86,7 +78,11 @@ executables: []
|
|
|
86
78
|
extensions: []
|
|
87
79
|
extra_rdoc_files: []
|
|
88
80
|
files:
|
|
81
|
+
- ".github/workflows/release.yml"
|
|
82
|
+
- ".release-please-manifest.json"
|
|
89
83
|
- ".rspec"
|
|
84
|
+
- ".rubocop.yml"
|
|
85
|
+
- CHANGELOG.md
|
|
90
86
|
- LICENSE.txt
|
|
91
87
|
- Rakefile
|
|
92
88
|
- lib/elelem/tools.rb
|
|
@@ -108,15 +104,23 @@ files:
|
|
|
108
104
|
- lib/elelem/tools/task.rb
|
|
109
105
|
- lib/elelem/tools/verify.rb
|
|
110
106
|
- lib/elelem/tools/version.rb
|
|
107
|
+
- release-please-config.json
|
|
108
|
+
- spec/elelem/tools/arguments_spec.rb
|
|
109
|
+
- spec/elelem/tools/edit_spec.rb
|
|
110
|
+
- spec/elelem/tools/fallback_spec.rb
|
|
111
|
+
- spec/elelem/tools/git_spec.rb
|
|
111
112
|
- spec/elelem/tools/permissions_spec.rb
|
|
113
|
+
- spec/elelem/tools/verify_spec.rb
|
|
112
114
|
- spec/spec_helper.rb
|
|
113
|
-
|
|
115
|
+
- spec/support/session.rb
|
|
116
|
+
homepage: https://elelem.sh
|
|
114
117
|
licenses:
|
|
115
118
|
- MIT
|
|
116
119
|
metadata:
|
|
117
120
|
allowed_push_host: https://rubygems.org
|
|
118
|
-
homepage_uri: https://
|
|
119
|
-
source_code_uri: https://
|
|
121
|
+
homepage_uri: https://elelem.sh
|
|
122
|
+
source_code_uri: https://github.com/elelem-sh/tools
|
|
123
|
+
rubygems_mfa_required: 'true'
|
|
120
124
|
rdoc_options: []
|
|
121
125
|
require_paths:
|
|
122
126
|
- lib
|
|
@@ -131,7 +135,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
131
135
|
- !ruby/object:Gem::Version
|
|
132
136
|
version: '0'
|
|
133
137
|
requirements: []
|
|
134
|
-
rubygems_version: 4.0.
|
|
138
|
+
rubygems_version: 4.0.6
|
|
135
139
|
specification_version: 4
|
|
136
140
|
summary: Optional tools for elelem.
|
|
137
141
|
test_files: []
|