Module: Rubino::Security::Sandbox
- Defined in:
- lib/rubino/security/sandbox.rb
Overview
OS-level filesystem WRITE jail around the single Process.spawn in shell_tool.rb (#290, #544). The per-command approval allowlist is a UX guard-rail, not a security boundary; this module is the real floor.
It returns an argv PREFIX (and a little extra env) to put in front of the ‘bash -o pipefail -c …` the shell tool already runs:
macOS → /usr/bin/sandbox-exec -p <SBPL> -D… -- bash … (Seatbelt)
Linux → <gem>/ext/landlock/rubino-landlock -- bash … (Landlock)
off / unavailable → [] (byte-identical to no sandbox)
Asymmetry by design: reads stay broad everywhere (#406); only writes are confined to roots, $TMPDIR, /tmp, /dev/null.
~/.rubino is DELIBERATELY NOT writable from the jailed shell: it holds the sandbox’s own trust anchors (the resolved helper binary, config.yml, .env, the session DB, skills/, commands/). The agent persists all of those in the Ruby PROCESS, never by spawning the shell tool’s bash — so confining the shell out of ~/.rubino loses no legitimate capability while closing the self-tamper persistence escape (helper/config poisoning).
Graceful degradation: when mode != off but no mechanism exists (old kernel, non-mac/linux, helper won’t compile) we fail OPEN — empty prefix —and surface a one-time loud banner (see #degraded? / #degradation_notice).
Constant Summary collapse
- ABS_SANDBOX_EXEC =
"/usr/bin/sandbox-exec"- WRITE_JAIL_HINT =
The one-line attribution appended to a tool’s output when an EACCES it surfaced is actually the OS write-jail denying a write OUTSIDE the writable roots (#74). Without it a jailed write reads like an ordinary perms error and the model misattributes it (chmod/sudo loops) instead of writing inside the workspace.
"(blocked by the workspace write-jail — tools.sandbox; write inside the workspace)"- DENIED_PATH =
Detects the OS-deny shape, capturing the offending path. EACCES from a write outside the jail surfaces as “Permission denied @ … - /abs/path” (Ruby Errno) or “<path>: Permission denied” (shell tools).
%r{ (?:Permission\ denied|EACCES|Operation\ not\ permitted) .*?(/[^\s'"`:]+) | (/[^\s'"`:]+)\s*:?\s*(?:Permission\ denied|Operation\ not\ permitted) }xi
Class Method Summary collapse
-
.active? ⇒ Boolean
True when the OS write-jail mechanism is PRESENT and configured on (mode != off AND a Seatbelt/Landlock mechanism exists).
-
.available_mechanism ⇒ Object
:seatbelt | :landlock | :none — memoised per process (the probe runs once, like EnvironmentInspector).
-
.command_prefix(cwd: nil) ⇒ Object
The argv prefix to splice before ‘bash …`.
-
.degradation_notice ⇒ Object
One-line reason for the degraded state, or nil when not degraded.
-
.degraded? ⇒ Boolean
True when the user asked for a sandbox (config mode != off) but no OS mechanism is available — the fail-open case the banner warns about.
-
.enforcing? ⇒ Boolean
True ONLY when the OS write-jail is PROVEN to confine writes at runtime.
-
.extra_env(cwd: nil) ⇒ Object
Extra env merged into the spawn.
-
.mode ⇒ Object
The effective mode: off | read-only | workspace-write.
-
.present_but_not_enforcing? ⇒ Boolean
True when a sandbox mechanism is configured+present but does NOT actually enforce at runtime (helper fails open / kernel lacks Landlock).
-
.refusal_reason ⇒ Object
nil when the shell may run; otherwise a one-line refusal message.
-
.required? ⇒ Boolean
True when the operator opted into FAIL-CLOSED: tools.sandbox.require.
-
.reset! ⇒ Object
Test/teardown hook — drop the memoised probe so a stubbed platform takes effect in the next example.
-
.status_summary ⇒ Object
Short status string for /status: e.g.
-
.wrap_argv(argv, cwd: nil) ⇒ Object
Jail ANY argv (not just bash): prepend the launcher prefix so every process-spawning tool (shell, ruby) goes through the same OS write-jail.
-
.wrap_env(cwd: nil) ⇒ Object
The extra env every wrapped spawn must merge (writable roots for Landlock; {} for Seatbelt/off).
-
.writable?(path, cwd: nil) ⇒ Boolean
True when
pathresolves under one of the current writable roots — i.e. -
.writable_roots(cwd: nil) ⇒ Object
The de-duped, existing absolute paths the jail allows writes to.
-
.write_jail_attribution(text, cwd: nil) ⇒ Object
Returns the attribution hint when
textcarries an EACCES/“Permission denied” against a path that is OUTSIDE the writable roots while the jail is PROVEN to be enforcing; nil otherwise.
Class Method Details
.active? ⇒ Boolean
True when the OS write-jail mechanism is PRESENT and configured on (mode != off AND a Seatbelt/Landlock mechanism exists). This is a PRESENCE check only — it does NOT prove the mechanism actually confines writes at runtime (the Landlock helper fails OPEN on a kernel without enforcement). Use it for the banner/status posture; the SECURITY-relaxation decision (slice 2 Part C) must use #enforcing? instead. False under degraded? (requested but no mechanism) and under mode == :off.
78 79 80 |
# File 'lib/rubino/security/sandbox.rb', line 78 def active? mode != :off && available_mechanism != :none end |
.available_mechanism ⇒ Object
:seatbelt | :landlock | :none — memoised per process (the probe runs once, like EnvironmentInspector).
40 41 42 43 44 |
# File 'lib/rubino/security/sandbox.rb', line 40 def available_mechanism return @available_mechanism if defined?(@available_mechanism) @available_mechanism = detect_mechanism end |
.command_prefix(cwd: nil) ⇒ Object
The argv prefix to splice before ‘bash …`. [] when off/unavailable.
143 144 145 146 147 148 149 150 151 |
# File 'lib/rubino/security/sandbox.rb', line 143 def command_prefix(cwd: nil) return [] if mode == :off case available_mechanism when :seatbelt then seatbelt_prefix(cwd: cwd) when :landlock then landlock_prefix else [] end end |
.degradation_notice ⇒ Object
One-line reason for the degraded state, or nil when not degraded.
63 64 65 66 67 68 69 |
# File 'lib/rubino/security/sandbox.rb', line 63 def degradation_notice return nil unless degraded? "OS write-sandbox unavailable on this host (no Landlock/Seatbelt); " \ "shell writes are NOT OS-confined. Approval prompts + the hardline " \ "floor are the only boundary. See tools.sandbox.mode." end |
.degraded? ⇒ Boolean
True when the user asked for a sandbox (config mode != off) but no OS mechanism is available — the fail-open case the banner warns about.
58 59 60 |
# File 'lib/rubino/security/sandbox.rb', line 58 def degraded? configured_mode != :off && available_mechanism == :none end |
.enforcing? ⇒ Boolean
True ONLY when the OS write-jail is PROVEN to confine writes at runtime. active? checks the mechanism is PRESENT; this runs the real launcher once against a throwaway command that tries to write a file OUTSIDE a writable root and returns true only if that write was DENIED. Closes the “helper present but Landlock not enforcing (fails open)” gap: a binary that execs unconfined produces the probe file ⇒ enforcing? == false. Memoised — one spawn at first use, like the mechanism probe.
This is the predicate the approval layer gates the conditional allowlist relaxation on (slice 2 Part C): only relax the pure-WRITE flag-forms when the jail DEMONSTRABLY confines them. When present-but-not-enforcing the state is DEGRADED and the broad prompt stays.
94 95 96 97 98 |
# File 'lib/rubino/security/sandbox.rb', line 94 def enforcing? return @enforcing if defined?(@enforcing) @enforcing = active? && probe_enforcement end |
.extra_env(cwd: nil) ⇒ Object
Extra env merged into the spawn. Landlock receives the writable roots here (never on argv, so a path with spaces/quotes is safe); Seatbelt passes them as -D params, so it needs none.
172 173 174 175 176 |
# File 'lib/rubino/security/sandbox.rb', line 172 def extra_env(cwd: nil) return {} unless mode != :off && available_mechanism == :landlock { "RUBINO_SANDBOX_WRITABLE_ROOTS" => landlock_roots_env(cwd: cwd) } end |
.mode ⇒ Object
The effective mode: off | read-only | workspace-write. Resolves to :off when config says so OR no mechanism exists (the §4 fail-open path).
48 49 50 51 52 53 54 |
# File 'lib/rubino/security/sandbox.rb', line 48 def mode configured = configured_mode return :off if configured == :off return :off if available_mechanism == :none configured end |
.present_but_not_enforcing? ⇒ Boolean
True when a sandbox mechanism is configured+present but does NOT actually enforce at runtime (helper fails open / kernel lacks Landlock). In this state writes are unconfined despite the mechanism appearing available, so callers must keep the broad write screen and say so honestly.
104 105 106 |
# File 'lib/rubino/security/sandbox.rb', line 104 def present_but_not_enforcing? active? && !enforcing? end |
.refusal_reason ⇒ Object
nil when the shell may run; otherwise a one-line refusal message. Refuses ONLY when the operator REQUIRES the sandbox but no mechanism can enforce it (required? && available_mechanism == :none) — the fail-closed path the foreground and background shell spawns both consult before launching. When a mechanism IS available (or require is off) this returns nil and execution proceeds as before.
124 125 126 127 128 129 |
# File 'lib/rubino/security/sandbox.rb', line 124 def refusal_reason return nil unless required? && available_mechanism == :none "sandbox required but unavailable on this host — " \ "set tools.sandbox.require=false to run unconfined" end |
.required? ⇒ Boolean
True when the operator opted into FAIL-CLOSED: tools.sandbox.require. When set AND no mechanism exists, shell execution must REFUSE rather than fall open (slice 2 Part B). Default false (fail-open, §4).
111 112 113 114 115 116 |
# File 'lib/rubino/security/sandbox.rb', line 111 def required? raw = Rubino.configuration&.dig("tools", "sandbox", "require") raw == true || raw.to_s == "true" rescue StandardError false end |
.reset! ⇒ Object
Test/teardown hook — drop the memoised probe so a stubbed platform takes effect in the next example.
264 265 266 267 268 |
# File 'lib/rubino/security/sandbox.rb', line 264 def reset! remove_instance_variable(:@available_mechanism) if defined?(@available_mechanism) remove_instance_variable(:@landlock_helper) if defined?(@landlock_helper) remove_instance_variable(:@enforcing) if defined?(@enforcing) end |
.status_summary ⇒ Object
Short status string for /status: e.g. “workspace-write (seatbelt)”, “off”, or “OFF (unavailable)”. Appends the enforcement posture (required vs best-effort) so the operator can tell a fail-closed require:true config from the fail-open default at a glance.
135 136 137 138 139 140 |
# File 'lib/rubino/security/sandbox.rb', line 135 def status_summary return required? ? "OFF (unavailable, required)" : "OFF (unavailable)" if degraded? return "off" if mode == :off "#{mode} (#{available_mechanism}, #{required? ? "required" : "best-effort"})" end |
.wrap_argv(argv, cwd: nil) ⇒ Object
Jail ANY argv (not just bash): prepend the launcher prefix so every process-spawning tool (shell, ruby) goes through the same OS write-jail. [] prefix when off/unavailable ⇒ byte-identical to no sandbox. Callers splat the result into Process.spawn/Open3 and merge #wrap_env into their env. ‘argv` is the already-built command argv.
158 159 160 |
# File 'lib/rubino/security/sandbox.rb', line 158 def wrap_argv(argv, cwd: nil) [*command_prefix(cwd: cwd), *argv] end |
.wrap_env(cwd: nil) ⇒ Object
The extra env every wrapped spawn must merge (writable roots for Landlock; {} for Seatbelt/off). Alias of #extra_env for symmetry with #wrap_argv at the call sites.
165 166 167 |
# File 'lib/rubino/security/sandbox.rb', line 165 def wrap_env(cwd: nil) extra_env(cwd: cwd) end |
.writable?(path, cwd: nil) ⇒ Boolean
True when path resolves under one of the current writable roots — i.e. a write there would NOT be blocked by the jail. Public so callers that already hold a concrete path (e.g. the DB-open read-only attribution, #Y2A) can ask directly instead of pattern-matching an error string. The path is canonicalized the SAME way the roots are (realpath, resolving the nearest EXISTING ancestor for a not-yet-created file) so a symlinked parent like macOS’s /var → /private/var doesn’t read as “outside”.
239 240 241 |
# File 'lib/rubino/security/sandbox.rb', line 239 def writable?(path, cwd: nil) inside_roots?(canonical_existing(path), writable_roots(cwd: cwd)) end |
.writable_roots(cwd: nil) ⇒ Object
The de-duped, existing absolute paths the jail allows writes to. The workspace set is read live from Workspace.canonical_roots, so an –add-dir mid-session is reflected on the next shell call; ‘cwd` is accepted for symmetry with the public API and future per-cwd derivation.
182 183 184 185 186 187 188 |
# File 'lib/rubino/security/sandbox.rb', line 182 def writable_roots(cwd: nil) # rubocop:disable Lint/UnusedMethodArgument roots = [] roots.concat(Workspace.canonical_roots) unless mode == :"read-only" roots.concat(temp_roots) roots.concat(extra_writable.filter_map { |p| canonical(p) }) roots.compact.uniq.select { |p| File.directory?(p) } end |
.write_jail_attribution(text, cwd: nil) ⇒ Object
Returns the attribution hint when text carries an EACCES/“Permission denied” against a path that is OUTSIDE the writable roots while the jail is PROVEN to be enforcing; nil otherwise. Only fires under #enforcing? so an unconfined host (no/degraded sandbox) never mislabels a genuine perms error as a jail denial. A normal perms failure INSIDE the workspace is not a jail block, so it returns nil too. Best-effort: any parse/probe error yields nil (no hint) rather than raising into a tool’s output.
215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 |
# File 'lib/rubino/security/sandbox.rb', line 215 def write_jail_attribution(text, cwd: nil) return nil if text.to_s.empty? return nil unless enforcing? roots = writable_roots(cwd: cwd) text.to_s.scan(DENIED_PATH).each do |groups| path = groups.compact.first next unless path target = canonical(path) || File.(path) return WRITE_JAIL_HINT unless inside_roots?(target, roots) end nil rescue StandardError nil end |