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 +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE +21 -0
- data/README.md +131 -0
- data/lib/ask/sandbox/base.rb +66 -0
- data/lib/ask/sandbox/cloudflare.rb +106 -0
- data/lib/ask/sandbox/daytona.rb +105 -0
- data/lib/ask/sandbox/docker.rb +231 -0
- data/lib/ask/sandbox/local.rb +198 -0
- data/lib/ask/sandbox/version.rb +7 -0
- data/lib/ask-sandbox-providers.rb +47 -0
- metadata +97 -0
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
|
+
[](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,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: []
|