microsandbox-rb 0.5.7 → 0.5.9

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.
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Microsandbox
4
+ # Factory for network-policy **rule destinations**. A destination is what an
5
+ # egress rule reaches (or, for an ingress rule, the connecting peer). Use the
6
+ # explicit constructors for unambiguous typing, or pass a plain String to
7
+ # {Rule.allow}/{Rule.deny} for shorthand classification (see {Rule}).
8
+ #
9
+ # @example
10
+ # Microsandbox::Destination.cidr("10.0.0.0/8")
11
+ # Microsandbox::Destination.domain("api.openai.com")
12
+ # Microsandbox::Destination.group(:public)
13
+ #
14
+ # Mirrors the `Destination` factory in the official Python/Node/Go SDKs.
15
+ module Destination
16
+ module_function
17
+
18
+ # Match any destination.
19
+ def any = { "destination_kind" => "any" }
20
+
21
+ # A single IP address (stored as a /32 or /128).
22
+ def ip(value) = { "destination_kind" => "ip", "destination" => value.to_s }
23
+
24
+ # An IP network in CIDR notation (e.g. "10.0.0.0/8").
25
+ def cidr(value) = { "destination_kind" => "cidr", "destination" => value.to_s }
26
+
27
+ # An exact domain name (matched against the resolved-hostname cache / SNI).
28
+ def domain(value) = { "destination_kind" => "domain", "destination" => value.to_s }
29
+
30
+ # A domain suffix — matches the apex and any subdomain (e.g. ".internal").
31
+ def domain_suffix(value) = { "destination_kind" => "domain_suffix", "destination" => value.to_s }
32
+
33
+ # A predefined group: :public, :loopback, :private, :link_local, :metadata,
34
+ # :multicast, or :host.
35
+ def group(value) = { "destination_kind" => "group", "destination" => value.to_s.tr("_", "-") }
36
+ end
37
+
38
+ # Factory for a single network-policy **rule**. A rule pairs an action
39
+ # (allow/deny) with a direction, a destination, and optional protocol/port
40
+ # filters; rules are evaluated first-match-wins per direction.
41
+ #
42
+ # @example
43
+ # Microsandbox::Rule.allow(destination: "1.1.1.1", protocol: :tcp, port: "443")
44
+ # Microsandbox::Rule.deny(destination: Microsandbox::Destination.group(:metadata))
45
+ # Microsandbox::Rule.allow(direction: :ingress, destination: "10.0.0.0/8", port: "8000-9000")
46
+ #
47
+ # `destination:` accepts a {Destination} Hash, a shorthand String
48
+ # ("*", "public", "1.1.1.1", "10.0.0.0/8", ".internal", "api.example.com"),
49
+ # or nil (any). Mirrors the `Rule` factory in the official SDKs.
50
+ module Rule
51
+ module_function
52
+
53
+ # Build an allow rule. See {Rule} for argument semantics.
54
+ # @return [Hash]
55
+ def allow(destination: nil, direction: :egress, protocol: nil, protocols: nil, port: nil, ports: nil)
56
+ build("allow", destination, direction, protocol, protocols, port, ports)
57
+ end
58
+
59
+ # Build a deny rule.
60
+ # @return [Hash]
61
+ def deny(destination: nil, direction: :egress, protocol: nil, protocols: nil, port: nil, ports: nil)
62
+ build("deny", destination, direction, protocol, protocols, port, ports)
63
+ end
64
+
65
+ # @api private
66
+ def build(action, destination, direction, protocol, protocols, port, ports)
67
+ rule = { "action" => action, "direction" => direction.to_s }
68
+ rule.merge!(normalize_destination(destination))
69
+ protos = (Array(protocols) + Array(protocol)).compact.map(&:to_s)
70
+ rule["protocols"] = protos unless protos.empty?
71
+ prts = (Array(ports) + Array(port)).compact.map(&:to_s)
72
+ rule["ports"] = prts unless prts.empty?
73
+ rule
74
+ end
75
+
76
+ # @api private
77
+ def normalize_destination(dest)
78
+ case dest
79
+ when nil then {}
80
+ when Hash then dest.each_with_object({}) { |(k, v), a| a[k.to_s] = v }
81
+ when String, Symbol then { "destination" => dest.to_s }
82
+ else raise ArgumentError, "invalid rule destination: #{dest.inspect}"
83
+ end
84
+ end
85
+ end
86
+
87
+ # A sandbox network policy: a preset, or a custom set of allow/deny {Rule}s
88
+ # with per-direction default actions and bulk domain denials.
89
+ #
90
+ # Pass to {Sandbox.create} via `network:` — either a {NetworkPolicy}, a preset
91
+ # name (String/Symbol), or a plain Hash with the same keys as {custom}.
92
+ #
93
+ # @example presets
94
+ # Sandbox.create("b", image: "alpine", network: NetworkPolicy.public_only)
95
+ # Sandbox.create("b", image: "alpine", network: :none)
96
+ #
97
+ # @example custom
98
+ # policy = Microsandbox::NetworkPolicy.custom(
99
+ # default_egress: :deny,
100
+ # rules: [
101
+ # Microsandbox::Rule.allow(destination: "api.openai.com", protocol: :tcp, port: "443"),
102
+ # ],
103
+ # deny_domain_suffixes: [".ads.example"],
104
+ # )
105
+ # Sandbox.create("b", image: "alpine", network: policy)
106
+ #
107
+ # Mirrors `NetworkPolicy` / `Network` in the official Python/Node/Go SDKs.
108
+ class NetworkPolicy
109
+ # Canonical preset names keyed by every accepted alias.
110
+ PRESET_ALIASES = {
111
+ "none" => "none", "disabled" => "none", "disable" => "none", "airgapped" => "none",
112
+ "public" => "public_only", "public_only" => "public_only", "public-only" => "public_only",
113
+ "default" => "public_only",
114
+ "all" => "allow_all", "allow_all" => "allow_all", "allow-all" => "allow_all",
115
+ "non_local" => "non_local", "non-local" => "non_local", "nonlocal" => "non_local"
116
+ }.freeze
117
+
118
+ class << self
119
+ # @return [NetworkPolicy] allow only public internet (the default)
120
+ def public_only = preset("public_only")
121
+
122
+ # @return [NetworkPolicy] block all network access
123
+ def none = preset("none")
124
+
125
+ # @return [NetworkPolicy] permit all traffic
126
+ def allow_all = preset("allow_all")
127
+
128
+ # @return [NetworkPolicy] allow public internet plus private/LAN egress
129
+ def non_local = preset("non_local")
130
+
131
+ # @return [NetworkPolicy] a bare preset policy
132
+ def preset(name)
133
+ new("preset" => canonical_preset(name))
134
+ end
135
+
136
+ # Build a custom policy — an ordered rule list with per-direction default
137
+ # actions. A custom policy stands on its own (no preset); to start from a
138
+ # preset, use the preset factories (optionally with `deny_domains:` via the
139
+ # Hash form passed to {Sandbox.create}). `preset:` and custom rules/defaults
140
+ # are mutually exclusive, mirroring the official SDKs.
141
+ #
142
+ # @param default_egress [:deny, :allow, nil] fall-through for unmatched
143
+ # outbound traffic (default :deny)
144
+ # @param default_ingress [:deny, :allow, nil] fall-through for unmatched
145
+ # inbound traffic (default :allow)
146
+ # @param rules [Array<Hash>] ordered {Rule}s (first match wins per direction)
147
+ # @param deny_domains [Array<String>] exact domains to deny egress to
148
+ # (prepended, so they outrank later allow rules)
149
+ # @param deny_domain_suffixes [Array<String>] domain suffixes to deny
150
+ # @return [NetworkPolicy]
151
+ def custom(default_egress: :deny, default_ingress: :allow, rules: [],
152
+ deny_domains: [], deny_domain_suffixes: [])
153
+ h = {}
154
+ h["default_egress"] = action_str(default_egress) unless default_egress.nil?
155
+ h["default_ingress"] = action_str(default_ingress) unless default_ingress.nil?
156
+ h["rules"] = Array(rules).map { |r| normalize_rule(r) }
157
+ add_deny_lists(h, deny_domains, deny_domain_suffixes)
158
+ new(h)
159
+ end
160
+
161
+ # Coerce a user-facing `network:` value into a normalized wire Hash.
162
+ # @api private
163
+ def coerce(network)
164
+ case network
165
+ when NetworkPolicy then network.to_h
166
+ when String, Symbol then { "preset" => canonical_preset(network) }
167
+ when Hash then from_hash(network)
168
+ else
169
+ raise ArgumentError,
170
+ "network: expects a preset name, a Microsandbox::NetworkPolicy, or a Hash " \
171
+ "(got #{network.class})"
172
+ end
173
+ end
174
+
175
+ private
176
+
177
+ # A `network:` Hash is either a preset (`preset:` + optional deny lists) or
178
+ # a custom policy (`default_egress:`/`default_ingress:`/`rules:` + optional
179
+ # deny lists). The two are mutually exclusive: a preset already defines its
180
+ # rules and defaults, so layering custom rules/defaults on top would silently
181
+ # override them (and diverge from the official SDKs, where preset and custom
182
+ # are separate paths). A bare preset (only `preset:`, no deny lists) is
183
+ # routed to the preset path by {Sandbox.create}, so its own defaults apply.
184
+ def from_hash(hash)
185
+ sym = hash.transform_keys(&:to_sym)
186
+ if sym.key?(:preset)
187
+ if sym.key?(:rules) || sym.key?(:default_egress) || sym.key?(:default_ingress)
188
+ raise ArgumentError,
189
+ "network preset: cannot be combined with rules:/default_egress:/" \
190
+ "default_ingress: (the preset already defines its rules and defaults); " \
191
+ "only deny_domains:/deny_domain_suffixes: may be layered on a preset"
192
+ end
193
+ h = { "preset" => canonical_preset(sym[:preset]) }
194
+ add_deny_lists(h, sym[:deny_domains], sym[:deny_domain_suffixes])
195
+ h
196
+ elsif sym.key?(:rules) || sym.key?(:default_egress) || sym.key?(:default_ingress)
197
+ # An explicit custom policy: the caller chose the rule list and/or the
198
+ # fall-through defaults (which default to :deny / :allow).
199
+ custom(
200
+ default_egress: sym.fetch(:default_egress, :deny),
201
+ default_ingress: sym.fetch(:default_ingress, :allow),
202
+ rules: sym[:rules] || [],
203
+ deny_domains: sym[:deny_domains] || [],
204
+ deny_domain_suffixes: sym[:deny_domain_suffixes] || []
205
+ ).to_h
206
+ else
207
+ # Deny-list-only shorthand (`network: { deny_domains: [...] }`): keep the
208
+ # rest of the network reachable and just block the listed domains, using
209
+ # permissive defaults — mirrors the official SDKs' "full network minus
210
+ # blocked domains" semantics. An empty Hash is a no-op (leaves the
211
+ # default policy in place).
212
+ dd = Array(sym[:deny_domains]).map(&:to_s)
213
+ ds = Array(sym[:deny_domain_suffixes]).map(&:to_s)
214
+ return {} if dd.empty? && ds.empty?
215
+
216
+ h = { "default_egress" => "allow", "default_ingress" => "allow" }
217
+ add_deny_lists(h, dd, ds)
218
+ h
219
+ end
220
+ end
221
+
222
+ # Append `deny_domains`/`deny_domain_suffixes` to a wire Hash, omitting
223
+ # empty lists. Returns the Hash.
224
+ def add_deny_lists(h, deny_domains, deny_domain_suffixes)
225
+ dd = Array(deny_domains).map(&:to_s)
226
+ h["deny_domains"] = dd unless dd.empty?
227
+ ds = Array(deny_domain_suffixes).map(&:to_s)
228
+ h["deny_domain_suffixes"] = ds unless ds.empty?
229
+ h
230
+ end
231
+
232
+ def canonical_preset(name)
233
+ key = name.to_s.downcase
234
+ PRESET_ALIASES[key] ||
235
+ raise(ArgumentError,
236
+ "unknown network preset #{name.inspect} " \
237
+ "(expected one of public_only/none/allow_all/non_local)")
238
+ end
239
+
240
+ def action_str(action)
241
+ case action.to_s.downcase
242
+ when "allow" then "allow"
243
+ when "deny" then "deny"
244
+ else raise ArgumentError, "network action must be :allow or :deny (got #{action.inspect})"
245
+ end
246
+ end
247
+
248
+ # Canonicalize a rule Hash (from the {Rule} factory or hand-written) into
249
+ # the wire shape the native parser reads. Accepts singular `protocol`/`port`
250
+ # (the spelling the Go/Python `PolicyRule` use) as well as the plural
251
+ # `protocols`/`ports`, and a `destination` that is a shorthand String or a
252
+ # {Destination} Hash — so a hand-written `{ action:, destination:, protocol:,
253
+ # port: }` rule behaves identically to a factory-built one (without this, a
254
+ # singular `protocol`/`port` was silently dropped, widening the rule).
255
+ def normalize_rule(rule)
256
+ unless rule.is_a?(Hash)
257
+ raise ArgumentError, "rule must be a Hash (use Microsandbox::Rule.allow/deny): #{rule.inspect}"
258
+ end
259
+ sym = rule.transform_keys { |k| k.to_s.to_sym }
260
+ out = {}
261
+ out["action"] = sym[:action].to_s if sym[:action]
262
+ out["direction"] = sym[:direction].to_s if sym[:direction]
263
+ normalize_rule_destination(sym, out)
264
+ protos = (Array(sym[:protocols]) + Array(sym[:protocol])).compact.map(&:to_s)
265
+ out["protocols"] = protos unless protos.empty?
266
+ ports = (Array(sym[:ports]) + Array(sym[:port])).compact.map(&:to_s)
267
+ out["ports"] = ports unless ports.empty?
268
+ out
269
+ end
270
+
271
+ # Resolve a rule's destination (explicit kind+value, a {Destination} Hash,
272
+ # or a shorthand String) onto the wire `out` Hash.
273
+ def normalize_rule_destination(sym, out)
274
+ if sym.key?(:destination_kind)
275
+ out["destination_kind"] = sym[:destination_kind].to_s
276
+ out["destination"] = sym[:destination].to_s unless sym[:destination].nil?
277
+ elsif sym[:destination].is_a?(Hash)
278
+ dest = sym[:destination].transform_keys(&:to_s)
279
+ out["destination_kind"] = dest["destination_kind"].to_s if dest["destination_kind"]
280
+ out["destination"] = dest["destination"].to_s if dest.key?("destination")
281
+ elsif !sym[:destination].nil?
282
+ out["destination"] = sym[:destination].to_s
283
+ end
284
+ end
285
+ end
286
+
287
+ def initialize(wire)
288
+ @wire = wire
289
+ end
290
+
291
+ # @return [Hash] the normalized wire representation
292
+ def to_h
293
+ @wire
294
+ end
295
+
296
+ def inspect
297
+ "#<Microsandbox::NetworkPolicy #{@wire.inspect}>"
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Microsandbox
4
+ # Factory for **rootfs patches** — modifications applied to a sandbox's root
5
+ # filesystem *before* the microVM boots. Pass them to {Sandbox.create} via the
6
+ # `patches:` keyword:
7
+ #
8
+ # @example
9
+ # Microsandbox::Sandbox.create("box", image: "alpine",
10
+ # patches: [
11
+ # Microsandbox::Patch.text("/etc/app.conf", "key = value\n", mode: 0o644),
12
+ # Microsandbox::Patch.mkdir("/opt/app"),
13
+ # Microsandbox::Patch.copy_file("./cert.pem", "/etc/ssl/app.pem"),
14
+ # Microsandbox::Patch.symlink("/etc/app.conf", "/etc/app.link"),
15
+ # Microsandbox::Patch.remove("/etc/motd"),
16
+ # ]) do |sb|
17
+ # # ...
18
+ # end
19
+ #
20
+ # Patches apply to OverlayFS (OCI) and bind rootfs; they are **not** compatible
21
+ # with disk-image roots. Each factory returns a plain Hash, so a patch list is
22
+ # just an Array of Hashes — you may also build them by hand. Mirrors the
23
+ # `Patch` factory in the official Python/Node/Go SDKs.
24
+ module Patch
25
+ module_function
26
+
27
+ # Write UTF-8 text to a file, creating it (or replacing it when +replace+).
28
+ # @param path [String] absolute guest path
29
+ # @param content [String] text to write
30
+ # @param mode [Integer, nil] file mode (e.g. 0o644)
31
+ # @param replace [Boolean] allow shadowing a path already in the rootfs
32
+ # @return [Hash]
33
+ def text(path, content, mode: nil, replace: false)
34
+ h = { "kind" => "text", "path" => path.to_s, "content" => content.to_s, "replace" => replace ? true : false }
35
+ h["mode"] = Integer(mode) unless mode.nil?
36
+ h
37
+ end
38
+
39
+ # Write raw bytes to a file (binary-safe; content may contain NUL).
40
+ # @param path [String] absolute guest path
41
+ # @param content [String] raw bytes to write
42
+ # @param mode [Integer, nil] file mode
43
+ # @param replace [Boolean]
44
+ # @return [Hash]
45
+ def file(path, content, mode: nil, replace: false)
46
+ h = { "kind" => "file", "path" => path.to_s, "content" => content.to_s, "replace" => replace ? true : false }
47
+ h["mode"] = Integer(mode) unless mode.nil?
48
+ h
49
+ end
50
+
51
+ # Append text to an existing file. For OCI roots, a file living only in a
52
+ # lower image layer is copied up first, then appended.
53
+ # @return [Hash]
54
+ def append(path, content)
55
+ { "kind" => "append", "path" => path.to_s, "content" => content.to_s }
56
+ end
57
+
58
+ # Copy a host file into the rootfs.
59
+ # @param src [String] host path
60
+ # @param dst [String] absolute guest destination
61
+ # @param mode [Integer, nil] file mode (preserves source mode when nil)
62
+ # @param replace [Boolean]
63
+ # @return [Hash]
64
+ def copy_file(src, dst, mode: nil, replace: false)
65
+ h = { "kind" => "copy_file", "src" => src.to_s, "dst" => dst.to_s, "replace" => replace ? true : false }
66
+ h["mode"] = Integer(mode) unless mode.nil?
67
+ h
68
+ end
69
+
70
+ # Copy a host directory (recursively) into the rootfs.
71
+ # @return [Hash]
72
+ def copy_dir(src, dst, replace: false)
73
+ { "kind" => "copy_dir", "src" => src.to_s, "dst" => dst.to_s, "replace" => replace ? true : false }
74
+ end
75
+
76
+ # Create a symlink at +link+ pointing to +target+.
77
+ # @return [Hash]
78
+ def symlink(target, link, replace: false)
79
+ { "kind" => "symlink", "target" => target.to_s, "link" => link.to_s, "replace" => replace ? true : false }
80
+ end
81
+
82
+ # Create a directory (idempotent — no error if it already exists).
83
+ # @param path [String] absolute guest path
84
+ # @param mode [Integer, nil] directory mode (e.g. 0o755)
85
+ # @return [Hash]
86
+ def mkdir(path, mode: nil)
87
+ h = { "kind" => "mkdir", "path" => path.to_s }
88
+ h["mode"] = Integer(mode) unless mode.nil?
89
+ h
90
+ end
91
+
92
+ # Remove a file or directory (idempotent — no error if absent).
93
+ # @return [Hash]
94
+ def remove(path)
95
+ { "kind" => "remove", "path" => path.to_s }
96
+ end
97
+ end
98
+ end
@@ -108,8 +108,15 @@ module Microsandbox
108
108
  # @param entrypoint [Array<String>, nil] image entrypoint override
109
109
  # @param ports [Hash, nil] host_port => guest_port TCP publications
110
110
  # @param ports_udp [Hash, nil] host_port => guest_port UDP publications
111
- # @param network ["public_only", "none", "allow_all", "non_local", nil] network
112
- # policy preset (default public_only)
111
+ # @param network [String, Symbol, NetworkPolicy, Hash, nil] network policy.
112
+ # A preset name ("public_only" (default), "none", "allow_all",
113
+ # "non_local"), a {NetworkPolicy} (e.g. {NetworkPolicy.custom}), or a Hash
114
+ # describing a custom policy (`default_egress:`, `default_ingress:`,
115
+ # `rules:`, `deny_domains:`, `deny_domain_suffixes:`). See {NetworkPolicy}
116
+ # and {Rule}.
117
+ # @param patches [Array<Hash>, nil] rootfs patches applied before boot, each
118
+ # built with the {Patch} factory (e.g. `Patch.text(...)`, `Patch.mkdir(...)`).
119
+ # Not compatible with disk-image roots.
113
120
  # @param log_level ["error","warn","info","debug","trace", nil] guest log verbosity
114
121
  # @param quiet_logs [Boolean] suppress sandbox process logs
115
122
  # @param security ["default", "restricted", nil] exec security profile
@@ -119,6 +126,14 @@ module Microsandbox
119
126
  # @param rlimits [Hash, nil] resource limits: { resource => limit } or
120
127
  # { resource => [soft, hard] } (e.g. { nofile: 65_535 })
121
128
  # @param pull_policy ["always","if-missing","never", nil] image pull behavior
129
+ # @param registry_auth [Hash, nil] credentials for a private/authenticated
130
+ # registry: { username:, password: } (the password may be a token).
131
+ # Without this the core's default resolution chain still applies (OS
132
+ # keyring, global config, `~/.docker/config.json`).
133
+ # @param registry_insecure [Boolean] reach the registry over plain HTTP
134
+ # instead of HTTPS (for local/self-hosted registries)
135
+ # @param registry_ca_certs [String, Array<String>, nil] extra PEM-encoded CA
136
+ # root certificate(s) to trust (for a registry with a private CA)
122
137
  # @param secrets [Array<Hash>, nil] placeholder-protected secrets, each
123
138
  # { env:, value:, host: } — the value is substituted by the TLS proxy only
124
139
  # for the allowed host (auto-enables TLS interception)
@@ -131,10 +146,13 @@ module Microsandbox
131
146
  image: nil, cpus: nil, memory: nil, env: nil, workdir: nil,
132
147
  shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
133
148
  entrypoint: nil, ports: nil, ports_udp: nil, volumes: nil, network: nil,
149
+ patches: nil,
134
150
  from_snapshot: nil, log_level: nil, quiet_logs: false, security: nil,
135
151
  oci_upper_size: nil, max_duration: nil, idle_timeout: nil, rlimits: nil,
136
- pull_policy: nil, secrets: nil,
152
+ pull_policy: nil, registry_auth: nil, registry_insecure: false,
153
+ registry_ca_certs: nil, secrets: nil,
137
154
  detached: false, replace: false, replace_with_timeout: nil)
155
+ Microsandbox.ensure_runtime!
138
156
  opts = {}
139
157
  opts["image"] = image.to_s if image
140
158
  opts["from_snapshot"] = from_snapshot.to_s if from_snapshot
@@ -151,7 +169,8 @@ module Microsandbox
151
169
  opts["ports"] = intify_ports(ports) if ports
152
170
  opts["ports_udp"] = intify_ports(ports_udp) if ports_udp
153
171
  opts["volumes"] = normalize_volumes(volumes) if volumes
154
- opts["network"] = network.to_s if network
172
+ opts["patches"] = normalize_patches(patches) if patches
173
+ apply_network_opts(opts, network) unless network.nil?
155
174
  opts["log_level"] = log_level.to_s if log_level
156
175
  opts["quiet_logs"] = true if quiet_logs
157
176
  opts["security"] = security.to_s if security
@@ -160,6 +179,7 @@ module Microsandbox
160
179
  opts["idle_timeout"] = Integer(idle_timeout) if idle_timeout
161
180
  opts["rlimits"] = normalize_rlimits(rlimits) if rlimits
162
181
  opts["pull_policy"] = pull_policy.to_s if pull_policy
182
+ apply_registry_opts(opts, registry_auth, registry_insecure, registry_ca_certs)
163
183
  opts["secrets"] = normalize_secrets(secrets) if secrets
164
184
  opts["detached"] = true if detached
165
185
  if replace_with_timeout
@@ -185,6 +205,7 @@ module Microsandbox
185
205
  # Restart a previously-defined sandbox by name.
186
206
  # @return [Sandbox]
187
207
  def start(name, detached: false)
208
+ Microsandbox.ensure_runtime!
188
209
  new(Native::Sandbox.start(name.to_s, { "detached" => detached }))
189
210
  end
190
211
 
@@ -225,6 +246,25 @@ module Microsandbox
225
246
  ports.each_with_object({}) { |(k, v), acc| acc[Integer(k)] = Integer(v) }
226
247
  end
227
248
 
249
+ # Flatten the registry options into the native layer's `registry_*` keys.
250
+ # `auth` is a Hash { username:, password: } (string or symbol keys); both
251
+ # are required when given. `ca_certs` accepts one PEM string or an Array.
252
+ def apply_registry_opts(opts, auth, insecure, ca_certs)
253
+ if auth
254
+ username = auth[:username] || auth["username"]
255
+ password = auth[:password] || auth["password"]
256
+ unless username && password
257
+ # Report only the keys given, never the values — auth carries secrets.
258
+ raise ArgumentError,
259
+ "registry_auth needs :username and :password (got keys: #{auth.keys.inspect})"
260
+ end
261
+ opts["registry_username"] = username.to_s
262
+ opts["registry_password"] = password.to_s
263
+ end
264
+ opts["registry_insecure"] = true if insecure
265
+ opts["registry_ca_certs"] = Array(ca_certs).map(&:to_s) if ca_certs
266
+ end
267
+
228
268
  # Normalize secrets into [env, value, host] triples for the native layer.
229
269
  # Each entry is a Hash { env:, value:, host: } (string or symbol keys).
230
270
  def normalize_secrets(secrets)
@@ -271,6 +311,33 @@ module Microsandbox
271
311
  end
272
312
  end
273
313
  end
314
+
315
+ # Normalize a list of patches (each a Hash from the {Patch} factory, or a
316
+ # plain Hash) into string-keyed Hashes for the native layer. Values are
317
+ # passed through unchanged (mode stays Integer, content stays String).
318
+ def normalize_patches(patches)
319
+ Array(patches).map do |p|
320
+ unless p.is_a?(Hash)
321
+ raise ArgumentError, "patch must be a Hash (use Microsandbox::Patch.*): #{p.inspect}"
322
+ end
323
+ p.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
324
+ end
325
+ end
326
+
327
+ # Route the `network:` argument to either the preset path
328
+ # (`opts["network"]`, the original string-preset behavior) or the custom
329
+ # policy path (`opts["network_policy"]`). Accepts a preset String/Symbol, a
330
+ # {NetworkPolicy}, or a plain Hash.
331
+ def apply_network_opts(opts, network)
332
+ norm = NetworkPolicy.coerce(network)
333
+ return if norm.empty? # e.g. network: {} — leave the default policy in place
334
+
335
+ if norm.keys == ["preset"]
336
+ opts["network"] = norm["preset"]
337
+ else
338
+ opts["network_policy"] = norm
339
+ end
340
+ end
274
341
  end
275
342
 
276
343
  def initialize(native)
@@ -320,12 +387,60 @@ module Microsandbox
320
387
  exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
321
388
  end
322
389
 
390
+ # Attach an interactive terminal to a command in the sandbox.
391
+ #
392
+ # Puts the **host** terminal into raw mode and forwards keystrokes (and
393
+ # SIGWINCH resizes) to the guest until the command exits or the detach
394
+ # sequence is typed. Requires a real TTY on stdin/stdout, so it is for CLI
395
+ # use, not library/automation code (use {#exec}/{#exec_stream} there). Blocks
396
+ # until the session ends. Mirrors the official SDKs' `attach`.
397
+ #
398
+ # @param command [String] the program to run
399
+ # @param args [Array<String>] its arguments
400
+ # @param cwd [String, nil] working directory
401
+ # @param user [String, nil] user to run as
402
+ # @param env [Hash, nil] extra environment variables
403
+ # @param detach_keys [String, nil] detach sequence (e.g. "ctrl-p,ctrl-q";
404
+ # default "ctrl-]")
405
+ # @param rlimits [Hash, nil] resource limits (see {#exec})
406
+ # @return [Integer] the command's exit code (or the code at detach)
407
+ def attach(command, args = [], cwd: nil, user: nil, env: nil, detach_keys: nil, rlimits: nil)
408
+ opts = {}
409
+ opts["cwd"] = cwd.to_s if cwd
410
+ opts["user"] = user.to_s if user
411
+ opts["env"] = env.each_with_object({}) { |(k, v), a| a[k.to_s] = v.to_s } if env
412
+ opts["detach_keys"] = detach_keys.to_s if detach_keys
413
+ if rlimits
414
+ opts["rlimits"] = rlimits.map do |resource, limit|
415
+ soft, hard = limit.is_a?(Array) ? [limit[0], limit[1]] : [limit, limit]
416
+ [resource.to_s, Integer(soft), Integer(hard)]
417
+ end
418
+ end
419
+ @native.attach(command.to_s, Array(args).map(&:to_s), opts)
420
+ end
421
+
422
+ # Attach an interactive terminal running the sandbox's default shell.
423
+ # See {#attach} for the host-TTY requirements.
424
+ # @return [Integer] the shell's exit code (or the code at detach)
425
+ def attach_shell
426
+ @native.attach_shell
427
+ end
428
+
323
429
  # Guest filesystem operations.
324
430
  # @return [FS]
325
431
  def fs
326
432
  @fs ||= FS.new(@native)
327
433
  end
328
434
 
435
+ # SSH access to the sandbox — open a native in-process SSH client or prepare
436
+ # a reusable server endpoint.
437
+ # @return [SshOps]
438
+ # @example
439
+ # sb.ssh.open_client { |c| puts c.exec("hostname").stdout }
440
+ def ssh
441
+ SshOps.new(@native)
442
+ end
443
+
329
444
  # Latest resource-usage snapshot.
330
445
  # @return [Metrics]
331
446
  def metrics