ask-sandbox-providers 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3b83198ae39be1c53ff4176db41a259387ad4919f005ab896cea2107b74f604e
4
+ data.tar.gz: 90199037118afe7acdfbcd957107ff3c05c30cd54f0f7433be03195da08cb23e
5
+ SHA512:
6
+ metadata.gz: fd54a037c2b1c7af1bb232497ddd1f16d5b94a998b1b4dd8365ebd64e46be9ecff80bb3a91f8562fc6c64d91173531608496205e3a5d0654f2bd8e0501277d3a
7
+ data.tar.gz: '079fbe78d05bd89be7662a14b4e3e8c12f80b4f507c9622353f0304d8fa345e91d6f705c22250d9af4e0b1e8a72ebb0e18681bc8e55e6b8a271fbb65789a7e68'
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - Unreleased
4
+
5
+ ### Added
6
+ - Initial release
7
+ - `Ask::Sandbox::Local` — subprocess execution with resource limits (rlimits, process group, tempdir)
8
+ - `Ask::Sandbox::Docker` — Docker container execution with hardened security flags
9
+ - `Ask::Sandbox::Daytona` — remote sandbox via the official Daytona SDK
10
+ - `Ask::Sandbox::Cloudflare` — Cloudflare Workers sandbox via a proxy Worker
11
+ - `Ask::Sandbox::Base` — abstract base class with unified `#call` interface
12
+ - Global provider configuration via `Ask::Sandbox.provider`
13
+ - Migration of `ask-tools-shell` Code and Bash tools to use sandbox providers
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Kaka Ruto
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,131 @@
1
+ # ask-sandbox-providers
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/ask-sandbox-providers.svg)](https://badge.fury.io/rb/ask-sandbox-providers)
4
+
5
+ Sandbox providers for the ask-rb ecosystem. Isolated code execution via four backends:
6
+
7
+ | Provider | Isolation | Speed | Requirement |
8
+ |---|---|---|---|
9
+ | `Local` | Process + rlimits (CPU, memory, processes, file size) | Instant | None (stdlib) |
10
+ | `Docker` | Full container (read-only rootfs, no network, no capabilities) | ~1s | Docker daemon |
11
+ | `Daytona` | Remote container via Daytona API | ~2-5s | `daytona` gem, API key |
12
+ | `Cloudflare` | Cloudflare Workers sandbox via proxy Worker | ~1-3s | Proxy Worker URL |
13
+
14
+ ## Installation
15
+
16
+ ```ruby
17
+ gem "ask-sandbox-providers"
18
+ ```
19
+
20
+ ## Quick Start
21
+
22
+ ```ruby
23
+ require "ask-sandbox-providers"
24
+
25
+ # Default: Local (subprocess + rlimits on macOS/Linux)
26
+ result = Ask::Sandbox.provider.call(["ruby", "-e", "puts 1+1"])
27
+ puts result.stdout # => "1\n"
28
+ puts result.exit_code # => 0
29
+ puts result.timed_out # => false
30
+ ```
31
+
32
+ ### Configure a different provider
33
+
34
+ ```ruby
35
+ # Docker (requires Docker daemon)
36
+ Ask::Sandbox.provider = Ask::Sandbox::Docker.new(
37
+ image: "ruby:3.4-alpine",
38
+ memory: "256m",
39
+ network: false
40
+ )
41
+
42
+ # Daytona (requires `daytona` gem + API key)
43
+ Ask::Sandbox.provider = Ask::Sandbox::Daytona.new(
44
+ api_key: Ask::Auth.lookup("DAYTONA_API_KEY"),
45
+ server_url: "https://api.daytona.io"
46
+ )
47
+
48
+ # Cloudflare (requires a deployed proxy Worker)
49
+ Ask::Sandbox.provider = Ask::Sandbox::Cloudflare.new(
50
+ worker_url: "https://sandbox-proxy.my-worker.workers.dev",
51
+ auth_token: ENV["CLOUDFLARE_SANDBOX_TOKEN"]
52
+ )
53
+ ```
54
+
55
+ ### String vs Array commands
56
+
57
+ ```ruby
58
+ # String → executed via shell (bash -c)
59
+ Ask::Sandbox.provider.call("ls -la | head -5")
60
+
61
+ # Array → executed directly, no shell (safer for untrusted input)
62
+ Ask::Sandbox.provider.call(["ruby", "-e", "puts ENV['HOME']"])
63
+ ```
64
+
65
+ ### Return value
66
+
67
+ All providers return an `Ask::Sandbox::Result`:
68
+
69
+ ```ruby
70
+ Result = Data.define(:stdout, :stderr, :exit_code, :timed_out)
71
+ ```
72
+
73
+ ## Provider Details
74
+
75
+ ### Local
76
+
77
+ The default provider. Runs commands in a temp directory with:
78
+
79
+ - **Process group isolation** — all child processes are killed on timeout
80
+ - **`Process.setrlimit`** — CPU (10s), address space (2GB), processes (50), file size (10MB), FDs (200)
81
+ - **Temp directory** — execution sandbox is auto-cleaned
82
+ - **Environment sanitization** — Bundler/Ruby env vars stripped
83
+
84
+ Available on every platform where Ruby runs (macOS, Linux). Zero external dependencies.
85
+
86
+ ### Docker
87
+
88
+ Runs commands in a Docker container with security hardening:
89
+
90
+ - `--read-only` — read-only root filesystem
91
+ - `--cap-drop ALL` — no Linux capabilities
92
+ - `--security-opt no-new-privileges` — no privilege escalation
93
+ - `--network none` — no network egress (configurable)
94
+ - `--memory`, `--cpus`, `--pids-limit` — resource limits
95
+ - `--rm` — auto-cleanup on exit
96
+
97
+ ### Daytona
98
+
99
+ Runs commands in a Daytona sandbox via the official `daytona` gem. Daytona provides secure, elastic sandboxes with full isolation, dedicated kernel, filesystem, and network stack. See [daytona.io/docs](https://www.daytona.io/docs).
100
+
101
+ ### Cloudflare
102
+
103
+ Runs commands in a Cloudflare Workers sandbox. Requires deploying a proxy Worker that wraps `@cloudflare/sandbox`. See the [Cloudflare Sandbox SDK docs](https://developers.cloudflare.com/sandbox/) for setup.
104
+
105
+ ## Migration from Direct Open3 Usage
106
+
107
+ If you're upgrading from `ask-tools-shell` v0.1.0 where `Code` and `Bash` tools called
108
+ `Open3.popen3` directly — no action needed. The default `Ask::Sandbox::Local` provider
109
+ behaves identically. To enable stronger isolation, switch the provider:
110
+
111
+ ```ruby
112
+ # Before: direct Open3 (implicit Local)
113
+ Ask::Tools::Code.new.call(code: "puts 1")
114
+
115
+ # After: configure Docker for stronger isolation
116
+ Ask::Sandbox.provider = Ask::Sandbox::Docker.new
117
+ # The Code tool now runs code inside a Docker container automatically
118
+ ```
119
+
120
+ ## Development
121
+
122
+ ```bash
123
+ git clone https://github.com/ask-rb/ask-sandbox-providers.git
124
+ cd ask-sandbox-providers
125
+ bundle install
126
+ bundle exec rake test
127
+ ```
128
+
129
+ ## License
130
+
131
+ MIT
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Sandbox
5
+ # @!group Errors
6
+
7
+ # Base error class for all sandbox provider errors.
8
+ class Error < StandardError; end
9
+
10
+ # Raised when a provider is misconfigured (missing API key, invalid image, etc.).
11
+ class ConfigurationError < Error; end
12
+
13
+ # Raised when a provider's runtime is unavailable (Docker not running, etc.).
14
+ class ProviderUnavailable < Error; end
15
+
16
+ # Raised when execution fails unexpectedly.
17
+ class ExecutionError < Error; end
18
+
19
+ # @!endgroup
20
+
21
+ # Structured result from a sandbox command execution.
22
+ #
23
+ # @!attribute [r] stdout
24
+ # @return [String] captured standard output
25
+ # @!attribute [r] stderr
26
+ # @return [String] captured standard error
27
+ # @!attribute [r] exit_code
28
+ # @return [Integer, nil] process exit code (nil if killed by signal)
29
+ # @!attribute [r] timed_out
30
+ # @return [Boolean] whether execution was terminated due to timeout
31
+ Result = Data.define(:stdout, :stderr, :exit_code, :timed_out) do
32
+ # @return [Boolean] true if the command exited successfully (exit code 0)
33
+ def success?
34
+ exit_code == 0
35
+ end
36
+ end
37
+
38
+ # Abstract base class for all sandbox providers.
39
+ #
40
+ # A sandbox provider is responsible for executing commands in an isolated
41
+ # environment. Subclasses implement +#call+ which runs a command and returns
42
+ # an {Ask::Sandbox::Result}.
43
+ #
44
+ # @example
45
+ # sandbox = Ask::Sandbox::Local.new
46
+ # result = sandbox.call("ls -la", timeout: 10)
47
+ # result.stdout # => "..."
48
+ # result.exit_code # => 0
49
+ #
50
+ class Base
51
+ # Execute a command in the sandbox.
52
+ #
53
+ # @param command [String, Array<String>]
54
+ # When a String, executed via a shell (bash -c).
55
+ # When an Array, executed directly with no shell interpretation.
56
+ # @param timeout [Integer] max execution time in seconds (default: 30)
57
+ # @param workdir [String, nil] working directory inside the sandbox
58
+ # @param env [Hash{String => String}] extra environment variables
59
+ # @param stdin [String, nil] data to pipe to the command's stdin
60
+ # @return [Ask::Sandbox::Result]
61
+ def call(command, timeout: 30, workdir: nil, env: {}, stdin: nil)
62
+ raise NotImplementedError, "#{self.class} must implement #call(command, ...)"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module Ask
7
+ module Sandbox
8
+ # Executes commands in a Cloudflare Workers sandbox via a user-deployed
9
+ # proxy Worker.
10
+ #
11
+ # The proxy Worker wraps the +@cloudflare/sandbox+ SDK and exposes an HTTP
12
+ # API. The user is responsible for deploying the proxy Worker to Cloudflare.
13
+ #
14
+ # @example
15
+ # sandbox = Ask::Sandbox::Cloudflare.new(
16
+ # worker_url: "https://sandbox-proxy.my-worker.workers.dev",
17
+ # auth_token: ENV["CLOUDFLARE_SANDBOX_TOKEN"]
18
+ # )
19
+ # sandbox.call(["ruby", "-e", "puts 1+1"])
20
+ #
21
+ class Cloudflare < Base
22
+ # @param worker_url [String] URL of the deployed proxy Worker
23
+ # @param auth_token [String, nil] auth token for the proxy Worker (optional)
24
+ # @param timeout [Integer] default timeout in seconds (default: 60)
25
+ def initialize(worker_url: nil, auth_token: nil, timeout: 60)
26
+ @worker_url = worker_url || ENV["CLOUDFLARE_SANDBOX_WORKER_URL"]
27
+ @auth_token = auth_token || ENV["CLOUDFLARE_SANDBOX_AUTH_TOKEN"]
28
+ @default_timeout = timeout
29
+ end
30
+
31
+ # (see Base#call)
32
+ def call(command, timeout: @default_timeout, workdir: nil, env: {}, stdin: nil)
33
+ raise ArgumentError, "command must not be nil" if command.nil?
34
+ raise ArgumentError, "command must not be empty" if command.respond_to?(:empty?) && command.empty?
35
+ raise ConfigurationError, "Cloudflare sandbox requires a worker_url. " \
36
+ "Set CLOUDFLARE_SANDBOX_WORKER_URL in your " \
37
+ "environment or pass `worker_url:` to #{self.class.name}.new" \
38
+ unless @worker_url
39
+
40
+ command_str = case command
41
+ when Array then command.map(&:to_s).join(" ")
42
+ when String then command
43
+ end
44
+
45
+ make_request(command_str, timeout)
46
+ end
47
+
48
+ private
49
+
50
+ def make_request(command, timeout)
51
+ require "net/http"
52
+ require "json"
53
+
54
+ uri = URI(@worker_url)
55
+ http = Net::HTTP.new(uri.host, uri.port || (uri.scheme == "https" ? 443 : 80))
56
+ http.use_ssl = uri.scheme == "https"
57
+ http.open_timeout = 10
58
+ http.read_timeout = timeout + 5
59
+
60
+ request = Net::HTTP::Post.new(uri.request_uri)
61
+ request["Content-Type"] = "application/json"
62
+ request["Authorization"] = "Bearer #{@auth_token}" if @auth_token
63
+ request.body = JSON.generate({
64
+ sandbox_id: SecureRandom.uuid,
65
+ command: command,
66
+ timeout: timeout
67
+ })
68
+
69
+ response = http.request(request)
70
+
71
+ case response
72
+ when Net::HTTPOK
73
+ body = JSON.parse(response.body)
74
+ Result.new(
75
+ stdout: body["stdout"].to_s,
76
+ stderr: body["stderr"].to_s,
77
+ exit_code: body["exit_code"],
78
+ timed_out: body["timed_out"] == true
79
+ )
80
+ when Net::HTTPUnauthorized, Net::HTTPForbidden
81
+ raise ConfigurationError,
82
+ "Cloudflare sandbox authentication failed (#{response.code}). " \
83
+ "Check your auth token."
84
+ when Net::HTTPServerError
85
+ raise ProviderUnavailable,
86
+ "Cloudflare sandbox proxy Worker returned #{response.code}: #{response.body}"
87
+ when Net::HTTPNotFound
88
+ raise ProviderUnavailable,
89
+ "Cloudflare sandbox proxy Worker not found at #{@worker_url}. " \
90
+ "Is the Worker deployed?"
91
+ else
92
+ raise ProviderUnavailable,
93
+ "Cloudflare sandbox proxy Worker returned unexpected #{response.code}: #{response.body}"
94
+ end
95
+
96
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
97
+ raise ProviderUnavailable,
98
+ "Cloudflare sandbox connection timed out: #{e.message}"
99
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH => e
100
+ raise ProviderUnavailable,
101
+ "Cloudflare sandbox unavailable: #{e.message}. " \
102
+ "Is the proxy Worker deployed and reachable at #{@worker_url}?"
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Sandbox
5
+ # Executes commands in a Daytona sandbox via the official +daytona+ gem.
6
+ #
7
+ # The +daytona+ gem is loaded lazily (inside +#call+). If the gem is not
8
+ # installed, a clear {LoadError} is raised with installation instructions.
9
+ #
10
+ # @example
11
+ # sandbox = Ask::Sandbox::Daytona.new(
12
+ # api_key: "dta_xxxx",
13
+ # server_url: "https://api.daytona.io"
14
+ # )
15
+ # sandbox.call(["ruby", "-e", "puts 1+1"])
16
+ #
17
+ class Daytona < Base
18
+ # @param api_key [String, nil] Daytona API key. If nil, tries to resolve
19
+ # via {Ask::Auth.lookup}("DAYTONA_API_KEY") or +ENV["DAYTONA_API_KEY"]+.
20
+ # @param server_url [String, nil] Daytona server URL. Falls back to the
21
+ # +daytona+ gem's default.
22
+ # @param image [String, nil] sandbox image (e.g. "ruby:3.4")
23
+ # @param timeout [Integer] default timeout in seconds (default: 120 —
24
+ # Daytona sandboxes may take time to boot)
25
+ def initialize(api_key: nil, server_url: nil, image: nil, timeout: 120)
26
+ @api_key = api_key
27
+ @server_url = server_url
28
+ @image = image
29
+ @default_timeout = timeout
30
+ end
31
+
32
+ # (see Base#call)
33
+ #
34
+ # Lazily loads the +daytona+ gem on first call.
35
+ def call(command, timeout: @default_timeout, workdir: nil, env: {}, stdin: nil)
36
+ raise ArgumentError, "command must not be nil" if command.nil?
37
+ raise ArgumentError, "command must not be empty" if command.respond_to?(:empty?) && command.empty?
38
+
39
+ api_key = resolve_api_key
40
+ ensure_daytona_gem!
41
+ execute_on_daytona(command, api_key, timeout)
42
+ end
43
+
44
+ private
45
+
46
+ def resolve_api_key
47
+ key = @api_key
48
+ key ||= ENV["DAYTONA_API_KEY"]
49
+
50
+ if defined?(Ask::Auth) && Ask::Auth.respond_to?(:lookup)
51
+ key ||= Ask::Auth.lookup("DAYTONA_API_KEY") rescue nil
52
+ end
53
+
54
+ raise ConfigurationError,
55
+ "Daytona sandbox requires an API key. Set DAYTONA_API_KEY in your " \
56
+ "environment or pass `api_key:` to #{self.class.name}.new" if key.nil? || key.empty?
57
+
58
+ key
59
+ end
60
+
61
+ def ensure_daytona_gem!
62
+ require "daytona"
63
+ rescue LoadError => e
64
+ raise LoadError,
65
+ "The `daytona` gem is required for the Daytona sandbox provider. " \
66
+ "Add `gem 'daytona'` to your Gemfile or run `gem install daytona`. " \
67
+ "(original: #{e.message})"
68
+ end
69
+
70
+ def execute_on_daytona(command, api_key, timeout)
71
+ # Build the command string
72
+ command_str = case command
73
+ when Array then command.map(&:to_s).join(" ")
74
+ when String then command
75
+ end
76
+
77
+ # Create a Daytona client and sandbox
78
+ client = Daytona::Client.new(api_key: api_key, server_url: @server_url)
79
+
80
+ sandbox = client.sandboxes.create(image: @image || "ruby:3.4")
81
+
82
+ begin
83
+ result = sandbox.process.execute(command_str, timeout: timeout)
84
+
85
+ Result.new(
86
+ stdout: result["stdout"].to_s,
87
+ stderr: result["stderr"].to_s,
88
+ exit_code: result["exit_code"],
89
+ timed_out: result["timed_out"] == true
90
+ )
91
+ rescue => e
92
+ raise ExecutionError,
93
+ "Daytona sandbox execution failed: #{e.message}"
94
+ ensure
95
+ # Clean up the sandbox
96
+ begin
97
+ sandbox.delete
98
+ rescue => e
99
+ # Non-fatal: log but don't propagate
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Sandbox
5
+ # Executes commands in a Docker container with hardened security settings.
6
+ #
7
+ # Uses the +docker+ CLI binary directly (no gem dependencies). Requires
8
+ # a running Docker daemon.
9
+ #
10
+ # Security measures:
11
+ # - Read-only root filesystem (+--read-only+)
12
+ # - All Linux capabilities dropped (+--cap-drop ALL+)
13
+ # - No privilege escalation (+--security-opt no-new-privileges+)
14
+ # - Network egress disabled by default (+--network none+)
15
+ # - PIDs limit (+--pids-limit 100+)
16
+ # - Memory and CPU limits
17
+ # - Container auto-removal (+--rm+)
18
+ #
19
+ # @example
20
+ # sandbox = Ask::Sandbox::Docker.new(image: "ruby:3.4-alpine")
21
+ # sandbox.call(["ruby", "-e", "puts 1+1"])
22
+ #
23
+ class Docker < Base
24
+ # @param image [String] Docker image to use (default: "ruby:3.4-alpine")
25
+ # @param memory [String, nil] memory limit (e.g. "512m", "1g")
26
+ # @param cpus [Float, nil] CPU limit (e.g. 1.0)
27
+ # @param network [Boolean] allow network egress (default: false)
28
+ # @param read_only [Boolean] read-only root filesystem (default: true)
29
+ # @param cap_drop [String, nil] capabilities to drop (default: "ALL")
30
+ # @param user [String, nil] run as specific user (default: nil = container default)
31
+ # @param timeout [Integer] default timeout in seconds
32
+ # @param remove [Boolean] auto-remove container after execution (default: true)
33
+ def initialize(
34
+ image: "ruby:3.4-alpine",
35
+ memory: "512m",
36
+ cpus: 1.0,
37
+ network: false,
38
+ read_only: true,
39
+ cap_drop: "ALL",
40
+ user: nil,
41
+ timeout: 30,
42
+ remove: true
43
+ )
44
+ @image = image
45
+ @memory = memory
46
+ @cpus = cpus
47
+ @network = network
48
+ @read_only = read_only
49
+ @cap_drop = cap_drop
50
+ @user = user
51
+ @default_timeout = timeout
52
+ @remove = remove
53
+ end
54
+
55
+ # (see Base#call)
56
+ def call(command, timeout: @default_timeout, workdir: nil, env: {}, stdin: nil)
57
+ raise ArgumentError, "command must not be nil" if command.nil?
58
+ raise ArgumentError, "command must not be empty" if command.respond_to?(:empty?) && command.empty?
59
+
60
+ check_docker_available!
61
+
62
+ docker_argv = build_docker_argv(command, env, workdir)
63
+ run_docker(docker_argv, timeout, stdin)
64
+ end
65
+
66
+ private
67
+
68
+ def check_docker_available!
69
+ return if @docker_checked
70
+ system("docker", "info", out: File::NULL, err: File::NULL)
71
+ @docker_checked = true
72
+ rescue SystemCallError
73
+ raise ProviderUnavailable,
74
+ "Docker sandbox unavailable: `docker info` failed. " \
75
+ "Is Docker installed and the daemon running?"
76
+ end
77
+
78
+ def build_docker_argv(command, env, workdir)
79
+ argv = ["docker", "run", "--rm", "-i"]
80
+
81
+ # Resource limits
82
+ argv << "--memory" << @memory.to_s if @memory
83
+ argv << "--cpus" << @cpus.to_s if @cpus
84
+
85
+ # Security hardening
86
+ argv << "--network" << "none" unless @network
87
+ argv << "--read-only" if @read_only
88
+ argv << "--cap-drop" << @cap_drop.to_s if @cap_drop
89
+ argv << "--security-opt" << "no-new-privileges"
90
+ argv << "--pids-limit" << "100"
91
+
92
+ # User
93
+ argv << "--user" << @user.to_s if @user
94
+
95
+ # Working directory
96
+ argv << "--workdir" << workdir if workdir
97
+
98
+ # Environment variables
99
+ env.each do |key, value|
100
+ argv << "--env" << "#{key}=#{value}"
101
+ end
102
+
103
+ # Don't use ENTRYPOINT — we control the command
104
+ argv << "--entrypoint" << ""
105
+
106
+ # Image
107
+ argv << @image
108
+
109
+ # Command — use array form for clean argv forwarding
110
+ case command
111
+ when String
112
+ # String commands run via the container's shell
113
+ argv << "/bin/sh" << "-c" << command
114
+ when Array
115
+ # Array commands forward directly as argv
116
+ argv.concat(command.map(&:to_s))
117
+ end
118
+
119
+ argv
120
+ end
121
+
122
+ def run_docker(docker_argv, timeout, stdin_data)
123
+ stdout_r, stdout_w = IO.pipe
124
+ stderr_r, stderr_w = IO.pipe
125
+ stdin_r, stdin_w = IO.pipe
126
+
127
+ pid = Process.spawn(
128
+ *docker_argv,
129
+ pgroup: true,
130
+ in: stdin_r,
131
+ out: stdout_w,
132
+ err: stderr_w
133
+ )
134
+
135
+ stdin_r.close
136
+ stdout_w.close
137
+ stderr_w.close
138
+
139
+ # Write stdin in a thread
140
+ stdin_thread = Thread.new do
141
+ if stdin_data && !stdin_data.empty?
142
+ begin
143
+ stdin_w.write(stdin_data)
144
+ rescue Errno::EPIPE
145
+ # Command closed stdin early
146
+ end
147
+ end
148
+ stdin_w.close
149
+ rescue IOError
150
+ # Already closed
151
+ end
152
+
153
+ # Capture output in threads
154
+ stdout_chunks = []
155
+ stderr_chunks = []
156
+ stdout_thread = Thread.new { read_stream(stdout_r, stdout_chunks) }
157
+ stderr_thread = Thread.new { read_stream(stderr_r, stderr_chunks) }
158
+
159
+ exit_status, timed_out = wait_for_docker(pid, timeout)
160
+
161
+ stdin_thread.join
162
+ stdout_thread.join
163
+ stderr_thread.join
164
+
165
+ [stdout_r, stderr_r, stdin_w].each { |io| io.close rescue nil }
166
+
167
+ # Final reap
168
+ begin
169
+ Process.waitpid(pid, Process::WNOHANG)
170
+ rescue Errno::ECHILD
171
+ end
172
+
173
+ stdout_str = stdout_chunks.join
174
+ stderr_str = stderr_chunks.join
175
+
176
+ Result.new(
177
+ stdout: stdout_str,
178
+ stderr: stderr_str,
179
+ exit_code: exit_status&.exitstatus,
180
+ timed_out: timed_out
181
+ )
182
+ end
183
+
184
+ def wait_for_docker(pid, timeout)
185
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
186
+
187
+ loop do
188
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
189
+ break if remaining <= 0
190
+
191
+ _, status = Process.waitpid2(pid, Process::WNOHANG)
192
+ return [status, false] if status
193
+
194
+ sleep 0.1
195
+ end
196
+
197
+ # Timeout — use docker stop (graceful) then docker kill (force)
198
+ begin
199
+ # We need the container ID to stop it. For "docker run --rm",
200
+ # the container name can be extracted, but it's tricky.
201
+ # Simplest: just kill the docker CLI process, which stops the container
202
+ # since we used --rm.
203
+ Process.kill(:TERM, pid)
204
+ _, status = Process.waitpid2(pid, 3)
205
+ return [status, true] if status
206
+ rescue Errno::ESRCH, Errno::ECHILD
207
+ end
208
+
209
+ begin
210
+ Process.kill(:KILL, pid)
211
+ Process.waitpid(pid, 1)
212
+ rescue Errno::ESRCH, Errno::ECHILD
213
+ end
214
+
215
+ [nil, true]
216
+ end
217
+
218
+ def read_stream(io, chunks)
219
+ buffer = String.new(capacity: 102_400)
220
+ while (data = io.read(8192))
221
+ buffer << data
222
+ break if buffer.bytesize > 102_400
223
+ end
224
+ chunks << buffer
225
+ rescue IOError
226
+ ensure
227
+ io.close rescue nil
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "fileutils"
5
+
6
+ module Ask
7
+ module Sandbox
8
+ # Executes commands in a local subprocess with resource limits.
9
+ # Available on all platforms (macOS, Linux). Uses stdlib only.
10
+ class Local < Base
11
+ MAX_OUTPUT_SIZE = 102_400
12
+
13
+ STRIP_ENV_PREFIXES = %w[BUNDLE_ GEM_].freeze
14
+ STRIP_ENV_VARS = %w[RUBYOPT RUBYLIB BASH_ENV GEM_PATH GEM_HOME
15
+ BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_BIN_PATH
16
+ BUNDLE_SETUP BUNDLE_WITHOUT BUNDLE_FROZEN].freeze
17
+
18
+ RLIMITS = {
19
+ rlimit_cpu: [10, 30],
20
+ rlimit_nproc: [50, 50],
21
+ rlimit_fsize: [10_485_760, 10_485_760],
22
+ rlimit_nofile: [200, 200],
23
+ rlimit_as: [2_147_483_648, 2_147_483_648]
24
+ }.freeze
25
+
26
+ POLL_INTERVAL = 0.05
27
+
28
+ def initialize(timeout: 30, max_output: MAX_OUTPUT_SIZE)
29
+ @default_timeout = timeout
30
+ @max_output = max_output
31
+ end
32
+
33
+ def call(command, timeout: @default_timeout, workdir: nil, env: {}, stdin: nil)
34
+ raise ArgumentError, "command must not be nil" if command.nil?
35
+ raise ArgumentError, "command must not be empty" if command.respond_to?(:empty?) && command.empty?
36
+
37
+ argv = build_argv(command)
38
+ child_env = build_environment(env)
39
+
40
+ if workdir
41
+ execute_in_dir(argv, child_env, workdir, timeout, stdin)
42
+ else
43
+ Dir.mktmpdir("ask_sandbox") do |dir|
44
+ execute_in_dir(argv, child_env, dir, timeout, stdin)
45
+ end
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def build_argv(command)
52
+ case command
53
+ when String then ["bash", "-c", command]
54
+ when Array then command.map(&:to_s)
55
+ else raise ArgumentError, "command must be a String or Array of Strings"
56
+ end
57
+ end
58
+
59
+ def build_environment(extra_env)
60
+ env = {}
61
+ STRIP_ENV_VARS.each { |v| env[v] = nil }
62
+ ENV.each_key do |key|
63
+ next if key.start_with?("ASK_")
64
+ STRIP_ENV_PREFIXES.each { |p| env[key] = nil if key.start_with?(p) }
65
+ end
66
+ extra_env.each { |k, v| env[k.to_s] = v.to_s }
67
+ env
68
+ end
69
+
70
+ def self.supported_rlimits
71
+ @supported_rlimits ||= {}.tap do |opts|
72
+ RLIMITS.each do |option, (soft, hard)|
73
+ begin
74
+ pid = Process.spawn({}, "true", {option => [soft, hard]})
75
+ Process.waitpid(pid)
76
+ opts[option] = [soft, hard]
77
+ rescue ArgumentError, Errno::EINVAL, NotImplementedError
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ def execute_in_dir(argv, env, dir, timeout, stdin_data)
84
+ stdout_r, stdout_w = IO.pipe
85
+ stderr_r, stderr_w = IO.pipe
86
+ stdin_r, stdin_w = IO.pipe
87
+
88
+ spawn_opts = {
89
+ pgroup: true,
90
+ chdir: dir,
91
+ in: stdin_r,
92
+ out: stdout_w,
93
+ err: stderr_w
94
+ }.merge!(self.class.supported_rlimits)
95
+
96
+ pid = Process.spawn(env, *argv, **spawn_opts)
97
+
98
+ stdin_r.close
99
+ stdout_w.close
100
+ stderr_w.close
101
+
102
+ stdin_thread = Thread.new do
103
+ if stdin_data && !stdin_data.empty?
104
+ begin
105
+ stdin_w.write(stdin_data)
106
+ rescue Errno::EPIPE
107
+ end
108
+ end
109
+ stdin_w.close
110
+ rescue IOError
111
+ end
112
+
113
+ stdout_chunks = []
114
+ stderr_chunks = []
115
+ stdout_thread = Thread.new { read_stream(stdout_r, stdout_chunks) }
116
+ stderr_thread = Thread.new { read_stream(stderr_r, stderr_chunks) }
117
+
118
+ exit_status, timed_out = wait_for_process(pid, timeout)
119
+
120
+ stdin_thread.join
121
+ stdout_thread.join
122
+ stderr_thread.join
123
+
124
+ [stdout_r, stderr_r, stdin_w].each { |io| io.close rescue nil }
125
+
126
+ begin
127
+ Process.waitpid(pid, Process::WNOHANG)
128
+ rescue Errno::ECHILD
129
+ end
130
+
131
+ stdout_str = truncate_output(stdout_chunks.join)
132
+ stderr_str = truncate_output(stderr_chunks.join)
133
+
134
+ Result.new(
135
+ stdout: stdout_str,
136
+ stderr: stderr_str,
137
+ exit_code: exit_status&.exitstatus,
138
+ timed_out: timed_out
139
+ )
140
+ end
141
+
142
+ def wait_for_process(pid, timeout)
143
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
144
+
145
+ loop do
146
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
147
+ break if remaining <= 0
148
+
149
+ _, status = Process.waitpid2(pid, Process::WNOHANG)
150
+ return [status, false] if status
151
+
152
+ sleep POLL_INTERVAL
153
+ end
154
+
155
+ kill_process_group(pid)
156
+ [nil, true]
157
+ rescue Errno::ECHILD
158
+ [nil, false]
159
+ end
160
+
161
+ def kill_process_group(pid)
162
+ begin
163
+ Process.kill(:TERM, -pid)
164
+ _, status = Process.waitpid2(pid, 1)
165
+ return if status
166
+ rescue Errno::ESRCH, Errno::ECHILD
167
+ return
168
+ end
169
+
170
+ begin
171
+ Process.kill(:KILL, -pid)
172
+ Process.waitpid(pid, 1)
173
+ rescue Errno::ESRCH, Errno::ECHILD
174
+ end
175
+ end
176
+
177
+ def read_stream(io, chunks)
178
+ buffer = String.new(capacity: @max_output + 4096)
179
+ while (data = io.read(8192))
180
+ buffer << data
181
+ break if buffer.bytesize > @max_output
182
+ end
183
+ chunks << buffer
184
+ rescue IOError
185
+ ensure
186
+ io.close rescue nil
187
+ end
188
+
189
+ def truncate_output(str)
190
+ if str.bytesize > @max_output
191
+ str.byteslice(0, @max_output) << "\n[Truncated — output exceeds #{@max_output} bytes]\n"
192
+ else
193
+ str
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Sandbox
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ask/sandbox/version"
4
+ require_relative "ask/sandbox/base"
5
+ require_relative "ask/sandbox/local"
6
+ require_relative "ask/sandbox/docker"
7
+ require_relative "ask/sandbox/daytona"
8
+ require_relative "ask/sandbox/cloudflare"
9
+
10
+ module Ask
11
+ module Sandbox
12
+ class << self
13
+ # The currently configured sandbox provider.
14
+ # Defaults to {Ask::Sandbox::Local} (subprocess + rlimits).
15
+ #
16
+ # @return [Ask::Sandbox::Base]
17
+ def provider
18
+ @provider ||= Ask::Sandbox::Local.new
19
+ end
20
+
21
+ # Set the sandbox provider.
22
+ #
23
+ # @param provider [Ask::Sandbox::Base, Symbol]
24
+ # Pass a provider instance, or +:local+/+:docker+/+:daytona+/+:cloudflare+
25
+ # to use a default instance of that provider.
26
+ def provider=(provider)
27
+ @provider = case provider
28
+ when Symbol then resolve_provider(provider)
29
+ else provider
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def resolve_provider(name)
36
+ case name
37
+ when :local then Local.new
38
+ when :docker then Docker.new
39
+ when :daytona then Daytona.new
40
+ when :cloudflare then Cloudflare.new
41
+ else raise ArgumentError, "Unknown sandbox provider: #{name.inspect}. " \
42
+ "Supported: :local, :docker, :daytona, :cloudflare"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ask-sandbox-providers
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kaka Ruto
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.25'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.25'
26
+ - !ruby/object:Gem::Dependency
27
+ name: mocha
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.1'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ description: Isolated code execution via Local (subprocess + rlimits), Docker (containers),
55
+ Daytona (remote sandboxes), and Cloudflare (Workers sandbox). Zero external runtime
56
+ dependencies.
57
+ email:
58
+ - kaka@myrrlabs.com
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - CHANGELOG.md
64
+ - LICENSE
65
+ - README.md
66
+ - lib/ask-sandbox-providers.rb
67
+ - lib/ask/sandbox/base.rb
68
+ - lib/ask/sandbox/cloudflare.rb
69
+ - lib/ask/sandbox/daytona.rb
70
+ - lib/ask/sandbox/docker.rb
71
+ - lib/ask/sandbox/local.rb
72
+ - lib/ask/sandbox/version.rb
73
+ homepage: https://github.com/ask-rb/ask-sandbox-providers
74
+ licenses:
75
+ - MIT
76
+ metadata:
77
+ homepage_uri: https://github.com/ask-rb/ask-sandbox-providers
78
+ source_code_uri: https://github.com/ask-rb/ask-sandbox-providers
79
+ changelog_uri: https://github.com/ask-rb/ask-sandbox-providers/blob/master/CHANGELOG.md
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '3.2'
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 4.0.3
95
+ specification_version: 4
96
+ summary: Sandbox providers for the ask-rb ecosystem
97
+ test_files: []