localvault 1.6.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,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
@@ -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
@@ -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
@@ -73,7 +72,10 @@ module LocalVault
73
72
  success_response(id, {
74
73
  "protocolVersion" => "2025-11-25",
75
74
  "capabilities" => { "tools" => {} },
76
- "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."
77
79
  })
78
80
  when "tools/list"
79
81
  success_response(id, { "tools" => Tools::DEFINITIONS })
@@ -85,7 +87,7 @@ module LocalVault
85
87
  return error_response(id, -32602, "Unknown tool: #{tool_name}")
86
88
  end
87
89
 
88
- result = Tools.call(tool_name, arguments, method(:vault_for))
90
+ result = Tools.call(tool_name, arguments, method(:vault_for), method(:vault_status))
89
91
  success_response(id, result)
90
92
  else
91
93
  error_response(id, -32601, "Method not found: #{method}")
@@ -99,70 +101,11 @@ module LocalVault
99
101
  # Resolve vault by name, lazily — tries session token, then Keychain.
100
102
  # Returns nil if vault is not unlocked.
101
103
  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
104
+ VaultResolver.resolve(name).vault
150
105
  end
151
106
 
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
107
+ def vault_status(name = nil)
108
+ VaultResolver.status(name)
166
109
  end
167
110
 
168
111
  def success_response(id, result)
@@ -1,4 +1,7 @@
1
1
  require "json"
2
+ require_relative "../key_lookup"
3
+ require_relative "../version"
4
+ require_relative "exec_command_builder"
2
5
 
3
6
  module LocalVault
4
7
  module MCP
@@ -15,24 +18,70 @@ module LocalVault
15
18
  DEFINITIONS = [
16
19
  {
17
20
  "name" => "get_secret",
18
- "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.",
19
22
  "inputSchema" => {
20
23
  "type" => "object",
21
24
  "properties" => {
22
- "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" },
23
27
  **VAULT_PARAM
24
28
  },
25
- "required" => ["key"]
26
- }
29
+ "required" => ["key", "allow_plaintext"]
30
+ },
31
+ "annotations" => { "readOnlyHint" => true, "openWorldHint" => false }
27
32
  },
28
33
  {
29
34
  "name" => "list_secrets",
30
- "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.",
36
+ "inputSchema" => {
37
+ "type" => "object",
38
+ "properties" => {
39
+ "prefix" => { "type" => "string", "description" => "Only return keys starting with this prefix" },
40
+ "query" => { "type" => "string", "description" => "Case-insensitive substring filter for key names" },
41
+ **VAULT_PARAM
42
+ },
43
+ "required" => []
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 }
75
+ },
76
+ {
77
+ "name" => "localvault_whoami",
78
+ "description" => "Show which localvault home, vault, and unlocked sessions the MCP server can see",
31
79
  "inputSchema" => {
32
80
  "type" => "object",
33
81
  "properties" => { **VAULT_PARAM },
34
82
  "required" => []
35
- }
83
+ },
84
+ "annotations" => { "readOnlyHint" => true, "openWorldHint" => false }
36
85
  },
37
86
  {
38
87
  "name" => "set_secret",
@@ -45,6 +94,12 @@ module LocalVault
45
94
  **VAULT_PARAM
46
95
  },
47
96
  "required" => ["key", "value"]
97
+ },
98
+ "annotations" => {
99
+ "readOnlyHint" => false,
100
+ "destructiveHint" => true,
101
+ "idempotentHint" => false,
102
+ "openWorldHint" => false
48
103
  }
49
104
  },
50
105
  {
@@ -57,6 +112,12 @@ module LocalVault
57
112
  **VAULT_PARAM
58
113
  },
59
114
  "required" => ["key"]
115
+ },
116
+ "annotations" => {
117
+ "readOnlyHint" => false,
118
+ "destructiveHint" => true,
119
+ "idempotentHint" => true,
120
+ "openWorldHint" => false
60
121
  }
61
122
  }
62
123
  ].freeze
@@ -72,45 +133,87 @@ module LocalVault
72
133
  # and returns a Vault instance or nil
73
134
  # @return [Hash] MCP content result with "content" array and optional "isError"
74
135
  # @raise [ArgumentError] if the tool name is unknown
75
- def self.call(name, arguments, vault_resolver)
136
+ def self.call(name, arguments, vault_resolver, status_resolver = nil)
76
137
  unless DEFINITIONS.any? { |t| t["name"] == name }
77
138
  raise ArgumentError, "Unknown tool: #{name}"
78
139
  end
79
140
 
141
+ return error_result("Invalid arguments; expected object") unless arguments.is_a?(Hash)
142
+
80
143
  vault_name = arguments["vault"]
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
154
+
81
155
  vault = vault_resolver.call(vault_name)
82
156
 
83
157
  unless vault
84
158
  hint = vault_name ? "localvault show -v #{vault_name}" : "localvault show"
85
- 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}")
86
160
  end
87
161
 
88
162
  case name
89
163
  when "get_secret" then get_secret(arguments["key"], vault)
90
- when "list_secrets" then list_secrets(vault)
164
+ when "list_secrets" then list_secrets(vault, prefix: arguments["prefix"], query: arguments["query"])
91
165
  when "set_secret" then set_secret(arguments["key"], arguments["value"], vault)
92
166
  when "delete_secret" then delete_secret(arguments["key"], vault)
93
167
  end
168
+ rescue StandardError => e
169
+ error_result(e.message)
94
170
  end
95
171
 
96
172
  def self.get_secret(key, vault)
97
- value = vault.get(key)
98
- value.nil? ? error_result("Key '#{key}' not found") : text_result(value)
173
+ return required_argument_error("key") unless present_string?(key)
174
+
175
+ lookup = KeyLookup.lookup(vault, key)
176
+ return text_result(lookup.value) if lookup.exact?
177
+
178
+ if lookup.multiple_matches?
179
+ return error_result(candidate_message("Multiple keys match '#{key}'. Be more specific:", lookup.matches))
180
+ end
181
+
182
+ if lookup.single_match?
183
+ return error_result(candidate_message("Key '#{key}' not found. Did you mean:", lookup.matches))
184
+ end
185
+
186
+ error_result("Key '#{key}' not found")
99
187
  end
100
188
 
101
- def self.list_secrets(vault)
189
+ def self.list_secrets(vault, prefix: nil, query: nil)
190
+ return string_argument_error("prefix") unless optional_string?(prefix)
191
+ return string_argument_error("query") unless optional_string?(query)
192
+
102
193
  keys = vault.list
103
- keys.empty? ? text_result("No secrets stored") : text_result(keys.join("\n"))
194
+ keys = keys.select { |key| key.start_with?(prefix) } if prefix && !prefix.empty?
195
+ keys = keys.select { |key| key.downcase.include?(query.downcase) } if query && !query.empty?
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
104
202
  end
105
203
 
106
204
  def self.set_secret(key, value, vault)
205
+ return required_argument_error("key") unless present_string?(key)
206
+ return required_argument_error("value") unless value.is_a?(String)
207
+
107
208
  vault.set(key, value)
108
209
  text_result("Stored #{key}")
109
- rescue Vault::InvalidKeyName => e
110
- error_result("Invalid key name: #{e.message}")
210
+ rescue Vault::InvalidKeyName, RuntimeError => e
211
+ error_result(e.message)
111
212
  end
112
213
 
113
214
  def self.delete_secret(key, vault)
215
+ return required_argument_error("key") unless present_string?(key)
216
+
114
217
  deleted = vault.delete(key)
115
218
  deleted.nil? ? error_result("Key '#{key}' not found") : text_result("Deleted #{key}")
116
219
  end
@@ -123,7 +226,60 @@ module LocalVault
123
226
  { "content" => [{ "type" => "text", "text" => text }], "isError" => true }
124
227
  end
125
228
 
126
- private_class_method :get_secret, :list_secrets, :set_secret, :delete_secret, :text_result, :error_result
229
+ def self.whoami(status)
230
+ structured = status.merge("version" => LocalVault::VERSION)
231
+ text = [
232
+ "LocalVault #{LocalVault::VERSION}",
233
+ "Home: #{structured["localvault_home"]}",
234
+ "Active vault: #{structured["active_vault"]} (#{structured["active_vault_source"]})",
235
+ "Active vault unlocked: #{structured["active_vault_unlocked"] ? "yes" : "no"}",
236
+ "Session vault: #{structured["session_vault"] || "-"}",
237
+ "Unlocked vaults: #{structured["unlocked_vaults"].empty? ? "-" : structured["unlocked_vaults"].join(", ")}"
238
+ ].join("\n")
239
+
240
+ text_result(text).merge("structuredContent" => structured)
241
+ end
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
+
260
+ def self.candidate_message(header, matches)
261
+ ([header] + matches.map { |match| " #{match}" }).join("\n")
262
+ end
263
+
264
+ def self.present_string?(value)
265
+ value.is_a?(String) && !value.empty?
266
+ end
267
+
268
+ def self.optional_string?(value)
269
+ value.nil? || value.is_a?(String)
270
+ end
271
+
272
+ def self.required_argument_error(name)
273
+ error_result("Missing required argument '#{name}'")
274
+ end
275
+
276
+ def self.string_argument_error(name)
277
+ error_result("Argument '#{name}' must be a string")
278
+ end
279
+
280
+ private_class_method :get_secret, :list_secrets, :set_secret, :delete_secret, :build_exec,
281
+ :text_result, :error_result, :whoami, :candidate_message,
282
+ :present_string?, :optional_string?, :required_argument_error, :string_argument_error
127
283
  end
128
284
  end
129
285
  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