localvault 1.7.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +36 -8
- data/bin/localvault +2 -1
- data/lib/localvault/cli/error_presenter.rb +189 -0
- data/lib/localvault/cli.rb +334 -15
- data/lib/localvault/env_projection.rb +8 -1
- data/lib/localvault/group_catalog.rb +67 -0
- data/lib/localvault/input_validation.rb +80 -0
- data/lib/localvault/mcp/exec_command_builder.rb +77 -0
- data/lib/localvault/mcp/server.rb +4 -1
- data/lib/localvault/mcp/tools.rb +87 -10
- data/lib/localvault/session_cache.rb +29 -3
- data/lib/localvault/stdin_secret_input.rb +22 -0
- data/lib/localvault/vault_resolver.rb +11 -0
- data/lib/localvault/version.rb +1 -1
- data/lib/localvault.rb +1 -0
- metadata +6 -1
data/lib/localvault/cli.rb
CHANGED
|
@@ -5,10 +5,106 @@ require "lipgloss"
|
|
|
5
5
|
require_relative "env_projection"
|
|
6
6
|
require_relative "key_lookup"
|
|
7
7
|
require_relative "session_cache"
|
|
8
|
+
require_relative "stdin_secret_input"
|
|
8
9
|
require_relative "vault_resolver"
|
|
10
|
+
require_relative "group_catalog"
|
|
9
11
|
|
|
10
12
|
module LocalVault
|
|
11
13
|
class CLI < Thor
|
|
14
|
+
USAGE_EXIT_STATUS = 1
|
|
15
|
+
GROUP_ALL_SENTINEL = "\0localvault-all-groups"
|
|
16
|
+
GROUP_OFF_SENTINEL = "\0localvault-groups-off"
|
|
17
|
+
CommandStatus = Data.define(:code) do
|
|
18
|
+
def self.ok
|
|
19
|
+
new(0)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.error
|
|
23
|
+
new(1)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
class GroupSaveError < Thor::Error
|
|
27
|
+
attr_reader :kind, :candidates
|
|
28
|
+
|
|
29
|
+
def initialize(kind, candidates: [])
|
|
30
|
+
@kind = kind
|
|
31
|
+
@candidates = candidates
|
|
32
|
+
super("group save failed")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def exit_status
|
|
36
|
+
1
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
class GroupSelectionError < Thor::Error
|
|
40
|
+
attr_reader :kind, :query, :candidates
|
|
41
|
+
|
|
42
|
+
def initialize(kind, query:, candidates: [])
|
|
43
|
+
@kind = kind
|
|
44
|
+
@query = query
|
|
45
|
+
@candidates = candidates
|
|
46
|
+
super("group selection failed")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def exit_status
|
|
50
|
+
1
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
class SetValueSourceError < Thor::Error
|
|
54
|
+
attr_reader :kind
|
|
55
|
+
|
|
56
|
+
def initialize(kind, message)
|
|
57
|
+
@kind = kind
|
|
58
|
+
super(message)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def exit_status
|
|
62
|
+
1
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.start(given_args = ARGV, config = {})
|
|
67
|
+
require_relative "cli/error_presenter"
|
|
68
|
+
config[:shell] ||= Thor::Base.shell.new
|
|
69
|
+
result = dispatch(nil, normalize_legacy_group_option(given_args.dup), nil, config)
|
|
70
|
+
result.is_a?(CommandStatus) ? result.code : 0
|
|
71
|
+
rescue Thor::Error => error
|
|
72
|
+
ErrorPresenter.new(self, given_args).render(error)
|
|
73
|
+
error.respond_to?(:exit_status) ? error.exit_status : USAGE_EXIT_STATUS
|
|
74
|
+
rescue Errno::EPIPE
|
|
75
|
+
0
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.normalize_legacy_group_option(arguments)
|
|
79
|
+
command = arguments.first
|
|
80
|
+
matches = all_commands.keys.select { |name| name.start_with?(command.to_s) }
|
|
81
|
+
return arguments unless command == "show" || matches == ["show"]
|
|
82
|
+
|
|
83
|
+
normalized = []
|
|
84
|
+
index = 0
|
|
85
|
+
while index < arguments.length
|
|
86
|
+
argument = arguments[index]
|
|
87
|
+
if argument == "--"
|
|
88
|
+
normalized.concat(arguments[index..])
|
|
89
|
+
break
|
|
90
|
+
end
|
|
91
|
+
if argument == "--group" && arguments[index + 1]&.match?(/\A(?:true|false|t|f)\z/i)
|
|
92
|
+
enabled = arguments[index + 1].match?(/\A(?:true|t)\z/i)
|
|
93
|
+
normalized << "--group=#{enabled ? GROUP_ALL_SENTINEL : GROUP_OFF_SENTINEL}"
|
|
94
|
+
index += 2
|
|
95
|
+
next
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
case argument
|
|
99
|
+
when /\A--group=(?:true|t)\z/i then normalized << "--group=#{GROUP_ALL_SENTINEL}"
|
|
100
|
+
when /\A--group=(?:false|f)\z/i, "--no-group", "--skip-group" then normalized << "--group=#{GROUP_OFF_SENTINEL}"
|
|
101
|
+
else normalized << argument
|
|
102
|
+
end
|
|
103
|
+
index += 1
|
|
104
|
+
end
|
|
105
|
+
normalized
|
|
106
|
+
end
|
|
107
|
+
|
|
12
108
|
class_option :vault, aliases: "-v", type: :string, desc: "Vault name"
|
|
13
109
|
|
|
14
110
|
def self.help(shell, subcommand = false)
|
|
@@ -22,9 +118,13 @@ module LocalVault
|
|
|
22
118
|
shell.say " localvault demo Create a demo vault to explore commands"
|
|
23
119
|
shell.say ""
|
|
24
120
|
shell.say "SECRETS"
|
|
25
|
-
shell.say " localvault set KEY
|
|
121
|
+
shell.say " printf '%s' \"$SECRET\" | localvault set KEY --stdin"
|
|
122
|
+
shell.say " Store a secret without argv/history exposure"
|
|
123
|
+
shell.say " localvault set KEY VALUE Store a secret (compatibility path)"
|
|
124
|
+
shell.say " localvault set --group G K V Store a secret inside group G"
|
|
26
125
|
shell.say " localvault get KEY Retrieve a secret"
|
|
27
126
|
shell.say " localvault show Display all secrets (masked by default)"
|
|
127
|
+
shell.say " localvault groups [QUERY] List or search stored groups (names only)"
|
|
28
128
|
shell.say " localvault list List secret key names"
|
|
29
129
|
shell.say " localvault delete KEY Remove a secret"
|
|
30
130
|
shell.say " localvault import FILE Bulk-import from .env / .json / .yml"
|
|
@@ -71,6 +171,8 @@ module LocalVault
|
|
|
71
171
|
shell.say "AI / MCP"
|
|
72
172
|
shell.say " localvault install-mcp Configure MCP server in your AI tool"
|
|
73
173
|
shell.say " localvault mcp Start MCP server (stdio)"
|
|
174
|
+
shell.say " localvault mcp --check Check setup and active-vault readiness"
|
|
175
|
+
shell.say " Agents: list names, then use localvault_build_exec for safe injection"
|
|
74
176
|
shell.say ""
|
|
75
177
|
shell.say "LEGACY SHARING (pre-v1.2 direct share, still works as fallback)"
|
|
76
178
|
shell.say " localvault keygen Generate X25519 keypair (same as `keys generate`)"
|
|
@@ -82,6 +184,7 @@ module LocalVault
|
|
|
82
184
|
shell.say " localvault login --status Show current login status"
|
|
83
185
|
shell.say " localvault logout Log out"
|
|
84
186
|
shell.say " localvault version Print version"
|
|
187
|
+
shell.say " localvault doctor Check install and PATH readiness"
|
|
85
188
|
shell.say " localvault help [COMMAND] Full help for any command"
|
|
86
189
|
shell.say ""
|
|
87
190
|
end
|
|
@@ -110,10 +213,14 @@ module LocalVault
|
|
|
110
213
|
abort_with e.message
|
|
111
214
|
end
|
|
112
215
|
|
|
113
|
-
desc "set KEY VALUE", "Store a secret (supports dot-notation for nested keys)"
|
|
216
|
+
desc "set KEY [VALUE]", "Store a secret (supports dot-notation for nested keys)"
|
|
114
217
|
long_desc <<~DESC
|
|
115
218
|
Store a secret in the current vault.
|
|
116
219
|
|
|
220
|
+
SAFE INPUT (preferred):
|
|
221
|
+
\x05 printf '%s' "$SECRET" | localvault set KEY --stdin
|
|
222
|
+
\x05 printf '%s' "$SECRET" | localvault set --group GROUP KEY --stdin
|
|
223
|
+
|
|
117
224
|
FLAT KEY (simple):
|
|
118
225
|
\x05 localvault set DATABASE_URL postgres://localhost/myapp
|
|
119
226
|
\x05 localvault set STRIPE_KEY sk_live_abc123
|
|
@@ -123,14 +230,47 @@ module LocalVault
|
|
|
123
230
|
\x05 localvault set platepose.SECRET_KEY_BASE abc123 -v intellectaco
|
|
124
231
|
\x05 localvault set inventlist.STRIPE_KEY sk_live_abc123 -v intellectaco
|
|
125
232
|
|
|
233
|
+
GUIDED GROUP SAVE (same storage, easier to discover):
|
|
234
|
+
\x05 localvault set --group GROUP KEY VALUE
|
|
235
|
+
\x05 localvault set GROUP.KEY VALUE
|
|
236
|
+
\x05 localvault groups [QUERY]
|
|
237
|
+
|
|
238
|
+
Positional values may be visible in process lists and shell history.
|
|
126
239
|
The dot separates project from key name. One vault can hold many projects.
|
|
127
240
|
Use `localvault show -p platepose -v vault` to view a single project.
|
|
128
241
|
Use `localvault import` to bulk-load from a .env, .json, or .yml file.
|
|
129
242
|
DESC
|
|
130
|
-
|
|
243
|
+
method_option :group, type: :string, desc: "Store KEY and VALUE inside this named group"
|
|
244
|
+
method_option :stdin, type: :boolean, default: false, desc: "Read VALUE from stdin instead of argv"
|
|
245
|
+
def set(key, value = nil)
|
|
246
|
+
validate_secret_value_source!(value)
|
|
131
247
|
vault = open_vault!
|
|
132
|
-
|
|
133
|
-
|
|
248
|
+
if options[:group]
|
|
249
|
+
group = canonical_group_name(vault, options[:group])
|
|
250
|
+
validate_group_segment!(group)
|
|
251
|
+
validate_group_segment!(key)
|
|
252
|
+
raise GroupSaveError, :collision if vault.all.key?(group) && !vault.all[group].is_a?(Hash)
|
|
253
|
+
value = read_secret_value(value)
|
|
254
|
+
vault.set("#{group}.#{key}", value)
|
|
255
|
+
$stdout.puts "Set #{key} in group `#{group}` in vault `#{vault.name}`."
|
|
256
|
+
$stdout.puts
|
|
257
|
+
$stdout.puts "Stored as:"
|
|
258
|
+
$stdout.puts " #{group}.#{key}"
|
|
259
|
+
else
|
|
260
|
+
value = read_secret_value(value)
|
|
261
|
+
vault.set(key, value)
|
|
262
|
+
$stdout.puts "Set #{key} in vault '#{vault.name}'"
|
|
263
|
+
end
|
|
264
|
+
rescue StdinSecretInput::InteractiveInput, StdinSecretInput::InvalidEncoding => e
|
|
265
|
+
raise SetValueSourceError.new(:stdin, e.message)
|
|
266
|
+
rescue Vault::InvalidKeyName => e
|
|
267
|
+
raise GroupSaveError, :invalid if options[:group]
|
|
268
|
+
abort_with e.message
|
|
269
|
+
CommandStatus.error
|
|
270
|
+
rescue RuntimeError => e
|
|
271
|
+
raise GroupSaveError, :collision if options[:group]
|
|
272
|
+
abort_with e.message
|
|
273
|
+
CommandStatus.error
|
|
134
274
|
end
|
|
135
275
|
|
|
136
276
|
desc "get KEY", "Retrieve a secret value by key"
|
|
@@ -185,6 +325,30 @@ module LocalVault
|
|
|
185
325
|
vault.list.each { |key| $stdout.puts key }
|
|
186
326
|
end
|
|
187
327
|
|
|
328
|
+
desc "groups [QUERY]", "List or search stored secret groups without revealing values"
|
|
329
|
+
long_desc <<~DESC
|
|
330
|
+
Discover dot-notation namespaces and flat-key prefix groups.
|
|
331
|
+
|
|
332
|
+
\x05 localvault groups
|
|
333
|
+
\x05 localvault groups str
|
|
334
|
+
\x05 localvault show --group STRIPE
|
|
335
|
+
\x05 localvault set --group STRIPE API_KEY VALUE
|
|
336
|
+
DESC
|
|
337
|
+
def groups(query = nil)
|
|
338
|
+
vault = open_vault!
|
|
339
|
+
matches = GroupCatalog.new(vault.all).search(query)
|
|
340
|
+
if matches.empty?
|
|
341
|
+
$stdout.puts "No groups match #{query}"
|
|
342
|
+
return
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
heading = query ? "Groups matching `#{query}`" : "Groups"
|
|
346
|
+
$stdout.puts "#{heading} in vault `#{vault.name}`:"
|
|
347
|
+
$stdout.puts
|
|
348
|
+
$stdout.printf(" %-20s %-6s %s\n", "Group", "Keys", "Kind")
|
|
349
|
+
matches.each { |group| $stdout.printf(" %-20s %-6d %s\n", group.name, group.count, group.kind) }
|
|
350
|
+
end
|
|
351
|
+
|
|
188
352
|
desc "delete KEY", "Remove a secret or entire project group"
|
|
189
353
|
long_desc <<~DESC
|
|
190
354
|
Delete a single key or an entire project group.
|
|
@@ -350,14 +514,15 @@ module LocalVault
|
|
|
350
514
|
\x05 localvault show -p platepose -v intellectaco # one project only
|
|
351
515
|
\x05 localvault show -p platepose -v intellectaco --reveal
|
|
352
516
|
DESC
|
|
353
|
-
method_option :group,
|
|
517
|
+
method_option :group, type: :string, lazy_default: GROUP_ALL_SENTINEL, desc: "Show all groups or one group by name"
|
|
354
518
|
method_option :reveal, type: :boolean, default: false, desc: "Show full values instead of masking"
|
|
355
519
|
method_option :project, aliases: "-p", type: :string, desc: "Show only this project group"
|
|
356
520
|
def show
|
|
357
521
|
vault = open_vault!
|
|
358
522
|
secrets = vault.all
|
|
359
523
|
|
|
360
|
-
|
|
524
|
+
named_group_query = options[:group] && ![GROUP_ALL_SENTINEL, GROUP_OFF_SENTINEL].include?(options[:group])
|
|
525
|
+
if secrets.empty? && !named_group_query
|
|
361
526
|
$stdout.puts "No secrets in vault '#{vault.name}'."
|
|
362
527
|
return
|
|
363
528
|
end
|
|
@@ -369,7 +534,17 @@ module LocalVault
|
|
|
369
534
|
return
|
|
370
535
|
end
|
|
371
536
|
render_table(group.sort.to_h, "#{vault.name}/#{options[:project]}", reveal: options[:reveal])
|
|
372
|
-
elsif options[:group]
|
|
537
|
+
elsif options[:group] && ![GROUP_ALL_SENTINEL, GROUP_OFF_SENTINEL].include?(options[:group])
|
|
538
|
+
match = GroupCatalog.new(secrets).resolve(options[:group])
|
|
539
|
+
if match.group
|
|
540
|
+
entries = match.group.entries.to_h { |entry| [entry.label, entry.value] }
|
|
541
|
+
render_table(entries, "#{vault.name}/#{match.group.name}", reveal: options[:reveal])
|
|
542
|
+
elsif match.kind == :ambiguous
|
|
543
|
+
raise GroupSelectionError.new(:ambiguous, query: options[:group], candidates: match.groups.map(&:name))
|
|
544
|
+
else
|
|
545
|
+
raise GroupSelectionError.new(:absent, query: options[:group])
|
|
546
|
+
end
|
|
547
|
+
elsif options[:group] != GROUP_OFF_SENTINEL && (options[:group] || secrets.values.any? { |v| v.is_a?(Hash) })
|
|
373
548
|
render_grouped_table(secrets, vault.name, reveal: options[:reveal])
|
|
374
549
|
else
|
|
375
550
|
render_table(secrets.sort.to_h, vault.name, reveal: options[:reveal])
|
|
@@ -468,8 +643,44 @@ module LocalVault
|
|
|
468
643
|
end
|
|
469
644
|
|
|
470
645
|
desc "mcp", "Start MCP server (stdio)"
|
|
646
|
+
long_desc <<~DESC
|
|
647
|
+
Start LocalVault's stdio MCP server for an AI client.
|
|
648
|
+
|
|
649
|
+
Install and verify:
|
|
650
|
+
\x05 localvault install-mcp [claude-code|cursor|windsurf]
|
|
651
|
+
\x05 localvault mcp --check
|
|
652
|
+
\x05 localvault show
|
|
653
|
+
|
|
654
|
+
Do not run `localvault mcp` directly to test it: stdio servers wait for
|
|
655
|
+
JSON-RPC input and therefore appear idle. Use `--check`, then restart the
|
|
656
|
+
configured AI client.
|
|
657
|
+
|
|
658
|
+
Agents should call `list_secrets`, then `localvault_build_exec` to inject
|
|
659
|
+
secrets into a subprocess. Plaintext `get_secret` requires the explicit
|
|
660
|
+
`allow_plaintext: true` acknowledgement.
|
|
661
|
+
DESC
|
|
662
|
+
method_option :check, type: :boolean, default: false, desc: "Check installation and active-vault readiness, then exit"
|
|
471
663
|
def mcp
|
|
472
|
-
|
|
664
|
+
if options[:check]
|
|
665
|
+
require_relative "mcp/tools"
|
|
666
|
+
status = VaultResolver.readiness_status(options[:vault])
|
|
667
|
+
ready = status["active_vault_unlocked"]
|
|
668
|
+
tool_names = MCP::Tools::DEFINITIONS.map { |definition| definition.fetch("name") }
|
|
669
|
+
$stdout.puts "LocalVault #{VERSION}"
|
|
670
|
+
$stdout.puts "Home: #{Config.root_path}"
|
|
671
|
+
$stdout.puts "MCP readiness: #{ready ? "ready" : "locked"}"
|
|
672
|
+
$stdout.puts "Active vault: #{status["active_vault"]} (#{status["active_vault_source"]})"
|
|
673
|
+
$stdout.puts "Vault session: #{ready ? "available" : "unlock with `localvault show`"}"
|
|
674
|
+
$stdout.puts "MCP tools: #{tool_names.join(", ")}"
|
|
675
|
+
$stdout.puts "Plaintext gate: enabled"
|
|
676
|
+
$stdout.puts "Server instructions: enabled"
|
|
677
|
+
$stdout.puts
|
|
678
|
+
$stdout.puts "Safe agent workflow: list_secrets → localvault_build_exec → run the generated command"
|
|
679
|
+
$stdout.puts "Plaintext retrieval is opt-in with allow_plaintext: true."
|
|
680
|
+
return ready ? CommandStatus.ok : CommandStatus.error
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
require_relative "mcp/server"
|
|
473
684
|
MCP::Server.new.start
|
|
474
685
|
end
|
|
475
686
|
|
|
@@ -1281,8 +1492,48 @@ module LocalVault
|
|
|
1281
1492
|
$stdout.puts "localvault #{VERSION}"
|
|
1282
1493
|
end
|
|
1283
1494
|
|
|
1495
|
+
desc "doctor", "Check install and PATH readiness"
|
|
1496
|
+
long_desc <<~DESC
|
|
1497
|
+
Check whether the localvault executable selected by PATH matches the
|
|
1498
|
+
install you expect.
|
|
1499
|
+
|
|
1500
|
+
This catches common Homebrew/asdf shadowing issues after upgrades:
|
|
1501
|
+
\x05 localvault doctor
|
|
1502
|
+
\x05 which -a localvault
|
|
1503
|
+
DESC
|
|
1504
|
+
def doctor
|
|
1505
|
+
paths = localvault_paths
|
|
1506
|
+
warnings = localvault_path_warnings(paths)
|
|
1507
|
+
|
|
1508
|
+
$stdout.puts "LocalVault doctor"
|
|
1509
|
+
$stdout.puts "Version: localvault #{VERSION}"
|
|
1510
|
+
$stdout.puts "Home: #{Config.root_path}"
|
|
1511
|
+
|
|
1512
|
+
if paths.empty?
|
|
1513
|
+
$stdout.puts "Executable selected by PATH: not found"
|
|
1514
|
+
else
|
|
1515
|
+
$stdout.puts "Executable selected by PATH: #{paths.first}"
|
|
1516
|
+
$stdout.puts "All localvault executables on PATH:"
|
|
1517
|
+
paths.each_with_index { |path, index| $stdout.puts " #{index + 1}. #{path}" }
|
|
1518
|
+
end
|
|
1519
|
+
|
|
1520
|
+
if warnings.empty?
|
|
1521
|
+
$stdout.puts "PATH: ok"
|
|
1522
|
+
CommandStatus.ok
|
|
1523
|
+
else
|
|
1524
|
+
$stdout.puts
|
|
1525
|
+
warnings.each { |warning| $stdout.puts "Warning: #{warning}" }
|
|
1526
|
+
$stdout.puts
|
|
1527
|
+
$stdout.puts "Suggested checks:"
|
|
1528
|
+
$stdout.puts " asdf reshim ruby"
|
|
1529
|
+
$stdout.puts " hash -r"
|
|
1530
|
+
$stdout.puts " which -a localvault"
|
|
1531
|
+
CommandStatus.error
|
|
1532
|
+
end
|
|
1533
|
+
end
|
|
1534
|
+
|
|
1284
1535
|
def self.exit_on_failure?
|
|
1285
|
-
|
|
1536
|
+
false
|
|
1286
1537
|
end
|
|
1287
1538
|
|
|
1288
1539
|
no_commands do
|
|
@@ -1307,6 +1558,39 @@ module LocalVault
|
|
|
1307
1558
|
|
|
1308
1559
|
private
|
|
1309
1560
|
|
|
1561
|
+
def validate_secret_value_source!(value)
|
|
1562
|
+
if options[:stdin] && !value.nil?
|
|
1563
|
+
raise SetValueSourceError.new(:multiple, "Use either a positional VALUE or --stdin, not both.")
|
|
1564
|
+
end
|
|
1565
|
+
|
|
1566
|
+
return if options[:stdin] || !value.nil?
|
|
1567
|
+
|
|
1568
|
+
raise SetValueSourceError.new(:missing, "Provide a VALUE or read one with --stdin.")
|
|
1569
|
+
end
|
|
1570
|
+
|
|
1571
|
+
def read_secret_value(value)
|
|
1572
|
+
return value unless options[:stdin]
|
|
1573
|
+
|
|
1574
|
+
StdinSecretInput.read($stdin)
|
|
1575
|
+
end
|
|
1576
|
+
|
|
1577
|
+
def canonical_group_name(vault, supplied)
|
|
1578
|
+
groups = GroupCatalog.new(vault.all).groups
|
|
1579
|
+
return supplied if groups.any? { |group| group.name == supplied }
|
|
1580
|
+
|
|
1581
|
+
insensitive = groups.select { |group| group.name.casecmp?(supplied) }
|
|
1582
|
+
return insensitive.first.name if insensitive.one?
|
|
1583
|
+
raise GroupSaveError.new(:ambiguous, candidates: insensitive.map(&:name)) if insensitive.length > 1
|
|
1584
|
+
|
|
1585
|
+
supplied
|
|
1586
|
+
end
|
|
1587
|
+
|
|
1588
|
+
def validate_group_segment!(segment)
|
|
1589
|
+
unless segment.match?(Vault::KEY_SEGMENT_PATTERN) && segment.length <= 128
|
|
1590
|
+
raise GroupSaveError, :invalid
|
|
1591
|
+
end
|
|
1592
|
+
end
|
|
1593
|
+
|
|
1310
1594
|
# ── Demo data ──────────────────────────────────────────────────
|
|
1311
1595
|
DEMO_DATA = {
|
|
1312
1596
|
"default" => {
|
|
@@ -1694,22 +1978,57 @@ module LocalVault
|
|
|
1694
1978
|
def print_next_steps(client_name)
|
|
1695
1979
|
$stdout.puts ""
|
|
1696
1980
|
$stdout.puts "Next steps:"
|
|
1697
|
-
$stdout.puts " 1.
|
|
1698
|
-
$stdout.puts " 2.
|
|
1699
|
-
$stdout.puts " 3.
|
|
1981
|
+
$stdout.puts " 1. Unlock your vault once: localvault show"
|
|
1982
|
+
$stdout.puts " 2. Verify readiness: localvault mcp --check"
|
|
1983
|
+
$stdout.puts " 3. Restart #{client_name}"
|
|
1984
|
+
$stdout.puts " 4. Ask the agent to list names, then use localvault_build_exec"
|
|
1985
|
+
$stdout.puts " for process injection. Plaintext get_secret is explicit opt-in."
|
|
1700
1986
|
$stdout.puts " Switch vaults: localvault switch <vault>"
|
|
1701
1987
|
end
|
|
1702
1988
|
|
|
1703
1989
|
no_commands do
|
|
1704
1990
|
def find_binary(name)
|
|
1705
|
-
|
|
1706
|
-
path.empty? ? nil : path
|
|
1991
|
+
executable_paths_for(name).first
|
|
1707
1992
|
end
|
|
1708
1993
|
|
|
1709
1994
|
def system_command_exists?(cmd)
|
|
1710
1995
|
!find_binary(cmd).nil?
|
|
1711
1996
|
end
|
|
1712
1997
|
|
|
1998
|
+
def localvault_paths
|
|
1999
|
+
executable_paths_for("localvault")
|
|
2000
|
+
end
|
|
2001
|
+
|
|
2002
|
+
def executable_paths_for(name)
|
|
2003
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).filter_map do |directory|
|
|
2004
|
+
next if directory.empty?
|
|
2005
|
+
|
|
2006
|
+
path = File.join(directory, name)
|
|
2007
|
+
path if File.executable?(path) && !File.directory?(path)
|
|
2008
|
+
end.uniq
|
|
2009
|
+
end
|
|
2010
|
+
|
|
2011
|
+
def localvault_path_warnings(paths)
|
|
2012
|
+
warnings = []
|
|
2013
|
+
if paths.empty?
|
|
2014
|
+
warnings << "localvault is not on PATH. Brew upgrades may be installed but unreachable."
|
|
2015
|
+
return warnings
|
|
2016
|
+
end
|
|
2017
|
+
|
|
2018
|
+
if paths.first.include?("/.asdf/shims/") && paths.any? { |path| homebrew_localvault_path?(path) }
|
|
2019
|
+
warnings << "PATH selects an asdf shim before Homebrew localvault. " \
|
|
2020
|
+
"A stale shim can hide the upgraded brew executable."
|
|
2021
|
+
elsif paths.length > 1
|
|
2022
|
+
warnings << "Multiple localvault executables are on PATH. Confirm the first entry is the one you intend."
|
|
2023
|
+
end
|
|
2024
|
+
|
|
2025
|
+
warnings
|
|
2026
|
+
end
|
|
2027
|
+
|
|
2028
|
+
def homebrew_localvault_path?(path)
|
|
2029
|
+
path.start_with?("/opt/homebrew/bin/", "/usr/local/bin/")
|
|
2030
|
+
end
|
|
2031
|
+
|
|
1713
2032
|
def cursor_settings_path
|
|
1714
2033
|
File.expand_path("~/.cursor/mcp.json")
|
|
1715
2034
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative "input_validation"
|
|
2
|
+
|
|
1
3
|
module LocalVault
|
|
2
4
|
module EnvProjection
|
|
3
5
|
class InvalidMapping < StandardError; end
|
|
@@ -34,7 +36,10 @@ module LocalVault
|
|
|
34
36
|
def self.parse_selectors(value)
|
|
35
37
|
values = Array(value).compact.flat_map { |v| v.to_s.split(",") }
|
|
36
38
|
selectors = values.map(&:strip).reject(&:empty?)
|
|
39
|
+
selectors.each { |selector| InputValidation.selector!(selector) }
|
|
37
40
|
selectors.empty? ? nil : selectors
|
|
41
|
+
rescue InputValidation::InvalidInput => e
|
|
42
|
+
raise InvalidMapping, e.message
|
|
38
43
|
end
|
|
39
44
|
|
|
40
45
|
def self.parse_map(value)
|
|
@@ -43,10 +48,12 @@ module LocalVault
|
|
|
43
48
|
|
|
44
49
|
key, env_name = pair.split("=", 2).map(&:strip)
|
|
45
50
|
raise InvalidMapping, "Invalid map '#{pair}'. Use KEY=ENV_NAME" if key.to_s.empty? || env_name.to_s.empty?
|
|
46
|
-
|
|
51
|
+
InputValidation.mapping!(key, env_name)
|
|
47
52
|
|
|
48
53
|
hash[key] = env_name
|
|
49
54
|
end
|
|
55
|
+
rescue InputValidation::InvalidInput => e
|
|
56
|
+
raise InvalidMapping, e.message
|
|
50
57
|
end
|
|
51
58
|
|
|
52
59
|
def self.profile_config(profile)
|
|
@@ -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
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
module LocalVault
|
|
2
|
+
module InputValidation
|
|
3
|
+
class InvalidInput < StandardError; end
|
|
4
|
+
|
|
5
|
+
SEGMENT_PATTERN = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
6
|
+
VAULT_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9_-]{0,63}\z/
|
|
7
|
+
PROFILES = %w[aws].freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def selector!(value)
|
|
12
|
+
string!(value, "selector")
|
|
13
|
+
reject_delimiters!(value, "selector")
|
|
14
|
+
parts = value.split(".", -1)
|
|
15
|
+
valid = parts.length == 1 ? segment?(parts.first) :
|
|
16
|
+
parts.length == 2 && segment?(parts.first) && (segment?(parts.last) || parts.last == "*")
|
|
17
|
+
raise InvalidInput, "selector must be KEY, GROUP.KEY, or GROUP.*" unless valid
|
|
18
|
+
|
|
19
|
+
value
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def mapping!(source, target)
|
|
23
|
+
selector!(source)
|
|
24
|
+
raise InvalidInput, "mapping source must be an exact key" if source.end_with?(".*")
|
|
25
|
+
string!(target, "mapping target")
|
|
26
|
+
reject_delimiters!(target, "mapping target")
|
|
27
|
+
raise InvalidInput, "mapping target must be an environment variable name" unless segment?(target)
|
|
28
|
+
|
|
29
|
+
[source, target]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def project!(value)
|
|
33
|
+
string!(value, "project")
|
|
34
|
+
reject_delimiters!(value, "project")
|
|
35
|
+
raise InvalidInput, "project must be one key segment" unless segment?(value)
|
|
36
|
+
|
|
37
|
+
value
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def vault_name!(value)
|
|
41
|
+
string!(value, "vault")
|
|
42
|
+
raise InvalidInput, "vault must be a valid vault name" unless value.match?(VAULT_PATTERN)
|
|
43
|
+
|
|
44
|
+
value
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def argv!(value)
|
|
48
|
+
unless value.is_a?(Array) && !value.empty? &&
|
|
49
|
+
value.all? { |token| token.is_a?(String) && !token.empty? && !token.include?("\0") }
|
|
50
|
+
raise InvalidInput, "command must be a non-empty array of non-empty NUL-free strings"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
value
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def profile!(value)
|
|
57
|
+
string!(value, "profile")
|
|
58
|
+
raise InvalidInput, "profile must be one of: #{PROFILES.join(", ")}" unless PROFILES.include?(value)
|
|
59
|
+
|
|
60
|
+
value
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def segment?(value)
|
|
64
|
+
value.is_a?(String) && value.match?(SEGMENT_PATTERN)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def string!(value, label)
|
|
68
|
+
raise InvalidInput, "#{label} must be a non-empty string" unless value.is_a?(String) && !value.empty?
|
|
69
|
+
raise InvalidInput, "#{label} must not contain NUL bytes" if value.include?("\0")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def reject_delimiters!(value, label)
|
|
73
|
+
if value.include?(",") || value.include?("=")
|
|
74
|
+
raise InvalidInput, "#{label} must not contain commas or equals signs"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private_class_method :string!, :reject_delimiters!
|
|
79
|
+
end
|
|
80
|
+
end
|