localvault 1.6.1 → 1.7.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: 9cf502e2c11320a16bbf04e20ce1202d799d360775b664c1902c6ef337557302
4
- data.tar.gz: 1504ab893d079a1308519a8dd9fdfd941560348fca5d92cc1548edd9f6bf6549
3
+ metadata.gz: 2ff1cbda409eb9a922e301dfb73866fde96fa06dc7ace9e670df15ef587dd336
4
+ data.tar.gz: 8f1b78e9c49e3f6f99a37cd97bbca36054e75c15d99d126699beb61f00a07840
5
5
  SHA512:
6
- metadata.gz: 5aee20035eb1f290f95648c146b7a05f650f033d2f4cfa4ee4dbc565133031871607974c8c3eca58009bf1a3b5fff53ff11940c8841b476d281909d8d72d6ac4
7
- data.tar.gz: d8a9f184fc3c636742293a74901d24e12c7c8d48191b1816824e90cf0d7162f670373e78dd54ecc4bc6ef7daffbe6e73f1e341fdb786b258ba7ef3d8ffc1a4da
6
+ metadata.gz: 35b625b3e4bd845ba5b5832016f4fd3f310b410e763c476a569c32920f8707317b007f00be9cbd441f704aeccf126d8c4d48646706cab7cc597d1e7fabc07d58
7
+ data.tar.gz: b509a7068ba88ea63e30ecb9c036c1b3b2d63d58da73cd0e2a812069f907ad8439d0ee1ae5d10d17ca7fb97595c0c420c02ab5f9fe151c96525f0c07b85c91d6
data/README.md CHANGED
@@ -105,7 +105,6 @@ localvault exec -- rails server
105
105
  | `sync push [NAME]` | Push one vault to cloud |
106
106
  | `sync pull [NAME]` | Pull one vault from cloud (auto-unlocks if you have a key slot) |
107
107
  | `sync status` | Show sync state for all vaults |
108
- | `config set server URL` | Point at a custom server (default: inventlist.com) |
109
108
 
110
109
  ### Team Sharing (v1.3.0)
111
110
 
@@ -139,7 +138,7 @@ backward compatibility but the top-level forms are preferred.
139
138
 
140
139
  | Command | Description |
141
140
  |---------|-------------|
142
- | `install-mcp [CLIENT]` | Configure MCP server in claude-code, cursor, windsurf, or zed |
141
+ | `install-mcp [CLIENT]` | Configure MCP server in claude-code, cursor, or windsurf |
143
142
  | `mcp` | Start MCP server (stdio transport) |
144
143
 
145
144
  All commands accept `--vault NAME` (or `-v NAME`) to target a specific vault. Default vault is `default`.
@@ -215,28 +214,30 @@ localvault remove @alice -v production --rotate
215
214
 
216
215
  ## MCP Server (AI Agents)
217
216
 
218
- Give AI agents safe secret access. Keys never appear in agent context or config files.
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.
219
218
 
220
219
  ```bash
221
220
  # One-command install for Claude Code
222
221
  localvault install-mcp claude-code
223
- # Also supports: cursor, windsurf, zed
222
+ # Also supports: cursor, windsurf
224
223
 
225
224
  # Unlock your vault for the session
226
225
  localvault unlock
227
226
 
228
227
  # MCP tools available to the agent:
229
- # get_secret(key, vault?) read a secret
230
- # list_secrets(vault?, prefix?) list key names
228
+ # localvault_whoami diagnose active vault/session state
229
+ # get_secret(key, vault?) read an exact secret key
230
+ # list_secrets(vault?, prefix?, query?) — list/search key names
231
231
  # set_secret(key, value, vault?) — store a secret
232
- # delete_secret(key, vault?) — remove a secret
232
+ # delete_secret(key, vault?) — remove a secret
233
233
  ```
234
234
 
235
- **exec_action** agent declares intent, LocalVault executes with secrets injected. The agent never sees the key:
235
+ Command injection stays in the CLI. Use selectors, mappings, and profiles to keep subprocess envs scoped:
236
236
 
237
237
  ```bash
238
- localvault exec_action -- curl -s https://api.openai.com/v1/models \
239
- -H "Authorization: Bearer $OPENAI_API_KEY"
238
+ localvault exec --profile aws -- aws sts get-caller-identity
239
+ localvault exec --only AWS_IAM.*,AWS_SES.* --except AWS_SES.smtp_password -- your-script
240
+ localvault env --map AWS_IAM.access_key_id=AWS_ACCESS_KEY_ID
240
241
  ```
241
242
 
242
243
  ## Multi-Project Vaults
@@ -257,6 +258,10 @@ localvault show -p myapp -v work
257
258
  # Export one project
258
259
  eval $(localvault env -p myapp -v work)
259
260
 
261
+ # Export all projects with project.key transformed to PROJECT__key
262
+ localvault env -v work
263
+ # → MYAPP__DATABASE_URL, API__DATABASE_URL
264
+
260
265
  # Bulk import
261
266
  localvault import .env --prefix myapp -v work
262
267
  ```
@@ -273,7 +278,7 @@ localvault get API_KEY
273
278
  localvault exec -- rails server
274
279
  ```
275
280
 
276
- Session lives in `LOCALVAULT_SESSION` disappears when the terminal closes.
281
+ Unlocking writes a derived key to `LOCALVAULT_SESSION` and also caches it with an 8-hour TTL in Keychain or LocalVault's file fallback so MCP and new terminals can reuse it until `localvault lock`.
277
282
 
278
283
  ## Security
279
284
 
@@ -325,7 +330,7 @@ localvault login --server https://vaulthost.example
325
330
  git clone https://github.com/inventlist/localvault.git
326
331
  cd localvault
327
332
  bundle install
328
- bundle exec rake test # 463 tests, 918 assertions
333
+ bundle exec rake test
329
334
  ```
330
335
 
331
336
  ## Used by
@@ -17,6 +17,7 @@ module LocalVault
17
17
  # - Both exist, only local changed → push
18
18
  # - Both exist, only remote changed → pull
19
19
  # - Both exist, neither changed → skip
20
+ # - Both exist, no baseline but secrets identical → adopt (record baseline)
20
21
  # - Both exist, both changed → CONFLICT (manual resolution)
21
22
  # - Shared vault (not owned by you) → pull-only
22
23
  def all
@@ -34,7 +35,7 @@ module LocalVault
34
35
  return
35
36
  end
36
37
 
37
- plan = all_names.map { |name| classify_vault(name, local_set, remote_map, my_handle) }
38
+ plan = all_names.map { |name| classify_vault(name, local_set, remote_map, my_handle, client) }
38
39
 
39
40
  # Print plan
40
41
  max_name = (["Vault"] + plan.map { |p| p[:name] }).map(&:length).max
@@ -55,7 +56,7 @@ module LocalVault
55
56
  end
56
57
 
57
58
  # Execute
58
- pushed = pulled = skipped = conflicts = errors = 0
59
+ pushed = pulled = skipped = adopted = conflicts = errors = 0
59
60
  plan.each do |entry|
60
61
  case entry[:action]
61
62
  when :push
@@ -70,6 +71,12 @@ module LocalVault
70
71
  else
71
72
  errors += 1
72
73
  end
74
+ when :adopt
75
+ if perform_adopt(entry[:name])
76
+ adopted += 1
77
+ else
78
+ errors += 1
79
+ end
73
80
  when :skip
74
81
  skipped += 1
75
82
  when :conflict
@@ -79,10 +86,11 @@ module LocalVault
79
86
 
80
87
  # Summary
81
88
  parts = []
82
- parts << "#{pushed} pushed" if pushed > 0
83
- parts << "#{pulled} pulled" if pulled > 0
89
+ parts << "#{pushed} pushed" if pushed > 0
90
+ parts << "#{pulled} pulled" if pulled > 0
91
+ parts << "#{adopted} baselined" if adopted > 0
84
92
  parts << "#{skipped} up to date" if skipped > 0
85
- parts << "#{errors} failed" if errors > 0
93
+ parts << "#{errors} failed" if errors > 0
86
94
  parts << "#{conflicts} conflict#{conflicts == 1 ? "" : "s"}" if conflicts > 0
87
95
  $stdout.puts "Summary: #{parts.join(", ")}"
88
96
 
@@ -292,8 +300,9 @@ module LocalVault
292
300
  # @param local_set [Set<String>] vaults that exist on disk
293
301
  # @param remote_map [Hash{String => Hash}] remote vault info keyed by name
294
302
  # @param my_handle [String] current user's InventList handle
303
+ # @param client [ApiClient] authenticated API client (used to hash remote secrets)
295
304
  # @return [Hash] +{name:, action:, reason:}+
296
- def classify_vault(name, local_set, remote_map, my_handle)
305
+ def classify_vault(name, local_set, remote_map, my_handle, client)
297
306
  l_exists = local_set.include?(name)
298
307
  r_info = remote_map[name]
299
308
  r_exists = !r_info.nil?
@@ -303,8 +312,25 @@ module LocalVault
303
312
  s_exists = ss.exists?
304
313
  baseline = ss.last_synced_checksum
305
314
 
306
- local_checksum = l_exists && store ? SyncState.local_checksum(store) : nil
307
- remote_checksum = r_info&.dig("checksum")
315
+ local_checksum = l_exists && store ? SyncState.local_checksum(store) : nil
316
+
317
+ # The server's list `checksum` hashes the whole sync bundle, which lives
318
+ # in a different hash space than our baseline and +local_checksum+ (both
319
+ # SHA256 of the encrypted *secrets* bytes). Comparing across those spaces
320
+ # never matches, so it would flag every both-exist vault as changed or
321
+ # conflicting. To compare like-for-like we download the bundle and hash
322
+ # its secrets bytes — but only when both sides exist, since for local-only
323
+ # / remote-only vaults the direction is unambiguous and no compare is needed.
324
+ if l_exists && r_exists
325
+ begin
326
+ remote_checksum = remote_secrets_checksum(name, client)
327
+ rescue SyncBundle::UnpackError
328
+ # A corrupt/unparseable remote bundle must not be conflated with an
329
+ # empty vault (both would otherwise yield nil and could falsely
330
+ # "adopt"). Surface it as a conflict the user can inspect.
331
+ return { name: name, action: :conflict, reason: "remote bundle unreadable — cannot compare" }
332
+ end
333
+ end
308
334
 
309
335
  # Ownership
310
336
  owner_handle = r_info&.dig("owner_handle")
@@ -337,12 +363,14 @@ module LocalVault
337
363
  # Neither (shouldn't happen since we iterate union)
338
364
  return [:skip, "no data"] unless l_exists && r_exists
339
365
 
340
- # Both exist — no baseline (first sync for this vault)
366
+ # Both exist — no baseline (first sync for this vault). Compare the
367
+ # secrets bytes directly: if they match, the sides are already in sync
368
+ # and we just record a baseline so future syncs can detect drift.
341
369
  unless s_exists
342
- if local_cs == remote_cs || (local_cs.nil? && remote_cs.nil?)
343
- return [:skip, "already in sync (first check)"]
370
+ if local_cs == remote_cs
371
+ return [:adopt, "in sync recording baseline"]
344
372
  else
345
- return [:conflict, "both exist, no sync baseline — cannot determine which side changed"]
373
+ return [:conflict, "both exist, no sync baseline — run 'sync push' or 'sync pull' to resolve"]
346
374
  end
347
375
  end
348
376
 
@@ -363,6 +391,45 @@ module LocalVault
363
391
 
364
392
  # ── Helpers ──────────────────────────────────────────────────
365
393
 
394
+ # Record a sync baseline for a vault whose local and remote secrets are
395
+ # already byte-identical, without transferring any data. Lets the next
396
+ # sync detect drift instead of re-comparing from scratch every time.
397
+ #
398
+ # @param vault_name [String] vault to baseline
399
+ # @return [Boolean] true on success, false on any error
400
+ def perform_adopt(vault_name)
401
+ store = Store.new(vault_name)
402
+ SyncState.new(vault_name).write!(
403
+ checksum: SyncState.local_checksum(store),
404
+ direction: "adopt"
405
+ )
406
+ $stdout.puts " baselined #{vault_name} (already in sync)"
407
+ true
408
+ rescue StandardError => e
409
+ $stderr.puts "Error baselining '#{vault_name}': #{e.message}"
410
+ false
411
+ end
412
+
413
+ # SHA256 of a remote vault's encrypted secrets bytes — the same hash space
414
+ # as +SyncState.local_checksum+ and the stored baseline, so the two can be
415
+ # compared directly. Returns nil when the vault has no secrets (empty), is
416
+ # missing remotely (404), or the bundle can't be parsed.
417
+ #
418
+ # @param vault_name [String] vault to fetch
419
+ # @param client [ApiClient] authenticated API client
420
+ # @return [String, nil] hex digest of the remote secrets bytes, or nil if empty/missing
421
+ # @raise [SyncBundle::UnpackError] if the bundle exists but can't be parsed
422
+ def remote_secrets_checksum(vault_name, client)
423
+ blob = client.pull_vault(vault_name)
424
+ return nil unless blob.is_a?(String) && !blob.empty?
425
+ secrets = SyncBundle.unpack(blob)[:secrets]
426
+ return nil if secrets.nil? || secrets.empty?
427
+ Digest::SHA256.hexdigest(secrets)
428
+ rescue ApiClient::ApiError => e
429
+ raise unless e.status == 404
430
+ nil
431
+ end
432
+
366
433
  def try_unlock_via_key_slot(vault_name, key_slots)
367
434
  return false unless key_slots.is_a?(Hash) && !key_slots.empty?
368
435
  return false unless Identity.exists?
@@ -2,7 +2,10 @@ 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"
6
9
 
7
10
  module LocalVault
8
11
  class CLI < Thor
@@ -146,28 +149,18 @@ module LocalVault
146
149
  DESC
147
150
  def get(key)
148
151
  vault = open_vault!
149
- value = vault.get(key)
150
-
151
- if value.nil?
152
- # Fall back to case-insensitive substring match
153
- all_keys = vault.list
154
- matches = all_keys.select { |k| k.downcase.include?(key.downcase) }
155
-
156
- if matches.size == 1
157
- value = vault.get(matches.first)
158
- $stdout.puts value
159
- return
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
152
+ lookup = KeyLookup.lookup(vault, key)
153
+
154
+ if lookup.exact?
155
+ $stdout.puts lookup.value
156
+ elsif lookup.single_match?
157
+ $stdout.puts vault.get(lookup.matches.first)
158
+ elsif lookup.multiple_matches?
159
+ $stderr.puts "Error: Multiple keys match '#{key}'. Be more specific:"
160
+ lookup.matches.each { |k| $stderr.puts " #{k}" }
161
+ else
162
+ abort_with "Key '#{key}' not found in vault '#{vault.name}'"
168
163
  end
169
-
170
- $stdout.puts value
171
164
  end
172
165
 
173
166
  desc "list", "List all secret keys in the vault"
@@ -231,13 +224,25 @@ module LocalVault
231
224
  \x05 eval $(localvault env -v intellectaco)
232
225
  \x05 # → export PLATEPOSE__DATABASE_URL=... export INVENTLIST__DATABASE_URL=...
233
226
 
227
+ SCOPED EXPORTS:
228
+ \x05 localvault env --only AWS_IAM.*
229
+ \x05 localvault env --only AWS_IAM.*,AWS_SES.* --except AWS_SES.smtp_password
230
+ \x05 localvault env --map AWS_IAM.access_key_id=AWS_ACCESS_KEY_ID
231
+ \x05 localvault env --profile aws
232
+
234
233
  Use `localvault exec` to inject directly into a subprocess without eval.
235
234
  DESC
236
235
  method_option :project, aliases: "-p", type: :string, desc: "Export only this project group (no prefix)"
236
+ method_option :only, type: :string, desc: "Export only exact keys or namespaces (KEY,GROUP.*)"
237
+ method_option :except, type: :string, desc: "Exclude exact keys or namespaces after --only"
238
+ method_option :map, type: :string, desc: "Map vault keys to env vars (KEY=ENV_NAME)"
239
+ method_option :profile, type: :string, desc: "Apply a built-in env mapping profile (aws)"
237
240
  def env
238
241
  vault = open_vault!
239
242
  skip_warn = ->(k) { $stderr.puts "Warning: skipping unsafe key '#{k}'" }
240
- $stdout.puts vault.export_env(project: options[:project], on_skip: skip_warn)
243
+ $stdout.puts vault.export_env(**env_projection_options(on_skip: skip_warn))
244
+ rescue EnvProjection::InvalidMapping, EnvProjection::UnknownProfile => e
245
+ abort_with e.message
241
246
  end
242
247
 
243
248
  desc "exec -- CMD", "Run a command with secrets injected as environment variables"
@@ -256,13 +261,24 @@ module LocalVault
256
261
  TEAM VAULT — all projects (keys prefixed to avoid collisions):
257
262
  \x05 localvault exec -v intellectaco -- your-script
258
263
  \x05 # → PLATEPOSE__DATABASE_URL, INVENTLIST__DATABASE_URL, etc.
264
+
265
+ SCOPED INJECTION:
266
+ \x05 localvault exec --only AWS_IAM.* -- aws sts get-caller-identity
267
+ \x05 localvault exec --profile aws -- aws s3 ls
268
+ \x05 localvault exec --map AWS_IAM.access_key_id=AWS_ACCESS_KEY_ID -- env
259
269
  DESC
260
270
  method_option :project, aliases: "-p", type: :string, desc: "Inject only this project group (no prefix)"
271
+ method_option :only, type: :string, desc: "Inject only exact keys or namespaces (KEY,GROUP.*)"
272
+ method_option :except, type: :string, desc: "Exclude exact keys or namespaces after --only"
273
+ method_option :map, type: :string, desc: "Map vault keys to env vars (KEY=ENV_NAME)"
274
+ method_option :profile, type: :string, desc: "Apply a built-in env mapping profile (aws)"
261
275
  def exec(*cmd)
262
276
  vault = open_vault!
263
277
  skip_warn = ->(k) { $stderr.puts "Warning: skipping unsafe key '#{k}'" }
264
- env_vars = vault.env_hash(project: options[:project], on_skip: skip_warn)
278
+ env_vars = vault.env_hash(**env_projection_options(on_skip: skip_warn))
265
279
  Kernel.exec(env_vars, *cmd)
280
+ rescue EnvProjection::InvalidMapping, EnvProjection::UnknownProfile => e
281
+ abort_with e.message
266
282
  end
267
283
 
268
284
  desc "vaults", "List all vaults with secret counts"
@@ -466,8 +482,10 @@ module LocalVault
466
482
  cursor Adds to ~/.cursor/mcp.json
467
483
  windsurf Adds to ~/.codeium/windsurf/mcp_config.json
468
484
 
469
- The MCP server uses whichever vault is your current default (localvault switch).
470
- Unlock the vault once with `localvault show`, then the AI tool picks it up via Keychain.
485
+ The MCP server uses the same active vault rules as the CLI: explicit vault,
486
+ then LOCALVAULT_VAULT, then the configured default from `localvault switch`.
487
+ Unlock with `localvault show` or `localvault unlock`; `localvault lock`
488
+ revokes access without restarting the AI tool.
471
489
  DESC
472
490
  def install_mcp(client = "claude-code")
473
491
  case client.downcase
@@ -1514,7 +1532,18 @@ module LocalVault
1514
1532
  end
1515
1533
 
1516
1534
  def resolve_vault_name
1517
- options[:vault] || Config.default_vault
1535
+ VaultResolver.active_vault_name(options[:vault]).first
1536
+ end
1537
+
1538
+ def env_projection_options(on_skip:)
1539
+ {
1540
+ project: options[:project],
1541
+ only: options[:only],
1542
+ except: options[:except],
1543
+ map: options[:map],
1544
+ profile: options[:profile],
1545
+ on_skip: on_skip
1546
+ }
1518
1547
  end
1519
1548
 
1520
1549
  def open_vault_by_name!(vault_name)
@@ -0,0 +1,131 @@
1
+ module LocalVault
2
+ module EnvProjection
3
+ class InvalidMapping < StandardError; end
4
+ class UnknownProfile < StandardError; end
5
+
6
+ Entry = Struct.new(:key, :env_name, :value, keyword_init: true)
7
+
8
+ ENV_NAME_PATTERN = /\A[A-Za-z_][A-Za-z0-9_]*\z/
9
+
10
+ PROFILES = {
11
+ "aws" => {
12
+ only: ["AWS_IAM.*"],
13
+ map: {
14
+ "AWS_IAM.access_key_id" => "AWS_ACCESS_KEY_ID",
15
+ "AWS_IAM.secret_access_key" => "AWS_SECRET_ACCESS_KEY",
16
+ "AWS_IAM.session_token" => "AWS_SESSION_TOKEN"
17
+ }
18
+ }
19
+ }.freeze
20
+
21
+ def self.entries(secrets, project: nil, only: nil, except: nil, map: nil, profile: nil, on_skip: nil)
22
+ profile_config = profile_config(profile)
23
+ selectors = parse_selectors(only) || profile_config[:only]
24
+ exclusions = parse_selectors(except) || []
25
+ mappings = profile_config[:map].merge(parse_map(map))
26
+
27
+ flatten(secrets, project: project, on_skip: on_skip)
28
+ .select { |entry| include_entry?(entry.key, selectors) }
29
+ .reject { |entry| selector_match?(entry.key, exclusions) }
30
+ .map { |entry| apply_mapping(entry, mappings, on_skip: on_skip) }
31
+ .compact
32
+ end
33
+
34
+ def self.parse_selectors(value)
35
+ values = Array(value).compact.flat_map { |v| v.to_s.split(",") }
36
+ selectors = values.map(&:strip).reject(&:empty?)
37
+ selectors.empty? ? nil : selectors
38
+ end
39
+
40
+ def self.parse_map(value)
41
+ Array(value).compact.flat_map { |v| v.to_s.split(",") }.each_with_object({}) do |pair, hash|
42
+ next if pair.strip.empty?
43
+
44
+ key, env_name = pair.split("=", 2).map(&:strip)
45
+ 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)
47
+
48
+ hash[key] = env_name
49
+ end
50
+ end
51
+
52
+ def self.profile_config(profile)
53
+ return { only: nil, map: {} } if profile.nil? || profile.to_s.empty?
54
+
55
+ PROFILES.fetch(profile.to_s) do
56
+ raise UnknownProfile, "Unknown env profile '#{profile}'"
57
+ end
58
+ end
59
+
60
+ def self.flatten(secrets, project:, on_skip:)
61
+ if project
62
+ group = secrets[project]
63
+ return [] unless group.is_a?(Hash)
64
+
65
+ group.filter_map do |key, value|
66
+ if safe_env_name?(key)
67
+ Entry.new(key: key, env_name: key, value: value.to_s)
68
+ else
69
+ on_skip&.call(key)
70
+ nil
71
+ end
72
+ end
73
+ else
74
+ secrets.flat_map do |key, value|
75
+ if value.is_a?(Hash)
76
+ flatten_group(key, value, on_skip: on_skip)
77
+ elsif safe_env_name?(key)
78
+ [Entry.new(key: key, env_name: key, value: value.to_s)]
79
+ else
80
+ on_skip&.call(key)
81
+ []
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ def self.flatten_group(group, pairs, on_skip:)
88
+ unless safe_env_name?(group)
89
+ on_skip&.call(group)
90
+ return []
91
+ end
92
+
93
+ pairs.filter_map do |key, value|
94
+ if safe_env_name?(key)
95
+ Entry.new(key: "#{group}.#{key}", env_name: "#{group.upcase}__#{key}", value: value.to_s)
96
+ else
97
+ on_skip&.call("#{group}.#{key}")
98
+ nil
99
+ end
100
+ end
101
+ end
102
+
103
+ def self.include_entry?(key, selectors)
104
+ selectors.nil? || selector_match?(key, selectors)
105
+ end
106
+
107
+ def self.selector_match?(key, selectors)
108
+ selectors.any? do |selector|
109
+ if selector.end_with?(".*")
110
+ key.start_with?("#{selector.delete_suffix(".*")}.")
111
+ else
112
+ key == selector
113
+ end
114
+ end
115
+ end
116
+
117
+ def self.apply_mapping(entry, mappings, on_skip:)
118
+ mapped_name = mappings.fetch(entry.key, entry.env_name)
119
+ unless safe_env_name?(mapped_name)
120
+ on_skip&.call(entry.key)
121
+ return nil
122
+ end
123
+
124
+ Entry.new(key: entry.key, env_name: mapped_name, value: entry.value)
125
+ end
126
+
127
+ def self.safe_env_name?(name)
128
+ name.is_a?(String) && name.match?(ENV_NAME_PATTERN)
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,30 @@
1
+ module LocalVault
2
+ module KeyLookup
3
+ Result = Struct.new(:key, :value, :matches, keyword_init: true) do
4
+ def exact?
5
+ !value.nil?
6
+ end
7
+
8
+ def single_match?
9
+ matches.size == 1
10
+ end
11
+
12
+ def multiple_matches?
13
+ matches.size > 1
14
+ end
15
+ end
16
+
17
+ def self.lookup(vault, key)
18
+ value = vault.get(key)
19
+ return Result.new(key: key, value: value, matches: []) unless value.nil?
20
+
21
+ matches = candidates(vault, key)
22
+ Result.new(key: key, value: nil, matches: matches)
23
+ end
24
+
25
+ def self.candidates(vault, key)
26
+ query = key.to_s.downcase
27
+ vault.list.select { |candidate| candidate.downcase.include?(query) }.sort
28
+ end
29
+ end
30
+ end
@@ -6,6 +6,7 @@ require_relative "../config"
6
6
  require_relative "../store"
7
7
  require_relative "../vault"
8
8
  require_relative "../session_cache"
9
+ require_relative "../vault_resolver"
9
10
  require_relative "tools"
10
11
 
11
12
  module LocalVault
@@ -16,10 +17,8 @@ module LocalVault
16
17
  # @param input [IO] input stream for JSON-RPC messages (default: $stdin)
17
18
  # @param output [IO] output stream for JSON-RPC responses (default: $stdout)
18
19
  def initialize(input: $stdin, output: $stdout)
19
- @input = input
20
- @output = output
21
- @vault_cache = {} # name => Vault — lazily populated per-call
22
- @session_vault = load_session_vault # LOCALVAULT_SESSION fast-path
20
+ @input = input
21
+ @output = output
23
22
  end
24
23
 
25
24
  # Start the MCP server loop, reading JSON-RPC messages line-by-line.
@@ -29,7 +28,7 @@ module LocalVault
29
28
  #
30
29
  # @return [void]
31
30
  def start
32
- unlocked = unlocked_vault_names
31
+ unlocked = VaultResolver.unlocked_vault_names
33
32
  label = unlocked.empty? ? "no unlocked vaults (run: localvault show)" : "vaults=#{unlocked.join(', ')}"
34
33
  $stderr.puts "[localvault-mcp] started v#{LocalVault::VERSION} #{label}"
35
34
  $stderr.flush
@@ -85,7 +84,7 @@ module LocalVault
85
84
  return error_response(id, -32602, "Unknown tool: #{tool_name}")
86
85
  end
87
86
 
88
- result = Tools.call(tool_name, arguments, method(:vault_for))
87
+ result = Tools.call(tool_name, arguments, method(:vault_for), method(:vault_status))
89
88
  success_response(id, result)
90
89
  else
91
90
  error_response(id, -32601, "Method not found: #{method}")
@@ -99,70 +98,11 @@ module LocalVault
99
98
  # Resolve vault by name, lazily — tries session token, then Keychain.
100
99
  # Returns nil if vault is not unlocked.
101
100
  def vault_for(name = nil)
102
- # No specific vault requested: session vault takes priority over default
103
- if name.nil? && @session_vault
104
- @vault_cache[@session_vault.name] ||= @session_vault
105
- return @session_vault
106
- end
107
-
108
- vault_name = name || default_vault_name
109
-
110
- return @vault_cache[vault_name] if @vault_cache.key?(vault_name)
111
-
112
- # Fast-path: LOCALVAULT_SESSION matches by name
113
- if @session_vault && @session_vault.name == vault_name
114
- @vault_cache[vault_name] = @session_vault
115
- return @session_vault
116
- end
117
-
118
- # Keychain lookup
119
- if (master_key = SessionCache.get(vault_name))
120
- vault = Vault.new(name: vault_name, master_key: master_key)
121
- vault.all # verify decryption
122
- @vault_cache[vault_name] = vault
123
- return vault
124
- end
125
-
126
- nil
127
- rescue Crypto::DecryptionError
128
- nil
129
- end
130
-
131
- def default_vault_name
132
- ENV["LOCALVAULT_VAULT"] || Config.default_vault
133
- end
134
-
135
- # Parse LOCALVAULT_SESSION on startup (single-vault legacy path).
136
- def load_session_vault
137
- token = ENV["LOCALVAULT_SESSION"]
138
- return nil unless token
139
-
140
- decoded = Base64.strict_decode64(token)
141
- vault_name, key_b64 = decoded.split(":", 2)
142
- return nil unless vault_name && key_b64
143
-
144
- master_key = Base64.strict_decode64(key_b64)
145
- vault = Vault.new(name: vault_name, master_key: master_key)
146
- vault.all # verify decryption
147
- vault
148
- rescue ArgumentError, Crypto::DecryptionError
149
- nil
101
+ VaultResolver.resolve(name).vault
150
102
  end
151
103
 
152
- # List vault names that are currently unlocked (for the startup log).
153
- def unlocked_vault_names
154
- names = []
155
-
156
- # From LOCALVAULT_SESSION
157
- names << @session_vault.name if @session_vault
158
-
159
- # From Keychain — check all known vaults
160
- Store.list_vaults.each do |n|
161
- next if names.include?(n)
162
- names << n if SessionCache.get(n)
163
- end
164
-
165
- names
104
+ def vault_status(name = nil)
105
+ VaultResolver.status(name)
166
106
  end
167
107
 
168
108
  def success_response(id, result)
@@ -1,4 +1,6 @@
1
1
  require "json"
2
+ require_relative "../key_lookup"
3
+ require_relative "../version"
2
4
 
3
5
  module LocalVault
4
6
  module MCP
@@ -28,6 +30,19 @@ module LocalVault
28
30
  {
29
31
  "name" => "list_secrets",
30
32
  "description" => "List all secret keys in a localvault vault",
33
+ "inputSchema" => {
34
+ "type" => "object",
35
+ "properties" => {
36
+ "prefix" => { "type" => "string", "description" => "Only return keys starting with this prefix" },
37
+ "query" => { "type" => "string", "description" => "Case-insensitive substring filter for key names" },
38
+ **VAULT_PARAM
39
+ },
40
+ "required" => []
41
+ }
42
+ },
43
+ {
44
+ "name" => "localvault_whoami",
45
+ "description" => "Show which localvault home, vault, and unlocked sessions the MCP server can see",
31
46
  "inputSchema" => {
32
47
  "type" => "object",
33
48
  "properties" => { **VAULT_PARAM },
@@ -72,12 +87,16 @@ module LocalVault
72
87
  # and returns a Vault instance or nil
73
88
  # @return [Hash] MCP content result with "content" array and optional "isError"
74
89
  # @raise [ArgumentError] if the tool name is unknown
75
- def self.call(name, arguments, vault_resolver)
90
+ def self.call(name, arguments, vault_resolver, status_resolver = nil)
76
91
  unless DEFINITIONS.any? { |t| t["name"] == name }
77
92
  raise ArgumentError, "Unknown tool: #{name}"
78
93
  end
79
94
 
95
+ return error_result("Invalid arguments; expected object") unless arguments.is_a?(Hash)
96
+
80
97
  vault_name = arguments["vault"]
98
+ return whoami(status_resolver.call(vault_name)) if name == "localvault_whoami"
99
+
81
100
  vault = vault_resolver.call(vault_name)
82
101
 
83
102
  unless vault
@@ -87,30 +106,54 @@ module LocalVault
87
106
 
88
107
  case name
89
108
  when "get_secret" then get_secret(arguments["key"], vault)
90
- when "list_secrets" then list_secrets(vault)
109
+ when "list_secrets" then list_secrets(vault, prefix: arguments["prefix"], query: arguments["query"])
91
110
  when "set_secret" then set_secret(arguments["key"], arguments["value"], vault)
92
111
  when "delete_secret" then delete_secret(arguments["key"], vault)
93
112
  end
113
+ rescue StandardError => e
114
+ error_result(e.message)
94
115
  end
95
116
 
96
117
  def self.get_secret(key, vault)
97
- value = vault.get(key)
98
- value.nil? ? error_result("Key '#{key}' not found") : text_result(value)
118
+ return required_argument_error("key") unless present_string?(key)
119
+
120
+ lookup = KeyLookup.lookup(vault, key)
121
+ return text_result(lookup.value) if lookup.exact?
122
+
123
+ if lookup.multiple_matches?
124
+ return error_result(candidate_message("Multiple keys match '#{key}'. Be more specific:", lookup.matches))
125
+ end
126
+
127
+ if lookup.single_match?
128
+ return error_result(candidate_message("Key '#{key}' not found. Did you mean:", lookup.matches))
129
+ end
130
+
131
+ error_result("Key '#{key}' not found")
99
132
  end
100
133
 
101
- def self.list_secrets(vault)
134
+ def self.list_secrets(vault, prefix: nil, query: nil)
135
+ return string_argument_error("prefix") unless optional_string?(prefix)
136
+ return string_argument_error("query") unless optional_string?(query)
137
+
102
138
  keys = vault.list
139
+ keys = keys.select { |key| key.start_with?(prefix) } if prefix && !prefix.empty?
140
+ keys = keys.select { |key| key.downcase.include?(query.downcase) } if query && !query.empty?
103
141
  keys.empty? ? text_result("No secrets stored") : text_result(keys.join("\n"))
104
142
  end
105
143
 
106
144
  def self.set_secret(key, value, vault)
145
+ return required_argument_error("key") unless present_string?(key)
146
+ return required_argument_error("value") unless value.is_a?(String)
147
+
107
148
  vault.set(key, value)
108
149
  text_result("Stored #{key}")
109
- rescue Vault::InvalidKeyName => e
110
- error_result("Invalid key name: #{e.message}")
150
+ rescue Vault::InvalidKeyName, RuntimeError => e
151
+ error_result(e.message)
111
152
  end
112
153
 
113
154
  def self.delete_secret(key, vault)
155
+ return required_argument_error("key") unless present_string?(key)
156
+
114
157
  deleted = vault.delete(key)
115
158
  deleted.nil? ? error_result("Key '#{key}' not found") : text_result("Deleted #{key}")
116
159
  end
@@ -123,7 +166,43 @@ module LocalVault
123
166
  { "content" => [{ "type" => "text", "text" => text }], "isError" => true }
124
167
  end
125
168
 
126
- private_class_method :get_secret, :list_secrets, :set_secret, :delete_secret, :text_result, :error_result
169
+ def self.whoami(status)
170
+ structured = status.merge("version" => LocalVault::VERSION)
171
+ text = [
172
+ "LocalVault #{LocalVault::VERSION}",
173
+ "Home: #{structured["localvault_home"]}",
174
+ "Active vault: #{structured["active_vault"]} (#{structured["active_vault_source"]})",
175
+ "Active vault unlocked: #{structured["active_vault_unlocked"] ? "yes" : "no"}",
176
+ "Session vault: #{structured["session_vault"] || "-"}",
177
+ "Unlocked vaults: #{structured["unlocked_vaults"].empty? ? "-" : structured["unlocked_vaults"].join(", ")}"
178
+ ].join("\n")
179
+
180
+ text_result(text).merge("structuredContent" => structured)
181
+ end
182
+
183
+ def self.candidate_message(header, matches)
184
+ ([header] + matches.map { |match| " #{match}" }).join("\n")
185
+ end
186
+
187
+ def self.present_string?(value)
188
+ value.is_a?(String) && !value.empty?
189
+ end
190
+
191
+ def self.optional_string?(value)
192
+ value.nil? || value.is_a?(String)
193
+ end
194
+
195
+ def self.required_argument_error(name)
196
+ error_result("Missing required argument '#{name}'")
197
+ end
198
+
199
+ def self.string_argument_error(name)
200
+ error_result("Argument '#{name}' must be a string")
201
+ end
202
+
203
+ private_class_method :get_secret, :list_secrets, :set_secret, :delete_secret,
204
+ :text_result, :error_result, :whoami, :candidate_message,
205
+ :present_string?, :optional_string?, :required_argument_error, :string_argument_error
127
206
  end
128
207
  end
129
208
  end
@@ -116,7 +116,11 @@ module LocalVault
116
116
  # Fall back to file store if Keychain fails (e.g., in CI or sandboxed env)
117
117
  unless success
118
118
  file = session_file(vault_name)
119
- File.write(file, payload, perm: 0o600)
119
+ File.write(file, payload)
120
+ # chmod unconditionally: File.write's perm: only applies on creation,
121
+ # so an existing looser-mode file would otherwise keep its perms and
122
+ # leak the master key.
123
+ File.chmod(0o600, file)
120
124
  end
121
125
  else
122
126
  keychain_delete(vault_name)
@@ -90,7 +90,7 @@ module LocalVault
90
90
  def self.unpack(blob, expected_name: nil)
91
91
  data = JSON.parse(blob)
92
92
  version = data["version"]
93
- raise UnpackError, "Unsupported bundle version: #{version}" if version && !SUPPORTED_VERSIONS.include?(version)
93
+ raise UnpackError, "Unsupported bundle version: #{version.inspect}" unless SUPPORTED_VERSIONS.include?(version)
94
94
 
95
95
  meta_raw = Base64.strict_decode64(data.fetch("meta"))
96
96
  secrets_raw = Base64.strict_decode64(data.fetch("secrets"))
@@ -1,5 +1,6 @@
1
1
  require "json"
2
2
  require "shellwords"
3
+ require_relative "env_projection"
3
4
 
4
5
  module LocalVault
5
6
  # Encrypted key-value store backed by a single JSON blob.
@@ -143,44 +144,15 @@ module LocalVault
143
144
  # @example
144
145
  # vault.export_env(project: "myapp")
145
146
  # # => "export DB_URL=postgres%3A//..."
146
- def export_env(project: nil, on_skip: nil)
147
- secrets = all
148
- if project
149
- group = secrets[project]
150
- return "" unless group.is_a?(Hash)
151
- group.filter_map do |k, v|
152
- if shell_safe_key?(k)
153
- "export #{k}=#{Shellwords.escape(v.to_s)}"
154
- else
155
- on_skip&.call(k)
156
- nil
157
- end
158
- end.join("\n")
159
- else
160
- secrets.flat_map do |k, v|
161
- if v.is_a?(Hash)
162
- unless shell_safe_key?(k)
163
- on_skip&.call(k)
164
- next []
165
- end
166
- v.filter_map do |sk, sv|
167
- if shell_safe_key?(sk)
168
- "export #{k.upcase}__#{sk}=#{Shellwords.escape(sv.to_s)}"
169
- else
170
- on_skip&.call("#{k}.#{sk}")
171
- nil
172
- end
173
- end
174
- else
175
- if shell_safe_key?(k)
176
- ["export #{k}=#{Shellwords.escape(v.to_s)}"]
177
- else
178
- on_skip&.call(k)
179
- []
180
- end
181
- end
182
- end.join("\n")
183
- end
147
+ def export_env(project: nil, on_skip: nil, only: nil, except: nil, map: nil, profile: nil)
148
+ EnvProjection.entries(all,
149
+ project: project,
150
+ only: only,
151
+ except: except,
152
+ map: map,
153
+ profile: profile,
154
+ on_skip: on_skip
155
+ ).map { |entry| "export #{entry.env_name}=#{Shellwords.escape(entry.value)}" }.join("\n")
184
156
  end
185
157
 
186
158
  # Returns a flat hash suitable for env injection.
@@ -195,40 +167,16 @@ module LocalVault
195
167
  # @example
196
168
  # vault.env_hash(project: "myapp")
197
169
  # # => {"DB_URL" => "postgres://...", "SECRET" => "abc"}
198
- def env_hash(project: nil, on_skip: nil)
199
- secrets = all
200
- if project
201
- group = secrets[project]
202
- return {} unless group.is_a?(Hash)
203
- group.each_with_object({}) do |(k, v), h|
204
- if shell_safe_key?(k)
205
- h[k] = v.to_s
206
- else
207
- on_skip&.call(k)
208
- end
209
- end
210
- else
211
- secrets.each_with_object({}) do |(k, v), h|
212
- if v.is_a?(Hash)
213
- unless shell_safe_key?(k)
214
- on_skip&.call(k)
215
- next
216
- end
217
- v.each do |sk, sv|
218
- if shell_safe_key?(sk)
219
- h["#{k.upcase}__#{sk}"] = sv.to_s
220
- else
221
- on_skip&.call("#{k}.#{sk}")
222
- end
223
- end
224
- else
225
- if shell_safe_key?(k)
226
- h[k] = v.to_s
227
- else
228
- on_skip&.call(k)
229
- end
230
- end
231
- end
170
+ def env_hash(project: nil, on_skip: nil, only: nil, except: nil, map: nil, profile: nil)
171
+ EnvProjection.entries(all,
172
+ project: project,
173
+ only: only,
174
+ except: except,
175
+ map: map,
176
+ profile: profile,
177
+ on_skip: on_skip
178
+ ).each_with_object({}) do |entry, hash|
179
+ hash[entry.env_name] = entry.value
232
180
  end
233
181
  end
234
182
 
@@ -0,0 +1,81 @@
1
+ require "base64"
2
+
3
+ module LocalVault
4
+ module VaultResolver
5
+ Result = Struct.new(:vault, :active_vault, :active_vault_source, :session_vault, keyword_init: true)
6
+
7
+ def self.resolve(name = nil)
8
+ active_vault, source = active_vault_name(name)
9
+ session_name, session_key = session_credentials
10
+
11
+ if session_name == active_vault && session_key
12
+ vault = build_vault(active_vault, session_key)
13
+ return Result.new(vault: vault, active_vault: active_vault, active_vault_source: source, session_vault: session_name) if vault
14
+ end
15
+
16
+ if (master_key = SessionCache.get(active_vault))
17
+ vault = build_vault(active_vault, master_key)
18
+ return Result.new(vault: vault, active_vault: active_vault, active_vault_source: source, session_vault: session_name) if vault
19
+ end
20
+
21
+ Result.new(vault: nil, active_vault: active_vault, active_vault_source: source, session_vault: session_name)
22
+ end
23
+
24
+ def self.status(name = nil)
25
+ result = resolve(name)
26
+ {
27
+ "localvault_home" => Config.root_path,
28
+ "active_vault" => result.active_vault,
29
+ "active_vault_source" => result.active_vault_source,
30
+ "session_vault" => result.session_vault,
31
+ "unlocked_vaults" => unlocked_vault_names,
32
+ "active_vault_unlocked" => !result.vault.nil?
33
+ }
34
+ end
35
+
36
+ def self.active_vault_name(name = nil)
37
+ return [name, "argument"] if name && !name.empty?
38
+ return [ENV["LOCALVAULT_VAULT"], "env"] if ENV["LOCALVAULT_VAULT"] && !ENV["LOCALVAULT_VAULT"].empty?
39
+
40
+ [Config.default_vault, "config"]
41
+ end
42
+
43
+ def self.session_vault_name
44
+ session_credentials.first
45
+ end
46
+
47
+ def self.unlocked_vault_names
48
+ names = []
49
+ session_name, session_key = session_credentials
50
+ names << session_name if session_name && session_key && build_vault(session_name, session_key)
51
+
52
+ Store.list_vaults.each do |vault_name|
53
+ next if names.include?(vault_name)
54
+ names << vault_name if SessionCache.get(vault_name)
55
+ end
56
+
57
+ names.sort
58
+ end
59
+
60
+ def self.session_credentials
61
+ token = ENV["LOCALVAULT_SESSION"]
62
+ return [nil, nil] unless token
63
+
64
+ decoded = Base64.strict_decode64(token)
65
+ vault_name, key_b64 = decoded.split(":", 2)
66
+ return [nil, nil] unless vault_name && key_b64
67
+
68
+ [vault_name, Base64.strict_decode64(key_b64)]
69
+ rescue ArgumentError
70
+ [nil, nil]
71
+ end
72
+
73
+ def self.build_vault(name, master_key)
74
+ vault = Vault.new(name: name, master_key: master_key)
75
+ vault.all
76
+ vault
77
+ rescue Crypto::DecryptionError, Store::InvalidVaultName
78
+ nil
79
+ end
80
+ end
81
+ end
@@ -1,3 +1,3 @@
1
1
  module LocalVault
2
- VERSION = "1.6.1"
2
+ VERSION = "1.7.0"
3
3
  end
data/lib/localvault.rb CHANGED
@@ -2,6 +2,9 @@ require_relative "localvault/version"
2
2
  require_relative "localvault/crypto"
3
3
  require_relative "localvault/config"
4
4
  require_relative "localvault/store"
5
+ require_relative "localvault/env_projection"
6
+ require_relative "localvault/key_lookup"
7
+ require_relative "localvault/vault_resolver"
5
8
  require_relative "localvault/vault"
6
9
  require_relative "localvault/identity"
7
10
  require_relative "localvault/share_crypto"
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.6.1
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq
@@ -114,7 +114,9 @@ files:
114
114
  - lib/localvault/cli/team_helpers.rb
115
115
  - lib/localvault/config.rb
116
116
  - lib/localvault/crypto.rb
117
+ - lib/localvault/env_projection.rb
117
118
  - lib/localvault/identity.rb
119
+ - lib/localvault/key_lookup.rb
118
120
  - lib/localvault/key_slot.rb
119
121
  - lib/localvault/mcp/server.rb
120
122
  - lib/localvault/mcp/tools.rb
@@ -124,6 +126,7 @@ files:
124
126
  - lib/localvault/sync_bundle.rb
125
127
  - lib/localvault/sync_state.rb
126
128
  - lib/localvault/vault.rb
129
+ - lib/localvault/vault_resolver.rb
127
130
  - lib/localvault/version.rb
128
131
  homepage: https://github.com/inventlist/localvault
129
132
  licenses: