saiph 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 94fa9d2587e5b09e6b37300f5be444effbbed9c97945be0f64e9f5ae89a6cb11
4
+ data.tar.gz: 3a486c04f59ad325635bd3c160b44eb5563d13c9daa1457ada03864368c3537b
5
+ SHA512:
6
+ metadata.gz: 14e0a18f41563078f57ca175496d1a7c2a63024bcba1437d782ec641092f19ff68e11fcdaf66e38de9fac50cb7c63cc127412767d4e3a2adaf8e737a7bb8ea70
7
+ data.tar.gz: 7ace16f008e4a5b08cba397870ad1d8e46dc8efc6af2b9a960bb7660cd58cf123d31112ec00586dfaa4dfbefa786f92efa0e210ae3282d8c8b60c13772ae7705
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-09-16
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # Saiph
2
+
3
+ Saiph applies native operating-system restrictions to child processes. It is a
4
+ small, Pure Ruby boundary for running untrusted plugins without depending on an
5
+ editor or plugin API.
6
+
7
+ Saiph fails closed. `available?` is true only after the native backend passes a
8
+ local capability probe, and a child exits with status 126 if setup fails. Saiph
9
+ never substitutes an ordinary unsandboxed process.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ bundle add saiph
15
+ ```
16
+
17
+ Saiph supports Ruby 3.1 and later.
18
+
19
+ ## Usage
20
+
21
+ ```ruby
22
+ require "saiph"
23
+
24
+ unless Saiph.available?
25
+ warn "OS sandbox unavailable"
26
+ return
27
+ end
28
+
29
+ policy = Saiph::Policy.new(
30
+ read_paths: [File.expand_path("plugin.rb", __dir__)],
31
+ write_paths: [File.expand_path("tmp", __dir__)],
32
+ network: false,
33
+ exec: false,
34
+ env: ["LANG"]
35
+ )
36
+
37
+ pid = Saiph.spawn(
38
+ [RbConfig.ruby, File.expand_path("plugin.rb", __dir__)],
39
+ policy: policy,
40
+ env: {"LANG" => "en_US.UTF-8"},
41
+ out: $stdout,
42
+ err: $stderr
43
+ )
44
+ status = Process.waitpid2(pid).last
45
+ raise "sandbox setup failed" if status.exitstatus == 126
46
+ ```
47
+
48
+ `spawn` returns a process ID and accepts normal `Process.spawn` redirections
49
+ and options. Pass the command as an argument array. A string is treated as one
50
+ executable name; Saiph never invokes a shell.
51
+
52
+ `Policy` fields have these meanings:
53
+
54
+ - `read_paths`: existing absolute files or directory trees the child may read.
55
+ - `write_paths`: existing absolute files or directory trees the child may read
56
+ and modify.
57
+ - `network`: whether network access is allowed.
58
+ - `exec`: whether the child may create descendant processes.
59
+ - `env`: names of environment variables visible to the child. Values may be
60
+ overridden with `spawn(..., env: {...})`; undeclared names are rejected.
61
+
62
+ The launcher and system dynamic libraries need a small implicit read-only
63
+ runtime allowance. On Unix this covers the selected executable, the active
64
+ Ruby installation, and system library directories. It does not include the
65
+ home directory or temporary directories.
66
+
67
+ `Saiph.apply!(policy)` restricts the current process and is irreversible. Use
68
+ `spawn` unless the current process was created solely to become a sandbox.
69
+
70
+ ## Backends
71
+
72
+ | Platform | `backend` | Enforcement |
73
+ | --- | --- | --- |
74
+ | macOS | `:seatbelt` | `sandbox_init` profile with deny-by-default file, network, and process rules |
75
+ | Linux | `:seccomp` | user/mount/network namespaces, Landlock filesystem rules, and seccomp-BPF |
76
+ | Windows | `:appcontainer` | ephemeral AppContainer plus a Job Object process limit |
77
+
78
+ Linux reports unavailable unless namespaces, Landlock, and seccomp all work in
79
+ the current environment. This is common in restricted CI containers and is an
80
+ expected reason to skip integration tests.
81
+
82
+ Windows 0.1 supports a no-filesystem-grant, no-network policy. Non-empty path
83
+ grants and `network: true` raise `Saiph::Unsupported` because safely changing
84
+ host ACLs requires application-owned provisioning. `apply!` is also unsupported
85
+ on Windows; AppContainer tokens must be selected at process creation.
86
+
87
+ ## Security and cleanup
88
+
89
+ - Paths are resolved before launch, so a symlink cannot retarget an allowance.
90
+ - The child environment is rebuilt from its allowlist.
91
+ - Policy transfer uses a private inherited pipe, not a temporary file.
92
+ - Backend probes run in disposable children because Unix restrictions cannot be
93
+ relaxed.
94
+ - Linux blocks signal delivery to host processes in addition to ptrace and
95
+ cross-process memory access.
96
+ - AppContainer profiles, native handles, pipes, and Landlock descriptors are
97
+ closed on both success and failure.
98
+
99
+ Saiph reduces OS capabilities; it does not validate plugin code or replace
100
+ application-level authentication and authorization.
101
+
102
+ ## Development
103
+
104
+ ```bash
105
+ bundle install
106
+ bundle exec rake test
107
+ bundle exec rbs -I sig validate
108
+ BUDGET=1 bundle exec rake bench
109
+ gem build --strict saiph.gemspec
110
+ ```
111
+
112
+ ## License
113
+
114
+ MIT.
@@ -0,0 +1,17 @@
1
+ # ADR NNN: Implementation decision title
2
+
3
+ - Status: Proposed
4
+ - Date: YYYY-MM-DD
5
+
6
+ ## Context
7
+
8
+ Describe the concrete implementation question and its compatibility, runtime,
9
+ or security constraints.
10
+
11
+ ## Decision
12
+
13
+ Describe the durable boundary or architecture choice.
14
+
15
+ ## Consequences
16
+
17
+ Describe the important trade-offs and when to revisit the decision.
@@ -0,0 +1,34 @@
1
+ # ADR 001: Require complete native enforcement
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-15
5
+
6
+ ## Context
7
+
8
+ A platform may expose only part of the mechanisms needed for a process sandbox.
9
+ For example, seccomp reduces Linux system calls but does not restrict pathnames,
10
+ and a namespace may be disabled inside a container. Treating one partial
11
+ mechanism as a sandbox would give callers a false security guarantee.
12
+
13
+ ## Decision
14
+
15
+ Saiph reports a backend as available only after its required native mechanisms
16
+ can be activated in a disposable process. Linux requires namespaces, Landlock,
17
+ and seccomp together. macOS requires a compiled deny-by-default Seatbelt
18
+ profile. Windows requires successful creation and launch of an AppContainer.
19
+
20
+ Policy setup happens before user code. Any setup error terminates the launcher
21
+ with status 126. No backend falls back to `Process.spawn` without restrictions.
22
+ The implementation uses Ruby's standard library and native OS APIs through
23
+ Fiddle; it does not add a privileged helper or native extension.
24
+
25
+ ## Consequences
26
+
27
+ Saiph can be unavailable in otherwise supported operating systems, especially
28
+ inside CI containers. Callers must surface that state or choose their own
29
+ explicit fallback. Supporting more Linux hosts may later justify an audited
30
+ external helper, but it must preserve the same fail-closed contract.
31
+
32
+ Windows filesystem grants remain unsupported until the embedding application
33
+ can provision and restore ACLs it owns. Saiph rejects those policies instead of
34
+ silently broadening access.
@@ -0,0 +1,5 @@
1
+ # Architecture decision records
2
+
3
+ These records document Saiph's durable security boundaries.
4
+
5
+ - [ADR 001: Require complete native enforcement](001-require-complete-native-enforcement.md)
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Saiph
4
+ module Backends
5
+ module Spawn
6
+ module_function
7
+
8
+ def call(backend, command, policy:, **options)
9
+ overrides = options.delete(:env) || {}
10
+ raise ArgumentError, "env must be a hash" unless overrides.is_a?(Hash)
11
+
12
+ overrides = overrides.to_h { |key, value| [String(key), value.nil? ? nil : String(value)] }
13
+ denied = overrides.keys - policy.env
14
+ raise ArgumentError, "environment variable is not allowed: #{denied.first}" unless denied.empty?
15
+
16
+ environment = policy.env.to_h { |name| [name, overrides.fetch(name, ENV[name])] }
17
+ reader, writer = IO.pipe
18
+ fd = 3
19
+ environment["SAIPH_PAYLOAD_FD"] = fd.to_s
20
+ environment["SAIPH_BACKEND"] = backend.name.to_s
21
+ payload = JSON.generate(
22
+ "command" => command,
23
+ "policy" => {
24
+ "read_paths" => policy.read_paths,
25
+ "write_paths" => policy.write_paths,
26
+ "network" => policy.network,
27
+ "exec" => policy.exec,
28
+ "env" => policy.env
29
+ }
30
+ )
31
+ redirects = options.merge(fd => reader, close_others: true, unsetenv_others: true)
32
+ pid = Process.spawn(environment, RbConfig.ruby, "-I", File.expand_path("..", __dir__), "-rsaiph/runner", "-e", "Saiph::Runner.run", redirects)
33
+ reader.close
34
+ writer.write(payload)
35
+ writer.close
36
+ pid
37
+ rescue Exception
38
+ if pid
39
+ begin
40
+ Process.kill("TERM", pid)
41
+ rescue Errno::ESRCH
42
+ nil
43
+ end
44
+ begin
45
+ Process.wait(pid)
46
+ rescue Errno::ECHILD
47
+ nil
48
+ end
49
+ end
50
+ reader&.close unless reader&.closed?
51
+ writer&.close unless writer&.closed?
52
+ raise
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+
5
+ require_relative "../backend"
6
+
7
+ module Saiph
8
+ module Backends
9
+ module Linux
10
+ module_function
11
+
12
+ CLONE_NEWNS = 0x00020000
13
+ CLONE_NEWUSER = 0x10000000
14
+ CLONE_NEWNET = 0x40000000
15
+ MS_REC = 0x4000
16
+ MS_PRIVATE = 1 << 18
17
+ PR_SET_SECCOMP = 22
18
+ PR_SET_NO_NEW_PRIVS = 38
19
+ SECCOMP_MODE_FILTER = 2
20
+ LANDLOCK_CREATE_RULESET_VERSION = 1
21
+ LANDLOCK_RULE_PATH_BENEATH = 1
22
+ O_PATH = 0x200000
23
+ O_CLOEXEC = 0x80000
24
+
25
+ FS_EXECUTE = 1 << 0
26
+ FS_WRITE_FILE = 1 << 1
27
+ FS_READ_FILE = 1 << 2
28
+ FS_READ_DIR = 1 << 3
29
+ FS_REMOVE_DIR = 1 << 4
30
+ FS_REMOVE_FILE = 1 << 5
31
+ FS_MAKE_CHAR = 1 << 6
32
+ FS_MAKE_DIR = 1 << 7
33
+ FS_MAKE_REG = 1 << 8
34
+ FS_MAKE_SOCK = 1 << 9
35
+ FS_MAKE_FIFO = 1 << 10
36
+ FS_MAKE_BLOCK = 1 << 11
37
+ FS_MAKE_SYM = 1 << 12
38
+ FS_REFER = 1 << 13
39
+ FS_TRUNCATE = 1 << 14
40
+ FS_IOCTL_DEV = 1 << 15
41
+ FS_BASE = (1 << 13) - 1
42
+ FS_WRITE = FS_WRITE_FILE | FS_REMOVE_DIR | FS_REMOVE_FILE | FS_MAKE_DIR |
43
+ FS_MAKE_REG | FS_MAKE_SOCK | FS_MAKE_FIFO | FS_MAKE_SYM
44
+
45
+ BPF_LD_W_ABS = 0x20
46
+ BPF_JMP_JEQ_K = 0x15
47
+ BPF_JMP_JSET_K = 0x45
48
+ BPF_RET_K = 0x06
49
+ SECCOMP_RET_KILL_PROCESS = 0x80000000
50
+ SECCOMP_RET_ERRNO = 0x00050000
51
+ SECCOMP_RET_ALLOW = 0x7fff0000
52
+ EPERM = 1
53
+ CLONE_THREAD = 0x00010000
54
+
55
+ SYSTEM_READ_PATHS = ["/lib", "/lib64", "/usr/lib", "/etc/ld.so.cache", RbConfig::CONFIG["prefix"]].freeze
56
+ AUDIT_ARCH = {"x86_64" => 0xc000003e, "aarch64" => 0xc00000b7, "arm64" => 0xc00000b7}.freeze
57
+ SYSCALLS = {
58
+ "x86_64" => {
59
+ clone: 56, fork: 57, vfork: 58, clone3: 435, ptrace: 101, bpf: 321, perf_event_open: 298,
60
+ userfaultfd: 323, kexec_load: 246, finit_module: 313, init_module: 175, delete_module: 176,
61
+ reboot: 169, swapon: 167, swapoff: 168, mount: 165, umount2: 166, pivot_root: 155,
62
+ setns: 308, unshare: 272, keyctl: 250, add_key: 248, request_key: 249,
63
+ process_vm_readv: 310, process_vm_writev: 311, open_by_handle_at: 304, kill: 62, tkill: 200,
64
+ tgkill: 234, rt_sigqueueinfo: 129, rt_tgsigqueueinfo: 297, pidfd_send_signal: 424,
65
+ socket: 41, connect: 42, accept: 43, sendto: 44, recvfrom: 45, sendmsg: 46, recvmsg: 47,
66
+ bind: 49, listen: 50, socketpair: 53, accept4: 288, recvmmsg: 299, sendmmsg: 307
67
+ },
68
+ "aarch64" => {
69
+ clone: 220, clone3: 435, ptrace: 117, bpf: 280, perf_event_open: 241, userfaultfd: 282,
70
+ kexec_load: 104, finit_module: 273, init_module: 105, delete_module: 106, reboot: 142,
71
+ swapon: 224, swapoff: 225, mount: 40, umount2: 39, pivot_root: 41, setns: 268,
72
+ unshare: 97, keyctl: 219, add_key: 217, request_key: 218, process_vm_readv: 270,
73
+ process_vm_writev: 271, open_by_handle_at: 265, kill: 129, tkill: 130, tgkill: 131,
74
+ rt_sigqueueinfo: 138, rt_tgsigqueueinfo: 240, pidfd_send_signal: 424, socket: 198,
75
+ socketpair: 199, bind: 200, listen: 201, accept: 202, connect: 203, sendto: 206,
76
+ recvfrom: 207, sendmsg: 211, recvmsg: 212, accept4: 242, recvmmsg: 243, sendmmsg: 269
77
+ }
78
+ }.freeze
79
+
80
+ def name = :seccomp
81
+
82
+ def available?
83
+ return @available if defined?(@available_pid) && @available_pid == Process.pid
84
+
85
+ @available_pid = Process.pid
86
+ @available = supported_architecture? && landlock_abi.positive? && probe
87
+ rescue StandardError, LoadError
88
+ @available = false
89
+ end
90
+
91
+ def spawn(command, policy:, **options) = Spawn.call(self, command, policy: policy, **options)
92
+
93
+ def apply!(policy, command: nil)
94
+ raise Unsupported, "Linux sandbox requires x86-64 or AArch64" unless supported_architecture?
95
+
96
+ setup_namespaces(network: policy.network)
97
+ apply_landlock(policy, command)
98
+ apply_seccomp(policy)
99
+ true
100
+ end
101
+
102
+ def setup_namespaces(network:)
103
+ flags = CLONE_NEWNS | (network ? 0 : CLONE_NEWNET)
104
+ original_uid, original_gid = Process.uid, Process.gid
105
+ if unshare.call(flags | CLONE_NEWUSER).zero?
106
+ File.write("/proc/self/setgroups", "deny") if File.exist?("/proc/self/setgroups")
107
+ File.write("/proc/self/uid_map", "0 #{original_uid} 1")
108
+ File.write("/proc/self/gid_map", "0 #{original_gid} 1")
109
+ elsif unshare.call(flags) != 0
110
+ system_error("unshare")
111
+ end
112
+ system_error("mount private namespace") unless mount.call(0, "/", 0, MS_REC | MS_PRIVATE, 0).zero?
113
+ end
114
+
115
+ def apply_landlock(policy, command)
116
+ abi = landlock_abi
117
+ handled = FS_BASE
118
+ handled |= FS_REFER if abi >= 2
119
+ handled |= FS_TRUNCATE if abi >= 3
120
+ handled |= FS_IOCTL_DEV if abi >= 5
121
+ ruleset = landlock_create(Fiddle::Pointer[[handled].pack("Q<")], 8, 0)
122
+ system_error("landlock_create_ruleset") if ruleset.negative?
123
+
124
+ begin
125
+ rules = {}
126
+ SYSTEM_READ_PATHS.each { |path| merge_rule(rules, path, read_rights(path) | FS_EXECUTE) }
127
+ policy.read_paths.each { |path| merge_rule(rules, path, read_rights(path)) }
128
+ policy.write_paths.each { |path| merge_rule(rules, path, read_rights(path) | write_rights(path, abi)) }
129
+ policy.read_paths.each { |path| merge_rule(rules, path, FS_EXECUTE) } if policy.exec
130
+ merge_rule(rules, command.first, FS_EXECUTE | FS_READ_FILE) if command
131
+ rules.each { |path, rights| add_landlock_rule(ruleset, path, rights & handled) }
132
+
133
+ system_error("prctl(PR_SET_NO_NEW_PRIVS)") unless prctl.call(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0).zero?
134
+ system_error("landlock_restrict_self") unless landlock_restrict(ruleset, 0).zero?
135
+ ensure
136
+ IO.new(ruleset).close
137
+ end
138
+ end
139
+
140
+ def merge_rule(rules, path, rights)
141
+ return unless path && File.exist?(path)
142
+
143
+ canonical = File.realpath(path)
144
+ rules[canonical] = rules.fetch(canonical, 0) | rights
145
+ end
146
+
147
+ def read_rights(path) = FS_READ_FILE | (File.directory?(path) ? FS_READ_DIR : 0)
148
+
149
+ def write_rights(path, abi)
150
+ rights = FS_WRITE
151
+ rights |= FS_REFER if abi >= 2
152
+ rights |= FS_TRUNCATE if abi >= 3
153
+ File.directory?(path) ? rights : (FS_WRITE_FILE | (abi >= 3 ? FS_TRUNCATE : 0))
154
+ end
155
+
156
+ def add_landlock_rule(ruleset, path, rights)
157
+ descriptor = IO.sysopen(path, O_PATH | O_CLOEXEC)
158
+ attribute = [rights, descriptor].pack("Q<l<x4")
159
+ system_error("landlock_add_rule") unless landlock_add(ruleset, LANDLOCK_RULE_PATH_BENEATH, Fiddle::Pointer[attribute], 0).zero?
160
+ ensure
161
+ IO.new(descriptor).close if descriptor
162
+ end
163
+
164
+ def apply_seccomp(policy)
165
+ calls = syscall_table
166
+ denied = %i[ptrace bpf perf_event_open userfaultfd kexec_load finit_module init_module delete_module reboot
167
+ swapon swapoff mount umount2 pivot_root setns unshare keyctl add_key request_key process_vm_readv
168
+ process_vm_writev open_by_handle_at kill tkill tgkill rt_sigqueueinfo rt_tgsigqueueinfo pidfd_send_signal]
169
+ denied.concat(%i[socket socketpair bind listen accept accept4 connect sendto recvfrom sendmsg recvmsg recvmmsg sendmmsg]) unless policy.network
170
+ filters = [[BPF_LD_W_ABS, 0, 0, 4], [BPF_JMP_JEQ_K, 1, 0, audit_arch], [BPF_RET_K, 0, 0, SECCOMP_RET_KILL_PROCESS], [BPF_LD_W_ABS, 0, 0, 0]]
171
+ unless policy.exec
172
+ if calls[:clone]
173
+ filters.concat([[BPF_JMP_JEQ_K, 0, 4, calls[:clone]], [BPF_LD_W_ABS, 0, 0, 16],
174
+ [BPF_JMP_JSET_K, 1, 0, CLONE_THREAD], [BPF_RET_K, 0, 0, SECCOMP_RET_ERRNO | EPERM], [BPF_LD_W_ABS, 0, 0, 0]])
175
+ end
176
+ denied.concat(%i[fork vfork clone3])
177
+ end
178
+ denied.filter_map { |name| calls[name] }.uniq.each do |number|
179
+ filters << [BPF_JMP_JEQ_K, 0, 1, number]
180
+ filters << [BPF_RET_K, 0, 0, SECCOMP_RET_ERRNO | EPERM]
181
+ end
182
+ filters << [BPF_RET_K, 0, 0, SECCOMP_RET_ALLOW]
183
+ install_filter(filters)
184
+ end
185
+
186
+ def install_filter(filters)
187
+ bytes = filters.map { |filter| filter.pack("S<CCL<") }.join
188
+ pointer = Fiddle::Pointer[bytes]
189
+ program = [filters.length].pack("S<") + "\0" * 6 + [pointer.to_i].pack("Q<")
190
+ system_error("prctl(PR_SET_SECCOMP)") unless prctl.call(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, Fiddle::Pointer[program], 0, 0).zero?
191
+ end
192
+
193
+ def probe
194
+ pid = Process.fork do
195
+ command = [File.realpath(RbConfig.ruby), "-e", "exit!(0)"]
196
+ apply!(Policy.new([], [], false, false, []), command: command)
197
+ exec({}, *command, unsetenv_others: true)
198
+ rescue Exception
199
+ exit!(1)
200
+ end
201
+ Process.waitpid2(pid).last.success?
202
+ end
203
+
204
+ def host_cpu = RbConfig::CONFIG["host_cpu"]
205
+ def supported_architecture? = Fiddle::SIZEOF_VOIDP == 8 && AUDIT_ARCH.key?(host_cpu)
206
+ def audit_arch = AUDIT_ARCH.fetch(host_cpu)
207
+ def syscall_table = SYSCALLS.fetch(host_cpu == "arm64" ? "aarch64" : host_cpu)
208
+
209
+ def landlock_abi
210
+ @landlock_abi ||= landlock_create(0, 0, LANDLOCK_CREATE_RULESET_VERSION)
211
+ end
212
+
213
+ def function(name, arguments, result = Fiddle::TYPE_INT)
214
+ Fiddle::Function.new(Fiddle::Handle::DEFAULT[name], arguments, result)
215
+ end
216
+
217
+ def unshare = (@unshare ||= function("unshare", [Fiddle::TYPE_INT]))
218
+ def mount = (@mount ||= function("mount", [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG, Fiddle::TYPE_VOIDP]))
219
+ def prctl = (@prctl ||= function("prctl", [Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG]))
220
+
221
+ def landlock_create(pointer, size, flags)
222
+ fn = (@landlock_create ||= function("syscall", [Fiddle::TYPE_LONG, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_INT], Fiddle::TYPE_LONG))
223
+ fn.call(444, pointer, size, flags)
224
+ end
225
+
226
+ def landlock_add(ruleset, type, pointer, flags)
227
+ fn = (@landlock_add ||= function("syscall", [Fiddle::TYPE_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_LONG))
228
+ fn.call(445, ruleset, type, pointer, flags)
229
+ end
230
+
231
+ def landlock_restrict(ruleset, flags)
232
+ fn = (@landlock_restrict ||= function("syscall", [Fiddle::TYPE_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_INT], Fiddle::TYPE_LONG))
233
+ fn.call(446, ruleset, flags)
234
+ end
235
+
236
+ def system_error(operation) = raise(SystemCallError.new(operation, Fiddle.last_error))
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+
5
+ require_relative "../backend"
6
+
7
+ module Saiph
8
+ module Backends
9
+ module MacOS
10
+ module_function
11
+
12
+ LIBRARIES = ["/usr/lib/libsandbox.1.dylib", "/usr/lib/libSystem.B.dylib"].freeze
13
+ BASE_READ_PATHS = ["/System", "/usr/lib", "/private/var/db/dyld", RbConfig::CONFIG["prefix"]].compact.freeze
14
+
15
+ def name = :seatbelt
16
+
17
+ def available?
18
+ return @available if defined?(@available_pid) && @available_pid == Process.pid
19
+
20
+ @available_pid = Process.pid
21
+ @available = probe
22
+ rescue StandardError, LoadError
23
+ @available = false
24
+ end
25
+
26
+ def spawn(command, policy:, **options) = Spawn.call(self, command, policy: policy, **options)
27
+
28
+ def apply!(policy, command: nil)
29
+ profile = build_profile(policy, command)
30
+ error = [0].pack("J")
31
+ result = sandbox_init.call(Fiddle::Pointer[profile], 0, Fiddle::Pointer[error])
32
+ return true if result.zero?
33
+
34
+ pointer = error.unpack1("J")
35
+ message = pointer.zero? ? "sandbox_init failed" : Fiddle::Pointer.new(pointer).to_s
36
+ sandbox_free_error.call(pointer) unless pointer.zero?
37
+ raise Error, message
38
+ end
39
+
40
+ def build_profile(policy, command = nil)
41
+ reads = (BASE_READ_PATHS + policy.read_paths + policy.write_paths + Array(command&.first)).uniq
42
+ lines = ["(version 1)", "(deny default)", "(allow sysctl-read)", "(allow process-info*)"]
43
+ lines << rule("file-read*", reads)
44
+ lines << rule("file-write*", policy.write_paths) unless policy.write_paths.empty?
45
+ lines << "(allow network*)" if policy.network
46
+ if command
47
+ lines << "(allow process-exec (literal #{quote(command.first)}))"
48
+ lines << "(allow process-fork)" if policy.exec
49
+ elsif policy.exec
50
+ lines << rule("process-exec", policy.read_paths)
51
+ lines << "(allow process-fork)"
52
+ end
53
+ lines.join("\n")
54
+ end
55
+
56
+ def rule(operation, paths)
57
+ filters = paths.filter_map do |path|
58
+ next unless File.exist?(path)
59
+
60
+ "(#{File.directory?(path) ? 'subpath' : 'literal'} #{quote(path)})"
61
+ end
62
+ "(allow #{operation} #{filters.join(' ')})"
63
+ end
64
+
65
+ def quote(value) = JSON.generate(value)
66
+
67
+ def probe
68
+ pid = Process.fork do
69
+ STDERR.reopen(File::NULL, "w")
70
+ command = [File.realpath(RbConfig.ruby), "-e", "exit!(0)"]
71
+ apply!(Policy.new([], [], false, false, []), command: command)
72
+ exec({}, *command, unsetenv_others: true)
73
+ rescue Exception
74
+ exit!(1)
75
+ end
76
+ Process.waitpid2(pid).last.success?
77
+ end
78
+
79
+ def library
80
+ @library ||= LIBRARIES.lazy.filter_map do |path|
81
+ Fiddle.dlopen(path)
82
+ rescue Fiddle::DLError
83
+ nil
84
+ end.first or raise LoadError, "sandbox library not found"
85
+ end
86
+
87
+ def sandbox_init
88
+ @sandbox_init ||= Fiddle::Function.new(
89
+ library["sandbox_init"],
90
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_VOIDP],
91
+ Fiddle::TYPE_INT
92
+ )
93
+ end
94
+
95
+ def sandbox_free_error
96
+ @sandbox_free_error ||= Fiddle::Function.new(library["sandbox_free_error"], [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID)
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+ require "securerandom"
5
+
6
+ require_relative "../backend"
7
+
8
+ module Saiph
9
+ module Backends
10
+ module Windows
11
+ module_function
12
+
13
+ P = Fiddle::TYPE_VOIDP
14
+ I = Fiddle::TYPE_INT
15
+ U = Fiddle::TYPE_INT
16
+ N = Fiddle::TYPE_SIZE_T
17
+ L = Fiddle::TYPE_LONG
18
+ V = Fiddle::TYPE_VOID
19
+ PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES = 0x00020009
20
+ EXTENDED_STARTUPINFO_PRESENT = 0x00080000
21
+ CREATE_UNICODE_ENVIRONMENT = 0x00000400
22
+ CREATE_SUSPENDED = 0x00000004
23
+ STARTF_USESTDHANDLES = 0x00000100
24
+ JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 0x00000008
25
+ JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
26
+ JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9
27
+ INFINITE = 0xffffffff
28
+
29
+ def name = :appcontainer
30
+
31
+ def available?
32
+ return false unless Fiddle::SIZEOF_VOIDP == 8
33
+ return @available if defined?(@available_pid) && @available_pid == Process.pid
34
+
35
+ @available_pid = Process.pid
36
+ profile, sid = create_profile("Probe")
37
+ @available = create_and_wait([RbConfig.ruby, "-e", "exit!(0)"], sid, allow_children: false).zero?
38
+ rescue StandardError, LoadError, Fiddle::DLError
39
+ @available = false
40
+ ensure
41
+ free_sid.call(sid) if sid && !sid.zero?
42
+ delete_profile.call(wide(profile)) if profile
43
+ end
44
+
45
+ def spawn(command, policy:, **options)
46
+ validate_policy!(policy)
47
+ Spawn.call(self, command, policy: policy, **options)
48
+ end
49
+
50
+ def apply!(_policy, command: nil)
51
+ raise Unsupported, "AppContainer can only be applied while creating a child process"
52
+ end
53
+
54
+ def launch(command, policy:)
55
+ validate_policy!(policy)
56
+ profile, sid = create_profile("Process")
57
+ create_and_wait(command, sid, allow_children: policy.exec)
58
+ ensure
59
+ free_sid.call(sid) if sid && !sid.zero?
60
+ delete_profile.call(wide(profile)) if profile
61
+ end
62
+
63
+ def validate_policy!(policy)
64
+ unless policy.read_paths.empty? && policy.write_paths.empty?
65
+ raise Unsupported, "AppContainer path grants require explicit Windows ACL provisioning"
66
+ end
67
+ raise Unsupported, "AppContainer network capabilities are not available" if policy.network
68
+ end
69
+
70
+ def create_and_wait(command, sid, allow_children:)
71
+ attributes = initialize_attributes
72
+ security = [sid, 0, 0, 0].pack("JJL<L<")
73
+ check(update_attributes.call(attributes, 0, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES,
74
+ Fiddle::Pointer[security], security.bytesize, 0, 0), "UpdateProcThreadAttribute")
75
+ startup = startup_info(attributes)
76
+ information = "\0".b * 24
77
+ line = wide(command.map { |argument| quote_argument(argument) }.join(" "))
78
+ flags = EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED
79
+ check(create_process.call(wide(command.first), line, 0, 0, 1, flags, 0, 0, Fiddle::Pointer[startup], Fiddle::Pointer[information]), "CreateProcessW")
80
+ process, thread = information.unpack("JJ")
81
+ job = create_job.call(0, 0)
82
+ check(job, "CreateJobObjectW")
83
+ configure_job(job, allow_children: allow_children)
84
+ check(assign_job.call(job, process), "AssignProcessToJobObject")
85
+ resumed = resume_thread.call(thread)
86
+ raise SystemCallError.new("ResumeThread", Fiddle.last_error) if resumed == 0xffffffff
87
+
88
+ check(wait.call(process, INFINITE) != 0xffffffff ? 1 : 0, "WaitForSingleObject")
89
+ status = [0].pack("L<")
90
+ check(exit_code.call(process, Fiddle::Pointer[status]), "GetExitCodeProcess")
91
+ status.unpack1("L<")
92
+ rescue Exception
93
+ terminate.call(process, 126) if process && !process.zero?
94
+ raise
95
+ ensure
96
+ delete_attributes.call(attributes) if attributes
97
+ [thread, process, job].compact.each { |handle| close_handle.call(handle) unless handle.zero? }
98
+ end
99
+
100
+ def configure_job(job, allow_children:)
101
+ information = "\0".b * 144
102
+ flags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
103
+ unless allow_children
104
+ flags |= JOB_OBJECT_LIMIT_ACTIVE_PROCESS
105
+ information[40, 4] = [1].pack("L<")
106
+ end
107
+ information[16, 4] = [flags].pack("L<")
108
+ check(set_job.call(job, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, Fiddle::Pointer[information], information.bytesize),
109
+ "SetInformationJobObject")
110
+ end
111
+
112
+ def initialize_attributes
113
+ size = [0].pack("J")
114
+ initialize_attribute_list.call(0, 1, 0, Fiddle::Pointer[size])
115
+ attributes = Fiddle::Pointer.malloc(size.unpack1("J"), Fiddle::RUBY_FREE)
116
+ check(initialize_attribute_list.call(attributes, 1, 0, Fiddle::Pointer[size]), "InitializeProcThreadAttributeList")
117
+ attributes
118
+ end
119
+
120
+ def startup_info(attributes)
121
+ value = "\0".b * 112
122
+ value[0, 4] = [112].pack("L<")
123
+ value[60, 4] = [STARTF_USESTDHANDLES].pack("L<")
124
+ value[80, 24] = [-10, -11, -12].map { |number| get_std_handle.call(number) }.pack("J3")
125
+ value[104, 8] = [attributes.to_i].pack("J")
126
+ value
127
+ end
128
+
129
+ def create_profile(suffix)
130
+ name = "Noxdea.Saiph.#{suffix}.#{Process.pid}.#{SecureRandom.hex(8)}"
131
+ sid = [0].pack("J")
132
+ result = create_profile_fn.call(wide(name), wide("Saiph"), wide("Ephemeral Saiph sandbox"), 0, 0, Fiddle::Pointer[sid])
133
+ raise Error, "CreateAppContainerProfile failed (0x#{(result & 0xffffffff).to_s(16)})" if (result & 0x80000000) != 0
134
+
135
+ [name, sid.unpack1("J")]
136
+ end
137
+
138
+ def quote_argument(value)
139
+ return value unless value.empty? || value.match?(/[\s"]/)
140
+
141
+ '"' + value.gsub(/(\\*)"/) { Regexp.last_match(1) * 2 + '\\"' }
142
+ .sub(/(\\+)\z/) { Regexp.last_match(1) * 2 } + '"'
143
+ end
144
+
145
+ def wide(value) = value.encode("UTF-16LE").b + "\0\0"
146
+
147
+ def check(result, operation)
148
+ raise SystemCallError.new(operation, Fiddle.last_error) if result.to_i.zero?
149
+
150
+ result
151
+ end
152
+
153
+ def library(name) = Fiddle.dlopen(name)
154
+ def kernel = (@kernel ||= library("kernel32.dll"))
155
+ def userenv = (@userenv ||= library("userenv.dll"))
156
+ def advapi = (@advapi ||= library("advapi32.dll"))
157
+ def fn(handle, name, arguments, result) = Fiddle::Function.new(handle[name], arguments, result)
158
+ def create_profile_fn = (@create_profile_fn ||= fn(userenv, "CreateAppContainerProfile", [P, P, P, P, U, P], L))
159
+ def delete_profile = (@delete_profile ||= fn(userenv, "DeleteAppContainerProfile", [P], L))
160
+ def free_sid = (@free_sid ||= fn(advapi, "FreeSid", [P], P))
161
+ def initialize_attribute_list = (@initialize_attribute_list ||= fn(kernel, "InitializeProcThreadAttributeList", [P, U, U, P], I))
162
+ def update_attributes = (@update_attributes ||= fn(kernel, "UpdateProcThreadAttribute", [P, U, N, P, N, P, P], I))
163
+ def delete_attributes = (@delete_attributes ||= fn(kernel, "DeleteProcThreadAttributeList", [P], V))
164
+ def create_process = (@create_process ||= fn(kernel, "CreateProcessW", [P, P, P, P, I, U, P, P, P, P], I))
165
+ def get_std_handle = (@get_std_handle ||= fn(kernel, "GetStdHandle", [I], P))
166
+ def create_job = (@create_job ||= fn(kernel, "CreateJobObjectW", [P, P], P))
167
+ def set_job = (@set_job ||= fn(kernel, "SetInformationJobObject", [P, I, P, U], I))
168
+ def assign_job = (@assign_job ||= fn(kernel, "AssignProcessToJobObject", [P, P], I))
169
+ def resume_thread = (@resume_thread ||= fn(kernel, "ResumeThread", [P], U))
170
+ def wait = (@wait ||= fn(kernel, "WaitForSingleObject", [P, U], U))
171
+ def exit_code = (@exit_code ||= fn(kernel, "GetExitCodeProcess", [P, P], I))
172
+ def terminate = (@terminate ||= fn(kernel, "TerminateProcess", [P, U], I))
173
+ def close_handle = (@close_handle ||= fn(kernel, "CloseHandle", [P], I))
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../saiph" unless defined?(Saiph::Policy)
4
+
5
+ module Saiph
6
+ module Runner
7
+ module_function
8
+
9
+ def run
10
+ fd = Integer(ENV.delete("SAIPH_PAYLOAD_FD"), 10)
11
+ requested = ENV.delete("SAIPH_BACKEND")
12
+ input = IO.for_fd(fd)
13
+ payload = JSON.parse(input.read)
14
+ input.close
15
+ values = payload.fetch("policy").values_at("read_paths", "write_paths", "network", "exec", "env")
16
+ policy = Policy.new(*values)
17
+ implementation = Saiph.send(:require_backend!)
18
+ raise Unsupported, "sandbox backend changed while starting" unless implementation.name.to_s == requested
19
+
20
+ if implementation.respond_to?(:launch)
21
+ exit!(implementation.launch(payload.fetch("command"), policy: policy))
22
+ end
23
+
24
+ implementation.apply!(policy, command: payload.fetch("command"))
25
+ exec(*payload.fetch("command"))
26
+ rescue Exception => error
27
+ warn("saiph: #{error.message}")
28
+ exit!(126)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Saiph
4
+ VERSION = "0.1.0"
5
+ end
data/lib/saiph.rb ADDED
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rbconfig"
5
+
6
+ require_relative "saiph/version"
7
+
8
+ module Saiph
9
+ class Error < StandardError; end
10
+ class Unsupported < Error; end
11
+
12
+ module Value
13
+ module_function
14
+
15
+ def define(*members)
16
+ return Data.define(*members) if defined?(Data)
17
+
18
+ Struct.new(*members) do
19
+ members.each { |member| undef_method("#{member}=") }
20
+
21
+ def initialize(*values, **keywords)
22
+ unless keywords.empty?
23
+ raise ArgumentError, "cannot mix positional and keyword arguments" unless values.empty?
24
+
25
+ missing = self.class.members - keywords.keys
26
+ unknown = keywords.keys - self.class.members
27
+ raise ArgumentError, "missing keyword: #{missing.first.inspect}" unless missing.empty?
28
+ raise ArgumentError, "unknown keyword: #{unknown.first.inspect}" unless unknown.empty?
29
+
30
+ values = self.class.members.map { |member| keywords.fetch(member) }
31
+ end
32
+ raise ArgumentError, "wrong number of arguments" unless values.length == self.class.members.length
33
+
34
+ super(*values)
35
+ freeze
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ Policy = Value.define(:read_paths, :write_paths, :network, :exec, :env)
42
+ private_constant :Value
43
+
44
+ class << self
45
+ def available? = !!(platform && platform.available?)
46
+ def backend = available? ? platform.name : nil
47
+
48
+ def spawn(command, policy:, **spawn_options)
49
+ normalized = normalize_policy(policy)
50
+ executable = resolve_command(command)
51
+ require_backend!.spawn(executable, policy: normalized, **spawn_options)
52
+ end
53
+
54
+ def apply!(policy)
55
+ normalized = normalize_policy(policy)
56
+ require_backend!.apply!(normalized)
57
+ ENV.keep_if { |name, _value| normalized.env.include?(name) }
58
+ true
59
+ end
60
+
61
+ private
62
+
63
+ def platform
64
+ @platform ||= case RUBY_PLATFORM
65
+ when /darwin/ then require_relative("saiph/backends/macos") && Backends::MacOS
66
+ when /linux/ then require_relative("saiph/backends/linux") && Backends::Linux
67
+ when /mswin|mingw/ then require_relative("saiph/backends/windows") && Backends::Windows
68
+ else false
69
+ end
70
+ @platform || nil
71
+ end
72
+
73
+ def require_backend!
74
+ candidate = platform
75
+ raise Unsupported, "no supported sandbox backend is available" unless candidate&.available?
76
+
77
+ candidate
78
+ end
79
+
80
+ def normalize_policy(policy)
81
+ raise ArgumentError, "policy must be a Saiph::Policy" unless policy.is_a?(Policy)
82
+
83
+ reads = normalize_paths(policy.read_paths, :read_paths)
84
+ writes = normalize_paths(policy.write_paths, :write_paths)
85
+ raise ArgumentError, "network must be true or false" unless [true, false].include?(policy.network)
86
+ raise ArgumentError, "exec must be true or false" unless [true, false].include?(policy.exec)
87
+
88
+ names = Array(policy.env).map do |name|
89
+ value = String(name)
90
+ raise ArgumentError, "invalid environment name" if value.empty? || value.include?("=") || value.include?("\0")
91
+
92
+ value.freeze
93
+ end.uniq.freeze
94
+ Policy.new(reads, writes, policy.network, policy.exec, names)
95
+ end
96
+
97
+ def normalize_paths(paths, field)
98
+ Array(paths).map do |path|
99
+ value = String(path)
100
+ raise ArgumentError, "#{field} must contain absolute paths" unless File.absolute_path(value) == value
101
+ raise ArgumentError, "#{field} path does not exist: #{value}" unless File.exist?(value)
102
+
103
+ File.realpath(value).freeze
104
+ end.uniq.freeze
105
+ end
106
+
107
+ def resolve_command(command)
108
+ parts = (command.is_a?(Array) ? command : [command]).map do |part|
109
+ value = String(part)
110
+ raise ArgumentError, "command contains a null byte" if value.include?("\0")
111
+
112
+ value
113
+ end
114
+ raise ArgumentError, "command must not be empty" if parts.empty? || parts.first.empty?
115
+
116
+ executable = parts.first
117
+ separators = [File::SEPARATOR, File::ALT_SEPARATOR].compact
118
+ unless separators.any? { |separator| executable.include?(separator) }
119
+ executable = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).find do |directory|
120
+ path = File.expand_path(executable, directory)
121
+ break path if File.file?(path) && File.executable?(path)
122
+ end
123
+ else
124
+ executable = File.expand_path(executable)
125
+ end
126
+ raise ArgumentError, "command executable was not found" unless executable && File.file?(executable) && File.executable?(executable)
127
+
128
+ parts[0] = File.realpath(executable)
129
+ parts.freeze
130
+ end
131
+ end
132
+ end
133
+
134
+ require_relative "saiph/runner"
data/sig/saiph.rbs ADDED
@@ -0,0 +1,25 @@
1
+ module Saiph
2
+ VERSION: String
3
+
4
+ class Error < StandardError
5
+ end
6
+
7
+ class Unsupported < Error
8
+ end
9
+
10
+ class Policy
11
+ attr_reader read_paths: Array[String]
12
+ attr_reader write_paths: Array[String]
13
+ attr_reader network: bool
14
+ attr_reader exec: bool
15
+ attr_reader env: Array[String]
16
+
17
+ def self.new: (Array[String] read_paths, Array[String] write_paths, bool network, bool exec, Array[String] env) -> Policy
18
+ | (read_paths: Array[String], write_paths: Array[String], network: bool, exec: bool, env: Array[String]) -> Policy
19
+ end
20
+
21
+ def self.available?: () -> bool
22
+ def self.backend: () -> (:seatbelt | :seccomp | :appcontainer)?
23
+ def self.spawn: (String | Array[String] command, policy: Policy, **untyped options) -> Integer
24
+ def self.apply!: (Policy policy) -> bool
25
+ end
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: saiph
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: fiddle
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.1'
26
+ email:
27
+ - t.yudai92@gmail.com
28
+ executables: []
29
+ extensions: []
30
+ extra_rdoc_files: []
31
+ files:
32
+ - CHANGELOG.md
33
+ - LICENSE.txt
34
+ - README.md
35
+ - docs/adr/000-template.md
36
+ - docs/adr/001-require-complete-native-enforcement.md
37
+ - docs/adr/README.md
38
+ - lib/saiph.rb
39
+ - lib/saiph/backend.rb
40
+ - lib/saiph/backends/linux.rb
41
+ - lib/saiph/backends/macos.rb
42
+ - lib/saiph/backends/windows.rb
43
+ - lib/saiph/runner.rb
44
+ - lib/saiph/version.rb
45
+ - sig/saiph.rbs
46
+ homepage: https://github.com/noxdea/saiph
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ source_code_uri: https://github.com/noxdea/saiph
51
+ changelog_uri: https://github.com/noxdea/saiph/blob/main/CHANGELOG.md
52
+ allowed_push_host: https://rubygems.org
53
+ rubygems_mfa_required: 'true'
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '3.1'
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 4.0.16
69
+ specification_version: 4
70
+ summary: Fail-closed native process sandboxing for Ruby
71
+ test_files: []