lex-exec 0.1.2 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7b3366ef2850eb31f9ac4e550547c509a2742c4d7905baa5e7785659144d7422
4
- data.tar.gz: dae5536d6035f67546136f12ffe1fc52d602bdffc8dd5eeba120fec2313c4cd7
3
+ metadata.gz: ce39d50a81ab331806b98ec1790aac9581d34425ea262d2beba00b88d0871536
4
+ data.tar.gz: d9c0e98ac019d2989d786444bf6b18175f64a811d8e543b5800e8800738693ea
5
5
  SHA512:
6
- metadata.gz: 0b2eb12cff14bf96642ce20f6799bd985a9e8d07afe1947d557dd9f589df4e2625a4725abf33f41b71d0b03febfcdd196cf819b388431af543507a37c06421eb
7
- data.tar.gz: 8cf8d948c812075b068479e25737a0d43999831f2dea87a018d6d62fe8c1049d96bb27076f88173ed91d4bd4ae1939669d768402ee9e8ce8e5c215c14014d909
6
+ metadata.gz: b41990118730fea1147ee66c270b49a34d1fedab7fdb347dfe0f71716a4959aecbacebf79c63fecd5e63dcf184b8f44ca99c7aafcc6c1267b749aed856d56550
7
+ data.tar.gz: 4346cb561525e40ad698a2f66746714f331cbb1296bc87af18c796eb08c079fce0db6a463bb57a3b4bfc0ec4281c9411d39d97e06e67d80c1e6fae15f8bc6d55
@@ -0,0 +1,16 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+
7
+ jobs:
8
+ ci:
9
+ uses: LegionIO/.github/.github/workflows/ci.yml@main
10
+
11
+ release:
12
+ needs: ci
13
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
14
+ uses: LegionIO/.github/.github/workflows/release.yml@main
15
+ secrets:
16
+ rubygems-api-key: ${{ secrets.RUBYGEMS_API_KEY }}
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ *.gem
10
+ Gemfile.lock
11
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,56 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.4
3
+ NewCops: enable
4
+ SuggestExtensions: false
5
+
6
+ Layout/LineLength:
7
+ Max: 160
8
+
9
+ Layout/SpaceAroundEqualsInParameterDefault:
10
+ EnforcedStyle: space
11
+
12
+ Layout/HashAlignment:
13
+ EnforcedHashRocketStyle: table
14
+ EnforcedColonStyle: table
15
+
16
+ Metrics/MethodLength:
17
+ Max: 50
18
+
19
+ Metrics/ClassLength:
20
+ Max: 1500
21
+
22
+ Metrics/ModuleLength:
23
+ Max: 1500
24
+
25
+ Metrics/BlockLength:
26
+ Max: 40
27
+ Exclude:
28
+ - 'spec/**/*'
29
+
30
+ Metrics/AbcSize:
31
+ Max: 60
32
+
33
+ Metrics/CyclomaticComplexity:
34
+ Max: 15
35
+
36
+ Metrics/PerceivedComplexity:
37
+ Max: 17
38
+
39
+ Style/Documentation:
40
+ Enabled: false
41
+
42
+ Style/SymbolArray:
43
+ Enabled: true
44
+
45
+ Style/FrozenStringLiteralComment:
46
+ Enabled: true
47
+ EnforcedStyle: always
48
+
49
+ Naming/FileName:
50
+ Enabled: false
51
+
52
+ Naming/PredicateMethod:
53
+ Enabled: false
54
+
55
+ Naming/PredicatePrefix:
56
+ Enabled: false
data/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ ## [0.1.3] - 2026-03-20
4
+
5
+ ### Fixed
6
+ - Gemspec missing `spec.files` declaration — gem build previously produced an empty gem with no files
7
+ - Entry point missing `require_relative` for `Helpers::Checkpoint` and `Helpers::Worktree`
8
+
9
+ ## [0.1.2] - 2026-03-20
10
+
11
+ ### Added
12
+ - `Helpers::Worktree` for git worktree creation, removal, and listing
13
+ - `Helpers::Checkpoint` for hidden-ref-based state snapshots and restore
14
+
15
+ ## [0.1.1] - 2026-03-18
16
+
17
+ ### Fixed
18
+ - `Git.push` default branch changed from `'master'` to `'main'` to match GitHub's default since 2020
19
+
20
+ ## [0.1.0] - 2026-03-13
21
+
22
+ ### Added
23
+ - Initial release
data/CLAUDE.md ADDED
@@ -0,0 +1,159 @@
1
+ # lex-exec: Sandboxed Shell Execution for LegionIO
2
+
3
+ **Repository Level 3 Documentation**
4
+ - **Parent**: `/Users/miverso2/rubymine/legion/extensions-core/CLAUDE.md`
5
+ - **Grandparent**: `/Users/miverso2/rubymine/legion/CLAUDE.md`
6
+
7
+ ## Purpose
8
+
9
+ Legion Extension that provides sandboxed shell execution within a LegionIO cluster. Runs shell commands, git operations, and bundler workflows with allowlist enforcement and a thread-safe in-memory audit log. Used by agentic swarm pipelines (e.g., `lex-swarm-github`) to validate and publish generated extensions.
10
+
11
+ **GitHub**: https://github.com/LegionIO/lex-exec
12
+ **License**: Apache-2.0
13
+ **Version**: 0.1.1
14
+
15
+ ## Architecture
16
+
17
+ ```
18
+ Legion::Extensions::Exec
19
+ ├── Runners/
20
+ │ ├── Shell # execute, audit — allowlist + blocked pattern enforcement, Open3 subprocess
21
+ │ ├── Git # init, add, commit, push, status, create_repo — git + gh CLI wrappers
22
+ │ └── Bundler # install, exec_rspec, exec_rubocop — structured output parsing
23
+ ├── Helpers/
24
+ │ ├── Sandbox # allowlist check, blocked pattern check
25
+ │ ├── AuditLog # thread-safe ring buffer (1000 entries max)
26
+ │ ├── ResultParser # parses RSpec and RuboCop stdout into structured hashes
27
+ │ └── Constants # ALLOWED_COMMANDS array, BLOCKED_PATTERNS array
28
+ └── Client # includes Shell + Git + Bundler; stores base_path
29
+ ```
30
+
31
+ No explicit actors directory. The framework auto-generates subscription actors for each runner.
32
+
33
+ ## Gem Info
34
+
35
+ | Field | Value |
36
+ |-------|-------|
37
+ | Gem name | `lex-exec` |
38
+ | Module | `Legion::Extensions::Exec` |
39
+ | Version | `0.1.1` |
40
+ | Ruby | `>= 3.4` |
41
+ | Runtime deps | `open3`, `timeout` (stdlib only) |
42
+ | License | Apache-2.0 |
43
+
44
+ ## File Structure
45
+
46
+ ```
47
+ lex-exec/
48
+ ├── lex-exec.gemspec
49
+ ├── Gemfile
50
+ ├── lib/
51
+ │ └── legion/
52
+ │ └── extensions/
53
+ │ ├── exec.rb # Entry point; requires all helpers/runners/client
54
+ │ └── exec/
55
+ │ ├── version.rb
56
+ │ ├── client.rb # Client class; includes Shell + Git + Bundler
57
+ │ ├── helpers/
58
+ │ │ ├── sandbox.rb # Allowlist + blocked pattern enforcement
59
+ │ │ ├── audit_log.rb # Thread-safe ring buffer (1000 entries)
60
+ │ │ ├── result_parser.rb # Parses RSpec/RuboCop stdout into structured hashes
61
+ │ │ └── constants.rb # ALLOWED_COMMANDS and BLOCKED_PATTERNS
62
+ │ └── runners/
63
+ │ ├── shell.rb # execute, audit
64
+ │ ├── git.rb # init, add, commit, push, status, create_repo
65
+ │ └── bundler.rb # install, exec_rspec, exec_rubocop
66
+ └── spec/
67
+ ```
68
+
69
+ ## Security Model
70
+
71
+ ### Allowlisted Commands
72
+
73
+ Only the following base commands are permitted:
74
+
75
+ ```
76
+ bundle git gh ruby rspec rubocop
77
+ ls cat mkdir cp mv rm touch echo wc head tail
78
+ ```
79
+
80
+ ### Blocked Patterns
81
+
82
+ Always rejected regardless of allowlist:
83
+ - `rm -rf /` — root deletion
84
+ - `rm -rf ~` — home deletion
85
+ - `rm -rf ..` — parent directory deletion
86
+ - `sudo` — privilege escalation
87
+ - `chmod 777` — world-writable permissions
88
+ - `curl | sh` — pipe-to-shell download execution
89
+ - Redirects to `/etc` or `/usr`
90
+
91
+ ### Limits
92
+
93
+ | Parameter | Default | Maximum |
94
+ |-----------|---------|---------|
95
+ | Timeout | 120,000 ms | 600,000 ms (10 min) |
96
+ | Output size (stdout + stderr) | — | 1,048,576 bytes (1 MB, truncated with flag) |
97
+ | Audit log entries | — | 1,000 (ring buffer, oldest evicted) |
98
+
99
+ ## Runner Details
100
+
101
+ ### Shell (`Runners::Shell`)
102
+
103
+ `extend self` — all methods callable on the module directly.
104
+
105
+ **`execute(command:, cwd: nil, timeout: 120_000, **)`**
106
+ - Validates command against allowlist and blocked patterns
107
+ - Runs with `Open3.capture3` under a `Timeout::timeout` guard
108
+ - Returns `{ success:, stdout:, stderr:, exit_code:, duration_ms:, truncated: }`
109
+ - On failure: `{ success: false, error: :blocked/:timeout/:exception }`
110
+
111
+ **`audit(limit: 100, **)`**
112
+ - Returns entries from the ring buffer
113
+ - Returns `{ success: true, entries: [], stats: { total:, success:, failure:, avg_duration_ms: } }`
114
+
115
+ ### Git (`Runners::Git`)
116
+
117
+ `extend self`.
118
+
119
+ | Method | Notes |
120
+ |--------|-------|
121
+ | `init(path:)` | `git init` |
122
+ | `add(path:, files:)` | `git add` with string or array |
123
+ | `commit(path:, message:)` | `git commit -m` |
124
+ | `push(path:, remote: 'origin', branch: 'main', set_upstream: false)` | `-u` flag when `set_upstream: true` |
125
+ | `status(path:)` | Parses `--porcelain` output into structured form |
126
+ | `create_repo(name:, org:, description: '', public: true)` | `gh repo create --clone`; defaults branch to `main` |
127
+
128
+ ### Bundler (`Runners::Bundler`)
129
+
130
+ `extend self`.
131
+
132
+ | Method | Notes |
133
+ |--------|-------|
134
+ | `install(path:)` | 5 min timeout |
135
+ | `exec_rspec(path:, format: 'progress')` | `result[:parsed]` => `{ examples:, failures:, pending:, passed: }` |
136
+ | `exec_rubocop(path:, autocorrect: false)` | `result[:parsed]` => `{ offenses:, files_inspected: }` |
137
+
138
+ ## Client
139
+
140
+ `Client.new(base_path: '/path/to/project')` stores `@base_path`. All runner methods delegate to the corresponding `extend self` modules with `path: @base_path`.
141
+
142
+ ## Integration Points
143
+
144
+ - **`lex-codegen`**: Natural companion. Codegen produces the file tree; exec runs `bundle install`, `bundle exec rspec`, `bundle exec rubocop`, then commits and pushes.
145
+ - **`lex-swarm-github`**: Swarm pipeline calls exec to validate and publish generated extensions.
146
+
147
+ ## Testing
148
+
149
+ ```bash
150
+ bundle install
151
+ bundle exec rspec # 127 examples, 0 failures
152
+ bundle exec rubocop # 0 offenses
153
+ ```
154
+
155
+ Specs mock `Open3.capture3` and `Timeout::timeout` — no real shell commands execute in tests.
156
+
157
+ ---
158
+
159
+ **Maintained By**: Matthew Iverson (@Esity)
data/Gemfile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+ gemspec
5
+
6
+ group :test do
7
+ gem 'rake'
8
+ gem 'rspec', '~> 3.13'
9
+ gem 'rspec_junit_formatter'
10
+ gem 'rubocop', '~> 1.75'
11
+ gem 'rubocop-rspec'
12
+ gem 'simplecov'
13
+ end
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Esity
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,204 @@
1
+ # lex-exec
2
+
3
+ Sandboxed shell execution extension for LegionIO. Runs shell commands, git operations, and bundler workflows with allowlist enforcement and an in-memory audit log. Used by agentic swarm pipelines (e.g., `lex-swarm-github`) to validate and publish generated extensions.
4
+
5
+ ## Installation
6
+
7
+ Add to your `Gemfile`:
8
+
9
+ ```ruby
10
+ gem 'lex-exec'
11
+ ```
12
+
13
+ Or install directly:
14
+
15
+ ```bash
16
+ gem install lex-exec
17
+ ```
18
+
19
+ ## Overview
20
+
21
+ `lex-exec` provides three runners:
22
+
23
+ - **Shell** - Execute arbitrary shell commands against an allowlist
24
+ - **Git** - Common git operations (init, add, commit, push, status, create_repo)
25
+ - **Bundler** - Run `bundle install`, `rspec`, and `rubocop` with structured output parsing
26
+
27
+ All shell execution goes through a `Sandbox` that checks the base command against an allowlist and rejects commands matching blocked patterns. Every execution is recorded in a thread-safe in-memory `AuditLog`.
28
+
29
+ ## Allowlisted Commands
30
+
31
+ Only the following base commands are permitted:
32
+
33
+ ```
34
+ bundle git gh ruby rspec rubocop
35
+ ls cat mkdir cp mv rm touch echo wc head tail
36
+ ```
37
+
38
+ Commands not in this list are rejected before execution with `success: false, error: :blocked`.
39
+
40
+ ## Blocked Patterns
41
+
42
+ The following patterns are always rejected regardless of allowlist membership:
43
+
44
+ - `rm -rf /` (root deletion)
45
+ - `rm -rf ~` (home deletion)
46
+ - `rm -rf ..` (parent directory deletion)
47
+ - `sudo` (privilege escalation)
48
+ - `chmod 777` (world-writable permissions)
49
+ - `curl | sh` (pipe-to-shell download execution)
50
+ - Redirects to `/etc` or `/usr`
51
+
52
+ ## Limits
53
+
54
+ | Parameter | Default | Maximum |
55
+ |-----------|---------|---------|
56
+ | Timeout | 120,000 ms | 600,000 ms (10 min) |
57
+ | Output size (stdout/stderr) | — | 1,048,576 bytes (1 MB) |
58
+ | Audit log entries | — | 1,000 (ring buffer) |
59
+
60
+ Output exceeding 1 MB is truncated; `truncated: true` is set in the result and recorded in the audit log.
61
+
62
+ ## Usage
63
+
64
+ ### Direct runner calls
65
+
66
+ ```ruby
67
+ # Shell runner
68
+ result = Legion::Extensions::Exec::Runners::Shell.execute(
69
+ command: 'bundle exec rspec',
70
+ cwd: '/path/to/project',
71
+ timeout: 120_000
72
+ )
73
+ # => { success: true, stdout: "...", stderr: "...", exit_code: 0, duration_ms: 1234, truncated: false }
74
+
75
+ # Retrieve audit log
76
+ audit = Legion::Extensions::Exec::Runners::Shell.audit(limit: 50)
77
+ # => { success: true, entries: [...], stats: { total:, success:, failure:, avg_duration_ms: } }
78
+ ```
79
+
80
+ ### Client interface
81
+
82
+ `Legion::Extensions::Exec::Client` provides a unified interface delegating to all three runners:
83
+
84
+ ```ruby
85
+ client = Legion::Extensions::Exec::Client.new(base_path: '/path/to/project')
86
+
87
+ # Shell
88
+ client.execute(command: 'ls -la')
89
+ client.audit(limit: 25)
90
+
91
+ # Git
92
+ client.init
93
+ client.add(files: ['lib/foo.rb', 'spec/foo_spec.rb'])
94
+ client.commit(message: 'add foo runner')
95
+ client.push(remote: 'origin', branch: 'main', set_upstream: true)
96
+ client.status
97
+ client.create_repo(name: 'lex-foo', org: 'LegionIO', description: 'foo extension', public: true)
98
+
99
+ # Bundler
100
+ client.install
101
+ client.exec_rspec(format: 'progress')
102
+ client.exec_rubocop(autocorrect: false)
103
+ ```
104
+
105
+ ### Git runner
106
+
107
+ ```ruby
108
+ # Initialize a new repo
109
+ Legion::Extensions::Exec::Runners::Git.init(path: '/path/to/dir')
110
+
111
+ # Stage files
112
+ Legion::Extensions::Exec::Runners::Git.add(path: '/path/to/dir', files: '.')
113
+ Legion::Extensions::Exec::Runners::Git.add(path: '/path/to/dir', files: ['file1.rb', 'file2.rb'])
114
+
115
+ # Commit
116
+ Legion::Extensions::Exec::Runners::Git.commit(path: '/path/to/dir', message: 'initial commit')
117
+
118
+ # Push (set_upstream: true adds -u flag)
119
+ Legion::Extensions::Exec::Runners::Git.push(path: '/path/to/dir', remote: 'origin', branch: 'main', set_upstream: true)
120
+
121
+ # Status (parses --porcelain output into structured form)
122
+ Legion::Extensions::Exec::Runners::Git.status(path: '/path/to/dir')
123
+
124
+ # Create GitHub repo via gh CLI
125
+ Legion::Extensions::Exec::Runners::Git.create_repo(
126
+ name: 'lex-myext',
127
+ org: 'LegionIO',
128
+ description: 'my extension',
129
+ public: true
130
+ )
131
+ ```
132
+
133
+ ### Bundler runner
134
+
135
+ ```ruby
136
+ # Install dependencies (5 min timeout)
137
+ Legion::Extensions::Exec::Runners::Bundler.install(path: '/path/to/project')
138
+
139
+ # Run RSpec with parsed output
140
+ result = Legion::Extensions::Exec::Runners::Bundler.exec_rspec(path: '/path/to/project', format: 'progress')
141
+ # result[:parsed] => { examples:, failures:, pending:, passed: }
142
+
143
+ # Run RuboCop with parsed output
144
+ result = Legion::Extensions::Exec::Runners::Bundler.exec_rubocop(path: '/path/to/project')
145
+ # result[:parsed] => { offenses:, files_inspected: }
146
+
147
+ # Run RuboCop with autocorrect
148
+ Legion::Extensions::Exec::Runners::Bundler.exec_rubocop(path: '/path/to/project', autocorrect: true)
149
+ ```
150
+
151
+ ## Return Value Shape
152
+
153
+ All runners return a hash with at minimum:
154
+
155
+ ```ruby
156
+ {
157
+ success: true | false,
158
+ stdout: "...", # present on success
159
+ stderr: "...", # present on success
160
+ exit_code: 0, # present on success
161
+ duration_ms: 123, # present on success
162
+ truncated: false # true if stdout exceeded 1 MB
163
+ }
164
+ ```
165
+
166
+ On failure:
167
+
168
+ ```ruby
169
+ { success: false, error: :blocked, reason: "command 'sudo' is not in the allowlist" }
170
+ { success: false, error: :timeout, timeout_ms: 120_000 }
171
+ { success: false, error: "invalid argument message" }
172
+ ```
173
+
174
+ ## Agentic Pipeline Integration
175
+
176
+ `lex-exec` is designed to work alongside `lex-codegen` in the agentic swarm pipeline:
177
+
178
+ ```
179
+ lex-codegen (scaffold_extension) # generates file tree from ERB templates
180
+ |
181
+ v
182
+ lex-exec (Bundler.install) # installs gem dependencies
183
+ |
184
+ v
185
+ lex-exec (Bundler.exec_rspec) # runs test suite, returns pass/fail counts
186
+ |
187
+ v
188
+ lex-exec (Bundler.exec_rubocop) # lints code, returns offense count
189
+ |
190
+ v
191
+ lex-exec (Git.commit + Git.push) # commits and pushes validated extension
192
+ ```
193
+
194
+ ## Development
195
+
196
+ ```bash
197
+ bundle install
198
+ bundle exec rspec
199
+ bundle exec rubocop
200
+ ```
201
+
202
+ ## License
203
+
204
+ Apache-2.0
data/lex-exec.gemspec ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/exec/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-exec'
7
+ spec.version = Legion::Extensions::Exec::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX::Exec'
12
+ spec.description = 'Safe sandboxed shell execution for LegionIO'
13
+ spec.homepage = 'https://github.com/LegionIO/lex-exec'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 3.4'
16
+
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-exec'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-exec'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-exec'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-exec/issues'
22
+ spec.metadata['rubygems_mfa_required'] = 'true'
23
+
24
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
26
+ end
27
+
28
+ spec.require_paths = ['lib']
29
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Exec
6
+ class Client
7
+ def initialize(base_path: Dir.pwd)
8
+ @base_path = base_path
9
+ end
10
+
11
+ # Shell delegation
12
+ def execute(command:, cwd: @base_path, **)
13
+ Runners::Shell.execute(command: command, cwd: cwd, **)
14
+ end
15
+
16
+ def audit(**)
17
+ Runners::Shell.audit(**)
18
+ end
19
+
20
+ # Git delegation
21
+ def init(path: @base_path, **)
22
+ Runners::Git.init(path: path)
23
+ end
24
+
25
+ def add(path: @base_path, **)
26
+ Runners::Git.add(path: path, **)
27
+ end
28
+
29
+ def commit(path: @base_path, **)
30
+ Runners::Git.commit(path: path, **)
31
+ end
32
+
33
+ def push(path: @base_path, **)
34
+ Runners::Git.push(path: path, **)
35
+ end
36
+
37
+ def status(path: @base_path, **)
38
+ Runners::Git.status(path: path)
39
+ end
40
+
41
+ def create_repo(**)
42
+ Runners::Git.create_repo(**)
43
+ end
44
+
45
+ # Bundler delegation
46
+ def install(path: @base_path, **)
47
+ Runners::Bundler.install(path: path)
48
+ end
49
+
50
+ def exec_rspec(path: @base_path, **)
51
+ Runners::Bundler.exec_rspec(path: path, **)
52
+ end
53
+
54
+ def exec_rubocop(path: @base_path, **)
55
+ Runners::Bundler.exec_rubocop(path: path, **)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Exec
6
+ module Helpers
7
+ class AuditLog
8
+ MAX_ENTRIES = 1000
9
+
10
+ def initialize
11
+ @entries = []
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ def record(command:, cwd:, exit_code:, duration_ms:, truncated: false)
16
+ entry = {
17
+ command: command,
18
+ cwd: cwd,
19
+ exit_code: exit_code,
20
+ duration_ms: duration_ms,
21
+ truncated: truncated,
22
+ executed_at: Time.now.utc.iso8601
23
+ }
24
+
25
+ @mutex.synchronize do
26
+ @entries << entry
27
+ @entries.shift while @entries.size > MAX_ENTRIES
28
+ end
29
+ end
30
+
31
+ def entries(limit: 50)
32
+ @mutex.synchronize { @entries.last(limit) }
33
+ end
34
+
35
+ def stats
36
+ @mutex.synchronize do
37
+ total = @entries.size
38
+ success = @entries.count { |e| e[:exit_code].zero? }
39
+ failure = total - success
40
+ avg_dur = total.zero? ? 0 : (@entries.sum { |e| e[:duration_ms] } / total.to_f).round(2)
41
+
42
+ { total: total, success: success, failure: failure, avg_duration_ms: avg_dur }
43
+ end
44
+ end
45
+
46
+ def clear
47
+ @mutex.synchronize { @entries.clear }
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Exec
8
+ module Helpers
9
+ module Checkpoint
10
+ class << self
11
+ def save(worktree_path:, label:, task_id:)
12
+ Dir.chdir(worktree_path) do
13
+ Open3.capture3('git', 'add', '-A')
14
+
15
+ tree_sha, _err, tree_status = Open3.capture3('git', 'write-tree')
16
+ return { success: false, reason: :write_tree_failed } unless tree_status.success?
17
+
18
+ tree_sha = tree_sha.strip
19
+ commit_sha, _err, commit_status = Open3.capture3(
20
+ 'git', 'commit-tree', tree_sha, '-m', "checkpoint: #{label}"
21
+ )
22
+ return { success: false, reason: :commit_tree_failed } unless commit_status.success?
23
+
24
+ commit_sha = commit_sha.strip
25
+ ref = "refs/checkpoints/#{task_id}/#{label}"
26
+ _out, _err, ref_status = Open3.capture3('git', 'update-ref', ref, commit_sha)
27
+ return { success: false, reason: :update_ref_failed } unless ref_status.success?
28
+
29
+ Open3.capture3('git', 'reset', 'HEAD')
30
+ { success: true, ref: ref, commit: commit_sha }
31
+ end
32
+ end
33
+
34
+ def restore(worktree_path:, label:, task_id:)
35
+ ref = "refs/checkpoints/#{task_id}/#{label}"
36
+ Dir.chdir(worktree_path) do
37
+ _stdout, stderr, status = Open3.capture3('git', 'checkout', ref, '--', '.')
38
+ status.success? ? { success: true, ref: ref } : { success: false, message: stderr.strip }
39
+ end
40
+ end
41
+
42
+ def list_checkpoints(task_id:)
43
+ pattern = "refs/checkpoints/#{task_id}/"
44
+ stdout, = Open3.capture3('git', 'for-each-ref', '--format=%(refname) %(creatordate:iso8601)', pattern)
45
+ checkpoints = stdout.strip.split("\n").filter_map do |line|
46
+ next if line.strip.empty?
47
+
48
+ parts = line.split(' ', 2)
49
+ label = parts[0].sub(pattern, '')
50
+ { label: label, created_at: parts[1] }
51
+ end
52
+ { success: true, checkpoints: checkpoints }
53
+ end
54
+
55
+ def prune(task_id:)
56
+ pattern = "refs/checkpoints/#{task_id}/"
57
+ stdout, = Open3.capture3('git', 'for-each-ref', '--format=%(refname)', pattern)
58
+ refs = stdout.strip.split("\n").reject(&:empty?)
59
+ refs.each { |ref| Open3.capture3('git', 'update-ref', '-d', ref) }
60
+ { success: true, pruned: refs.size }
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Exec
6
+ module Helpers
7
+ module Constants
8
+ DEFAULT_TIMEOUT = 120_000 # 120 seconds in ms
9
+ MAX_TIMEOUT = 600_000 # 10 minutes in ms
10
+ MAX_OUTPUT_BYTES = 1_048_576 # 1 MB
11
+
12
+ ALLOWED_COMMANDS = %w[
13
+ bundle git gh ruby rspec rubocop ls cat mkdir cp mv rm touch echo wc head tail
14
+ ].freeze
15
+
16
+ BLOCKED_PATTERNS = [
17
+ %r{rm\s+-rf\s+/},
18
+ /rm\s+-rf\s+~/,
19
+ /rm\s+-rf\s+\.\./,
20
+ /sudo/,
21
+ /chmod\s+777/,
22
+ /curl.*\|.*sh/,
23
+ %r{>\s*/etc},
24
+ %r{>\s*/usr}
25
+ ].freeze
26
+
27
+ AUDIT_FIELDS = %i[command cwd exit_code duration_ms executed_at truncated].freeze
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Exec
6
+ module Helpers
7
+ module ResultParser
8
+ module_function
9
+
10
+ def parse_rspec(output)
11
+ examples = 0
12
+ failures = 0
13
+ pending = 0
14
+
15
+ if (match = output.match(/(\d+)\s+examples?,\s+(\d+)\s+failures?/))
16
+ examples = match[1].to_i
17
+ failures = match[2].to_i
18
+ end
19
+
20
+ if (match = output.match(/(\d+)\s+pending/))
21
+ pending = match[1].to_i
22
+ end
23
+
24
+ { examples: examples, failures: failures, pending: pending, passed: failures.zero? }
25
+ end
26
+
27
+ def parse_rubocop(output)
28
+ files = 0
29
+ offenses = 0
30
+
31
+ if (match = output.match(/(\d+)\s+files?\s+inspected,\s+(\d+)\s+offenses?\s+detected/))
32
+ files = match[1].to_i
33
+ offenses = match[2].to_i
34
+ end
35
+
36
+ { files: files, offenses: offenses, clean: offenses.zero? }
37
+ end
38
+
39
+ def parse_git_status(output)
40
+ modified = []
41
+ untracked = []
42
+ deleted = []
43
+
44
+ output.each_line do |line|
45
+ code = line[0..1].strip
46
+ file = line[3..].strip
47
+
48
+ case code
49
+ when 'M', 'MM', 'AM'
50
+ modified << file
51
+ when '??'
52
+ untracked << file
53
+ when 'D', 'MD'
54
+ deleted << file
55
+ end
56
+ end
57
+
58
+ {
59
+ clean: modified.empty? && untracked.empty? && deleted.empty?,
60
+ modified: modified,
61
+ untracked: untracked,
62
+ deleted: deleted
63
+ }
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Exec
6
+ module Helpers
7
+ class Sandbox
8
+ def initialize(allowed_commands: Helpers::Constants::ALLOWED_COMMANDS,
9
+ blocked_patterns: Helpers::Constants::BLOCKED_PATTERNS)
10
+ @allowed_commands = allowed_commands
11
+ @blocked_patterns = blocked_patterns
12
+ end
13
+
14
+ def allowed?(command)
15
+ base = base_command(command)
16
+
17
+ return { allowed: false, reason: "command '#{base}' is not in the allowlist" } unless @allowed_commands.include?(base)
18
+
19
+ @blocked_patterns.each do |pattern|
20
+ return { allowed: false, reason: "command matches blocked pattern: #{pattern.source}" } if pattern.match?(command)
21
+ end
22
+
23
+ { allowed: true, reason: nil }
24
+ end
25
+
26
+ def sanitize(command)
27
+ command.gsub(/[`$()]/, '')
28
+ end
29
+
30
+ private
31
+
32
+ def base_command(command)
33
+ command.strip.split(/\s+/).first.to_s
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'fileutils'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Exec
9
+ module Helpers
10
+ module Worktree
11
+ class << self
12
+ def create(task_id:, branch: nil, base_ref: 'HEAD')
13
+ branch ||= "legion/#{task_id}"
14
+ path = worktree_path(task_id)
15
+ return { success: false, reason: :already_exists } if Dir.exist?(path)
16
+
17
+ FileUtils.mkdir_p(File.dirname(path))
18
+ _stdout, stderr, status = Open3.capture3('git', 'worktree', 'add', path, '-b', branch, base_ref)
19
+ if status.success?
20
+ { success: true, path: path, branch: branch }
21
+ else
22
+ { success: false, reason: :git_error, message: stderr.strip }
23
+ end
24
+ end
25
+
26
+ def remove(task_id:)
27
+ path = worktree_path(task_id)
28
+ return { success: false, reason: :not_found } unless Dir.exist?(path)
29
+
30
+ _stdout, stderr, status = Open3.capture3('git', 'worktree', 'remove', path, '--force')
31
+ if status.success?
32
+ { success: true }
33
+ else
34
+ { success: false, reason: :git_error, message: stderr.strip }
35
+ end
36
+ end
37
+
38
+ def list
39
+ stdout, _stderr, _status = Open3.capture3('git', 'worktree', 'list', '--porcelain')
40
+ worktrees = parse_worktree_list(stdout)
41
+ { success: true, worktrees: worktrees }
42
+ end
43
+
44
+ def worktree_path(task_id)
45
+ base = Legion::Settings.dig(:worktree, :base_dir) if defined?(Legion::Settings)
46
+ File.join(base || File.join(Dir.pwd, '.legion-worktrees'), task_id.to_s)
47
+ end
48
+
49
+ private
50
+
51
+ def parse_worktree_list(output)
52
+ output.split("\n\n").filter_map do |block|
53
+ lines = block.strip.split("\n")
54
+ next if lines.empty?
55
+
56
+ path = lines.find { |l| l.start_with?('worktree ') }&.sub('worktree ', '')
57
+ branch = lines.find { |l| l.start_with?('branch ') }&.sub('branch ', '')
58
+ { path: path, branch: branch } if path
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Exec
6
+ module Runners
7
+ module Bundler
8
+ module_function
9
+
10
+ def install(path:, **)
11
+ Runners::Shell.execute(command: 'bundle install', cwd: path, timeout: 300_000)
12
+ end
13
+
14
+ def exec_rspec(path:, format: 'progress', **)
15
+ result = Runners::Shell.execute(
16
+ command: "bundle exec rspec --format #{format}",
17
+ cwd: path,
18
+ timeout: 300_000
19
+ )
20
+ return result unless result[:stdout] || result[:stderr]
21
+
22
+ raw = result[:stdout] || result[:stderr] || ''
23
+ parsed = Helpers::ResultParser.parse_rspec(raw)
24
+ result.merge(parsed: parsed)
25
+ end
26
+
27
+ def exec_rubocop(path:, autocorrect: false, **)
28
+ cmd = autocorrect ? 'bundle exec rubocop -A' : 'bundle exec rubocop'
29
+ result = Runners::Shell.execute(command: cmd, cwd: path, timeout: 120_000)
30
+ return result unless result[:stdout]
31
+
32
+ parsed = Helpers::ResultParser.parse_rubocop(result[:stdout] || '')
33
+ result.merge(parsed: parsed)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Exec
6
+ module Runners
7
+ module Git
8
+ module_function
9
+
10
+ def init(path:, **)
11
+ Runners::Shell.execute(command: 'git init', cwd: path)
12
+ end
13
+
14
+ def add(path:, files: '.', **)
15
+ cmd = files == '.' ? 'git add -A' : "git add #{Array(files).join(' ')}"
16
+ Runners::Shell.execute(command: cmd, cwd: path)
17
+ end
18
+
19
+ def commit(path:, message:, **)
20
+ safe_msg = message.gsub("'", "\\'")
21
+ Runners::Shell.execute(command: "git commit -m '#{safe_msg}'", cwd: path)
22
+ end
23
+
24
+ def push(path:, remote: 'origin', branch: 'main', set_upstream: false, **)
25
+ cmd = set_upstream ? "git push -u #{remote} #{branch}" : 'git push'
26
+ Runners::Shell.execute(command: cmd, cwd: path)
27
+ end
28
+
29
+ def status(path:, **)
30
+ result = Runners::Shell.execute(command: 'git status --porcelain', cwd: path)
31
+ return result unless result[:success]
32
+
33
+ parsed = Helpers::ResultParser.parse_git_status(result[:stdout] || '')
34
+ result.merge(parsed: parsed)
35
+ end
36
+
37
+ def create_repo(name:, org: 'LegionIO', description: '', public: true, **)
38
+ visibility = public ? '--public' : '--private'
39
+ Runners::Shell.execute(
40
+ command: "gh repo create #{org}/#{name} #{visibility} --description '#{description}' --clone",
41
+ cwd: Dir.pwd
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'timeout'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Exec
9
+ module Runners
10
+ module Shell
11
+ extend self
12
+
13
+ def execute(command:, cwd: Dir.pwd, timeout: Helpers::Constants::DEFAULT_TIMEOUT, env: {}, **)
14
+ check = default_sandbox.allowed?(command)
15
+ return { success: false, error: :blocked, reason: check[:reason] } unless check[:allowed]
16
+
17
+ start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
18
+ timeout_secs = [timeout, Helpers::Constants::MAX_TIMEOUT].min / 1000.0
19
+
20
+ begin
21
+ stdout, stderr, status = Timeout.timeout(timeout_secs) do
22
+ Open3.capture3(env, command, chdir: cwd)
23
+ end
24
+ rescue Timeout::Error
25
+ return { success: false, error: :timeout, timeout_ms: timeout }
26
+ rescue ArgumentError => e
27
+ return { success: false, error: e.message }
28
+ end
29
+
30
+ duration_ms = ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time) * 1000).round
31
+ exit_code = status.exitstatus.to_i
32
+ truncated = false
33
+
34
+ if stdout.bytesize > Helpers::Constants::MAX_OUTPUT_BYTES
35
+ stdout = stdout.byteslice(0, Helpers::Constants::MAX_OUTPUT_BYTES)
36
+ truncated = true
37
+ end
38
+
39
+ stderr = stderr.byteslice(0, Helpers::Constants::MAX_OUTPUT_BYTES) if stderr.bytesize > Helpers::Constants::MAX_OUTPUT_BYTES
40
+
41
+ audit_log.record(command: command, cwd: cwd, exit_code: exit_code,
42
+ duration_ms: duration_ms, truncated: truncated)
43
+
44
+ Legion::Logging.debug("[lex-exec] exit=#{exit_code} duration=#{duration_ms}ms cmd=#{command}")
45
+
46
+ {
47
+ success: exit_code.zero?,
48
+ stdout: stdout,
49
+ stderr: stderr,
50
+ exit_code: exit_code,
51
+ duration_ms: duration_ms,
52
+ truncated: truncated
53
+ }
54
+ end
55
+
56
+ def audit(limit: 50, **)
57
+ { success: true, entries: audit_log.entries(limit: limit), stats: audit_log.stats }
58
+ end
59
+
60
+ private
61
+
62
+ def default_sandbox
63
+ @default_sandbox ||= Helpers::Sandbox.new
64
+ end
65
+
66
+ def audit_log
67
+ @audit_log ||= Helpers::AuditLog.new
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Exec
6
+ VERSION = '0.1.3'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require_relative 'exec/version'
5
+ require_relative 'exec/helpers/constants'
6
+ require_relative 'exec/helpers/sandbox'
7
+ require_relative 'exec/helpers/result_parser'
8
+ require_relative 'exec/helpers/audit_log'
9
+ require_relative 'exec/helpers/checkpoint'
10
+ require_relative 'exec/helpers/worktree'
11
+ require_relative 'exec/runners/shell'
12
+ require_relative 'exec/runners/git'
13
+ require_relative 'exec/runners/bundler'
14
+ require_relative 'exec/client'
15
+
16
+ module Legion
17
+ module Extensions
18
+ module Exec
19
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
20
+ end
21
+ end
22
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-exec
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -15,7 +15,29 @@ email:
15
15
  executables: []
16
16
  extensions: []
17
17
  extra_rdoc_files: []
18
- files: []
18
+ files:
19
+ - ".github/workflows/ci.yml"
20
+ - ".gitignore"
21
+ - ".rspec"
22
+ - ".rubocop.yml"
23
+ - CHANGELOG.md
24
+ - CLAUDE.md
25
+ - Gemfile
26
+ - LICENSE
27
+ - README.md
28
+ - lex-exec.gemspec
29
+ - lib/legion/extensions/exec.rb
30
+ - lib/legion/extensions/exec/client.rb
31
+ - lib/legion/extensions/exec/helpers/audit_log.rb
32
+ - lib/legion/extensions/exec/helpers/checkpoint.rb
33
+ - lib/legion/extensions/exec/helpers/constants.rb
34
+ - lib/legion/extensions/exec/helpers/result_parser.rb
35
+ - lib/legion/extensions/exec/helpers/sandbox.rb
36
+ - lib/legion/extensions/exec/helpers/worktree.rb
37
+ - lib/legion/extensions/exec/runners/bundler.rb
38
+ - lib/legion/extensions/exec/runners/git.rb
39
+ - lib/legion/extensions/exec/runners/shell.rb
40
+ - lib/legion/extensions/exec/version.rb
19
41
  homepage: https://github.com/LegionIO/lex-exec
20
42
  licenses:
21
43
  - MIT