From dbcc90bfb7a7e779ddbf4da6315d8d69f75b151b Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Tue, 9 Dec 2025 09:40:16 -0800 Subject: [PATCH 1/3] Add shell command execution on client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements secure shell command execution from server to client as requested in #8. Uses array-based execution (no shell interpretation) to prevent command injection attacks. Security features: - Commands must be explicitly permitted by prefix (e.g. "git status") - Array execution via Open3.capture3 prevents shell injection - Working directory (chdir) validated against path entitlements - Timeout limits (default 30s, max 300s) prevent resource exhaustion - Output size limits (10MB) prevent memory exhaustion API: result = context.shell.run("git", "status") result = context.shell.run("bundle", "exec", "rspec", timeout: 60) result.stdout # => "output" result.stderr # => "errors" result.exitstatus # => 0 result.success? # => true Closes #8 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../terminalwire/client/entitlement/policy.rb | 8 +- .../terminalwire/client/entitlement/shell.rb | 39 ++++ .../lib/terminalwire/client/resource.rb | 77 ++++++++ .../lib/terminalwire/server/context.rb | 2 + .../lib/terminalwire/server/resource.rb | 31 ++++ spec/integration/resources/shell_spec.rb | 174 ++++++++++++++++++ 6 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 gem/terminalwire-client/lib/terminalwire/client/entitlement/shell.rb create mode 100644 spec/integration/resources/shell_spec.rb diff --git a/gem/terminalwire-client/lib/terminalwire/client/entitlement/policy.rb b/gem/terminalwire-client/lib/terminalwire/client/entitlement/policy.rb index bb55f51..575598b 100644 --- a/gem/terminalwire-client/lib/terminalwire/client/entitlement/policy.rb +++ b/gem/terminalwire-client/lib/terminalwire/client/entitlement/policy.rb @@ -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 @@ -20,6 +20,9 @@ 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) end def root_path @@ -44,7 +47,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 diff --git a/gem/terminalwire-client/lib/terminalwire/client/entitlement/shell.rb b/gem/terminalwire-client/lib/terminalwire/client/entitlement/shell.rb new file mode 100644 index 0000000..03398e6 --- /dev/null +++ b/gem/terminalwire-client/lib/terminalwire/client/entitlement/shell.rb @@ -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 diff --git a/gem/terminalwire-client/lib/terminalwire/client/resource.rb b/gem/terminalwire-client/lib/terminalwire/client/resource.rb index 407e293..95d2d78 100644 --- a/gem/terminalwire-client/lib/terminalwire/client/resource.rb +++ b/gem/terminalwire-client/lib/terminalwire/client/resource.rb @@ -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. @@ -19,6 +20,7 @@ def initialize(adapter:, entitlement:) self << File self << Directory self << EnvironmentVariable + self << Shell yield self if block_given? end @@ -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 diff --git a/gem/terminalwire-server/lib/terminalwire/server/context.rb b/gem/terminalwire-server/lib/terminalwire/server/context.rb index 0ae9264..4713daf 100644 --- a/gem/terminalwire-server/lib/terminalwire/server/context.rb +++ b/gem/terminalwire-server/lib/terminalwire/server/context.rb @@ -12,6 +12,7 @@ class Context :browser, :file, :directory, :environment_variable, + :shell, :authority, :root_path, :authority_path, @@ -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) diff --git a/gem/terminalwire-server/lib/terminalwire/server/resource.rb b/gem/terminalwire-server/lib/terminalwire/server/resource.rb index 51d262f..1e45236 100644 --- a/gem/terminalwire-server/lib/terminalwire/server/resource.rb +++ b/gem/terminalwire-server/lib/terminalwire/server/resource.rb @@ -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] 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 diff --git a/spec/integration/resources/shell_spec.rb b/spec/integration/resources/shell_spec.rb new file mode 100644 index 0000000..3239c1f --- /dev/null +++ b/spec/integration/resources/shell_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Terminalwire::Server::Resource::Shell do + let(:integration) { + Sync::Integration.new(authority: 'shell-test.example.com') do |sync| + # Permit specific command prefixes for testing + sync.policy.shell.permit "echo" + sync.policy.shell.permit "ls" + sync.policy.shell.permit "cat" + sync.policy.shell.permit "pwd" + sync.policy.shell.permit "true" + sync.policy.shell.permit "false" + # Allow all paths for chdir testing + sync.policy.paths.permit("**/*", mode: 0o777) + end + } + let(:server_shell) { described_class.new("shell", integration.server_adapter) } + + describe '#run' do + it 'executes a simple command and returns stdout' do + result = server_shell.run("echo", "hello world") + + expect(result.stdout.strip).to eq("hello world") + expect(result.stderr).to eq("") + expect(result.exitstatus).to eq(0) + expect(result.success?).to be true + end + + it 'returns stderr for error output' do + result = server_shell.run("cat", "/nonexistent/file/path") + + expect(result.stderr).to include("No such file") + expect(result.success?).to be false + end + + it 'returns correct exit status for failing commands' do + result = server_shell.run("false") + + expect(result.exitstatus).to eq(1) + expect(result.success?).to be false + end + + it 'returns correct exit status for successful commands' do + result = server_shell.run("true") + + expect(result.exitstatus).to eq(0) + expect(result.success?).to be true + end + + it 'passes multiple arguments correctly' do + result = server_shell.run("echo", "arg1", "arg2", "arg3") + + expect(result.stdout.strip).to eq("arg1 arg2 arg3") + end + + it 'executes command in specified directory' do + Dir.mktmpdir do |tmpdir| + result = server_shell.run("pwd", chdir: tmpdir) + + expect(result.stdout.strip).to eq(File.realpath(tmpdir)) + end + end + + context 'with timeout' do + it 'applies default timeout' do + result = server_shell.run("echo", "fast") + expect(result.success?).to be true + end + + it 'respects custom timeout' do + result = server_shell.run("echo", "test", timeout: 60) + expect(result.success?).to be true + end + end + end + + describe 'security: array-based execution prevents shell injection' do + it 'treats shell metacharacters as literal arguments' do + # This would be dangerous with shell interpretation: + # "echo test && rm -rf /" would execute both commands + # With array execution, "test && rm -rf /" is a single argument to echo + result = server_shell.run("echo", "test && echo injected") + + # The entire string including && is treated as one argument + expect(result.stdout.strip).to eq("test && echo injected") + end + + it 'treats semicolons as literal characters' do + result = server_shell.run("echo", "test; echo injected") + + expect(result.stdout.strip).to eq("test; echo injected") + end + + it 'treats pipes as literal characters' do + result = server_shell.run("echo", "test | cat") + + expect(result.stdout.strip).to eq("test | cat") + end + + it 'treats backticks as literal characters' do + result = server_shell.run("echo", "`whoami`") + + expect(result.stdout.strip).to eq("`whoami`") + end + + it 'treats $() as literal characters' do + result = server_shell.run("echo", "$(whoami)") + + expect(result.stdout.strip).to eq("$(whoami)") + end + end + + describe 'unauthorized command access' do + let(:restricted_integration) { + Sync::Integration.new(authority: 'restricted-shell.example.com') do |sync| + # Only permit specific git commands + sync.policy.shell.permit "git status" + sync.policy.shell.permit "git log" + end + } + let(:restricted_shell) { described_class.new("shell", restricted_integration.server_adapter) } + + it 'denies commands not in the allowlist' do + expect { + restricted_shell.run("rm", "-rf", "/") + }.to raise_error(Terminalwire::Error, /denied/) + end + + it 'denies git commands not matching the prefix' do + expect { + restricted_shell.run("git", "push", "--force") + }.to raise_error(Terminalwire::Error, /denied/) + end + + it 'permits git status command' do + # Note: This will fail if git isn't installed, but tests the permission system + begin + result = restricted_shell.run("git", "status") + # If we get here without error, permission was granted + expect(result).to be_a(described_class::Result) + rescue Errno::ENOENT + skip "git not installed" + end + end + + it 'permits git log command with additional arguments' do + begin + result = restricted_shell.run("git", "log", "--oneline", "-n", "1") + expect(result).to be_a(described_class::Result) + rescue Errno::ENOENT + skip "git not installed" + end + end + end + + describe 'chdir path entitlement' do + let(:restricted_integration) { + Sync::Integration.new(authority: 'chdir-restricted.example.com') do |sync| + sync.policy.shell.permit "pwd" + # Only permit /tmp paths + sync.policy.paths.permit("/tmp/**/*", mode: 0o755) + end + } + let(:restricted_shell) { described_class.new("shell", restricted_integration.server_adapter) } + + it 'denies chdir to unauthorized paths' do + expect { + restricted_shell.run("pwd", chdir: "/etc") + }.to raise_error(Terminalwire::Error, /denied/) + end + end +end From 50b37bff7613215b901a1a9d053f96434e63ed31 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Tue, 9 Dec 2025 09:56:33 -0800 Subject: [PATCH 2/3] Add user config file support for entitlements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now configure shell, path, and environment variable permissions via a config file at ~/.terminalwire/authorities/{authority}/config.yml Example config: ```yaml shell: allow: - "git remote" - "git branch" paths: allow: - "~/.config/myapp" environment_variables: allow: - "HOME" ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../terminalwire/client/entitlement/policy.rb | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/gem/terminalwire-client/lib/terminalwire/client/entitlement/policy.rb b/gem/terminalwire-client/lib/terminalwire/client/entitlement/policy.rb index 575598b..b835b99 100644 --- a/gem/terminalwire-client/lib/terminalwire/client/entitlement/policy.rb +++ b/gem/terminalwire-client/lib/terminalwire/client/entitlement/policy.rb @@ -23,6 +23,32 @@ def initialize(authority:) @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 From 67e0f1b13663476296c8579204ab6495eb552dce Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Tue, 9 Dec 2025 19:23:35 -0800 Subject: [PATCH 3/3] Fix async cleanup warnings on exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use catch/throw instead of direct exit() to allow Async cleanup - Return exit status from connect() instead of calling exit inside - Suppress async cleanup errors during shutdown 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../lib/terminalwire/client.rb | 8 +++++++- .../lib/terminalwire/client/handler.rb | 14 +++++++++++--- .../terminalwire-client-0.3.5.rc1.gem | Bin 0 -> 11264 bytes .../terminalwire-core-0.3.5.rc1.gem | Bin 0 -> 7680 bytes gem/terminalwire/terminalwire-0.3.5.rc1.gem | Bin 0 -> 4608 bytes 5 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 gem/terminalwire-client/terminalwire-client-0.3.5.rc1.gem create mode 100644 gem/terminalwire-core/terminalwire-core-0.3.5.rc1.gem create mode 100644 gem/terminalwire/terminalwire-0.3.5.rc1.gem diff --git a/gem/terminalwire-client/lib/terminalwire/client.rb b/gem/terminalwire-client/lib/terminalwire/client.rb index 034d10c..d791439 100644 --- a/gem/terminalwire-client/lib/terminalwire/client.rb +++ b/gem/terminalwire-client/lib/terminalwire/client.rb @@ -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( @@ -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 diff --git a/gem/terminalwire-client/lib/terminalwire/client/handler.rb b/gem/terminalwire-client/lib/terminalwire/client/handler.rb index 63d4b4f..bb7ac5b 100644 --- a/gem/terminalwire-client/lib/terminalwire/client/handler.rb +++ b/gem/terminalwire-client/lib/terminalwire/client/handler.rb @@ -46,9 +46,15 @@ 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) @@ -56,7 +62,9 @@ def handle(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 diff --git a/gem/terminalwire-client/terminalwire-client-0.3.5.rc1.gem b/gem/terminalwire-client/terminalwire-client-0.3.5.rc1.gem new file mode 100644 index 0000000000000000000000000000000000000000..51277b33a1ea8913851513b2aa7c6fe78c87a470 GIT binary patch literal 11264 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 F{0Hv|>jD4( literal 0 HcmV?d00001 diff --git a/gem/terminalwire/terminalwire-0.3.5.rc1.gem b/gem/terminalwire/terminalwire-0.3.5.rc1.gem new file mode 100644 index 0000000000000000000000000000000000000000..08c4bbb98e473c1430eea478cba754f31e2595e7 GIT binary patch literal 4608 zcmc~zElEsCEJ@T$uVSDTFaQD*6B7my4Fu@4p`ocEgMpEenX$RKu^C9-(AdPpkU_zK zRt`cox3stWd(>_?a^4%N^4d2fTF)<$v@y@^OAh7TK zdWou&C;J5)t^^6PE?E`Ss-;!PpqRaP-i_{WN8inwP=9{SU6aD7tp$wg?C)*t&)3{f zi)1_h=Rywm?HYH%)!bJ?OCJZE*eYV0*V&@X4 z`28EFxif9Hsf|s0YNS2iIWp|Ay?!}=O`@)qiLR7l;hH-6XYH{^rcJo_|B#PHr`n|R zkNX>WX8G~gzFWR`c2oMpE0&Q)zjV)uJ+8L0HQV&EGNFK>@HI#BQ+DB{ZblaUa-yFc zo|%R-epg4NZXI-X@DEK;?i6!o z;gimJ@aNa0h0lVPx|+F6-rnAQYNed=ybi@rZoaqY1(w#G3DP*~Zo-)(Vd?6AKKp)D z;QG{66MHU8FAa7p4EcD&B=qfCx8v4P?(&PdLjz@)b=|IMYp(bm^5@T^UtO6!b1KeW zezG&DtU32>+lA|Uc?;upRm)d6=hcP>yjT}+Tk$i*zrlf7EQ0&CoN9^5+l7+O{Kd!j zG?lfn^4>q=&fw5xBCB=#udm@fRxPD-3;y0)oz|0bqzY_ zH!WMW=*`@pGo}W|h?L$mNwq!tbCK4oj`WLy1;@5M={K@tcy(ac`d6P%{}uN04@%c~ zyTotde9N8hS_S9Lna;R@FQ?lxHu7b!lzd)Z9jdtQ{;884=Xn0IeLFKz$VRSL z@TGdI>fa52J&ruzH~Xb*+U~moyhrrcThuIePv>>t)N1&)$}2%#_q^nnF4xBV#+m>1 zYZlpW`ro~5&d>c(S1LdJm;d_Tc8RQ`v|?U+8LQWWSWoY#>Zik29+q#}zD+#B$aWX+ zHGUSR#`}*&85sWmXJ*(~?X;hPhc0n35SAooq$Xzzip4kB{W}5|5Er|uhvhYcS%SlQ@CaAF+E|2 zUWQ2u_g{al`t##oU-Uh>^Uv4+>*(tL@$K{Z+mo$5HknO)Inkr3v32fml}T$tN;cc< z;(x+2xx-sre&Q|3%jWO3Un|$uKfU=<%=GRNjug&9g#cw6{?jX+H_tSQDqFiaV`^gN zxfcs=J>UGC?a%t>L0X|7nrye`F~+w&xNE!Zoo0fG>T>y*nGNcHPKhtdys_@WiKA;b z9+~;|a@^d--IAM