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

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.

Returns:

  • (Boolean)


78
79
80
# File 'lib/rubino/security/sandbox.rb', line 78

def active?
  mode != :off && available_mechanism != :none
end

.available_mechanismObject

: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_noticeObject

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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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

.modeObject

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.

Returns:

  • (Boolean)


104
105
106
# File 'lib/rubino/security/sandbox.rb', line 104

def present_but_not_enforcing?
  active? && !enforcing?
end

.refusal_reasonObject

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).

Returns:

  • (Boolean)


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_summaryObject

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”.

Returns:

  • (Boolean)


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.expand_path(path)
    return WRITE_JAIL_HINT unless inside_roots?(target, roots)
  end
  nil
rescue StandardError
  nil
end