localvault 1.6.2 → 1.8.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 +4 -4
- data/README.md +39 -10
- data/bin/localvault +2 -1
- data/lib/localvault/cli/error_presenter.rb +177 -0
- data/lib/localvault/cli.rb +266 -36
- data/lib/localvault/env_projection.rb +138 -0
- data/lib/localvault/group_catalog.rb +67 -0
- data/lib/localvault/input_validation.rb +80 -0
- data/lib/localvault/key_lookup.rb +30 -0
- data/lib/localvault/mcp/exec_command_builder.rb +77 -0
- data/lib/localvault/mcp/server.rb +12 -69
- data/lib/localvault/mcp/tools.rb +172 -16
- data/lib/localvault/session_cache.rb +29 -3
- data/lib/localvault/vault.rb +20 -72
- data/lib/localvault/vault_resolver.rb +92 -0
- data/lib/localvault/version.rb +1 -1
- data/lib/localvault.rb +3 -0
- metadata +8 -1
data/lib/localvault/cli.rb
CHANGED
|
@@ -2,10 +2,96 @@ require "thor"
|
|
|
2
2
|
require "io/console"
|
|
3
3
|
require "base64"
|
|
4
4
|
require "lipgloss"
|
|
5
|
+
require_relative "env_projection"
|
|
6
|
+
require_relative "key_lookup"
|
|
5
7
|
require_relative "session_cache"
|
|
8
|
+
require_relative "vault_resolver"
|
|
9
|
+
require_relative "group_catalog"
|
|
6
10
|
|
|
7
11
|
module LocalVault
|
|
8
12
|
class CLI < Thor
|
|
13
|
+
USAGE_EXIT_STATUS = 1
|
|
14
|
+
GROUP_ALL_SENTINEL = "\0localvault-all-groups"
|
|
15
|
+
GROUP_OFF_SENTINEL = "\0localvault-groups-off"
|
|
16
|
+
CommandStatus = Data.define(:code) do
|
|
17
|
+
def self.ok
|
|
18
|
+
new(0)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.error
|
|
22
|
+
new(1)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
class GroupSaveError < Thor::Error
|
|
26
|
+
attr_reader :kind, :candidates
|
|
27
|
+
|
|
28
|
+
def initialize(kind, candidates: [])
|
|
29
|
+
@kind = kind
|
|
30
|
+
@candidates = candidates
|
|
31
|
+
super("group save failed")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def exit_status
|
|
35
|
+
1
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
class GroupSelectionError < Thor::Error
|
|
39
|
+
attr_reader :kind, :query, :candidates
|
|
40
|
+
|
|
41
|
+
def initialize(kind, query:, candidates: [])
|
|
42
|
+
@kind = kind
|
|
43
|
+
@query = query
|
|
44
|
+
@candidates = candidates
|
|
45
|
+
super("group selection failed")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def exit_status
|
|
49
|
+
1
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.start(given_args = ARGV, config = {})
|
|
54
|
+
require_relative "cli/error_presenter"
|
|
55
|
+
config[:shell] ||= Thor::Base.shell.new
|
|
56
|
+
result = dispatch(nil, normalize_legacy_group_option(given_args.dup), nil, config)
|
|
57
|
+
result.is_a?(CommandStatus) ? result.code : 0
|
|
58
|
+
rescue Thor::Error => error
|
|
59
|
+
ErrorPresenter.new(self, given_args).render(error)
|
|
60
|
+
error.respond_to?(:exit_status) ? error.exit_status : USAGE_EXIT_STATUS
|
|
61
|
+
rescue Errno::EPIPE
|
|
62
|
+
0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.normalize_legacy_group_option(arguments)
|
|
66
|
+
command = arguments.first
|
|
67
|
+
matches = all_commands.keys.select { |name| name.start_with?(command.to_s) }
|
|
68
|
+
return arguments unless command == "show" || matches == ["show"]
|
|
69
|
+
|
|
70
|
+
normalized = []
|
|
71
|
+
index = 0
|
|
72
|
+
while index < arguments.length
|
|
73
|
+
argument = arguments[index]
|
|
74
|
+
if argument == "--"
|
|
75
|
+
normalized.concat(arguments[index..])
|
|
76
|
+
break
|
|
77
|
+
end
|
|
78
|
+
if argument == "--group" && arguments[index + 1]&.match?(/\A(?:true|false|t|f)\z/i)
|
|
79
|
+
enabled = arguments[index + 1].match?(/\A(?:true|t)\z/i)
|
|
80
|
+
normalized << "--group=#{enabled ? GROUP_ALL_SENTINEL : GROUP_OFF_SENTINEL}"
|
|
81
|
+
index += 2
|
|
82
|
+
next
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
case argument
|
|
86
|
+
when /\A--group=(?:true|t)\z/i then normalized << "--group=#{GROUP_ALL_SENTINEL}"
|
|
87
|
+
when /\A--group=(?:false|f)\z/i, "--no-group", "--skip-group" then normalized << "--group=#{GROUP_OFF_SENTINEL}"
|
|
88
|
+
else normalized << argument
|
|
89
|
+
end
|
|
90
|
+
index += 1
|
|
91
|
+
end
|
|
92
|
+
normalized
|
|
93
|
+
end
|
|
94
|
+
|
|
9
95
|
class_option :vault, aliases: "-v", type: :string, desc: "Vault name"
|
|
10
96
|
|
|
11
97
|
def self.help(shell, subcommand = false)
|
|
@@ -20,8 +106,10 @@ module LocalVault
|
|
|
20
106
|
shell.say ""
|
|
21
107
|
shell.say "SECRETS"
|
|
22
108
|
shell.say " localvault set KEY VALUE Store a secret"
|
|
109
|
+
shell.say " localvault set --group G K V Store a secret inside group G"
|
|
23
110
|
shell.say " localvault get KEY Retrieve a secret"
|
|
24
111
|
shell.say " localvault show Display all secrets (masked by default)"
|
|
112
|
+
shell.say " localvault groups [QUERY] List or search stored groups (names only)"
|
|
25
113
|
shell.say " localvault list List secret key names"
|
|
26
114
|
shell.say " localvault delete KEY Remove a secret"
|
|
27
115
|
shell.say " localvault import FILE Bulk-import from .env / .json / .yml"
|
|
@@ -68,6 +156,8 @@ module LocalVault
|
|
|
68
156
|
shell.say "AI / MCP"
|
|
69
157
|
shell.say " localvault install-mcp Configure MCP server in your AI tool"
|
|
70
158
|
shell.say " localvault mcp Start MCP server (stdio)"
|
|
159
|
+
shell.say " localvault mcp --check Check setup and active-vault readiness"
|
|
160
|
+
shell.say " Agents: list names, then use localvault_build_exec for safe injection"
|
|
71
161
|
shell.say ""
|
|
72
162
|
shell.say "LEGACY SHARING (pre-v1.2 direct share, still works as fallback)"
|
|
73
163
|
shell.say " localvault keygen Generate X25519 keypair (same as `keys generate`)"
|
|
@@ -120,14 +210,38 @@ module LocalVault
|
|
|
120
210
|
\x05 localvault set platepose.SECRET_KEY_BASE abc123 -v intellectaco
|
|
121
211
|
\x05 localvault set inventlist.STRIPE_KEY sk_live_abc123 -v intellectaco
|
|
122
212
|
|
|
213
|
+
GUIDED GROUP SAVE (same storage, easier to discover):
|
|
214
|
+
\x05 localvault set --group GROUP KEY VALUE
|
|
215
|
+
\x05 localvault set GROUP.KEY VALUE
|
|
216
|
+
\x05 localvault groups [QUERY]
|
|
217
|
+
|
|
123
218
|
The dot separates project from key name. One vault can hold many projects.
|
|
124
219
|
Use `localvault show -p platepose -v vault` to view a single project.
|
|
125
220
|
Use `localvault import` to bulk-load from a .env, .json, or .yml file.
|
|
126
221
|
DESC
|
|
222
|
+
method_option :group, type: :string, desc: "Store KEY and VALUE inside this named group"
|
|
127
223
|
def set(key, value)
|
|
128
224
|
vault = open_vault!
|
|
129
|
-
|
|
130
|
-
|
|
225
|
+
if options[:group]
|
|
226
|
+
group = canonical_group_name(vault, options[:group])
|
|
227
|
+
validate_group_segment!(group)
|
|
228
|
+
validate_group_segment!(key)
|
|
229
|
+
raise GroupSaveError, :collision if vault.all.key?(group) && !vault.all[group].is_a?(Hash)
|
|
230
|
+
vault.set("#{group}.#{key}", value)
|
|
231
|
+
$stdout.puts "Set #{key} in group `#{group}` in vault `#{vault.name}`."
|
|
232
|
+
$stdout.puts
|
|
233
|
+
$stdout.puts "Stored as:"
|
|
234
|
+
$stdout.puts " #{group}.#{key}"
|
|
235
|
+
else
|
|
236
|
+
vault.set(key, value)
|
|
237
|
+
$stdout.puts "Set #{key} in vault '#{vault.name}'"
|
|
238
|
+
end
|
|
239
|
+
rescue Vault::InvalidKeyName => e
|
|
240
|
+
raise GroupSaveError, :invalid if options[:group]
|
|
241
|
+
abort_with e.message
|
|
242
|
+
rescue RuntimeError => e
|
|
243
|
+
raise GroupSaveError, :collision if options[:group]
|
|
244
|
+
abort_with e.message
|
|
131
245
|
end
|
|
132
246
|
|
|
133
247
|
desc "get KEY", "Retrieve a secret value by key"
|
|
@@ -146,28 +260,18 @@ module LocalVault
|
|
|
146
260
|
DESC
|
|
147
261
|
def get(key)
|
|
148
262
|
vault = open_vault!
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
elsif matches.size > 1
|
|
161
|
-
$stderr.puts "Error: Multiple keys match '#{key}'. Be more specific:"
|
|
162
|
-
matches.sort.each { |k| $stderr.puts " #{k}" }
|
|
163
|
-
return
|
|
164
|
-
else
|
|
165
|
-
abort_with "Key '#{key}' not found in vault '#{vault.name}'"
|
|
166
|
-
return
|
|
167
|
-
end
|
|
263
|
+
lookup = KeyLookup.lookup(vault, key)
|
|
264
|
+
|
|
265
|
+
if lookup.exact?
|
|
266
|
+
$stdout.puts lookup.value
|
|
267
|
+
elsif lookup.single_match?
|
|
268
|
+
$stdout.puts vault.get(lookup.matches.first)
|
|
269
|
+
elsif lookup.multiple_matches?
|
|
270
|
+
$stderr.puts "Error: Multiple keys match '#{key}'. Be more specific:"
|
|
271
|
+
lookup.matches.each { |k| $stderr.puts " #{k}" }
|
|
272
|
+
else
|
|
273
|
+
abort_with "Key '#{key}' not found in vault '#{vault.name}'"
|
|
168
274
|
end
|
|
169
|
-
|
|
170
|
-
$stdout.puts value
|
|
171
275
|
end
|
|
172
276
|
|
|
173
277
|
desc "list", "List all secret keys in the vault"
|
|
@@ -192,6 +296,30 @@ module LocalVault
|
|
|
192
296
|
vault.list.each { |key| $stdout.puts key }
|
|
193
297
|
end
|
|
194
298
|
|
|
299
|
+
desc "groups [QUERY]", "List or search stored secret groups without revealing values"
|
|
300
|
+
long_desc <<~DESC
|
|
301
|
+
Discover dot-notation namespaces and flat-key prefix groups.
|
|
302
|
+
|
|
303
|
+
\x05 localvault groups
|
|
304
|
+
\x05 localvault groups str
|
|
305
|
+
\x05 localvault show --group STRIPE
|
|
306
|
+
\x05 localvault set --group STRIPE API_KEY VALUE
|
|
307
|
+
DESC
|
|
308
|
+
def groups(query = nil)
|
|
309
|
+
vault = open_vault!
|
|
310
|
+
matches = GroupCatalog.new(vault.all).search(query)
|
|
311
|
+
if matches.empty?
|
|
312
|
+
$stdout.puts "No groups match #{query}"
|
|
313
|
+
return
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
heading = query ? "Groups matching `#{query}`" : "Groups"
|
|
317
|
+
$stdout.puts "#{heading} in vault `#{vault.name}`:"
|
|
318
|
+
$stdout.puts
|
|
319
|
+
$stdout.printf(" %-20s %-6s %s\n", "Group", "Keys", "Kind")
|
|
320
|
+
matches.each { |group| $stdout.printf(" %-20s %-6d %s\n", group.name, group.count, group.kind) }
|
|
321
|
+
end
|
|
322
|
+
|
|
195
323
|
desc "delete KEY", "Remove a secret or entire project group"
|
|
196
324
|
long_desc <<~DESC
|
|
197
325
|
Delete a single key or an entire project group.
|
|
@@ -231,13 +359,25 @@ module LocalVault
|
|
|
231
359
|
\x05 eval $(localvault env -v intellectaco)
|
|
232
360
|
\x05 # → export PLATEPOSE__DATABASE_URL=... export INVENTLIST__DATABASE_URL=...
|
|
233
361
|
|
|
362
|
+
SCOPED EXPORTS:
|
|
363
|
+
\x05 localvault env --only AWS_IAM.*
|
|
364
|
+
\x05 localvault env --only AWS_IAM.*,AWS_SES.* --except AWS_SES.smtp_password
|
|
365
|
+
\x05 localvault env --map AWS_IAM.access_key_id=AWS_ACCESS_KEY_ID
|
|
366
|
+
\x05 localvault env --profile aws
|
|
367
|
+
|
|
234
368
|
Use `localvault exec` to inject directly into a subprocess without eval.
|
|
235
369
|
DESC
|
|
236
370
|
method_option :project, aliases: "-p", type: :string, desc: "Export only this project group (no prefix)"
|
|
371
|
+
method_option :only, type: :string, desc: "Export only exact keys or namespaces (KEY,GROUP.*)"
|
|
372
|
+
method_option :except, type: :string, desc: "Exclude exact keys or namespaces after --only"
|
|
373
|
+
method_option :map, type: :string, desc: "Map vault keys to env vars (KEY=ENV_NAME)"
|
|
374
|
+
method_option :profile, type: :string, desc: "Apply a built-in env mapping profile (aws)"
|
|
237
375
|
def env
|
|
238
376
|
vault = open_vault!
|
|
239
377
|
skip_warn = ->(k) { $stderr.puts "Warning: skipping unsafe key '#{k}'" }
|
|
240
|
-
$stdout.puts vault.export_env(
|
|
378
|
+
$stdout.puts vault.export_env(**env_projection_options(on_skip: skip_warn))
|
|
379
|
+
rescue EnvProjection::InvalidMapping, EnvProjection::UnknownProfile => e
|
|
380
|
+
abort_with e.message
|
|
241
381
|
end
|
|
242
382
|
|
|
243
383
|
desc "exec -- CMD", "Run a command with secrets injected as environment variables"
|
|
@@ -256,13 +396,24 @@ module LocalVault
|
|
|
256
396
|
TEAM VAULT — all projects (keys prefixed to avoid collisions):
|
|
257
397
|
\x05 localvault exec -v intellectaco -- your-script
|
|
258
398
|
\x05 # → PLATEPOSE__DATABASE_URL, INVENTLIST__DATABASE_URL, etc.
|
|
399
|
+
|
|
400
|
+
SCOPED INJECTION:
|
|
401
|
+
\x05 localvault exec --only AWS_IAM.* -- aws sts get-caller-identity
|
|
402
|
+
\x05 localvault exec --profile aws -- aws s3 ls
|
|
403
|
+
\x05 localvault exec --map AWS_IAM.access_key_id=AWS_ACCESS_KEY_ID -- env
|
|
259
404
|
DESC
|
|
260
405
|
method_option :project, aliases: "-p", type: :string, desc: "Inject only this project group (no prefix)"
|
|
406
|
+
method_option :only, type: :string, desc: "Inject only exact keys or namespaces (KEY,GROUP.*)"
|
|
407
|
+
method_option :except, type: :string, desc: "Exclude exact keys or namespaces after --only"
|
|
408
|
+
method_option :map, type: :string, desc: "Map vault keys to env vars (KEY=ENV_NAME)"
|
|
409
|
+
method_option :profile, type: :string, desc: "Apply a built-in env mapping profile (aws)"
|
|
261
410
|
def exec(*cmd)
|
|
262
411
|
vault = open_vault!
|
|
263
412
|
skip_warn = ->(k) { $stderr.puts "Warning: skipping unsafe key '#{k}'" }
|
|
264
|
-
env_vars = vault.env_hash(
|
|
413
|
+
env_vars = vault.env_hash(**env_projection_options(on_skip: skip_warn))
|
|
265
414
|
Kernel.exec(env_vars, *cmd)
|
|
415
|
+
rescue EnvProjection::InvalidMapping, EnvProjection::UnknownProfile => e
|
|
416
|
+
abort_with e.message
|
|
266
417
|
end
|
|
267
418
|
|
|
268
419
|
desc "vaults", "List all vaults with secret counts"
|
|
@@ -334,14 +485,15 @@ module LocalVault
|
|
|
334
485
|
\x05 localvault show -p platepose -v intellectaco # one project only
|
|
335
486
|
\x05 localvault show -p platepose -v intellectaco --reveal
|
|
336
487
|
DESC
|
|
337
|
-
method_option :group,
|
|
488
|
+
method_option :group, type: :string, lazy_default: GROUP_ALL_SENTINEL, desc: "Show all groups or one group by name"
|
|
338
489
|
method_option :reveal, type: :boolean, default: false, desc: "Show full values instead of masking"
|
|
339
490
|
method_option :project, aliases: "-p", type: :string, desc: "Show only this project group"
|
|
340
491
|
def show
|
|
341
492
|
vault = open_vault!
|
|
342
493
|
secrets = vault.all
|
|
343
494
|
|
|
344
|
-
|
|
495
|
+
named_group_query = options[:group] && ![GROUP_ALL_SENTINEL, GROUP_OFF_SENTINEL].include?(options[:group])
|
|
496
|
+
if secrets.empty? && !named_group_query
|
|
345
497
|
$stdout.puts "No secrets in vault '#{vault.name}'."
|
|
346
498
|
return
|
|
347
499
|
end
|
|
@@ -353,7 +505,17 @@ module LocalVault
|
|
|
353
505
|
return
|
|
354
506
|
end
|
|
355
507
|
render_table(group.sort.to_h, "#{vault.name}/#{options[:project]}", reveal: options[:reveal])
|
|
356
|
-
elsif options[:group]
|
|
508
|
+
elsif options[:group] && ![GROUP_ALL_SENTINEL, GROUP_OFF_SENTINEL].include?(options[:group])
|
|
509
|
+
match = GroupCatalog.new(secrets).resolve(options[:group])
|
|
510
|
+
if match.group
|
|
511
|
+
entries = match.group.entries.to_h { |entry| [entry.label, entry.value] }
|
|
512
|
+
render_table(entries, "#{vault.name}/#{match.group.name}", reveal: options[:reveal])
|
|
513
|
+
elsif match.kind == :ambiguous
|
|
514
|
+
raise GroupSelectionError.new(:ambiguous, query: options[:group], candidates: match.groups.map(&:name))
|
|
515
|
+
else
|
|
516
|
+
raise GroupSelectionError.new(:absent, query: options[:group])
|
|
517
|
+
end
|
|
518
|
+
elsif options[:group] != GROUP_OFF_SENTINEL && (options[:group] || secrets.values.any? { |v| v.is_a?(Hash) })
|
|
357
519
|
render_grouped_table(secrets, vault.name, reveal: options[:reveal])
|
|
358
520
|
else
|
|
359
521
|
render_table(secrets.sort.to_h, vault.name, reveal: options[:reveal])
|
|
@@ -452,8 +614,44 @@ module LocalVault
|
|
|
452
614
|
end
|
|
453
615
|
|
|
454
616
|
desc "mcp", "Start MCP server (stdio)"
|
|
617
|
+
long_desc <<~DESC
|
|
618
|
+
Start LocalVault's stdio MCP server for an AI client.
|
|
619
|
+
|
|
620
|
+
Install and verify:
|
|
621
|
+
\x05 localvault install-mcp [claude-code|cursor|windsurf]
|
|
622
|
+
\x05 localvault mcp --check
|
|
623
|
+
\x05 localvault show
|
|
624
|
+
|
|
625
|
+
Do not run `localvault mcp` directly to test it: stdio servers wait for
|
|
626
|
+
JSON-RPC input and therefore appear idle. Use `--check`, then restart the
|
|
627
|
+
configured AI client.
|
|
628
|
+
|
|
629
|
+
Agents should call `list_secrets`, then `localvault_build_exec` to inject
|
|
630
|
+
secrets into a subprocess. Plaintext `get_secret` requires the explicit
|
|
631
|
+
`allow_plaintext: true` acknowledgement.
|
|
632
|
+
DESC
|
|
633
|
+
method_option :check, type: :boolean, default: false, desc: "Check installation and active-vault readiness, then exit"
|
|
455
634
|
def mcp
|
|
456
|
-
|
|
635
|
+
if options[:check]
|
|
636
|
+
require_relative "mcp/tools"
|
|
637
|
+
status = VaultResolver.readiness_status(options[:vault])
|
|
638
|
+
ready = status["active_vault_unlocked"]
|
|
639
|
+
tool_names = MCP::Tools::DEFINITIONS.map { |definition| definition.fetch("name") }
|
|
640
|
+
$stdout.puts "LocalVault #{VERSION}"
|
|
641
|
+
$stdout.puts "Home: #{Config.root_path}"
|
|
642
|
+
$stdout.puts "MCP readiness: #{ready ? "ready" : "locked"}"
|
|
643
|
+
$stdout.puts "Active vault: #{status["active_vault"]} (#{status["active_vault_source"]})"
|
|
644
|
+
$stdout.puts "Vault session: #{ready ? "available" : "unlock with `localvault show`"}"
|
|
645
|
+
$stdout.puts "MCP tools: #{tool_names.join(", ")}"
|
|
646
|
+
$stdout.puts "Plaintext gate: enabled"
|
|
647
|
+
$stdout.puts "Server instructions: enabled"
|
|
648
|
+
$stdout.puts
|
|
649
|
+
$stdout.puts "Safe agent workflow: list_secrets → localvault_build_exec → run the generated command"
|
|
650
|
+
$stdout.puts "Plaintext retrieval is opt-in with allow_plaintext: true."
|
|
651
|
+
return ready ? CommandStatus.ok : CommandStatus.error
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
require_relative "mcp/server"
|
|
457
655
|
MCP::Server.new.start
|
|
458
656
|
end
|
|
459
657
|
|
|
@@ -466,8 +664,10 @@ module LocalVault
|
|
|
466
664
|
cursor Adds to ~/.cursor/mcp.json
|
|
467
665
|
windsurf Adds to ~/.codeium/windsurf/mcp_config.json
|
|
468
666
|
|
|
469
|
-
The MCP server uses
|
|
470
|
-
|
|
667
|
+
The MCP server uses the same active vault rules as the CLI: explicit vault,
|
|
668
|
+
then LOCALVAULT_VAULT, then the configured default from `localvault switch`.
|
|
669
|
+
Unlock with `localvault show` or `localvault unlock`; `localvault lock`
|
|
670
|
+
revokes access without restarting the AI tool.
|
|
471
671
|
DESC
|
|
472
672
|
def install_mcp(client = "claude-code")
|
|
473
673
|
case client.downcase
|
|
@@ -1264,7 +1464,7 @@ module LocalVault
|
|
|
1264
1464
|
end
|
|
1265
1465
|
|
|
1266
1466
|
def self.exit_on_failure?
|
|
1267
|
-
|
|
1467
|
+
false
|
|
1268
1468
|
end
|
|
1269
1469
|
|
|
1270
1470
|
no_commands do
|
|
@@ -1289,6 +1489,23 @@ module LocalVault
|
|
|
1289
1489
|
|
|
1290
1490
|
private
|
|
1291
1491
|
|
|
1492
|
+
def canonical_group_name(vault, supplied)
|
|
1493
|
+
groups = GroupCatalog.new(vault.all).groups
|
|
1494
|
+
return supplied if groups.any? { |group| group.name == supplied }
|
|
1495
|
+
|
|
1496
|
+
insensitive = groups.select { |group| group.name.casecmp?(supplied) }
|
|
1497
|
+
return insensitive.first.name if insensitive.one?
|
|
1498
|
+
raise GroupSaveError.new(:ambiguous, candidates: insensitive.map(&:name)) if insensitive.length > 1
|
|
1499
|
+
|
|
1500
|
+
supplied
|
|
1501
|
+
end
|
|
1502
|
+
|
|
1503
|
+
def validate_group_segment!(segment)
|
|
1504
|
+
unless segment.match?(Vault::KEY_SEGMENT_PATTERN) && segment.length <= 128
|
|
1505
|
+
raise GroupSaveError, :invalid
|
|
1506
|
+
end
|
|
1507
|
+
end
|
|
1508
|
+
|
|
1292
1509
|
# ── Demo data ──────────────────────────────────────────────────
|
|
1293
1510
|
DEMO_DATA = {
|
|
1294
1511
|
"default" => {
|
|
@@ -1514,7 +1731,18 @@ module LocalVault
|
|
|
1514
1731
|
end
|
|
1515
1732
|
|
|
1516
1733
|
def resolve_vault_name
|
|
1517
|
-
options[:vault]
|
|
1734
|
+
VaultResolver.active_vault_name(options[:vault]).first
|
|
1735
|
+
end
|
|
1736
|
+
|
|
1737
|
+
def env_projection_options(on_skip:)
|
|
1738
|
+
{
|
|
1739
|
+
project: options[:project],
|
|
1740
|
+
only: options[:only],
|
|
1741
|
+
except: options[:except],
|
|
1742
|
+
map: options[:map],
|
|
1743
|
+
profile: options[:profile],
|
|
1744
|
+
on_skip: on_skip
|
|
1745
|
+
}
|
|
1518
1746
|
end
|
|
1519
1747
|
|
|
1520
1748
|
def open_vault_by_name!(vault_name)
|
|
@@ -1665,9 +1893,11 @@ module LocalVault
|
|
|
1665
1893
|
def print_next_steps(client_name)
|
|
1666
1894
|
$stdout.puts ""
|
|
1667
1895
|
$stdout.puts "Next steps:"
|
|
1668
|
-
$stdout.puts " 1.
|
|
1669
|
-
$stdout.puts " 2.
|
|
1670
|
-
$stdout.puts " 3.
|
|
1896
|
+
$stdout.puts " 1. Unlock your vault once: localvault show"
|
|
1897
|
+
$stdout.puts " 2. Verify readiness: localvault mcp --check"
|
|
1898
|
+
$stdout.puts " 3. Restart #{client_name}"
|
|
1899
|
+
$stdout.puts " 4. Ask the agent to list names, then use localvault_build_exec"
|
|
1900
|
+
$stdout.puts " for process injection. Plaintext get_secret is explicit opt-in."
|
|
1671
1901
|
$stdout.puts " Switch vaults: localvault switch <vault>"
|
|
1672
1902
|
end
|
|
1673
1903
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
require_relative "input_validation"
|
|
2
|
+
|
|
3
|
+
module LocalVault
|
|
4
|
+
module EnvProjection
|
|
5
|
+
class InvalidMapping < StandardError; end
|
|
6
|
+
class UnknownProfile < StandardError; end
|
|
7
|
+
|
|
8
|
+
Entry = Struct.new(:key, :env_name, :value, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
ENV_NAME_PATTERN = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
11
|
+
|
|
12
|
+
PROFILES = {
|
|
13
|
+
"aws" => {
|
|
14
|
+
only: ["AWS_IAM.*"],
|
|
15
|
+
map: {
|
|
16
|
+
"AWS_IAM.access_key_id" => "AWS_ACCESS_KEY_ID",
|
|
17
|
+
"AWS_IAM.secret_access_key" => "AWS_SECRET_ACCESS_KEY",
|
|
18
|
+
"AWS_IAM.session_token" => "AWS_SESSION_TOKEN"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def self.entries(secrets, project: nil, only: nil, except: nil, map: nil, profile: nil, on_skip: nil)
|
|
24
|
+
profile_config = profile_config(profile)
|
|
25
|
+
selectors = parse_selectors(only) || profile_config[:only]
|
|
26
|
+
exclusions = parse_selectors(except) || []
|
|
27
|
+
mappings = profile_config[:map].merge(parse_map(map))
|
|
28
|
+
|
|
29
|
+
flatten(secrets, project: project, on_skip: on_skip)
|
|
30
|
+
.select { |entry| include_entry?(entry.key, selectors) }
|
|
31
|
+
.reject { |entry| selector_match?(entry.key, exclusions) }
|
|
32
|
+
.map { |entry| apply_mapping(entry, mappings, on_skip: on_skip) }
|
|
33
|
+
.compact
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.parse_selectors(value)
|
|
37
|
+
values = Array(value).compact.flat_map { |v| v.to_s.split(",") }
|
|
38
|
+
selectors = values.map(&:strip).reject(&:empty?)
|
|
39
|
+
selectors.each { |selector| InputValidation.selector!(selector) }
|
|
40
|
+
selectors.empty? ? nil : selectors
|
|
41
|
+
rescue InputValidation::InvalidInput => e
|
|
42
|
+
raise InvalidMapping, e.message
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.parse_map(value)
|
|
46
|
+
Array(value).compact.flat_map { |v| v.to_s.split(",") }.each_with_object({}) do |pair, hash|
|
|
47
|
+
next if pair.strip.empty?
|
|
48
|
+
|
|
49
|
+
key, env_name = pair.split("=", 2).map(&:strip)
|
|
50
|
+
raise InvalidMapping, "Invalid map '#{pair}'. Use KEY=ENV_NAME" if key.to_s.empty? || env_name.to_s.empty?
|
|
51
|
+
InputValidation.mapping!(key, env_name)
|
|
52
|
+
|
|
53
|
+
hash[key] = env_name
|
|
54
|
+
end
|
|
55
|
+
rescue InputValidation::InvalidInput => e
|
|
56
|
+
raise InvalidMapping, e.message
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.profile_config(profile)
|
|
60
|
+
return { only: nil, map: {} } if profile.nil? || profile.to_s.empty?
|
|
61
|
+
|
|
62
|
+
PROFILES.fetch(profile.to_s) do
|
|
63
|
+
raise UnknownProfile, "Unknown env profile '#{profile}'"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.flatten(secrets, project:, on_skip:)
|
|
68
|
+
if project
|
|
69
|
+
group = secrets[project]
|
|
70
|
+
return [] unless group.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
group.filter_map do |key, value|
|
|
73
|
+
if safe_env_name?(key)
|
|
74
|
+
Entry.new(key: key, env_name: key, value: value.to_s)
|
|
75
|
+
else
|
|
76
|
+
on_skip&.call(key)
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
else
|
|
81
|
+
secrets.flat_map do |key, value|
|
|
82
|
+
if value.is_a?(Hash)
|
|
83
|
+
flatten_group(key, value, on_skip: on_skip)
|
|
84
|
+
elsif safe_env_name?(key)
|
|
85
|
+
[Entry.new(key: key, env_name: key, value: value.to_s)]
|
|
86
|
+
else
|
|
87
|
+
on_skip&.call(key)
|
|
88
|
+
[]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.flatten_group(group, pairs, on_skip:)
|
|
95
|
+
unless safe_env_name?(group)
|
|
96
|
+
on_skip&.call(group)
|
|
97
|
+
return []
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
pairs.filter_map do |key, value|
|
|
101
|
+
if safe_env_name?(key)
|
|
102
|
+
Entry.new(key: "#{group}.#{key}", env_name: "#{group.upcase}__#{key}", value: value.to_s)
|
|
103
|
+
else
|
|
104
|
+
on_skip&.call("#{group}.#{key}")
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.include_entry?(key, selectors)
|
|
111
|
+
selectors.nil? || selector_match?(key, selectors)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.selector_match?(key, selectors)
|
|
115
|
+
selectors.any? do |selector|
|
|
116
|
+
if selector.end_with?(".*")
|
|
117
|
+
key.start_with?("#{selector.delete_suffix(".*")}.")
|
|
118
|
+
else
|
|
119
|
+
key == selector
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.apply_mapping(entry, mappings, on_skip:)
|
|
125
|
+
mapped_name = mappings.fetch(entry.key, entry.env_name)
|
|
126
|
+
unless safe_env_name?(mapped_name)
|
|
127
|
+
on_skip&.call(entry.key)
|
|
128
|
+
return nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
Entry.new(key: entry.key, env_name: mapped_name, value: entry.value)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.safe_env_name?(name)
|
|
135
|
+
name.is_a?(String) && name.match?(ENV_NAME_PATTERN)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module LocalVault
|
|
2
|
+
class GroupCatalog
|
|
3
|
+
Entry = Data.define(:label, :value, :source_kind)
|
|
4
|
+
Group = Data.define(:name, :kind, :entries) do
|
|
5
|
+
def count
|
|
6
|
+
entries.length
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
Match = Data.define(:kind, :query, :groups) do
|
|
10
|
+
def group
|
|
11
|
+
groups.one? ? groups.first : nil
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :groups
|
|
16
|
+
|
|
17
|
+
def initialize(secrets)
|
|
18
|
+
grouped = {}
|
|
19
|
+
|
|
20
|
+
secrets.each do |name, value|
|
|
21
|
+
if value.is_a?(Hash)
|
|
22
|
+
append_namespace(grouped, name, value)
|
|
23
|
+
elsif name.include?("_")
|
|
24
|
+
append_entry(grouped, name.split("_", 2).first, Entry.new(name, value, :prefix))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@groups = grouped.map do |name, entries|
|
|
29
|
+
kinds = entries.map(&:source_kind).uniq
|
|
30
|
+
kind = kinds.length > 1 ? :mixed : kinds.first
|
|
31
|
+
Group.new(name, kind, entries.sort_by(&:label).freeze)
|
|
32
|
+
end.sort_by { |group| [group.name.downcase, group.name] }.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def search(query = nil)
|
|
36
|
+
return groups if query.nil? || query.empty?
|
|
37
|
+
|
|
38
|
+
needle = query.downcase
|
|
39
|
+
groups.select { |group| group.name.downcase.start_with?(needle) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resolve(query)
|
|
43
|
+
exact = groups.select { |group| group.name == query }
|
|
44
|
+
return Match.new(:exact, query, exact) if exact.one?
|
|
45
|
+
|
|
46
|
+
insensitive = groups.select { |group| group.name.casecmp?(query) }
|
|
47
|
+
return Match.new(:unique, query, insensitive) if insensitive.one?
|
|
48
|
+
return Match.new(:ambiguous, query, insensitive) if insensitive.length > 1
|
|
49
|
+
|
|
50
|
+
matches = search(query)
|
|
51
|
+
kind = matches.one? ? :unique : (matches.empty? ? :absent : :ambiguous)
|
|
52
|
+
Match.new(kind, query, matches)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def append_namespace(grouped, name, values)
|
|
58
|
+
values.each do |label, value|
|
|
59
|
+
append_entry(grouped, name, Entry.new(label, value, :namespace))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def append_entry(grouped, name, entry)
|
|
64
|
+
(grouped[name] ||= []) << entry
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|