localvault 1.7.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2ff1cbda409eb9a922e301dfb73866fde96fa06dc7ace9e670df15ef587dd336
4
- data.tar.gz: 8f1b78e9c49e3f6f99a37cd97bbca36054e75c15d99d126699beb61f00a07840
3
+ metadata.gz: 86be5d86ef86c74bcfc777561088264d1b908eaca92a2b501d5e23d937e5a906
4
+ data.tar.gz: d3d079c65ff54ca198907957ae3193a3a73520aa84169cf0067bc254967fea05
5
5
  SHA512:
6
- metadata.gz: 35b625b3e4bd845ba5b5832016f4fd3f310b410e763c476a569c32920f8707317b007f00be9cbd441f704aeccf126d8c4d48646706cab7cc597d1e7fabc07d58
7
- data.tar.gz: b509a7068ba88ea63e30ecb9c036c1b3b2d63d58da73cd0e2a812069f907ad8439d0ee1ae5d10d17ca7fb97595c0c420c02ab5f9fe151c96525f0c07b85c91d6
6
+ metadata.gz: f0d5b4b8d1575b8b91fbd742afd0aef2f71401e72882149cf0b83e68cd233636592c5e0af85512007b4cff604d466ff38d5ac085ea7eea60e9b9446a3d7247a5
7
+ data.tar.gz: 98a51ac12f4857e0f381fa4b4449a63687d402fe3bf3d97e693f75bfc526440efbc0fd9df14f397c99771c4c871c9a8f164b51e29cca56a232bfa78c099ca8c9
data/README.md CHANGED
@@ -70,10 +70,13 @@ localvault exec -- rails server
70
70
  |---------|-------------|
71
71
  | `init [NAME]` | Create a vault (Argon2id key derivation) |
72
72
  | `set KEY VALUE` | Store a secret (supports dot-notation: `project.KEY`) |
73
+ | `set --group GROUP KEY VALUE` | Store a secret in a named group |
73
74
  | `get KEY` | Retrieve a secret (raw, pipeable) |
74
75
  | `show` | Display all secrets in a table (masked by default) |
75
76
  | `show --reveal` | Display with values visible |
76
77
  | `show --group` | Group by dot-notation prefix (one table per project) |
78
+ | `show --group QUERY` | Show one exact or uniquely matching group |
79
+ | `groups [QUERY]` | List/search group names and key counts without values |
77
80
  | `list` | List key names only |
78
81
  | `delete KEY` | Remove a secret |
79
82
  | `rename OLD NEW` | Rename a secret key |
@@ -214,7 +217,9 @@ localvault remove @alice -v production --rotate
214
217
 
215
218
  ## MCP Server (AI Agents)
216
219
 
217
- Give AI agents controlled secret access without hardcoding credentials in MCP config. Exact `get_secret` calls can return secret values to the agent; fuzzy reads return candidate names only.
220
+ Give AI agents controlled secret access without hardcoding credentials in MCP
221
+ config. The default workflow keeps values out of model context: discover names,
222
+ build a `localvault exec` command, then run it with process-scoped injection.
218
223
 
219
224
  ```bash
220
225
  # One-command install for Claude Code
@@ -224,15 +229,24 @@ localvault install-mcp claude-code
224
229
  # Unlock your vault for the session
225
230
  localvault unlock
226
231
 
232
+ # Verify setup without starting the blocking stdio server
233
+ localvault mcp --check
234
+
227
235
  # MCP tools available to the agent:
228
236
  # localvault_whoami — diagnose active vault/session state
229
- # get_secret(key, vault?) — read an exact secret key
230
237
  # list_secrets(vault?, prefix?, query?) — list/search key names
238
+ # localvault_build_exec(command, ...) — build safe injection (does not execute)
239
+ # get_secret(key, allow_plaintext: true, vault?) — explicit plaintext reveal
231
240
  # set_secret(key, value, vault?) — store a secret
232
241
  # delete_secret(key, vault?) — remove a secret
233
242
  ```
234
243
 
235
- Command injection stays in the CLI. Use selectors, mappings, and profiles to keep subprocess envs scoped:
244
+ Agents should prefer `localvault_build_exec` for commands, evaluation, API calls,
245
+ and configuration checks. It returns both argv and a shell-safe command without
246
+ opening a vault or reading a value. `get_secret` rejects calls unless
247
+ `allow_plaintext: true` is explicit.
248
+
249
+ Use selectors, mappings, and profiles to keep subprocess envs scoped:
236
250
 
237
251
  ```bash
238
252
  localvault exec --profile aws -- aws sts get-caller-identity
@@ -249,9 +263,18 @@ One vault, many projects. Dot-notation keeps secrets organized:
249
263
  localvault set myapp.DATABASE_URL postgres://localhost/myapp -v work
250
264
  localvault set api.DATABASE_URL postgres://localhost/api -v work
251
265
 
266
+ # Or use the guided group form
267
+ localvault set --group myapp DATABASE_URL postgres://localhost/myapp -v work
268
+
269
+ # Search groups without revealing values
270
+ localvault groups app -v work
271
+
252
272
  # View grouped by project
253
273
  localvault show --group -v work
254
274
 
275
+ # Show one group by exact or unique prefix
276
+ localvault show --group my -v work
277
+
255
278
  # Filter to one project
256
279
  localvault show -p myapp -v work
257
280
 
data/bin/localvault CHANGED
@@ -9,4 +9,5 @@ rescue LoadError
9
9
  require "localvault/cli"
10
10
  end
11
11
 
12
- LocalVault::CLI.start(ARGV)
12
+ status = LocalVault::CLI.start(ARGV)
13
+ exit(status) if status.is_a?(Integer) && !status.zero?
@@ -0,0 +1,177 @@
1
+ require "did_you_mean"
2
+
3
+ module LocalVault
4
+ class CLI
5
+ class ErrorPresenter
6
+ Context = Data.define(:command_class, :namespace, :token, :command)
7
+
8
+ def initialize(root_class, argv)
9
+ @root_class = root_class
10
+ @argv = argv.map(&:to_s)
11
+ end
12
+
13
+ def render(error, io: $stderr)
14
+ @error = error
15
+ context = @context = resolve_context
16
+ io.puts error_heading(context)
17
+ io.puts "Usage: #{usage_for(context)}"
18
+ render_option_hint(context, io)
19
+ io.puts
20
+ io.puts "Try:"
21
+ suggestions_for(context).first(5).each { |suggestion| io.puts " #{suggestion}" }
22
+ end
23
+
24
+ private
25
+
26
+ def resolve_context
27
+ command_class = @root_class
28
+ namespace = []
29
+ token = @argv.first
30
+ command = resolve_command(command_class, token)
31
+
32
+ if command && command_class.subcommand_classes.key?(command.name)
33
+ namespace << command.name
34
+ command_class = command_class.subcommand_classes.fetch(command.name)
35
+ token = @argv[1]
36
+ command = resolve_command(command_class, token)
37
+ end
38
+
39
+ Context.new(command_class, namespace, token, command)
40
+ end
41
+
42
+ def resolve_command(command_class, token)
43
+ return nil unless token && !token.start_with?("-")
44
+ return command_class.all_commands[token] if command_class.all_commands.key?(token)
45
+
46
+ matches = command_class.all_commands.keys.select { |name| name.start_with?(token) }
47
+ matches.one? ? command_class.all_commands[matches.first] : nil
48
+ end
49
+
50
+ def error_heading(context)
51
+ if @error.is_a?(CLI::GroupSelectionError)
52
+ if @error.kind == :ambiguous
53
+ "Error: Multiple groups match `#{@error.query}`."
54
+ else
55
+ "Error: No group matches `#{@error.query}`."
56
+ end
57
+ elsif @error.is_a?(CLI::GroupSaveError)
58
+ case @error.kind
59
+ when :collision then "Error: cannot create group: that name is already used by a secret key."
60
+ when :ambiguous then "Error: group name is ambiguous. Existing groups: #{@error.candidates.join(", ")}."
61
+ else "Error: group and key names may contain letters, digits, and underscores only."
62
+ end
63
+ elsif group_save_attempt?
64
+ "Error: saving in a group needs GROUP, KEY, and VALUE."
65
+ elsif unknown_option
66
+ "Error: unknown option `#{unknown_option}`."
67
+ elsif context.command.nil?
68
+ "Error: unknown or ambiguous command."
69
+ else
70
+ "Error: this command needs a different combination of arguments."
71
+ end
72
+ end
73
+
74
+ def usage_for(context)
75
+ if @error.is_a?(CLI::GroupSelectionError)
76
+ "localvault show --group GROUP"
77
+ elsif group_save_attempt?
78
+ "localvault set --group GROUP KEY VALUE"
79
+ elsif context.command
80
+ ["localvault", *context.namespace, context.command.usage].join(" ")
81
+ else
82
+ ["localvault", *context.namespace, "COMMAND"].join(" ")
83
+ end
84
+ end
85
+
86
+ def render_option_hint(context, io)
87
+ return unless unknown_option
88
+
89
+ correction = DidYouMean::SpellChecker.new(dictionary: option_names(context)).correct(unknown_option).first
90
+ io.puts "\nDid you mean `#{correction}`?" if correction
91
+ end
92
+
93
+ def suggestions_for(context)
94
+ if @error.is_a?(CLI::GroupSelectionError)
95
+ return @error.candidates.map { |name| "localvault show --group #{name}" } if @error.kind == :ambiguous
96
+ return ["localvault groups #{@error.query}", "localvault groups"]
97
+ end
98
+
99
+ return group_save_suggestions if group_save_attempt?
100
+ return ["localvault set KEY VALUE", *group_save_suggestions] if context.command&.name == "set"
101
+ return show_group_suggestions if context.command&.name == "show" || unknown_option == "--group-by"
102
+ return ["localvault add HANDLE", "localvault team add HANDLE"] if context.namespace == ["team"] && context.command&.name == "add"
103
+
104
+ if context.command
105
+ examples = curated_examples(context.command)
106
+ examples.empty? ? [usage_for(context)] : examples
107
+ else
108
+ command_suggestions(context)
109
+ end
110
+ end
111
+
112
+ def group_save_suggestions
113
+ [
114
+ "localvault set --group GROUP KEY VALUE",
115
+ "localvault set GROUP.KEY VALUE",
116
+ "localvault groups [QUERY]"
117
+ ]
118
+ end
119
+
120
+ def show_group_suggestions
121
+ [
122
+ "localvault show --group GROUP",
123
+ "localvault groups [QUERY]",
124
+ "localvault show --project PROJECT"
125
+ ]
126
+ end
127
+
128
+ def command_suggestions(context)
129
+ names = context.command_class.all_commands.keys.reject { |name| name == "help" }
130
+ matches = prefix_or_spelling_matches(names, context.token.to_s)
131
+ items = matches.map do |name|
132
+ command = context.command_class.all_commands.fetch(name)
133
+ ["localvault", *context.namespace, command.usage, " # #{command.description}"].join(" ")
134
+ end
135
+ items.empty? ? ["localvault help"] : items
136
+ end
137
+
138
+ def prefix_or_spelling_matches(names, token)
139
+ prefix = names.select { |name| name.start_with?(token) }
140
+ return prefix.sort unless prefix.empty?
141
+
142
+ DidYouMean::SpellChecker.new(dictionary: names).correct(token).first(5)
143
+ end
144
+
145
+ def curated_examples(command)
146
+ command.long_description.to_s.lines.filter_map do |line|
147
+ example = line.delete("\u0005").strip
148
+ example if example.start_with?("localvault ")
149
+ end.uniq
150
+ end
151
+
152
+ def option_names(context)
153
+ options = context.command_class.class_options.values
154
+ options += context.command.options.values if context.command
155
+
156
+ options.flat_map do |option|
157
+ ["--#{option.name.to_s.tr("_", "-")}", *Array(option.aliases)]
158
+ end.uniq
159
+ end
160
+
161
+ def unknown_option
162
+ return @unknown_option if defined?(@unknown_option)
163
+
164
+ known = option_names(@context)
165
+ @unknown_option = @argv.find do |argument|
166
+ next false unless argument.start_with?("--") && argument != "--"
167
+
168
+ !known.include?(argument.split("=", 2).first)
169
+ end
170
+ end
171
+
172
+ def group_save_attempt?
173
+ @argv.first == "set" && @argv.include?("--group")
174
+ end
175
+ end
176
+ end
177
+ end
@@ -6,9 +6,92 @@ require_relative "env_projection"
6
6
  require_relative "key_lookup"
7
7
  require_relative "session_cache"
8
8
  require_relative "vault_resolver"
9
+ require_relative "group_catalog"
9
10
 
10
11
  module LocalVault
11
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
+
12
95
  class_option :vault, aliases: "-v", type: :string, desc: "Vault name"
13
96
 
14
97
  def self.help(shell, subcommand = false)
@@ -23,8 +106,10 @@ module LocalVault
23
106
  shell.say ""
24
107
  shell.say "SECRETS"
25
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"
26
110
  shell.say " localvault get KEY Retrieve a secret"
27
111
  shell.say " localvault show Display all secrets (masked by default)"
112
+ shell.say " localvault groups [QUERY] List or search stored groups (names only)"
28
113
  shell.say " localvault list List secret key names"
29
114
  shell.say " localvault delete KEY Remove a secret"
30
115
  shell.say " localvault import FILE Bulk-import from .env / .json / .yml"
@@ -71,6 +156,8 @@ module LocalVault
71
156
  shell.say "AI / MCP"
72
157
  shell.say " localvault install-mcp Configure MCP server in your AI tool"
73
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"
74
161
  shell.say ""
75
162
  shell.say "LEGACY SHARING (pre-v1.2 direct share, still works as fallback)"
76
163
  shell.say " localvault keygen Generate X25519 keypair (same as `keys generate`)"
@@ -123,14 +210,38 @@ module LocalVault
123
210
  \x05 localvault set platepose.SECRET_KEY_BASE abc123 -v intellectaco
124
211
  \x05 localvault set inventlist.STRIPE_KEY sk_live_abc123 -v intellectaco
125
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
+
126
218
  The dot separates project from key name. One vault can hold many projects.
127
219
  Use `localvault show -p platepose -v vault` to view a single project.
128
220
  Use `localvault import` to bulk-load from a .env, .json, or .yml file.
129
221
  DESC
222
+ method_option :group, type: :string, desc: "Store KEY and VALUE inside this named group"
130
223
  def set(key, value)
131
224
  vault = open_vault!
132
- vault.set(key, value)
133
- $stdout.puts "Set #{key} in vault '#{vault.name}'"
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
134
245
  end
135
246
 
136
247
  desc "get KEY", "Retrieve a secret value by key"
@@ -185,6 +296,30 @@ module LocalVault
185
296
  vault.list.each { |key| $stdout.puts key }
186
297
  end
187
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
+
188
323
  desc "delete KEY", "Remove a secret or entire project group"
189
324
  long_desc <<~DESC
190
325
  Delete a single key or an entire project group.
@@ -350,14 +485,15 @@ module LocalVault
350
485
  \x05 localvault show -p platepose -v intellectaco # one project only
351
486
  \x05 localvault show -p platepose -v intellectaco --reveal
352
487
  DESC
353
- method_option :group, type: :boolean, default: false, desc: "Group flat keys by common prefix"
488
+ method_option :group, type: :string, lazy_default: GROUP_ALL_SENTINEL, desc: "Show all groups or one group by name"
354
489
  method_option :reveal, type: :boolean, default: false, desc: "Show full values instead of masking"
355
490
  method_option :project, aliases: "-p", type: :string, desc: "Show only this project group"
356
491
  def show
357
492
  vault = open_vault!
358
493
  secrets = vault.all
359
494
 
360
- if secrets.empty?
495
+ named_group_query = options[:group] && ![GROUP_ALL_SENTINEL, GROUP_OFF_SENTINEL].include?(options[:group])
496
+ if secrets.empty? && !named_group_query
361
497
  $stdout.puts "No secrets in vault '#{vault.name}'."
362
498
  return
363
499
  end
@@ -369,7 +505,17 @@ module LocalVault
369
505
  return
370
506
  end
371
507
  render_table(group.sort.to_h, "#{vault.name}/#{options[:project]}", reveal: options[:reveal])
372
- elsif options[:group] || secrets.values.any? { |v| v.is_a?(Hash) }
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) })
373
519
  render_grouped_table(secrets, vault.name, reveal: options[:reveal])
374
520
  else
375
521
  render_table(secrets.sort.to_h, vault.name, reveal: options[:reveal])
@@ -468,8 +614,44 @@ module LocalVault
468
614
  end
469
615
 
470
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"
471
634
  def mcp
472
- require "localvault/mcp/server"
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"
473
655
  MCP::Server.new.start
474
656
  end
475
657
 
@@ -1282,7 +1464,7 @@ module LocalVault
1282
1464
  end
1283
1465
 
1284
1466
  def self.exit_on_failure?
1285
- true
1467
+ false
1286
1468
  end
1287
1469
 
1288
1470
  no_commands do
@@ -1307,6 +1489,23 @@ module LocalVault
1307
1489
 
1308
1490
  private
1309
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
+
1310
1509
  # ── Demo data ──────────────────────────────────────────────────
1311
1510
  DEMO_DATA = {
1312
1511
  "default" => {
@@ -1694,9 +1893,11 @@ module LocalVault
1694
1893
  def print_next_steps(client_name)
1695
1894
  $stdout.puts ""
1696
1895
  $stdout.puts "Next steps:"
1697
- $stdout.puts " 1. Restart #{client_name}"
1698
- $stdout.puts " 2. Unlock your vault once: localvault show"
1699
- $stdout.puts " 3. The AI can now access secrets from your default vault"
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."
1700
1901
  $stdout.puts " Switch vaults: localvault switch <vault>"
1701
1902
  end
1702
1903
 
@@ -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
- raise InvalidMapping, "Invalid environment variable name '#{env_name}'" unless safe_env_name?(env_name)
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
@@ -0,0 +1,77 @@
1
+ require "shellwords"
2
+ require_relative "../input_validation"
3
+
4
+ module LocalVault
5
+ module MCP
6
+ class ExecCommandBuilder
7
+ def initialize(command:, vault: nil, project: nil, only: nil, except: nil, map: nil, profile: nil)
8
+ @command = command
9
+ @vault = vault
10
+ @project = project
11
+ @only = only
12
+ @except = except
13
+ @map = map
14
+ @profile = profile
15
+ end
16
+
17
+ def build
18
+ validate!
19
+ argv = ["localvault", "exec"]
20
+ append(argv, "-v", @vault)
21
+ append(argv, "--project", @project)
22
+ append(argv, "--only", serialize_selectors(@only))
23
+ append(argv, "--except", serialize_selectors(@except))
24
+ append(argv, "--map", serialize_map)
25
+ append(argv, "--profile", @profile)
26
+ argv.concat(["--", *@command])
27
+
28
+ {
29
+ "command" => Shellwords.join(argv),
30
+ "exposes_plaintext" => false,
31
+ "executes_command" => false,
32
+ "next_action" => "Run this command through your normal shell tool."
33
+ }.freeze
34
+ end
35
+
36
+ private
37
+
38
+ def validate!
39
+ InputValidation.argv!(@command)
40
+ InputValidation.vault_name!(@vault) if @vault
41
+ InputValidation.project!(@project) if @project
42
+ InputValidation.profile!(@profile) if @profile
43
+ validate_selectors!(@only, "only")
44
+ validate_selectors!(@except, "except")
45
+ validate_map!
46
+ end
47
+
48
+ def validate_selectors!(selectors, label)
49
+ return if selectors.nil?
50
+ unless selectors.is_a?(Array) && !selectors.empty?
51
+ raise InputValidation::InvalidInput, "#{label} must be a non-empty array of selectors"
52
+ end
53
+ selectors.each { |selector| InputValidation.selector!(selector) }
54
+ end
55
+
56
+ def validate_map!
57
+ return if @map.nil?
58
+ unless @map.is_a?(Hash) && !@map.empty?
59
+ raise InputValidation::InvalidInput, "map must be a non-empty object"
60
+ end
61
+ @map.each { |source, target| InputValidation.mapping!(source, target) }
62
+ end
63
+
64
+ def append(argv, flag, value)
65
+ argv.concat([flag, value]) if value
66
+ end
67
+
68
+ def serialize_selectors(selectors)
69
+ selectors&.join(",")
70
+ end
71
+
72
+ def serialize_map
73
+ @map&.sort_by { |source, _target| source }&.map { |source, target| "#{source}=#{target}" }&.join(",")
74
+ end
75
+ end
76
+ end
77
+ end
@@ -72,7 +72,10 @@ module LocalVault
72
72
  success_response(id, {
73
73
  "protocolVersion" => "2025-11-25",
74
74
  "capabilities" => { "tools" => {} },
75
- "serverInfo" => { "name" => "localvault", "version" => LocalVault::VERSION }
75
+ "serverInfo" => { "name" => "localvault", "version" => LocalVault::VERSION },
76
+ "instructions" => "Do not retrieve plaintext secrets for commands, API calls, evaluation, or configuration. " \
77
+ "Call list_secrets to discover names, then localvault_build_exec to construct process-scoped injection. " \
78
+ "Use get_secret with allow_plaintext: true only when the user task explicitly requires the value itself."
76
79
  })
77
80
  when "tools/list"
78
81
  success_response(id, { "tools" => Tools::DEFINITIONS })
@@ -1,6 +1,7 @@
1
1
  require "json"
2
2
  require_relative "../key_lookup"
3
3
  require_relative "../version"
4
+ require_relative "exec_command_builder"
4
5
 
5
6
  module LocalVault
6
7
  module MCP
@@ -17,19 +18,21 @@ module LocalVault
17
18
  DEFINITIONS = [
18
19
  {
19
20
  "name" => "get_secret",
20
- "description" => "Retrieve a secret value by key from a localvault vault",
21
+ "description" => "Reveal plaintext only when the task truly requires the value in model context. Prefer localvault_build_exec for commands, evaluations, and API calls.",
21
22
  "inputSchema" => {
22
23
  "type" => "object",
23
24
  "properties" => {
24
- "key" => { "type" => "string", "description" => "The secret key to retrieve" },
25
+ "key" => { "type" => "string", "description" => "The exact secret key to reveal" },
26
+ "allow_plaintext" => { "type" => "boolean", "description" => "Must be true to acknowledge that plaintext will enter model context" },
25
27
  **VAULT_PARAM
26
28
  },
27
- "required" => ["key"]
28
- }
29
+ "required" => ["key", "allow_plaintext"]
30
+ },
31
+ "annotations" => { "readOnlyHint" => true, "openWorldHint" => false }
29
32
  },
30
33
  {
31
34
  "name" => "list_secrets",
32
- "description" => "List all secret keys in a localvault vault",
35
+ "description" => "Discover secret names without values. After selecting names, use localvault_build_exec to inject them into a process.",
33
36
  "inputSchema" => {
34
37
  "type" => "object",
35
38
  "properties" => {
@@ -38,7 +41,37 @@ module LocalVault
38
41
  **VAULT_PARAM
39
42
  },
40
43
  "required" => []
41
- }
44
+ },
45
+ "annotations" => { "readOnlyHint" => true, "openWorldHint" => false }
46
+ },
47
+ {
48
+ "name" => "localvault_build_exec",
49
+ "description" => "Build, but never execute, a shell-safe localvault exec command that injects secrets directly into a subprocess without exposing values to the model.",
50
+ "inputSchema" => {
51
+ "type" => "object",
52
+ "properties" => {
53
+ "command" => { "type" => "array", "items" => { "type" => "string" }, "description" => "Command argv to run with injected secrets" },
54
+ "vault" => { "type" => "string", "description" => "Vault name" },
55
+ "project" => { "type" => "string", "description" => "Dot-notation project group to inject without a prefix" },
56
+ "only" => { "type" => "array", "items" => { "type" => "string" }, "description" => "Exact keys or GROUP.* selectors" },
57
+ "except" => { "type" => "array", "items" => { "type" => "string" }, "description" => "Selectors to exclude" },
58
+ "map" => { "type" => "object", "additionalProperties" => { "type" => "string" }, "description" => "Vault-key to environment-variable mappings" },
59
+ "profile" => { "type" => "string", "enum" => ["aws"], "description" => "Built-in mapping profile" }
60
+ },
61
+ "required" => ["command"]
62
+ },
63
+ "outputSchema" => {
64
+ "type" => "object",
65
+ "properties" => {
66
+ "command" => { "type" => "string" },
67
+ "exposes_plaintext" => { "type" => "boolean" },
68
+ "executes_command" => { "type" => "boolean" },
69
+ "next_action" => { "type" => "string" }
70
+ },
71
+ "required" => %w[command exposes_plaintext executes_command next_action],
72
+ "additionalProperties" => false
73
+ },
74
+ "annotations" => { "readOnlyHint" => true, "openWorldHint" => false }
42
75
  },
43
76
  {
44
77
  "name" => "localvault_whoami",
@@ -47,7 +80,8 @@ module LocalVault
47
80
  "type" => "object",
48
81
  "properties" => { **VAULT_PARAM },
49
82
  "required" => []
50
- }
83
+ },
84
+ "annotations" => { "readOnlyHint" => true, "openWorldHint" => false }
51
85
  },
52
86
  {
53
87
  "name" => "set_secret",
@@ -60,6 +94,12 @@ module LocalVault
60
94
  **VAULT_PARAM
61
95
  },
62
96
  "required" => ["key", "value"]
97
+ },
98
+ "annotations" => {
99
+ "readOnlyHint" => false,
100
+ "destructiveHint" => true,
101
+ "idempotentHint" => false,
102
+ "openWorldHint" => false
63
103
  }
64
104
  },
65
105
  {
@@ -72,6 +112,12 @@ module LocalVault
72
112
  **VAULT_PARAM
73
113
  },
74
114
  "required" => ["key"]
115
+ },
116
+ "annotations" => {
117
+ "readOnlyHint" => false,
118
+ "destructiveHint" => true,
119
+ "idempotentHint" => true,
120
+ "openWorldHint" => false
75
121
  }
76
122
  }
77
123
  ].freeze
@@ -96,12 +142,21 @@ module LocalVault
96
142
 
97
143
  vault_name = arguments["vault"]
98
144
  return whoami(status_resolver.call(vault_name)) if name == "localvault_whoami"
145
+ return build_exec(arguments) if name == "localvault_build_exec"
146
+ return required_argument_error("key") if name == "get_secret" && !present_string?(arguments["key"])
147
+ if name == "get_secret" && arguments["allow_plaintext"] != true
148
+ return error_result(
149
+ "Plaintext retrieval is blocked by default. For authentication or an external command, call " \
150
+ "localvault_build_exec instead. Retry get_secret with allow_plaintext=true only when the user " \
151
+ "explicitly needs the secret text or injection cannot complete the task."
152
+ )
153
+ end
99
154
 
100
155
  vault = vault_resolver.call(vault_name)
101
156
 
102
157
  unless vault
103
158
  hint = vault_name ? "localvault show -v #{vault_name}" : "localvault show"
104
- return error_result("No unlocked vault session. Run: #{hint}")
159
+ return error_result("No unlocked vault session. Run `localvault mcp --check`, then unlock with: #{hint}")
105
160
  end
106
161
 
107
162
  case name
@@ -138,7 +193,12 @@ module LocalVault
138
193
  keys = vault.list
139
194
  keys = keys.select { |key| key.start_with?(prefix) } if prefix && !prefix.empty?
140
195
  keys = keys.select { |key| key.downcase.include?(query.downcase) } if query && !query.empty?
141
- keys.empty? ? text_result("No secrets stored") : text_result(keys.join("\n"))
196
+ result = keys.empty? ? text_result("No secrets stored") : text_result(keys.join("\n"))
197
+ result["content"] << {
198
+ "type" => "text",
199
+ "text" => "Next: call localvault_build_exec with command argv and optional only/project mappings. Do not copy secret values."
200
+ }
201
+ result
142
202
  end
143
203
 
144
204
  def self.set_secret(key, value, vault)
@@ -180,6 +240,23 @@ module LocalVault
180
240
  text_result(text).merge("structuredContent" => structured)
181
241
  end
182
242
 
243
+ def self.build_exec(arguments)
244
+ result = ExecCommandBuilder.new(
245
+ command: arguments["command"],
246
+ vault: arguments["vault"],
247
+ project: arguments["project"],
248
+ only: arguments["only"],
249
+ except: arguments["except"],
250
+ map: arguments["map"],
251
+ profile: arguments["profile"]
252
+ ).build
253
+ text_result(
254
+ "Built command (not executed):\n#{result["command"]}\n\n#{result["next_action"]}"
255
+ ).merge("structuredContent" => result)
256
+ rescue InputValidation::InvalidInput => e
257
+ error_result(e.message)
258
+ end
259
+
183
260
  def self.candidate_message(header, matches)
184
261
  ([header] + matches.map { |match| " #{match}" }).join("\n")
185
262
  end
@@ -200,7 +277,7 @@ module LocalVault
200
277
  error_result("Argument '#{name}' must be a string")
201
278
  end
202
279
 
203
- private_class_method :get_secret, :list_secrets, :set_secret, :delete_secret,
280
+ private_class_method :get_secret, :list_secrets, :set_secret, :delete_secret, :build_exec,
204
281
  :text_result, :error_result, :whoami, :candidate_message,
205
282
  :present_string?, :optional_string?, :required_argument_error, :string_argument_error
206
283
  end
@@ -1,6 +1,6 @@
1
1
  require "base64"
2
2
  require "fileutils"
3
- require "shellwords"
3
+ require "open3"
4
4
 
5
5
  module LocalVault
6
6
  # Caches derived master keys to avoid re-prompting passphrase on every command.
@@ -95,8 +95,11 @@ module LocalVault
95
95
 
96
96
  def self.keychain_get(vault_name)
97
97
  if macos?
98
- out = `security find-generic-password -a #{Shellwords.escape(vault_name)} -s #{Shellwords.escape(KEYCHAIN_SERVICE)} -w 2>/dev/null`.chomp
99
- return out if $?.success? && !out.empty?
98
+ out = run_command(
99
+ ["security", "find-generic-password", "-a", vault_name, "-s", KEYCHAIN_SERVICE, "-w"],
100
+ timeout: 2
101
+ )
102
+ return out unless out.nil? || out.empty?
100
103
  end
101
104
  # File fallback (Linux, or macOS when Keychain unavailable)
102
105
  file = session_file(vault_name)
@@ -143,5 +146,28 @@ module LocalVault
143
146
  FileUtils.rm_f(session_file(vault_name))
144
147
  end
145
148
 
149
+ def self.run_command(argv, timeout:)
150
+ output = nil
151
+ Open3.popen3(*argv) do |stdin, stdout, stderr, wait_thread|
152
+ stdin.close
153
+ unless wait_thread.join(timeout)
154
+ Process.kill("TERM", wait_thread.pid)
155
+ unless wait_thread.join(0.1)
156
+ Process.kill("KILL", wait_thread.pid)
157
+ wait_thread.join
158
+ end
159
+ return nil
160
+ end
161
+
162
+ output = stdout.read.chomp if wait_thread.value.success?
163
+ stderr.close
164
+ rescue Errno::ESRCH
165
+ return nil
166
+ end
167
+ output
168
+ rescue Errno::ENOENT
169
+ nil
170
+ end
171
+
146
172
  end
147
173
  end
@@ -33,6 +33,17 @@ module LocalVault
33
33
  }
34
34
  end
35
35
 
36
+ def self.readiness_status(name = nil)
37
+ result = resolve(name)
38
+ {
39
+ "localvault_home" => Config.root_path,
40
+ "active_vault" => result.active_vault,
41
+ "active_vault_source" => result.active_vault_source,
42
+ "session_vault" => result.session_vault,
43
+ "active_vault_unlocked" => !result.vault.nil?
44
+ }
45
+ end
46
+
36
47
  def self.active_vault_name(name = nil)
37
48
  return [name, "argument"] if name && !name.empty?
38
49
  return [ENV["LOCALVAULT_VAULT"], "env"] if ENV["LOCALVAULT_VAULT"] && !ENV["LOCALVAULT_VAULT"].empty?
@@ -1,3 +1,3 @@
1
1
  module LocalVault
2
- VERSION = "1.7.0"
2
+ VERSION = "1.8.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: localvault
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.0
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq
@@ -108,6 +108,7 @@ files:
108
108
  - lib/localvault.rb
109
109
  - lib/localvault/api_client.rb
110
110
  - lib/localvault/cli.rb
111
+ - lib/localvault/cli/error_presenter.rb
111
112
  - lib/localvault/cli/keys.rb
112
113
  - lib/localvault/cli/sync.rb
113
114
  - lib/localvault/cli/team.rb
@@ -115,9 +116,12 @@ files:
115
116
  - lib/localvault/config.rb
116
117
  - lib/localvault/crypto.rb
117
118
  - lib/localvault/env_projection.rb
119
+ - lib/localvault/group_catalog.rb
118
120
  - lib/localvault/identity.rb
121
+ - lib/localvault/input_validation.rb
119
122
  - lib/localvault/key_lookup.rb
120
123
  - lib/localvault/key_slot.rb
124
+ - lib/localvault/mcp/exec_command_builder.rb
121
125
  - lib/localvault/mcp/server.rb
122
126
  - lib/localvault/mcp/tools.rb
123
127
  - lib/localvault/session_cache.rb