rkseal 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.
@@ -0,0 +1,432 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "securerandom"
5
+ require "rbconfig"
6
+ require "tmpdir"
7
+ require "open3"
8
+
9
+ module RKSeal
10
+ # Provides a RAM-backed scratch path for the plaintext edit buffer and
11
+ # guarantees its destruction. This is the single enforcement point for the
12
+ # hard rule: **plaintext must never touch persistent disk.**
13
+ #
14
+ # The medium is chosen per-OS behind one interface:
15
+ # - Linux: a tmpfs path (`/dev/shm`, or `$XDG_RUNTIME_DIR`).
16
+ # - macOS: an ephemeral `hdiutil`-backed RAM disk, attached for the duration
17
+ # of the edit and detached afterwards (macOS has no tmpfs/`/dev/shm`).
18
+ #
19
+ # There is **no on-disk `mktemp` fallback**. If a RAM-backed medium cannot be
20
+ # provisioned, the workspace raises {RKSeal::WorkspaceError} rather than
21
+ # degrade the security guarantee.
22
+ #
23
+ # The public API is block-scoped so callers cannot forget teardown: the path
24
+ # exists only inside the block, and on block exit (normal, exception, or
25
+ # signal) the file is best-effort shredded/overwritten and unlinked and any
26
+ # RAM disk is detached. Signal handling and `at_exit` registration guard
27
+ # against a crash leaking a mounted RAM disk. Secret values are never logged.
28
+ #
29
+ # rubocop:disable Metrics/ClassLength -- this single class deliberately holds
30
+ # the workspace orchestration plus its two tightly-coupled per-OS medium
31
+ # strategies (LinuxMedium, MacosMedium). They are one cohesive unit and the
32
+ # gem keeps one layer per file, so splitting them out would scatter the
33
+ # "never on disk" guarantee rather than clarify it.
34
+ class SecureWorkspace
35
+ # Filesystem permissions for the scratch file: owner read/write only.
36
+ FILE_MODE = 0o600
37
+
38
+ # Permissions for the (Linux) scratch *directory*: owner only.
39
+ DIR_MODE = 0o700
40
+
41
+ # Size of the macOS RAM disk. A few MB is plenty for a Secret manifest; the
42
+ # buffer holds one small YAML document, never bulk data.
43
+ RAM_DISK_BYTES = 8 * 1024 * 1024
44
+
45
+ # Bytes per disk sector, used to convert {RAM_DISK_BYTES} into the sector
46
+ # count `hdiutil attach ram://<sectors>` expects.
47
+ SECTOR_BYTES = 512
48
+
49
+ # Signals whose default action would terminate the process before `ensure`
50
+ # blocks normally run; we trap them so teardown still fires.
51
+ TRAPPED_SIGNALS = %w[INT TERM].freeze
52
+
53
+ # Process-wide registry of live workspaces, swept by the `at_exit`/signal
54
+ # safety net so a crash or Ctrl-C cannot leak a mounted RAM disk. Guarded by
55
+ # a mutex because signal handlers can run concurrently with normal teardown.
56
+ @registry = []
57
+ @registry_mutex = Mutex.new
58
+ @safety_net_installed = false
59
+
60
+ class << self
61
+ attr_reader :registry, :registry_mutex
62
+
63
+ # Provision a RAM-backed scratch file, yield its path, and guarantee
64
+ # teardown when the block returns or raises.
65
+ #
66
+ # @param basename [String] hint for the scratch file name (no secret data).
67
+ # @yieldparam path [String] absolute path to the RAM-backed file.
68
+ # @yieldreturn [Object] whatever the block returns is returned to caller.
69
+ # @return [Object] the block's return value.
70
+ # @raise [RKSeal::WorkspaceError] if a RAM-backed medium cannot be
71
+ # provisioned or mounted (never falls back to plain on-disk temp).
72
+ def with(basename: "rkseal")
73
+ workspace = new(basename: basename)
74
+ path = workspace.provision
75
+ yield path
76
+ ensure
77
+ workspace&.teardown
78
+ end
79
+
80
+ # Register a workspace in the process-wide safety net and lazily install
81
+ # the `at_exit` hook and signal traps on first use.
82
+ #
83
+ # @param workspace [SecureWorkspace]
84
+ # @return [void]
85
+ def register(workspace)
86
+ @registry_mutex.synchronize do
87
+ install_safety_net unless @safety_net_installed
88
+ @registry << workspace unless @registry.include?(workspace)
89
+ end
90
+ end
91
+
92
+ # Drop a workspace from the safety net once it has torn itself down.
93
+ #
94
+ # @param workspace [SecureWorkspace]
95
+ # @return [void]
96
+ def unregister(workspace)
97
+ @registry_mutex.synchronize { @registry.delete(workspace) }
98
+ end
99
+
100
+ private
101
+
102
+ # Wire up the crash-safety net exactly once: an `at_exit` hook for normal
103
+ # and exception exits, plus traps for INT/TERM that tear everything down
104
+ # and then re-raise the default behaviour so the process still dies.
105
+ def install_safety_net
106
+ at_exit { sweep_registry }
107
+
108
+ TRAPPED_SIGNALS.each do |signal|
109
+ previous = Signal.trap(signal) { handle_signal(signal) }
110
+ previous_handlers[signal] = previous
111
+ end
112
+
113
+ @safety_net_installed = true
114
+ end
115
+
116
+ # Tear down every still-live workspace. Best-effort: never raises, so it is
117
+ # safe to run from `at_exit` and signal contexts.
118
+ def sweep_registry
119
+ @registry.dup.each(&:teardown)
120
+ end
121
+
122
+ # Saved prior signal handlers, so trapping INT/TERM chains to whatever was
123
+ # installed before instead of swallowing the signal.
124
+ def previous_handlers
125
+ @previous_handlers ||= {}
126
+ end
127
+
128
+ # Signal handler: shred all workspaces, restore the previous disposition,
129
+ # and re-raise so the process terminates as the user expects.
130
+ def handle_signal(signal)
131
+ sweep_registry
132
+ restore_default(signal)
133
+ end
134
+
135
+ # Restore a signal to its previously-installed handler (or DEFAULT) and
136
+ # re-send it to ourselves so termination proceeds.
137
+ def restore_default(signal)
138
+ previous = previous_handlers[signal]
139
+ Signal.trap(signal, previous || "DEFAULT")
140
+ Process.kill(signal, Process.pid)
141
+ end
142
+ end
143
+
144
+ # @param basename [String] hint for the scratch file name (no secret data).
145
+ def initialize(basename: "rkseal")
146
+ @basename = sanitize_basename(basename)
147
+ @medium = build_medium
148
+ @path = nil
149
+ end
150
+
151
+ # Provision the RAM-backed medium and return the usable scratch path. The
152
+ # caller is then responsible for invoking {#teardown} (prefer the
153
+ # block-scoped {.with} which does this automatically).
154
+ #
155
+ # @return [String] absolute path to the RAM-backed file.
156
+ # @raise [RKSeal::WorkspaceError] if provisioning/mounting fails.
157
+ def provision
158
+ return @path if @path
159
+
160
+ # Register in the crash-safety net BEFORE touching any RAM medium, so a
161
+ # SIGINT/SIGTERM landing mid-provision -- e.g. after `hdiutil attach`
162
+ # succeeds but during `newfs_hfs`/`mount` -- still sweeps this workspace
163
+ # and detaches the attached device. The sweep would otherwise miss a
164
+ # half-provisioned workspace and leak an orphaned RAM device. Teardown is
165
+ # idempotent and tolerates any partial state, so early registration is
166
+ # safe and `unregister` on the rescue/teardown path keeps it accurate.
167
+ self.class.register(self)
168
+
169
+ directory = @medium.provision
170
+ @path = File.join(directory, "#{@basename}-#{SecureRandom.hex(8)}")
171
+ create_scratch_file(@path)
172
+ @path
173
+ rescue WorkspaceError
174
+ teardown
175
+ raise
176
+ rescue StandardError => e
177
+ teardown
178
+ raise WorkspaceError, "failed to provision RAM-backed workspace: #{e.message}"
179
+ end
180
+
181
+ # Best-effort shred + unlink the scratch file and detach/teardown any RAM
182
+ # disk. Idempotent and must not raise on a partially-provisioned workspace
183
+ # (it runs from `ensure`/signal paths). Logs nothing sensitive.
184
+ #
185
+ # @return [void]
186
+ def teardown
187
+ shred_and_unlink(@path)
188
+ @path = nil
189
+ @medium.teardown
190
+ self.class.unregister(self)
191
+ nil
192
+ rescue StandardError
193
+ # Teardown is a safety net and must never raise. We have already nulled
194
+ # @path so a retry is harmless; the RAM medium's own teardown retries a
195
+ # transiently-busy detach internally.
196
+ nil
197
+ end
198
+
199
+ private
200
+
201
+ attr_reader :basename
202
+
203
+ # Choose the RAM-backed medium for the current OS. Kept as a thin dispatch
204
+ # over {#os_family} so the selection logic is unit-testable in isolation.
205
+ def build_medium
206
+ case os_family
207
+ when :linux then LinuxMedium.new
208
+ when :macos then MacosMedium.new(bytes: RAM_DISK_BYTES)
209
+ else
210
+ raise WorkspaceError,
211
+ "no RAM-backed scratch medium for this platform " \
212
+ "(#{RbConfig::CONFIG["host_os"]}); refusing on-disk fallback"
213
+ end
214
+ end
215
+
216
+ # Classify the host OS from RbConfig. Returns :linux, :macos, or
217
+ # :unsupported. Isolated so specs can drive each branch deterministically.
218
+ def os_family
219
+ host = RbConfig::CONFIG["host_os"]
220
+ case host
221
+ when /linux/i then :linux
222
+ when /darwin|mac os/i then :macos
223
+ else :unsupported
224
+ end
225
+ end
226
+
227
+ # Strip anything that is not a safe filename fragment; the basename is a
228
+ # hint, never trusted input, and must not let a caller escape the directory.
229
+ def sanitize_basename(value)
230
+ cleaned = value.to_s.gsub(/[^A-Za-z0-9_.-]/, "_")
231
+ cleaned.empty? ? "rkseal" : cleaned
232
+ end
233
+
234
+ # Create the scratch file atomically with 0600 permissions. Using the
235
+ # explicit mode on open closes the brief window an open-then-chmod would
236
+ # leave the file world-readable.
237
+ def create_scratch_file(path)
238
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, FILE_MODE) { |file| file }
239
+ end
240
+
241
+ # Overwrite the file contents with random bytes before unlinking, so the
242
+ # plaintext does not linger in freed RAM pages, then remove it. Best-effort:
243
+ # the file living on RAM-backed storage means even a skipped shred never
244
+ # reaches persistent disk.
245
+ def shred_and_unlink(path)
246
+ return if path.nil? || !File.exist?(path)
247
+
248
+ overwrite_with_random(path)
249
+ File.unlink(path)
250
+ rescue StandardError
251
+ nil
252
+ end
253
+
254
+ def overwrite_with_random(path)
255
+ size = File.size(path)
256
+ File.open(path, File::WRONLY) do |file|
257
+ file.write(SecureRandom.random_bytes(size)) if size.positive?
258
+ file.flush
259
+ file.fsync
260
+ end
261
+ rescue StandardError
262
+ nil
263
+ end
264
+
265
+ # RAM-backed medium for Linux: a 0700 directory on an existing tmpfs mount
266
+ # (`/dev/shm` or `$XDG_RUNTIME_DIR`). tmpfs is already RAM, so there is
267
+ # nothing to attach or detach -- provision makes a private subdirectory and
268
+ # teardown removes it.
269
+ class LinuxMedium
270
+ # tmpfs mount points to try, in order of preference.
271
+ CANDIDATES = ["/dev/shm", ENV.fetch("XDG_RUNTIME_DIR", nil)].freeze
272
+
273
+ def initialize
274
+ @dir = nil
275
+ end
276
+
277
+ # @return [String] absolute path to a fresh 0700 scratch directory.
278
+ # @raise [RKSeal::WorkspaceError] if no tmpfs mount is writable.
279
+ def provision
280
+ base = CANDIDATES.compact.find { |candidate| usable?(candidate) }
281
+ unless base
282
+ raise WorkspaceError,
283
+ "no writable tmpfs mount (/dev/shm or $XDG_RUNTIME_DIR) for the scratch buffer"
284
+ end
285
+
286
+ @dir = File.join(base, "rkseal-#{SecureRandom.hex(8)}")
287
+ FileUtils.mkdir(@dir, mode: DIR_MODE)
288
+ @dir
289
+ end
290
+
291
+ # @return [void]
292
+ def teardown
293
+ return if @dir.nil?
294
+
295
+ FileUtils.remove_entry_secure(@dir) if File.directory?(@dir)
296
+ @dir = nil
297
+ rescue StandardError
298
+ nil
299
+ end
300
+
301
+ private
302
+
303
+ def usable?(path)
304
+ File.directory?(path) && File.writable?(path)
305
+ end
306
+ end
307
+
308
+ # RAM-backed medium for macOS: an ephemeral `hdiutil`-backed RAM disk.
309
+ #
310
+ # macOS has no tmpfs/`/dev/shm`, so the only way to keep plaintext off
311
+ # persistent disk is a RAM disk:
312
+ # 1. `hdiutil attach -nomount ram://<sectors>` allocates RAM and returns a
313
+ # raw device node (e.g. /dev/disk7) without mounting it.
314
+ # 2. `newfs_hfs -v <volname> <device>` lays down a tiny HFS+ filesystem.
315
+ # 3. mount it under a private 0700 directory in $TMPDIR (the *mount point*
316
+ # lives on disk but is empty; the *data* lives only on the RAM device).
317
+ # Teardown unmounts and `hdiutil detach`es the device (with a short retry in
318
+ # case it is transiently busy), then removes the empty mount point.
319
+ class MacosMedium
320
+ # How many times to retry a transiently-busy `hdiutil detach`.
321
+ DETACH_ATTEMPTS = 5
322
+ # Backoff between detach retries, in seconds.
323
+ DETACH_BACKOFF = 0.2
324
+
325
+ # @param bytes [Integer] RAM disk size; rounded up to whole sectors.
326
+ def initialize(bytes:)
327
+ @sectors = (bytes.to_f / SECTOR_BYTES).ceil
328
+ @device = nil
329
+ @mount_point = nil
330
+ end
331
+
332
+ # @return [String] absolute path to the mounted RAM disk root.
333
+ # @raise [RKSeal::WorkspaceError] if attach/format/mount fails.
334
+ def provision
335
+ @device = attach_ram_device
336
+ format_device(@device)
337
+ @mount_point = make_mount_point
338
+ mount(@device, @mount_point)
339
+ @mount_point
340
+ end
341
+
342
+ # @return [void]
343
+ def teardown
344
+ unmount(@mount_point) if @mount_point
345
+ detach_with_retry(@device) if @device
346
+ remove_mount_point(@mount_point) if @mount_point
347
+ @device = nil
348
+ @mount_point = nil
349
+ rescue StandardError
350
+ nil
351
+ end
352
+
353
+ private
354
+
355
+ # `hdiutil attach -nomount ram://<sectors>` prints the new device node on
356
+ # stdout. We capture it and reject anything that does not look like a
357
+ # /dev/disk path so a malformed result never reaches `detach`.
358
+ def attach_ram_device
359
+ out, status = run("hdiutil", "attach", "-nomount", "ram://#{@sectors}")
360
+ device = out.to_s.strip.split.first
361
+ unless status&.success? && device&.start_with?("/dev/")
362
+ raise WorkspaceError, "hdiutil could not attach a RAM disk (status #{status&.exitstatus})"
363
+ end
364
+
365
+ device
366
+ end
367
+
368
+ def format_device(device)
369
+ _out, status = run("newfs_hfs", "-v", "rkseal", device)
370
+ return if status&.success?
371
+
372
+ # Leave nothing attached if formatting failed.
373
+ detach_with_retry(device)
374
+ @device = nil
375
+ raise WorkspaceError,
376
+ "newfs_hfs could not format the RAM disk (status #{status&.exitstatus})"
377
+ end
378
+
379
+ def make_mount_point
380
+ dir = File.join(Dir.tmpdir, "rkseal-mnt-#{SecureRandom.hex(8)}")
381
+ FileUtils.mkdir(dir, mode: DIR_MODE)
382
+ dir
383
+ end
384
+
385
+ # Mount the freshly-formatted device read-write at our private mount point.
386
+ def mount(device, mount_point)
387
+ _out, status = run("mount", "-t", "hfs", device, mount_point)
388
+ return if status&.success?
389
+
390
+ raise WorkspaceError, "could not mount the RAM disk (status #{status&.exitstatus})"
391
+ end
392
+
393
+ def unmount(mount_point)
394
+ run("umount", mount_point)
395
+ rescue StandardError
396
+ nil
397
+ end
398
+
399
+ # `hdiutil detach` can fail with EBUSY if the volume was only just
400
+ # unmounted; retry a few times before giving up. Never raises.
401
+ def detach_with_retry(device)
402
+ detached = DETACH_ATTEMPTS.times.any? do |attempt|
403
+ _out, status = run("hdiutil", "detach", device)
404
+ break true if status&.success?
405
+
406
+ sleep(DETACH_BACKOFF) unless attempt == DETACH_ATTEMPTS - 1
407
+ false
408
+ end
409
+ # Last resort: force detach. Still best-effort; we do not raise from
410
+ # teardown.
411
+ run("hdiutil", "detach", "-force", device) unless detached
412
+ rescue StandardError
413
+ nil
414
+ end
415
+
416
+ def remove_mount_point(mount_point)
417
+ FileUtils.remove_entry(mount_point) if File.directory?(mount_point)
418
+ rescue StandardError
419
+ nil
420
+ end
421
+
422
+ # Run an external command with no shell, capturing stdout. Returns
423
+ # [stdout, Process::Status]. Stderr is sent to /dev/null -- it could echo a
424
+ # device path but never secret contents, and we surface our own messages.
425
+ def run(*argv)
426
+ out, _err, status = Open3.capture3(*argv)
427
+ [out, status]
428
+ end
429
+ end
430
+ end
431
+ # rubocop:enable Metrics/ClassLength
432
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RKSeal
4
+ VERSION = "0.1.0"
5
+ end
data/lib/rkseal.rb ADDED
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rkseal/version"
4
+
5
+ # Top-level namespace for the rkseal gem.
6
+ #
7
+ # rkseal wraps the `kubeseal` CLI to create and edit Kubernetes SealedSecrets
8
+ # interactively via `$EDITOR`, in the spirit of `knife vault create/edit`.
9
+ #
10
+ # == Layer map (one file per layer; each is independently testable/mockable)
11
+ #
12
+ # Foundation:
13
+ # RKSeal::Errors -- error hierarchy for fail-fast behavior
14
+ # (errors.rb)
15
+ #
16
+ # Domain:
17
+ # RKSeal::Secret -- build/parse the k8s Secret manifest, base64
18
+ # encode/decode, strip runtime metadata, convert
19
+ # between cluster JSON and the edit buffer
20
+ # (secret.rb)
21
+ # RKSeal::SealedSecret -- read a local SealedSecret's keys/scope/type and
22
+ # render the redacted `edit --local` buffer
23
+ # (sealed_secret.rb)
24
+ #
25
+ # External-binary adapters (shell out; stubbed in unit tests):
26
+ # RKSeal::Kubeseal -- adapter over `kubeseal` (seal/fetch_cert/
27
+ # merge_into/re_encrypt); owns scope/cert/
28
+ # controller flags (kubeseal.rb)
29
+ # RKSeal::Kubectl -- adapter over `kubectl` (get_secret/apply/
30
+ # current_context) (kubectl.rb)
31
+ # RKSeal::Editor -- launch `$EDITOR` on a buffer, return edited
32
+ # content (editor.rb)
33
+ #
34
+ # Environment / safety:
35
+ # RKSeal::SecureWorkspace -- per-OS RAM-backed scratch path with guaranteed
36
+ # teardown (secure_workspace.rb)
37
+ # RKSeal::ContextGuard -- enforce which kube context deploys are allowed
38
+ # against (context_guard.rb)
39
+ #
40
+ # Orchestration:
41
+ # RKSeal::Commands::Result -- shared command-outcome value object
42
+ # (commands/result.rb)
43
+ # RKSeal::Commands::Create -- the `create` flow (commands/create.rb)
44
+ # RKSeal::Commands::Edit -- the `edit` flow (commands/edit.rb)
45
+ # RKSeal::Commands::EditLocal -- the offline `edit --local` flow
46
+ # (commands/edit_local.rb)
47
+ # RKSeal::Commands::Reencrypt -- the `reencrypt` flow (commands/reencrypt.rb)
48
+ # RKSeal::Commands::Validate -- the `validate` flow (commands/validate.rb)
49
+ # RKSeal::Commands::View -- the `view` flow (commands/view.rb)
50
+ # RKSeal::Commands::List -- the `list` flow (commands/list.rb)
51
+ # RKSeal::CLI -- Thor command parsing & dispatch (cli.rb)
52
+ #
53
+ # == Require layout
54
+ #
55
+ # Requires are listed explicitly and ordered from leaves to roots so the
56
+ # dependency graph loads without surprises (errors and the domain model first,
57
+ # adapters and environment helpers next, orchestration last). Each layer lives
58
+ # in exactly one file, so the three implementation agents edit disjoint files
59
+ # and never need to co-edit this one. Adding a brand-new layer is the only
60
+ # reason to touch this file again.
61
+ module RKSeal
62
+ end
63
+
64
+ # Foundation.
65
+ require_relative "rkseal/errors"
66
+
67
+ # Domain models.
68
+ require_relative "rkseal/secret"
69
+ require_relative "rkseal/sealed_secret"
70
+
71
+ # External-binary adapters.
72
+ require_relative "rkseal/kubeseal"
73
+ require_relative "rkseal/kubectl"
74
+ require_relative "rkseal/editor"
75
+
76
+ # Environment / safety helpers.
77
+ require_relative "rkseal/secure_workspace"
78
+ require_relative "rkseal/context_guard"
79
+
80
+ # Orchestration (commands depend on everything above; CLI depends on commands).
81
+ require_relative "rkseal/commands/result"
82
+ require_relative "rkseal/commands/create"
83
+ require_relative "rkseal/commands/edit"
84
+ require_relative "rkseal/commands/edit_local"
85
+ require_relative "rkseal/commands/reencrypt"
86
+ require_relative "rkseal/commands/validate"
87
+ require_relative "rkseal/commands/view"
88
+ require_relative "rkseal/commands/list"
89
+ require_relative "rkseal/cli"
data/rkseal.gemspec ADDED
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/rkseal/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "rkseal"
7
+ spec.version = RKSeal::VERSION
8
+ spec.authors = ["Piotr Wojcieszonek"]
9
+ spec.email = ["piotr@wojcieszonek.pl"]
10
+
11
+ spec.summary = "Interactively create and edit Kubernetes SealedSecrets via $EDITOR."
12
+ spec.description = <<~DESC
13
+ rkseal wraps the kubeseal CLI to author and edit Kubernetes SealedSecrets.
14
+ The plaintext Secret manifest is edited in $EDITOR on a RAM-backed buffer
15
+ that never touches persistent disk, then sealed with the controller's public key.
16
+ Deploys to the cluster are explicit opt-in only and guarded by the active kube context.
17
+ DESC
18
+ spec.homepage = "https://github.com/pwojcieszonek/rkseal"
19
+ spec.license = "MIT"
20
+ spec.required_ruby_version = ">= 4.0.0"
21
+
22
+ spec.metadata["homepage_uri"] = spec.homepage
23
+ spec.metadata["source_code_uri"] = spec.homepage
24
+ spec.metadata["rubygems_mfa_required"] = "true"
25
+
26
+ # Files are listed explicitly to avoid depending on a git index (the gem may be
27
+ # built from a non-git export). Keep this in sync with the project layout.
28
+ spec.files = Dir[
29
+ "lib/**/*.rb",
30
+ "exe/*",
31
+ "README.md",
32
+ "LICENSE.txt",
33
+ "rkseal.gemspec"
34
+ ]
35
+ spec.bindir = "exe"
36
+ spec.executables = ["rkseal"]
37
+ spec.require_paths = ["lib"]
38
+
39
+ # Runtime dependency: the CLI framework.
40
+ spec.add_dependency "thor", "~> 1.3"
41
+
42
+ # base64 left the default gems in Ruby 3.4; on 4.0.2 it is no longer on the
43
+ # load path under `bundle exec` unless declared. RKSeal::Secret requires it
44
+ # for the base64 <-> plaintext data conversions, so it must be a runtime dep.
45
+ # (open3, also required by the adapters, is still a bundled default gem on
46
+ # 4.0.2 and loads cleanly under bundle exec, so it is intentionally not pinned.)
47
+ spec.add_dependency "base64", "~> 0.2"
48
+
49
+ # Development dependencies. Versions are intentionally left as compatible ranges
50
+ # rather than hard pins until the toolchain is proven on Ruby 4.0.2.
51
+ spec.add_development_dependency "rspec", "~> 3.13"
52
+ spec.add_development_dependency "rubocop", "~> 1.60"
53
+ end