Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion gem/terminalwire-client/lib/terminalwire/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def self.websocket(url:, arguments: ARGV, &configuration)
ENV["TERMINALWIRE_HOME"] ||= root_path.to_s

url = URI(url)
exit_status = 0

Async do |task|
endpoint = Async::HTTP::Endpoint.parse(
Expand All @@ -37,9 +38,14 @@ def self.websocket(url:, arguments: ARGV, &configuration)
Async::WebSocket::Client.connect(endpoint) do |connection|
transport = Terminalwire::Transport::WebSocket.new(connection)
adapter = Terminalwire::Adapter::Socket.new(transport)
Terminalwire::Client::Handler.new(adapter, arguments:, endpoint:, &configuration).connect
exit_status = Terminalwire::Client::Handler.new(adapter, arguments:, endpoint:, &configuration).connect
end
rescue => e
# Suppress async cleanup errors during shutdown
raise unless e.is_a?(Async::Stop) || e.message.include?("mutex")
end

exit(exit_status)
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module Terminalwire::Client::Entitlement
module Policy
# A policy has the authority, paths, and schemes that the server is allowed to access.
class Base
attr_reader :paths, :authority, :schemes, :environment_variables
attr_reader :paths, :authority, :schemes, :environment_variables, :shell

def initialize(authority:)
@authority = authority
Expand All @@ -20,6 +20,35 @@ def initialize(authority:)
@environment_variables = EnvironmentVariables.new
# Permit the HOME and TERMINALWIRE_HOME environment variables.
@environment_variables.permit "TERMINALWIRE_HOME"

@shell = Shell.new
# No shell commands permitted by default (deny-all)
# Load user-configured permissions from config file
load_user_config!
end

def load_user_config!
config_path = File.expand_path(authority_path.join("config.yml"))
return unless File.exist?(config_path)

config = YAML.safe_load_file(config_path, permitted_classes: [], symbolize_names: true)

# Load shell permissions
if config[:shell]&.[](:allow)
Array(config[:shell][:allow]).each { |cmd| @shell.permit(cmd) }
end

# Load path permissions
if config[:paths]&.[](:allow)
Array(config[:paths][:allow]).each { |path| @paths.permit(File.expand_path(path)) }
end

# Load environment variable permissions
if config[:environment_variables]&.[](:allow)
Array(config[:environment_variables][:allow]).each { |var| @environment_variables.permit(var) }
end
rescue => e
# Silently ignore config errors to avoid breaking CLI
end

def root_path
Expand All @@ -44,7 +73,8 @@ def serialize
authority: @authority,
schemes: @schemes.serialize,
paths: @paths.serialize,
environment_variables: @environment_variables.serialize
environment_variables: @environment_variables.serialize,
shell: @shell.serialize
}
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
module Terminalwire::Client::Entitlement
# Shell commands the server can execute on the client.
# Commands are permitted by prefix matching, e.g. "git status" permits
# "git status", "git status --short", etc.
class Shell
include Enumerable

# Default timeout in seconds
DEFAULT_TIMEOUT = 30
# Maximum timeout in seconds (5 minutes)
MAX_TIMEOUT = 300
# Maximum output size in bytes (10MB)
MAX_OUTPUT_SIZE = 10 * 1024 * 1024

def initialize
@permitted = Set.new
end

def each(&)
@permitted.each(&)
end

# Permit a command prefix, e.g. "git status", "bundle exec"
def permit(command_prefix)
@permitted << command_prefix.to_s
end

# Check if a command with args is permitted.
# Builds the full command string and checks if it starts with any permitted prefix.
def permitted?(command, args = [])
full_command = [command, *args].join(" ")
@permitted.any? { |prefix| full_command.start_with?(prefix) }
end

def serialize
map { |command| { command: } }
end
end
end
14 changes: 11 additions & 3 deletions gem/terminalwire-client/lib/terminalwire/client/handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,25 @@ def connect
}
)

loop do
handle @adapter.read
catch(:exit) do
loop do
handle @adapter.read
end
end

# Return exit status instead of calling exit directly
# This allows Async to clean up properly
@exit_status || 0
end

def handle(message)
case message
in { event: "resource", action: "command", name:, parameters: }
@resources.dispatch(**message)
in { event: "exit", status: }
exit Integer(status)
# Store exit status and break out of loop to allow clean shutdown
@exit_status = Integer(status)
throw :exit
end
end
end
Expand Down
77 changes: 77 additions & 0 deletions gem/terminalwire-client/lib/terminalwire/client/resource.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require "fileutils"
require "io/console"
require "open3"

module Terminalwire::Client::Resource
# Dispatches messages from the Client::Handler to the appropriate resource.
Expand All @@ -19,6 +20,7 @@ def initialize(adapter:, entitlement:)
self << File
self << Directory
self << EnvironmentVariable
self << Shell

yield self if block_given?
end
Expand Down Expand Up @@ -249,4 +251,79 @@ def permit(command, url:, **)
@entitlement.schemes.permitted? url
end
end

class Shell < Base
def self.key
"shell"
end

# Execute a command with arguments using array-based execution (no shell interpretation).
# Returns a hash with stdout, stderr, exitstatus, and success.
def run(command:, args: [], timeout: nil, chdir: nil)
# Apply timeout limits
timeout = resolve_timeout(timeout)

# Build execution options
options = {}
options[:chdir] = ::File.expand_path(chdir) if chdir

# Execute command with array form (safe - no shell interpretation)
stdout, stderr, status = execute_with_timeout(command, args, timeout, options)

# Truncate output if too large
stdout = truncate_output(stdout)
stderr = truncate_output(stderr)

{
stdout: stdout,
stderr: stderr,
exitstatus: status.exitstatus,
success: status.success?
}
end

protected

def permit(command_name, command:, args: [], chdir: nil, **)
# Check if command + args prefix is permitted
return false unless @entitlement.shell.permitted?(command, args)

# If chdir specified, it must be a permitted path
if chdir
return false unless @entitlement.paths.permitted?(chdir)
end

true
end

private

def resolve_timeout(timeout)
max_timeout = Terminalwire::Client::Entitlement::Shell::MAX_TIMEOUT
default_timeout = Terminalwire::Client::Entitlement::Shell::DEFAULT_TIMEOUT

if timeout.nil?
default_timeout
else
[timeout.to_i, max_timeout].min
end
end

def execute_with_timeout(command, args, timeout, options)
Timeout.timeout(timeout) do
Open3.capture3(command, *args, **options)
end
rescue Timeout::Error
["", "Command timed out after #{timeout} seconds", OpenStruct.new(exitstatus: 124, success?: false)]
end

def truncate_output(output)
max_size = Terminalwire::Client::Entitlement::Shell::MAX_OUTPUT_SIZE
if output.bytesize > max_size
output.byteslice(0, max_size) + "\n... (output truncated)"
else
output
end
end
end
end
Binary file not shown.
Binary file not shown.
2 changes: 2 additions & 0 deletions gem/terminalwire-server/lib/terminalwire/server/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class Context
:browser,
:file, :directory,
:environment_variable,
:shell,
:authority,
:root_path,
:authority_path,
Expand All @@ -32,6 +33,7 @@ def initialize(adapter:, entitlement:)
@file = Resource::File.new("file", @adapter)
@directory = Resource::Directory.new("directory", @adapter)
@environment_variable = Resource::EnvironmentVariable.new("environment_variable", @adapter)
@shell = Resource::Shell.new("shell", @adapter)

# Authority is provided by the client.
@authority = @entitlement.fetch(:authority)
Expand Down
31 changes: 31 additions & 0 deletions gem/terminalwire-server/lib/terminalwire/server/resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -116,5 +116,36 @@ def launch(url)
command("launch", url: url)
end
end

class Shell < Base
# Result object for shell command execution
Result = Struct.new(:stdout, :stderr, :exitstatus, :success, keyword_init: true) do
alias_method :success?, :success
end

# Run a command with arguments on the client.
# Uses array-based execution for security (no shell interpretation).
#
# @param cmd [String] The command to execute
# @param args [Array<String>] Arguments to pass to the command
# @param timeout [Integer, nil] Timeout in seconds (default: 30, max: 300)
# @param chdir [String, nil] Working directory for the command
# @return [Result] Result object with stdout, stderr, exitstatus, success?
def run(cmd, *args, timeout: nil, chdir: nil)
response = command("run",
command: cmd.to_s,
args: args.map(&:to_s),
timeout: timeout,
chdir: chdir&.to_s
)

Result.new(
stdout: response[:stdout],
stderr: response[:stderr],
exitstatus: response[:exitstatus],
success: response[:success]
)
end
end
end
end
Binary file added gem/terminalwire/terminalwire-0.3.5.rc1.gem
Binary file not shown.
Loading