vivarium 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a7b939d2599fb7e7115ef398bcc83830be78d3b8576a05e5c6d7c1adec4003a1
4
+ data.tar.gz: 39a158bacb6b706a4b77144b47554f027822263662818a9175b1828109538406
5
+ SHA512:
6
+ metadata.gz: 6b17e753a8ccaf72cb969e5fdb8b005d2e31fc2aa10f1d33984d93ba2b7c6a1e2d8e28560b39a4cb9f2860f453a498c3c9c974db9b4b316b4162b78b3e3841e6
7
+ data.tar.gz: 0bcd982884661f9e93ba27c3dc79cb1a952de8c68f715572022917cad708a771e4f4017af4bf12257f7f71ed38119535d14a5745d06666c580d6486c1d15b820
data/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # Vivarium
2
+
3
+ Vivarium is an observation and sandbox helper for Ruby.
4
+
5
+ It combines:
6
+
7
+ - eBPF LSM monitoring via RbBCC (`vivariumd`)
8
+ - Ruby-side method boundary observation via `TracePoint` (`Vivarium.observe`)
9
+
10
+ The goal is to visualize which Ruby method context triggered low-level events.
11
+
12
+ ## Current Scope
13
+
14
+ Implemented in this repository:
15
+
16
+ - BPF LSM hook on `file_open`
17
+ - Shared pinned maps on bpffs
18
+ - `config_targets` (PID -> 0/1)
19
+ - `event_invoked` (array length 64 with `event_t` records)
20
+ - `event_write_pos` (cursor for appending into `event_invoked`)
21
+ - Ruby API `Vivarium.observe do ... end`
22
+ - Registers current PID to `config_targets`
23
+ - On each `:return` / `:c_return`, drains `event_invoked`
24
+ - Prints stack trace + events
25
+ - Clears event slots and cursor
26
+ - Unregisters PID on block exit
27
+
28
+ `event_t` currently:
29
+
30
+ ```c
31
+ struct event_t {
32
+ u32 pid;
33
+ char event_name[8]; // "path_open"
34
+ char payload[64]; // opened path (truncated)
35
+ };
36
+ ```
37
+
38
+ ## Requirements
39
+
40
+ - Linux kernel/environment supporting BPF LSM
41
+ - `libbcc` installed
42
+ - `bpftool` installed (used to resolve `struct file::f_path` offset from BTF)
43
+ - root privileges for `vivariumd`
44
+ - bpffs mounted (typically `/sys/fs/bpf`)
45
+
46
+ ## Installation
47
+
48
+ Add to Gemfile:
49
+
50
+ ```ruby
51
+ gem "vivarium"
52
+ ```
53
+
54
+ Then:
55
+
56
+ ```bash
57
+ bundle install
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ 1) Start daemon (root):
63
+
64
+ ```bash
65
+ sudo bundle exec vivariumd
66
+ ```
67
+
68
+ 2) Observe in Ruby process:
69
+
70
+ ```ruby
71
+ require "vivarium"
72
+
73
+ Vivarium.observe do
74
+ File.read("/etc/hosts")
75
+ end
76
+ ```
77
+
78
+ You can override pin directory via `VIVARIUM_BPF_PIN_DIR` on both sides:
79
+
80
+ ```bash
81
+ VIVARIUM_BPF_PIN_DIR=/sys/fs/bpf/vivarium bundle exec vivariumd
82
+ ```
83
+
84
+ ```ruby
85
+ ENV["VIVARIUM_BPF_PIN_DIR"] = "/sys/fs/bpf/vivarium"
86
+ require "vivarium"
87
+ ```
88
+
89
+ ## Development
90
+
91
+ Run tests:
92
+
93
+ ```bash
94
+ bundle exec rake test
95
+ ```
96
+
97
+ Daemon entrypoint:
98
+
99
+ ```bash
100
+ bundle exec vivariumd --pin-dir /sys/fs/bpf/vivarium
101
+ ```
102
+
103
+ ## Notes
104
+
105
+ - Thread/Ractor-awareness is not yet implemented.
106
+ - `event_invoked` uses fixed 64 slots and wraps around when full.
107
+ - Payload is truncated to 64 bytes in kernel space.
108
+ - Current output format is textual and intended for iteration.
109
+ - `vivariumd` resolves `struct file::f_path` offset from `/sys/kernel/btf/vmlinux` at startup.
110
+ - You can override offset manually with `VIVARIUM_FILE_F_PATH_OFFSET` if auto-detection fails.
111
+
112
+ ## Contributing
113
+
114
+ Issues and pull requests are welcome.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task default: :test
data/exe/vivariumd ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "vivarium"
5
+
6
+ Vivarium.run_daemon!
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vivarium
4
+ VERSION = "0.0.1"
5
+ end
data/lib/vivarium.rb ADDED
@@ -0,0 +1,319 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+ require "fileutils"
5
+ require "optparse"
6
+ require "pathname"
7
+ require "rbbcc"
8
+ require_relative "vivarium/version"
9
+
10
+ module Vivarium
11
+ class Error < StandardError; end
12
+
13
+ PIN_DIR = ENV.fetch("VIVARIUM_BPF_PIN_DIR", "/sys/fs/bpf/vivarium")
14
+ CONFIG_TARGETS_PIN = File.join(PIN_DIR, "config_targets")
15
+ EVENT_INVOKED_PIN = File.join(PIN_DIR, "event_invoked")
16
+ EVENT_WRITE_POS_PIN = File.join(PIN_DIR, "event_write_pos")
17
+
18
+ EVENT_NAME_SIZE = 16
19
+ EVENT_PAYLOAD_SIZE = 64
20
+ EVENT_STRUCT_SIZE = 4 + EVENT_NAME_SIZE + EVENT_PAYLOAD_SIZE
21
+ EVENT_CAPACITY = 64
22
+
23
+ Event = Struct.new(:pid, :event_name, :payload, keyword_init: true) do
24
+ def empty?
25
+ pid.to_i.zero? && event_name.to_s.empty? && payload.to_s.empty?
26
+ end
27
+
28
+ def self.from_binary(raw)
29
+ bytes = raw.to_s.b
30
+ bytes = bytes.ljust(EVENT_STRUCT_SIZE, "\x00")
31
+
32
+ pid = bytes[0, 4].unpack1("L<")
33
+ event_name = bytes[4, EVENT_NAME_SIZE].delete("\x00")
34
+ payload = bytes[4 + EVENT_NAME_SIZE, EVENT_PAYLOAD_SIZE].delete("\x00")
35
+
36
+ new(pid: pid, event_name: event_name, payload: payload)
37
+ end
38
+ end
39
+
40
+ class MapStore
41
+ def initialize(pin_dir: PIN_DIR)
42
+ @pin_dir = pin_dir
43
+ @config_targets = RbBCC::HashTable.from_pin(
44
+ File.join(@pin_dir, "config_targets"),
45
+ "unsigned int",
46
+ "unsigned char",
47
+ keysize: 4,
48
+ leafsize: 1
49
+ )
50
+ @event_invoked = RbBCC::ArrayTable.from_pin(
51
+ File.join(@pin_dir, "event_invoked"),
52
+ "unsigned int",
53
+ "char[#{EVENT_STRUCT_SIZE}]",
54
+ keysize: 4,
55
+ leafsize: EVENT_STRUCT_SIZE
56
+ )
57
+ @event_write_pos = RbBCC::ArrayTable.from_pin(
58
+ File.join(@pin_dir, "event_write_pos"),
59
+ "unsigned int",
60
+ "unsigned int",
61
+ keysize: 4,
62
+ leafsize: 4
63
+ )
64
+ rescue StandardError => e
65
+ raise Error, "failed to open pinned maps under #{@pin_dir}: #{e.class}: #{e.message}"
66
+ end
67
+
68
+ def register_pid(pid)
69
+ @config_targets[pid] = 1
70
+ end
71
+
72
+ def unregister_pid(pid)
73
+ @config_targets.delete(pid)
74
+ rescue KeyError
75
+ nil
76
+ end
77
+
78
+ def drain_events
79
+ events = []
80
+ EVENT_CAPACITY.times do |idx|
81
+ ptr = @event_invoked[idx]
82
+ next unless ptr
83
+
84
+ event = Event.from_binary(ptr[0, EVENT_STRUCT_SIZE])
85
+ next if event.empty?
86
+
87
+ events << event
88
+ @event_invoked[idx] = zeroed_event_ptr
89
+ end
90
+
91
+ @event_write_pos[0] = 0
92
+ events
93
+ end
94
+
95
+ private
96
+
97
+ def zeroed_event_ptr
98
+ ptr = Fiddle::Pointer.malloc(EVENT_STRUCT_SIZE)
99
+ ptr[0, EVENT_STRUCT_SIZE] = "\x00" * EVENT_STRUCT_SIZE
100
+ ptr
101
+ end
102
+ end
103
+
104
+ class Daemon
105
+ BPF_PROGRAM_TEMPLATE = <<~CLANG
106
+ struct path {
107
+ void *mnt;
108
+ void *dentry;
109
+ };
110
+ struct file {
111
+ char __off[__VIVARIUM_F_PATH_OFFSET__];
112
+ struct path f_path;
113
+ };
114
+
115
+ struct event_t {
116
+ u32 pid;
117
+ char event_name[16];
118
+ char payload[64];
119
+ };
120
+
121
+ BPF_HASH(config_targets, u32, u32, 1024);
122
+ BPF_ARRAY(event_invoked, struct event_t, 64);
123
+ BPF_ARRAY(event_write_pos, u32, 1);
124
+
125
+ static __always_inline int target_enabled(u32 pid)
126
+ {
127
+ u32 *enabled = config_targets.lookup(&pid);
128
+ if (!enabled) {
129
+ return 0;
130
+ }
131
+ return *enabled == 1;
132
+ }
133
+
134
+ LSM_PROBE(file_open, struct file *file)
135
+ {
136
+ u32 pid = bpf_get_current_pid_tgid() >> 32;
137
+ if (!target_enabled(pid)) {
138
+ return 0;
139
+ }
140
+
141
+ u32 zero = 0;
142
+ u32 *write_pos = event_write_pos.lookup(&zero);
143
+ if (!write_pos) {
144
+ return 0;
145
+ }
146
+
147
+ u32 idx = *write_pos & 63;
148
+ __sync_fetch_and_add(write_pos, 1);
149
+ struct event_t ev = {};
150
+ int path_ret;
151
+ ev.pid = pid;
152
+ __builtin_memcpy(ev.event_name, "path_open", 9);
153
+
154
+ path_ret = bpf_d_path(&file->f_path, ev.payload, sizeof(ev.payload));
155
+ if (path_ret < 0) {
156
+ __builtin_memcpy(ev.payload, "<path_error>", 13);
157
+ }
158
+
159
+ event_invoked.update(&idx, &ev);
160
+
161
+ return 0;
162
+ }
163
+ CLANG
164
+
165
+ def initialize(pin_dir: PIN_DIR)
166
+ @pin_dir = pin_dir
167
+ end
168
+
169
+ def run
170
+ ensure_root!
171
+ FileUtils.mkdir_p(@pin_dir)
172
+
173
+ f_path_offset = detect_f_path_offset
174
+ program = BPF_PROGRAM_TEMPLATE.gsub("__VIVARIUM_F_PATH_OFFSET__", f_path_offset.to_s)
175
+
176
+ bpf = RbBCC::BCC.new(text: program)
177
+
178
+ config_targets = bpf["config_targets"]
179
+ event_invoked = bpf["event_invoked"]
180
+ event_write_pos = bpf["event_write_pos"]
181
+
182
+ clear_event_slots(event_invoked)
183
+ event_write_pos[0] = 0
184
+
185
+ pin_map(config_targets, File.join(@pin_dir, "config_targets"))
186
+ pin_map(event_invoked, File.join(@pin_dir, "event_invoked"))
187
+ pin_map(event_write_pos, File.join(@pin_dir, "event_write_pos"))
188
+
189
+ puts "[vivariumd] started"
190
+ puts "[vivariumd] pinned maps in #{@pin_dir}"
191
+ puts "[vivariumd] watching LSM file_open (f_path offset=#{f_path_offset})"
192
+
193
+ loop do
194
+ sleep 1
195
+ end
196
+ rescue Interrupt
197
+ puts "\n[vivariumd] stopping"
198
+ end
199
+
200
+ private
201
+
202
+ def ensure_root!
203
+ return if Process.uid.zero?
204
+
205
+ raise Error, "vivariumd requires root privileges"
206
+ end
207
+
208
+ def pin_map(table, path)
209
+ File.unlink(path) if File.exist?(path)
210
+ RbBCC::BCC.pin!(table.map_fd, path)
211
+ end
212
+
213
+ def clear_event_slots(table)
214
+ ptr = Fiddle::Pointer.malloc(EVENT_STRUCT_SIZE)
215
+ ptr[0, EVENT_STRUCT_SIZE] = "\x00" * EVENT_STRUCT_SIZE
216
+ EVENT_CAPACITY.times do |idx|
217
+ table[idx] = ptr
218
+ end
219
+ end
220
+
221
+ def detect_f_path_offset
222
+ env_offset = ENV["VIVARIUM_FILE_F_PATH_OFFSET"]
223
+ return Integer(env_offset, 10) if env_offset
224
+
225
+ raw = IO.popen(
226
+ %w[bpftool btf dump file /sys/kernel/btf/vmlinux format raw],
227
+ err: IO::NULL,
228
+ &:read
229
+ )
230
+
231
+ in_file_struct = false
232
+ f_path_bits_offset = nil
233
+ anon_union_bits_offset = nil
234
+
235
+ raw.each_line do |line|
236
+ if line =~ /^\[\d+\] STRUCT 'file' /
237
+ in_file_struct = true
238
+ next
239
+ end
240
+
241
+ if in_file_struct && line.start_with?("[")
242
+ break
243
+ end
244
+
245
+ next unless in_file_struct
246
+
247
+ if (match = line.match(/'f_path'.*bits_offset=(\d+)/))
248
+ f_path_bits_offset = Integer(match[1], 10)
249
+ next
250
+ end
251
+
252
+ if (match = line.match(/'\(anon\)'.*bits_offset=(\d+)/))
253
+ anon_union_bits_offset = Integer(match[1], 10)
254
+ end
255
+ end
256
+
257
+ if f_path_bits_offset && anon_union_bits_offset && f_path_bits_offset != anon_union_bits_offset
258
+ warn "[vivariumd] BTF offset mismatch: f_path=#{f_path_bits_offset / 8}, (anon)=#{anon_union_bits_offset / 8}; preferring (anon)"
259
+ end
260
+
261
+ bits_offset = anon_union_bits_offset || f_path_bits_offset
262
+ if bits_offset
263
+ if (bits_offset % 8).positive?
264
+ raise Error, "unsupported f_path bits offset=#{bits_offset}"
265
+ end
266
+
267
+ if bits_offset >= 1024
268
+ warn "[vivariumd] suspicious f_path offset=#{bits_offset / 8}, fallback to offset=64"
269
+ return 64
270
+ end
271
+
272
+ return bits_offset / 8
273
+ end
274
+
275
+ warn "[vivariumd] could not find struct file::f_path in BTF, fallback to offset=64"
276
+ 64
277
+ rescue Errno::ENOENT
278
+ raise Error, "bpftool is required to resolve struct file::f_path offset"
279
+ end
280
+ end
281
+
282
+ def self.observe(pin_dir: PIN_DIR, out: $stdout)
283
+ raise ArgumentError, "block is required" unless block_given?
284
+
285
+ store = MapStore.new(pin_dir: pin_dir)
286
+ pid = Process.pid
287
+ store.register_pid(pid)
288
+
289
+ tracer = TracePoint.new(:return, :c_return) do |tp|
290
+ events = store.drain_events
291
+ next if events.empty?
292
+
293
+ out.puts "[vivarium] #{events.size} event(s) at #{tp.defined_class}##{tp.method_id} (#{tp.event})"
294
+ events.each do |event|
295
+ out.puts " pid=#{event.pid} #{event.event_name} payload=#{event.payload.inspect}"
296
+ end
297
+ out.puts " stack:"
298
+ caller_locations(0, 12).each do |loc|
299
+ out.puts " #{loc.path}:#{loc.lineno}:in #{loc.base_label}"
300
+ end
301
+ end
302
+
303
+ tracer.enable
304
+ yield
305
+ ensure
306
+ tracer&.disable
307
+ store&.unregister_pid(pid)
308
+ end
309
+
310
+ def self.run_daemon!(argv = ARGV)
311
+ options = { pin_dir: PIN_DIR }
312
+ OptionParser.new do |opts|
313
+ opts.banner = "Usage: vivariumd [--pin-dir PATH]"
314
+ opts.on("--pin-dir PATH", "Pinned map directory") { |v| options[:pin_dir] = v }
315
+ end.parse!(argv)
316
+
317
+ Daemon.new(pin_dir: options[:pin_dir]).run
318
+ end
319
+ end
data/sig/vivarium.rbs ADDED
@@ -0,0 +1,41 @@
1
+ module Vivarium
2
+ VERSION: String
3
+
4
+ class Error < ::StandardError
5
+ end
6
+
7
+ class Event < ::Struct
8
+ attr_accessor pid: Integer?
9
+ attr_accessor event_name: String?
10
+ attr_accessor payload: String?
11
+
12
+ def empty?: bool
13
+ def self.from_binary: (String raw) -> Event
14
+ end
15
+
16
+ class MapStore
17
+ def initialize: (?pin_dir: String pin_dir) -> void
18
+ def register_pid: (Integer pid) -> untyped
19
+ def unregister_pid: (Integer pid) -> untyped
20
+ def drain_events: () -> Array[Event]
21
+ end
22
+
23
+ class Daemon
24
+ BPF_PROGRAM: String
25
+ def initialize: (?pin_dir: String pin_dir) -> void
26
+ def run: () -> void
27
+ end
28
+
29
+ PIN_DIR: String
30
+ CONFIG_TARGETS_PIN: String
31
+ EVENT_INVOKED_PIN: String
32
+ EVENT_WRITE_POS_PIN: String
33
+
34
+ EVENT_NAME_SIZE: Integer
35
+ EVENT_PAYLOAD_SIZE: Integer
36
+ EVENT_STRUCT_SIZE: Integer
37
+ EVENT_CAPACITY: Integer
38
+
39
+ def self.observe: (?pin_dir: String pin_dir, ?out: untyped out) { () -> untyped } -> untyped
40
+ def self.run_daemon!: (?Array[String] argv) -> void
41
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: vivarium
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Uchio Kondo
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rbbcc
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.11.2
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.11.2
26
+ description: Vivarium visualizes low-level events such as file open paths and relates
27
+ them to Ruby method boundaries by combining RbBCC (eBPF LSM) and TracePoint.
28
+ email:
29
+ - udzura@udzura.jp
30
+ executables:
31
+ - vivariumd
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - README.md
36
+ - Rakefile
37
+ - exe/vivariumd
38
+ - lib/vivarium.rb
39
+ - lib/vivarium/version.rb
40
+ - sig/vivarium.rbs
41
+ homepage: https://github.com/udzura/vivarium
42
+ licenses: []
43
+ metadata:
44
+ homepage_uri: https://github.com/udzura/vivarium
45
+ source_code_uri: https://github.com/udzura/vivarium
46
+ changelog_uri: https://github.com/udzura/vivarium/blob/main/README.md
47
+ bug_tracker_uri: https://github.com/udzura/vivarium/issues
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: 3.3.0
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 4.0.6
63
+ specification_version: 4
64
+ summary: Ruby observation and sandbox helper with RbBCC + TracePoint
65
+ test_files: []