localvault 1.6.2 → 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: 3fe9cdf4fe4857588507532f6478318a7b02cdc47487bbecbe7ce54715e69e33
4
- data.tar.gz: 1be1af8d9476e1171895a0ba0677e206832de1386416cd5fb7e7060e7b8b85f5
3
+ metadata.gz: 2ff1cbda409eb9a922e301dfb73866fde96fa06dc7ace9e670df15ef587dd336
4
+ data.tar.gz: 8f1b78e9c49e3f6f99a37cd97bbca36054e75c15d99d126699beb61f00a07840
5
5
  SHA512:
6
- metadata.gz: 500451ad18462b5380839eb06b031b83b3acb91872430855e07038896c392f9488934aaf1649bd311886890400aaef9c45a062d8503c6db0760a2927b56a1650
7
- data.tar.gz: aa1aa1b764c2de50ab0c8fb425b14a77e05085f2224131c7f53dc1eaaccc9b80732f160716a1826ef479c0d9653e5451074ca585927a86630ef68a92f23b9b8c
6
+ metadata.gz: 35b625b3e4bd845ba5b5832016f4fd3f310b410e763c476a569c32920f8707317b007f00be9cbd441f704aeccf126d8c4d48646706cab7cc597d1e7fabc07d58
7
+ data.tar.gz: b509a7068ba88ea63e30ecb9c036c1b3b2d63d58da73cd0e2a812069f907ad8439d0ee1ae5d10d17ca7fb97595c0c420c02ab5f9fe151c96525f0c07b85c91d6
data/README.md CHANGED
@@ -214,28 +214,30 @@ localvault remove @alice -v production --rotate
214
214
 
215
215
  ## MCP Server (AI Agents)
216
216
 
217
- 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.
218
218
 
219
219
  ```bash
220
220
  # One-command install for Claude Code
221
221
  localvault install-mcp claude-code
222
- # Also supports: cursor, windsurf, zed
222
+ # Also supports: cursor, windsurf
223
223
 
224
224
  # Unlock your vault for the session
225
225
  localvault unlock
226
226
 
227
227
  # MCP tools available to the agent:
228
- # get_secret(key, vault?) read a secret
229
- # 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
230
231
  # set_secret(key, value, vault?) — store a secret
231
- # delete_secret(key, vault?) — remove a secret
232
+ # delete_secret(key, vault?) — remove a secret
232
233
  ```
233
234
 
234
- **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:
235
236
 
236
237
  ```bash
237
- localvault exec_action -- curl -s https://api.openai.com/v1/models \
238
- -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
239
241
  ```
240
242
 
241
243
  ## Multi-Project Vaults
@@ -256,6 +258,10 @@ localvault show -p myapp -v work
256
258
  # Export one project
257
259
  eval $(localvault env -p myapp -v work)
258
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
+
259
265
  # Bulk import
260
266
  localvault import .env --prefix myapp -v work
261
267
  ```
@@ -272,7 +278,7 @@ localvault get API_KEY
272
278
  localvault exec -- rails server
273
279
  ```
274
280
 
275
- 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`.
276
282
 
277
283
  ## Security
278
284
 
@@ -324,7 +330,7 @@ localvault login --server https://vaulthost.example
324
330
  git clone https://github.com/inventlist/localvault.git
325
331
  cd localvault
326
332
  bundle install
327
- bundle exec rake test # 463 tests, 918 assertions
333
+ bundle exec rake test
328
334
  ```
329
335
 
330
336
  ## Used by
@@ -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
@@ -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.2"
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.2
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: