little_ghost 0.3.0 → 0.5.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 +4 -4
- data/README.md +90 -84
- data/docs/guides/assemblies.md +412 -0
- data/docs/guides/code_mode.md +275 -0
- data/docs/guides/core_concepts.md +150 -239
- data/docs/guides/getting_started.md +125 -87
- data/docs/guides/integrations.md +217 -0
- data/docs/guides/models_and_providers.md +125 -0
- data/docs/guides/production.md +253 -0
- data/docs/guides/prompt_views.md +139 -0
- data/docs/guides/sandboxing.md +282 -0
- data/docs/guides/skills.md +141 -0
- data/docs/guides/structured_outputs_and_content.md +135 -0
- data/docs/guides/tools.md +329 -0
- data/lib/little_ghost/ag_ui/adapter.rb +5 -5
- data/lib/little_ghost/agent/delegation.rb +3 -3
- data/lib/little_ghost/agent/skills.rb +6 -1
- data/lib/little_ghost/agent/tool_loop.rb +6 -2
- data/lib/little_ghost/agent.rb +411 -214
- data/lib/little_ghost/agent_builder.rb +61 -22
- data/lib/little_ghost/{agent_interruptions.rb → agent_interjections.rb} +12 -12
- data/lib/little_ghost/agent_stream_source.rb +262 -0
- data/lib/little_ghost/artifact.rb +182 -0
- data/lib/little_ghost/artifacts/presentation_budget.rb +44 -0
- data/lib/little_ghost/artifacts/workspace_store.rb +370 -0
- data/lib/little_ghost/artifacts.rb +11 -0
- data/lib/little_ghost/assembly.rb +110 -30
- data/lib/little_ghost/assembly_builder.rb +59 -21
- data/lib/little_ghost/assembly_execution.rb +110 -6
- data/lib/little_ghost/code_mode/broker.rb +164 -0
- data/lib/little_ghost/code_mode/catalog.rb +54 -0
- data/lib/little_ghost/code_mode/engine.rb +58 -0
- data/lib/little_ghost/code_mode/javascript/catalog.rb +118 -0
- data/lib/little_ghost/code_mode/javascript/client.rb +550 -0
- data/lib/little_ghost/code_mode/javascript/host.rb +347 -0
- data/lib/little_ghost/code_mode/javascript/session.rb +579 -0
- data/lib/little_ghost/code_mode/javascript_engine.rb +172 -0
- data/lib/little_ghost/code_mode/protocol.rb +78 -0
- data/lib/little_ghost/code_mode/ruby/catalog.rb +80 -0
- data/lib/little_ghost/code_mode/ruby/host.rb +102 -0
- data/lib/little_ghost/code_mode/ruby/session.rb +508 -0
- data/lib/little_ghost/code_mode/ruby_engine.rb +86 -0
- data/lib/little_ghost/code_mode/runtime.rb +335 -0
- data/lib/little_ghost/code_mode/session.rb +61 -0
- data/lib/little_ghost/code_mode/types.rb +58 -0
- data/lib/little_ghost/code_mode.rb +53 -0
- data/lib/little_ghost/configuration.rb +366 -47
- data/lib/little_ghost/content.rb +24 -13
- data/lib/little_ghost/data_map.rb +209 -0
- data/lib/little_ghost/errors.rb +28 -9
- data/lib/little_ghost/execution.rb +33 -34
- data/lib/little_ghost/graph.rb +376 -197
- data/lib/little_ghost/mcp/client.rb +487 -88
- data/lib/little_ghost/mcp/toolset.rb +210 -0
- data/lib/little_ghost/mcp/types.rb +216 -0
- data/lib/little_ghost/mcp.rb +3 -0
- data/lib/little_ghost/message.rb +4 -4
- data/lib/little_ghost/model_capabilities.rb +9 -6
- data/lib/little_ghost/model_request.rb +0 -12
- data/lib/little_ghost/model_resolver.rb +15 -5
- data/lib/little_ghost/model_response.rb +3 -7
- data/lib/little_ghost/network/authorizer_server.rb +162 -0
- data/lib/little_ghost/network/certificate_authority.rb +98 -0
- data/lib/little_ghost/network/envoy_config.rb +362 -0
- data/lib/little_ghost/network/envoy_gateway.rb +409 -0
- data/lib/little_ghost/network/external_gateway.rb +68 -0
- data/lib/little_ghost/network.rb +96 -0
- data/lib/little_ghost/prompt_resolver.rb +9 -7
- data/lib/little_ghost/provider_registry.rb +3 -3
- data/lib/little_ghost/providers/anthropic.rb +8 -1
- data/lib/little_ghost/providers/gemini.rb +10 -1
- data/lib/little_ghost/providers/vertex_ai.rb +6 -1
- data/lib/little_ghost/run.rb +162 -54
- data/lib/little_ghost/run_context.rb +42 -20
- data/lib/little_ghost/run_result.rb +0 -7
- data/lib/little_ghost/runtime/hook.rb +8 -3
- data/lib/little_ghost/runtime/hooks/artifacts.rb +338 -0
- data/lib/little_ghost/runtime.rb +160 -52
- data/lib/little_ghost/sandbox/capabilities.rb +68 -0
- data/lib/little_ghost/sandbox/environment_policy.rb +41 -0
- data/lib/little_ghost/sandbox/filesystem.rb +377 -0
- data/lib/little_ghost/sandbox/isolated_backend.rb +163 -0
- data/lib/little_ghost/sandbox/limits.rb +48 -0
- data/lib/little_ghost/sandbox/mount.rb +129 -0
- data/lib/little_ghost/sandbox/network_policy.rb +108 -0
- data/lib/little_ghost/sandbox/policy.rb +88 -0
- data/lib/little_ghost/sandbox/process_runner.rb +103 -0
- data/lib/little_ghost/sandbox/process_session.rb +335 -0
- data/lib/little_ghost/sandbox/scope.rb +304 -0
- data/lib/little_ghost/sandbox.rb +158 -32
- data/lib/little_ghost/sandboxes/bubblewrap.rb +351 -0
- data/lib/little_ghost/sandboxes/native.rb +51 -0
- data/lib/little_ghost/sandboxes/seatbelt.rb +261 -0
- data/lib/little_ghost/sandboxes/unrestricted.rb +241 -0
- data/lib/little_ghost/session.rb +39 -26
- data/lib/little_ghost/session_store.rb +9 -5
- data/lib/little_ghost/session_stores/agent_core_memory.rb +64 -56
- data/lib/little_ghost/session_stores/filesystem.rb +261 -0
- data/lib/little_ghost/session_stores/memory.rb +7 -0
- data/lib/little_ghost/skills/catalog.rb +94 -14
- data/lib/little_ghost/skills/resource_root.rb +42 -0
- data/lib/little_ghost/skills/skill.rb +0 -3
- data/lib/little_ghost/stream_event.rb +8 -13
- data/lib/little_ghost/subagents/control_tool.rb +8 -0
- data/lib/little_ghost/subagents/manager.rb +53 -50
- data/lib/little_ghost/support/callbacks.rb +3 -1
- data/lib/little_ghost/support/content_capture.rb +3 -3
- data/lib/little_ghost/support/http_client.rb +2 -2
- data/lib/little_ghost/support/redactor.rb +1 -1
- data/lib/little_ghost/swarm.rb +13 -5
- data/lib/little_ghost/tool.rb +157 -64
- data/lib/little_ghost/tool_registry.rb +1 -1
- data/lib/little_ghost/tools/filesystem.rb +1 -1
- data/lib/little_ghost/tools/shell.rb +4 -3
- data/lib/little_ghost/tools/write_todos.rb +6 -1
- data/lib/little_ghost/tracing/open_telemetry.rb +1 -1
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost/workflow.rb +30 -21
- data/lib/little_ghost/workspace.rb +222 -8
- data/lib/little_ghost.rb +40 -27
- metadata +104 -3
- data/lib/little_ghost/unrestricted_sandbox.rb +0 -306
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module LittleGhost
|
|
6
|
+
class Sandbox
|
|
7
|
+
# Maps one trusted host directory into the sandbox's virtual filesystem.
|
|
8
|
+
# Process and Filesystem Tool visibility are separate: +tools: false+ keeps the
|
|
9
|
+
# mount available to child processes while denying it to the filesystem
|
|
10
|
+
# broker. +protect_aliases+ preserves restrictive access when the same host
|
|
11
|
+
# files are reachable through a broader mount.
|
|
12
|
+
class Mount # :nodoc:
|
|
13
|
+
ACCESS_MODES = %i[read_only read_write].freeze # :nodoc:
|
|
14
|
+
|
|
15
|
+
# Returns +value+ unchanged or builds a mount from a Hash.
|
|
16
|
+
def self.coerce(value, root: nil)
|
|
17
|
+
return value if value.is_a?(self)
|
|
18
|
+
raise PolicyError, "mount must be a Hash or Sandbox::Mount" unless value.is_a?(Hash)
|
|
19
|
+
|
|
20
|
+
new(**value.transform_keys(&:to_sym), root:)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Maps +source+ at the absolute virtual +target+ with the selected access.
|
|
24
|
+
# +source+ is trusted host configuration, not model input.
|
|
25
|
+
def initialize(source:, target:, access: :read_only, protect_aliases: false, tools: true, root: nil)
|
|
26
|
+
source = File.expand_path(String(source), root)
|
|
27
|
+
target = normalize_target(target)
|
|
28
|
+
access = access.to_sym
|
|
29
|
+
raise PolicyError, "mount access must be :read_only or :read_write" unless ACCESS_MODES.include?(access)
|
|
30
|
+
|
|
31
|
+
@source = source.freeze
|
|
32
|
+
@target = target.freeze
|
|
33
|
+
@access = access
|
|
34
|
+
@protect_aliases = !!protect_aliases
|
|
35
|
+
@tools = !!tools
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Absolute host directory exposed by this mount.
|
|
40
|
+
attr_reader :source
|
|
41
|
+
# Absolute path presented inside the sandbox.
|
|
42
|
+
attr_reader :target
|
|
43
|
+
# Either +:read_only+ or +:read_write+.
|
|
44
|
+
attr_reader :access
|
|
45
|
+
# Indicates whether the mount's access applies through physical aliases.
|
|
46
|
+
attr_reader :protect_aliases
|
|
47
|
+
# Indicates whether direct filesystem tools may traverse this mount.
|
|
48
|
+
attr_reader :tools
|
|
49
|
+
|
|
50
|
+
# Indicates that the mount does not permit writes.
|
|
51
|
+
def read_only? = access == :read_only
|
|
52
|
+
# Indicates that the mount permits writes.
|
|
53
|
+
def writable? = access == :read_write
|
|
54
|
+
# Indicates that physical aliases retain this mount's access.
|
|
55
|
+
def protect_aliases? = protect_aliases
|
|
56
|
+
# Indicates that direct filesystem tools may traverse this mount.
|
|
57
|
+
def tool_visible? = tools
|
|
58
|
+
|
|
59
|
+
# Indicates whether +path+ is this mount target or one of its descendants.
|
|
60
|
+
def covers?(path)
|
|
61
|
+
path = self.class.send(:normalize_virtual_path, path)
|
|
62
|
+
path == target || path.start_with?("#{target}/")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns a copy narrowed to +target+ and +access+. Widening raises
|
|
66
|
+
# CapabilityError.
|
|
67
|
+
def narrow(target: self.target, access: self.access)
|
|
68
|
+
target = self.class.send(:normalize_virtual_path, target)
|
|
69
|
+
raise CapabilityError, "sandbox scope cannot expose a path outside its parent mount" unless covers?(target)
|
|
70
|
+
if read_only? && access.to_sym == :read_write
|
|
71
|
+
raise CapabilityError, "sandbox scope cannot make a read-only mount writable"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
relative = target.delete_prefix(self.target).delete_prefix("/")
|
|
75
|
+
parent_root = File.realpath(source)
|
|
76
|
+
child_root = relative.empty? ? parent_root : File.realpath(File.join(parent_root, relative))
|
|
77
|
+
unless child_root == parent_root || child_root.start_with?("#{parent_root}#{File::SEPARATOR}")
|
|
78
|
+
raise CapabilityError, "sandbox scope mount source escapes its parent"
|
|
79
|
+
end
|
|
80
|
+
self.class.new(
|
|
81
|
+
source: child_root,
|
|
82
|
+
target:,
|
|
83
|
+
access:,
|
|
84
|
+
protect_aliases: protect_aliases?,
|
|
85
|
+
tools: tool_visible?
|
|
86
|
+
)
|
|
87
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
88
|
+
raise CapabilityError, "sandbox scope mount source is unavailable"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Mounts compare by their normalized mapping and access semantics.
|
|
92
|
+
def ==(other)
|
|
93
|
+
other.is_a?(self.class) &&
|
|
94
|
+
[comparison_source, target, access, protect_aliases?, tool_visible?] ==
|
|
95
|
+
[other.send(:comparison_source), other.target, other.access, other.protect_aliases?, other.tool_visible?]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
alias_method :eql?, :==
|
|
99
|
+
|
|
100
|
+
# Hashes the normalized mapping and access semantics.
|
|
101
|
+
def hash = [comparison_source, target, access, protect_aliases?, tool_visible?].hash
|
|
102
|
+
|
|
103
|
+
def self.normalize_virtual_path(path)
|
|
104
|
+
value = String(path)
|
|
105
|
+
raise PolicyError, "mount target contains a null byte" if value.include?("\0")
|
|
106
|
+
raise PolicyError, "mount target must be absolute" unless value.start_with?(File::SEPARATOR)
|
|
107
|
+
raise PolicyError, "mount target cannot contain traversal" if value.split(File::SEPARATOR).include?("..")
|
|
108
|
+
|
|
109
|
+
Pathname.new(value).cleanpath.to_s
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
class << self
|
|
113
|
+
private :normalize_virtual_path
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def comparison_source
|
|
119
|
+
File.realpath(source)
|
|
120
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
121
|
+
source
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def normalize_target(path)
|
|
125
|
+
self.class.send(:normalize_virtual_path, path)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
class Sandbox
|
|
5
|
+
# Declares outbound connectivity for sandbox-launched processes. A network
|
|
6
|
+
# policy does not apply to providers or arbitrary Ruby tools in the host.
|
|
7
|
+
class NetworkPolicy
|
|
8
|
+
MODES = %i[inherit none allowlist].freeze # :nodoc:
|
|
9
|
+
INSPECTION_MODES = %i[connect http].freeze # :nodoc:
|
|
10
|
+
|
|
11
|
+
# Returns +value+ unchanged or converts a mode or Hash to a policy.
|
|
12
|
+
def self.coerce(value)
|
|
13
|
+
return nil if value.nil?
|
|
14
|
+
return value if value.is_a?(self)
|
|
15
|
+
return new(mode: value) if value.is_a?(Symbol) || value.is_a?(String)
|
|
16
|
+
raise PolicyError, "network policy must be a mode, Hash, or Sandbox::NetworkPolicy" unless value.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
new(**value.transform_keys(&:to_sym))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Builds an outbound policy. Enforcement remains the configured gateway's
|
|
22
|
+
# responsibility.
|
|
23
|
+
def initialize(mode:, allow: [], inspection: :connect, gateway: nil, authorizer: nil, forward_headers: [], mutation_headers: [])
|
|
24
|
+
mode = mode.to_sym
|
|
25
|
+
inspection = inspection.to_sym
|
|
26
|
+
raise PolicyError, "network mode must be :inherit, :none, or :allowlist" unless MODES.include?(mode)
|
|
27
|
+
unless INSPECTION_MODES.include?(inspection)
|
|
28
|
+
raise PolicyError, "network inspection must be :connect or :http"
|
|
29
|
+
end
|
|
30
|
+
if mode != :allowlist && (!Array(allow).empty? || gateway || authorizer || !Array(forward_headers).empty? || !Array(mutation_headers).empty?)
|
|
31
|
+
raise PolicyError, "network allow, gateway, authorizer, and header policy require mode :allowlist"
|
|
32
|
+
end
|
|
33
|
+
if inspection == :http && mode != :allowlist
|
|
34
|
+
raise PolicyError, "HTTP inspection requires mode :allowlist"
|
|
35
|
+
end
|
|
36
|
+
if inspection == :http && !authorizer
|
|
37
|
+
raise PolicyError, "HTTP inspection requires an authorizer"
|
|
38
|
+
end
|
|
39
|
+
if mode == :allowlist && Array(allow).empty?
|
|
40
|
+
raise PolicyError, "network mode :allowlist requires at least one exact destination"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
@mode = mode
|
|
44
|
+
@allow = Array(allow).map { |endpoint| normalize_endpoint(endpoint) }.uniq.freeze
|
|
45
|
+
@inspection = inspection
|
|
46
|
+
@gateway = gateway
|
|
47
|
+
@authorizer = authorizer
|
|
48
|
+
@forward_headers = Array(forward_headers).map { |name| normalize_header_name(name) }.uniq.freeze
|
|
49
|
+
@mutation_headers = Array(mutation_headers).map { |name| normalize_header_name(name) }.uniq.freeze
|
|
50
|
+
freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Connectivity mode: +:inherit+, +:none+, or +:allowlist+.
|
|
54
|
+
attr_reader :mode
|
|
55
|
+
# Normalized endpoints accepted by an allowlist gateway.
|
|
56
|
+
attr_reader :allow
|
|
57
|
+
# Inspection level requested from the gateway.
|
|
58
|
+
attr_reader :inspection
|
|
59
|
+
# Explicit gateway declaration, when supplied.
|
|
60
|
+
attr_reader :gateway
|
|
61
|
+
# Trusted request authorizer used by HTTP inspection, when supplied.
|
|
62
|
+
attr_reader :authorizer
|
|
63
|
+
# Header names the gateway may pass to an HTTP authorizer.
|
|
64
|
+
attr_reader :forward_headers
|
|
65
|
+
# Header names an HTTP authorizer may set on an upstream request.
|
|
66
|
+
attr_reader :mutation_headers
|
|
67
|
+
|
|
68
|
+
# Indicates unrestricted backend-provided connectivity.
|
|
69
|
+
def inherit? = mode == :inherit
|
|
70
|
+
# Indicates that outbound connectivity must be disabled.
|
|
71
|
+
def none? = mode == :none
|
|
72
|
+
# Indicates that outbound traffic must pass an allowlist gateway.
|
|
73
|
+
def allowlist? = mode == :allowlist
|
|
74
|
+
|
|
75
|
+
# Policies compare by their normalized enforcement declaration.
|
|
76
|
+
def ==(other)
|
|
77
|
+
other.is_a?(self.class) &&
|
|
78
|
+
[mode, allow, inspection, gateway, authorizer, forward_headers, mutation_headers] ==
|
|
79
|
+
[other.mode, other.allow, other.inspection, other.gateway, other.authorizer,
|
|
80
|
+
other.forward_headers, other.mutation_headers]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
alias_method :eql?, :==
|
|
84
|
+
|
|
85
|
+
# Hashes the normalized enforcement declaration.
|
|
86
|
+
def hash = [mode, allow, inspection, gateway, authorizer, forward_headers, mutation_headers].hash
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def normalize_endpoint(endpoint)
|
|
91
|
+
value = String(endpoint).strip.downcase
|
|
92
|
+
raise PolicyError, "network allowlist entries cannot be empty" if value.empty?
|
|
93
|
+
raise PolicyError, "network allowlist entries cannot contain whitespace" if value.match?(/\s/)
|
|
94
|
+
|
|
95
|
+
value.freeze
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def normalize_header_name(name)
|
|
99
|
+
value = String(name).downcase
|
|
100
|
+
unless value.match?(/\A[a-z0-9][a-z0-9-]*\z/)
|
|
101
|
+
raise PolicyError, "forwarded network header names must be lowercase HTTP tokens"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
value.freeze
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
class Sandbox
|
|
5
|
+
# Normalizes requested filesystem, process, environment, and child-network
|
|
6
|
+
# controls into one immutable policy. Policy is a declaration, not proof of
|
|
7
|
+
# isolation; the selected backend exposes #effective_policy and rejects
|
|
8
|
+
# controls it cannot enforce.
|
|
9
|
+
class Policy
|
|
10
|
+
COMMON_KEYS = %i[
|
|
11
|
+
files runtime_paths root_filesystem environment network
|
|
12
|
+
].freeze # :nodoc:
|
|
13
|
+
ACCESS_MODES = %i[read_only read_write].freeze # :nodoc:
|
|
14
|
+
ROOT_FILESYSTEM_MODES = %i[isolated read_only read_write].freeze # :nodoc:
|
|
15
|
+
# Returns an existing policy or builds one from a Hash and keyword options.
|
|
16
|
+
def self.coerce(value = nil, **options)
|
|
17
|
+
return value if value.is_a?(self) && options.empty?
|
|
18
|
+
values = value.nil? ? {} : value
|
|
19
|
+
raise PolicyError, "sandbox policy must be a Hash or Sandbox::Policy" unless values.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
new(**values.transform_keys(&:to_sym).merge(options))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Builds a backend-independent policy from named Workspace paths.
|
|
25
|
+
def initialize(
|
|
26
|
+
files: {root: :read_only},
|
|
27
|
+
runtime_paths: {},
|
|
28
|
+
root_filesystem: :isolated,
|
|
29
|
+
environment: {},
|
|
30
|
+
network: nil
|
|
31
|
+
)
|
|
32
|
+
@files = normalize_paths(files, "files")
|
|
33
|
+
@runtime_paths = normalize_paths(runtime_paths, "runtime_paths")
|
|
34
|
+
@root_filesystem = enum!(root_filesystem, ROOT_FILESYSTEM_MODES, "root filesystem")
|
|
35
|
+
@environment = EnvironmentPolicy.coerce(environment)
|
|
36
|
+
@network = NetworkPolicy.coerce(network)
|
|
37
|
+
freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Named Workspace paths visible to tools and child processes.
|
|
41
|
+
attr_reader :files
|
|
42
|
+
# Named workspace paths visible only to sandboxed processes.
|
|
43
|
+
attr_reader :runtime_paths
|
|
44
|
+
# Requested host-root access: +:isolated+, +:read_only+, or +:read_write+.
|
|
45
|
+
attr_reader :root_filesystem
|
|
46
|
+
# Environment inheritance and explicit values.
|
|
47
|
+
attr_reader :environment
|
|
48
|
+
# Network policy, or +nil+ for a backend-specific secure default.
|
|
49
|
+
attr_reader :network
|
|
50
|
+
# Whether the +:root+ entry in +files+ requests +:read_write+ access.
|
|
51
|
+
def workspace_writable? = files.fetch(:root, :read_only) == :read_write
|
|
52
|
+
|
|
53
|
+
# Builds internal identity grants for a concrete Workspace.
|
|
54
|
+
def process_grants(workspace) # :nodoc:
|
|
55
|
+
file_mounts = files.map do |name, access|
|
|
56
|
+
source = workspace_directory(workspace, name)
|
|
57
|
+
Mount.new(source:, target: source, access:, protect_aliases: true)
|
|
58
|
+
end
|
|
59
|
+
process_mounts = runtime_paths.map do |name, access|
|
|
60
|
+
source = workspace_directory(workspace, name)
|
|
61
|
+
Mount.new(source:, target: source, access:, protect_aliases: true, tools: false)
|
|
62
|
+
end
|
|
63
|
+
(file_mounts + process_mounts).sort_by { |mount| -mount.target.length }.freeze
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def enum!(value, choices, label)
|
|
69
|
+
value = value.to_sym
|
|
70
|
+
raise PolicyError, "#{label} must be one of #{choices.map { |choice| ":#{choice}" }.join(", ")}" unless choices.include?(value)
|
|
71
|
+
|
|
72
|
+
value
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def normalize_paths(value, label)
|
|
76
|
+
raise PolicyError, "sandbox #{label} must be a Hash" unless value.respond_to?(:each_pair)
|
|
77
|
+
|
|
78
|
+
value.each_pair.to_h do |name, access|
|
|
79
|
+
[name.to_sym, enum!(access, ACCESS_MODES, "#{label} access")]
|
|
80
|
+
end.freeze
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def workspace_directory(workspace, name)
|
|
84
|
+
(name.to_sym == :root) ? workspace.root : workspace.path(name)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module LittleGhost
|
|
6
|
+
class Sandbox
|
|
7
|
+
# Runs one bounded child process and terminates its entire process group when
|
|
8
|
+
# the Run is cancelled or the command exceeds its timeout.
|
|
9
|
+
class ProcessRunner # :nodoc:
|
|
10
|
+
POLL_INTERVAL = 0.01
|
|
11
|
+
TERMINATION_GRACE = 0.5
|
|
12
|
+
|
|
13
|
+
def self.run(...) = new(...).run
|
|
14
|
+
|
|
15
|
+
def initialize(command:, timeout:, context: nil, max_output_bytes: 1_000_000,
|
|
16
|
+
environment: {}, inherit_environment: false, chdir: nil)
|
|
17
|
+
@command = Array(command).map(&:to_s)
|
|
18
|
+
@timeout = Float(timeout)
|
|
19
|
+
@context = context
|
|
20
|
+
@max_output_bytes = Integer(max_output_bytes)
|
|
21
|
+
@environment = environment.transform_keys(&:to_s).transform_values(&:to_s)
|
|
22
|
+
@inherit_environment = inherit_environment
|
|
23
|
+
@chdir = chdir
|
|
24
|
+
|
|
25
|
+
raise ToolError, "Command must contain an executable" if @command.empty? || @command.first.empty?
|
|
26
|
+
raise ArgumentError, "timeout must be positive" unless @timeout.positive? && @timeout.finite?
|
|
27
|
+
raise ArgumentError, "max_output_bytes must be positive" unless @max_output_bytes.positive?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run
|
|
31
|
+
result = nil
|
|
32
|
+
options = {pgroup: true, unsetenv_others: !@inherit_environment}
|
|
33
|
+
options[:chdir] = @chdir if @chdir
|
|
34
|
+
Open3.popen3(@environment, *@command, **options) do |stdin, stdout, stderr, wait_thread|
|
|
35
|
+
stdin.close
|
|
36
|
+
stdout_reader = Thread.new { drain(stdout) }
|
|
37
|
+
stderr_reader = Thread.new { drain(stderr) }
|
|
38
|
+
wait_for(wait_thread, [stdout_reader, stderr_reader])
|
|
39
|
+
result = Execution.new(
|
|
40
|
+
stdout: stdout_reader.value,
|
|
41
|
+
stderr: stderr_reader.value,
|
|
42
|
+
exit_code: wait_thread.value.exitstatus
|
|
43
|
+
)
|
|
44
|
+
ensure
|
|
45
|
+
stdout_reader&.kill
|
|
46
|
+
stderr_reader&.kill
|
|
47
|
+
end
|
|
48
|
+
result
|
|
49
|
+
rescue Errno::ENOENT
|
|
50
|
+
Execution.new(stdout: "", stderr: "#{@command.first}: command not found\n", exit_code: 127)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def wait_for(wait_thread, readers)
|
|
56
|
+
deadline = monotonic_time + @timeout
|
|
57
|
+
until !wait_thread.alive? && readers.none?(&:alive?)
|
|
58
|
+
@context&.check!
|
|
59
|
+
raise ToolError, "Command timed out after #{@timeout} seconds" if monotonic_time >= deadline
|
|
60
|
+
|
|
61
|
+
wait_thread.join(POLL_INTERVAL)
|
|
62
|
+
end
|
|
63
|
+
rescue
|
|
64
|
+
terminate(wait_thread.pid)
|
|
65
|
+
raise
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def terminate(pid)
|
|
69
|
+
Process.kill("TERM", -pid)
|
|
70
|
+
deadline = monotonic_time + TERMINATION_GRACE
|
|
71
|
+
Thread.pass while monotonic_time < deadline && process_group_alive?(pid)
|
|
72
|
+
Process.kill("KILL", -pid) if process_group_alive?(pid)
|
|
73
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def process_group_alive?(pid)
|
|
78
|
+
Process.kill(0, -pid)
|
|
79
|
+
true
|
|
80
|
+
rescue Errno::ESRCH
|
|
81
|
+
false
|
|
82
|
+
rescue Errno::EPERM
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def drain(io)
|
|
87
|
+
captured = String.new(encoding: Encoding::BINARY)
|
|
88
|
+
truncated = false
|
|
89
|
+
while (chunk = io.read(16_384))
|
|
90
|
+
remaining = @max_output_bytes - captured.bytesize
|
|
91
|
+
captured << chunk.byteslice(0, remaining) if remaining.positive?
|
|
92
|
+
truncated ||= chunk.bytesize > remaining
|
|
93
|
+
end
|
|
94
|
+
captured = captured.force_encoding(Encoding::UTF_8).scrub
|
|
95
|
+
truncated ? "#{captured}\n[output truncated]" : captured
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def monotonic_time
|
|
99
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|