lex-exec
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.
Installation
Add to your Gemfile:
gem 'lex-exec'
Or install directly:
gem install lex-exec
Overview
lex-exec provides four runners:
- Shell - Execute arbitrary shell commands against an allowlist
- Git - Common git operations (init, add, commit, push, status, diff, branch, log, show, create_repo)
- Filesystem - Native wrappers for common file commands (
pwd,ls,mkdir,touch,cat,head,tail,wc,cp,mv,rm) - Bundler - Run
bundle install,rspec, andrubocopwith structured output parsing
All runner methods expose Legion definition metadata (mcp_prefix, category, inputs, idempotency, and trigger words) so the extension can be discovered as native tools. 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.
Allowlisted Commands
Only the following base commands are permitted:
bundle git gh ruby rspec rubocop
ls cat mkdir cp mv rm touch echo wc head tail
pwd python3 pip3
Commands not in this list are rejected before execution with success: false, error: :blocked.
Blocked Patterns
The following patterns are always rejected regardless of allowlist membership:
rm -rf /(root deletion)rm -rf ~(home deletion)rm -rf ..(parent directory deletion)sudo(privilege escalation)chmod 777(world-writable permissions)curl | sh(pipe-to-shell download execution)- Redirects to
/etcor/usr
Limits
| Parameter | Default | Maximum |
|---|---|---|
| Timeout | 120,000 ms | 600,000 ms (10 min) |
| Output size (stdout/stderr) | — | 1,048,576 bytes (1 MB) |
| Audit log entries | — | 1,000 (ring buffer) |
Output exceeding 1 MB is truncated; truncated: true is set in the result and recorded in the audit log.
Usage
Direct runner calls
# Shell runner
result = Legion::Extensions::Exec::Runners::Shell.execute(
command: 'bundle exec rspec',
cwd: '/path/to/project',
timeout: 120_000
)
# => { success: true, stdout: "...", stderr: "...", exit_code: 0, duration_ms: 1234, truncated: false }
# Retrieve audit log
audit = Legion::Extensions::Exec::Runners::Shell.audit(limit: 50)
# => { success: true, entries: [...], stats: { total:, success:, failure:, avg_duration_ms: } }
Client interface
Legion::Extensions::Exec::Client provides a unified interface delegating to all three runners:
client = Legion::Extensions::Exec::Client.new(base_path: '/path/to/project')
# Shell
client.execute(command: 'ls -la')
client.audit(limit: 25)
# Git
client.init
client.add(files: ['lib/foo.rb', 'spec/foo_spec.rb'])
client.commit(message: 'add foo runner')
client.push(remote: 'origin', branch: 'main', set_upstream: true)
client.status
client.diff(staged: true)
client.branch(all: true)
client.log(max_count: 10)
client.show(ref: 'HEAD')
client.create_repo(name: 'lex-foo', org: 'LegionIO', description: 'foo extension', public: true)
# Filesystem
client.pwd
client.ls(path: '/path/to/project', all: true, long: true)
client.mkdir(path: '/path/to/project/tmp/reports')
client.cat(path: '/path/to/project/README.md')
client.head(path: '/path/to/project/CHANGELOG.md', lines: 20)
# Bundler
client.install
client.exec_rspec(format: 'progress')
client.exec_rubocop(autocorrect: false)
Git runner
# Initialize a new repo
Legion::Extensions::Exec::Runners::Git.init(path: '/path/to/dir')
# Stage files
Legion::Extensions::Exec::Runners::Git.add(path: '/path/to/dir', files: '.')
Legion::Extensions::Exec::Runners::Git.add(path: '/path/to/dir', files: ['file1.rb', 'file2.rb'])
# Commit
Legion::Extensions::Exec::Runners::Git.commit(path: '/path/to/dir', message: 'initial commit')
# Push (set_upstream: true adds -u flag)
Legion::Extensions::Exec::Runners::Git.push(path: '/path/to/dir', remote: 'origin', branch: 'main', set_upstream: true)
# Status (parses --porcelain output into structured form)
Legion::Extensions::Exec::Runners::Git.status(path: '/path/to/dir')
# Diff, branch, log, and show
Legion::Extensions::Exec::Runners::Git.diff(path: '/path/to/dir', staged: true)
Legion::Extensions::Exec::Runners::Git.branch(path: '/path/to/dir', all: true)
Legion::Extensions::Exec::Runners::Git.log(path: '/path/to/dir', max_count: 20)
Legion::Extensions::Exec::Runners::Git.show(path: '/path/to/dir', ref: 'HEAD')
# Create GitHub repo via gh CLI
Legion::Extensions::Exec::Runners::Git.create_repo(
name: 'lex-myext',
org: 'LegionIO',
description: 'my extension',
public: true
)
Filesystem runner
Legion::Extensions::Exec::Runners::Filesystem.pwd(path: '/path/to/project')
Legion::Extensions::Exec::Runners::Filesystem.ls(path: '/path/to/project', all: true, long: true)
Legion::Extensions::Exec::Runners::Filesystem.mkdir(path: '/path/to/project/tmp/reports')
Legion::Extensions::Exec::Runners::Filesystem.touch(path: '/path/to/project/tmp/report.txt')
Legion::Extensions::Exec::Runners::Filesystem.cat(path: '/path/to/project/README.md')
Legion::Extensions::Exec::Runners::Filesystem.head(path: '/path/to/project/CHANGELOG.md', lines: 20)
Legion::Extensions::Exec::Runners::Filesystem.tail(path: '/path/to/project/log/development.log', lines: 50)
Legion::Extensions::Exec::Runners::Filesystem.wc(path: '/path/to/project/README.md')
Legion::Extensions::Exec::Runners::Filesystem.cp(source: '/tmp/a.txt', destination: '/tmp/b.txt')
Legion::Extensions::Exec::Runners::Filesystem.mv(source: '/tmp/b.txt', destination: '/tmp/c.txt')
Legion::Extensions::Exec::Runners::Filesystem.rm(path: '/tmp/c.txt')
Bundler runner
# Install dependencies (5 min timeout)
Legion::Extensions::Exec::Runners::Bundler.install(path: '/path/to/project')
# Run RSpec with parsed output
result = Legion::Extensions::Exec::Runners::Bundler.exec_rspec(path: '/path/to/project', format: 'progress')
# result[:parsed] => { examples:, failures:, pending:, passed: }
# Run RuboCop with parsed output
result = Legion::Extensions::Exec::Runners::Bundler.exec_rubocop(path: '/path/to/project')
# result[:parsed] => { offenses:, files_inspected: }
# Run RuboCop with autocorrect
Legion::Extensions::Exec::Runners::Bundler.exec_rubocop(path: '/path/to/project', autocorrect: true)
Return Value Shape
All runners return a hash with at minimum:
{
success: true | false,
stdout: "...", # present on success
stderr: "...", # present on success
exit_code: 0, # present on success
duration_ms: 123, # present on success
truncated: false # true if stdout exceeded 1 MB
}
On failure:
{ success: false, error: :blocked, reason: "command 'sudo' is not in the allowlist" }
{ success: false, error: :timeout, timeout_ms: 120_000 }
{ success: false, error: "invalid argument message" }
Agentic Pipeline Integration
lex-exec is designed to work alongside lex-codegen in the agentic swarm pipeline:
lex-codegen (scaffold_extension) # generates file tree from ERB templates
|
v
lex-exec (Bundler.install) # installs gem dependencies
|
v
lex-exec (Bundler.exec_rspec) # runs test suite, returns pass/fail counts
|
v
lex-exec (Bundler.exec_rubocop) # lints code, returns offense count
|
v
lex-exec (Git.commit + Git.push) # commits and pushes validated extension
Development
bundle install
bundle exec rspec
bundle exec rubocop
License
MIT