localvault 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ require "shellwords"
2
+ require_relative "../input_validation"
3
+
4
+ module LocalVault
5
+ module MCP
6
+ class ExecCommandBuilder
7
+ def initialize(command:, vault: nil, project: nil, only: nil, except: nil, map: nil, profile: nil)
8
+ @command = command
9
+ @vault = vault
10
+ @project = project
11
+ @only = only
12
+ @except = except
13
+ @map = map
14
+ @profile = profile
15
+ end
16
+
17
+ def build
18
+ validate!
19
+ argv = ["localvault", "exec"]
20
+ append(argv, "-v", @vault)
21
+ append(argv, "--project", @project)
22
+ append(argv, "--only", serialize_selectors(@only))
23
+ append(argv, "--except", serialize_selectors(@except))
24
+ append(argv, "--map", serialize_map)
25
+ append(argv, "--profile", @profile)
26
+ argv.concat(["--", *@command])
27
+
28
+ {
29
+ "command" => Shellwords.join(argv),
30
+ "exposes_plaintext" => false,
31
+ "executes_command" => false,
32
+ "next_action" => "Run this command through your normal shell tool."
33
+ }.freeze
34
+ end
35
+
36
+ private
37
+
38
+ def validate!
39
+ InputValidation.argv!(@command)
40
+ InputValidation.vault_name!(@vault) if @vault
41
+ InputValidation.project!(@project) if @project
42
+ InputValidation.profile!(@profile) if @profile
43
+ validate_selectors!(@only, "only")
44
+ validate_selectors!(@except, "except")
45
+ validate_map!
46
+ end
47
+
48
+ def validate_selectors!(selectors, label)
49
+ return if selectors.nil?
50
+ unless selectors.is_a?(Array) && !selectors.empty?
51
+ raise InputValidation::InvalidInput, "#{label} must be a non-empty array of selectors"
52
+ end
53
+ selectors.each { |selector| InputValidation.selector!(selector) }
54
+ end
55
+
56
+ def validate_map!
57
+ return if @map.nil?
58
+ unless @map.is_a?(Hash) && !@map.empty?
59
+ raise InputValidation::InvalidInput, "map must be a non-empty object"
60
+ end
61
+ @map.each { |source, target| InputValidation.mapping!(source, target) }
62
+ end
63
+
64
+ def append(argv, flag, value)
65
+ argv.concat([flag, value]) if value
66
+ end
67
+
68
+ def serialize_selectors(selectors)
69
+ selectors&.join(",")
70
+ end
71
+
72
+ def serialize_map
73
+ @map&.sort_by { |source, _target| source }&.map { |source, target| "#{source}=#{target}" }&.join(",")
74
+ end
75
+ end
76
+ end
77
+ end
@@ -72,7 +72,10 @@ module LocalVault
72
72
  success_response(id, {
73
73
  "protocolVersion" => "2025-11-25",
74
74
  "capabilities" => { "tools" => {} },
75
- "serverInfo" => { "name" => "localvault", "version" => LocalVault::VERSION }
75
+ "serverInfo" => { "name" => "localvault", "version" => LocalVault::VERSION },
76
+ "instructions" => "Do not retrieve plaintext secrets for commands, API calls, evaluation, or configuration. " \
77
+ "Call list_secrets to discover names, then localvault_build_exec to construct process-scoped injection. " \
78
+ "Use get_secret with allow_plaintext: true only when the user task explicitly requires the value itself."
76
79
  })
77
80
  when "tools/list"
78
81
  success_response(id, { "tools" => Tools::DEFINITIONS })
@@ -1,6 +1,7 @@
1
1
  require "json"
2
2
  require_relative "../key_lookup"
3
3
  require_relative "../version"
4
+ require_relative "exec_command_builder"
4
5
 
5
6
  module LocalVault
6
7
  module MCP
@@ -17,19 +18,21 @@ module LocalVault
17
18
  DEFINITIONS = [
18
19
  {
19
20
  "name" => "get_secret",
20
- "description" => "Retrieve a secret value by key from a localvault vault",
21
+ "description" => "Reveal plaintext only when the task truly requires the value in model context. Prefer localvault_build_exec for commands, evaluations, and API calls.",
21
22
  "inputSchema" => {
22
23
  "type" => "object",
23
24
  "properties" => {
24
- "key" => { "type" => "string", "description" => "The secret key to retrieve" },
25
+ "key" => { "type" => "string", "description" => "The exact secret key to reveal" },
26
+ "allow_plaintext" => { "type" => "boolean", "description" => "Must be true to acknowledge that plaintext will enter model context" },
25
27
  **VAULT_PARAM
26
28
  },
27
- "required" => ["key"]
28
- }
29
+ "required" => ["key", "allow_plaintext"]
30
+ },
31
+ "annotations" => { "readOnlyHint" => true, "openWorldHint" => false }
29
32
  },
30
33
  {
31
34
  "name" => "list_secrets",
32
- "description" => "List all secret keys in a localvault vault",
35
+ "description" => "Discover secret names without values. After selecting names, use localvault_build_exec to inject them into a process.",
33
36
  "inputSchema" => {
34
37
  "type" => "object",
35
38
  "properties" => {
@@ -38,7 +41,37 @@ module LocalVault
38
41
  **VAULT_PARAM
39
42
  },
40
43
  "required" => []
41
- }
44
+ },
45
+ "annotations" => { "readOnlyHint" => true, "openWorldHint" => false }
46
+ },
47
+ {
48
+ "name" => "localvault_build_exec",
49
+ "description" => "Build, but never execute, a shell-safe localvault exec command that injects secrets directly into a subprocess without exposing values to the model.",
50
+ "inputSchema" => {
51
+ "type" => "object",
52
+ "properties" => {
53
+ "command" => { "type" => "array", "items" => { "type" => "string" }, "description" => "Command argv to run with injected secrets" },
54
+ "vault" => { "type" => "string", "description" => "Vault name" },
55
+ "project" => { "type" => "string", "description" => "Dot-notation project group to inject without a prefix" },
56
+ "only" => { "type" => "array", "items" => { "type" => "string" }, "description" => "Exact keys or GROUP.* selectors" },
57
+ "except" => { "type" => "array", "items" => { "type" => "string" }, "description" => "Selectors to exclude" },
58
+ "map" => { "type" => "object", "additionalProperties" => { "type" => "string" }, "description" => "Vault-key to environment-variable mappings" },
59
+ "profile" => { "type" => "string", "enum" => ["aws"], "description" => "Built-in mapping profile" }
60
+ },
61
+ "required" => ["command"]
62
+ },
63
+ "outputSchema" => {
64
+ "type" => "object",
65
+ "properties" => {
66
+ "command" => { "type" => "string" },
67
+ "exposes_plaintext" => { "type" => "boolean" },
68
+ "executes_command" => { "type" => "boolean" },
69
+ "next_action" => { "type" => "string" }
70
+ },
71
+ "required" => %w[command exposes_plaintext executes_command next_action],
72
+ "additionalProperties" => false
73
+ },
74
+ "annotations" => { "readOnlyHint" => true, "openWorldHint" => false }
42
75
  },
43
76
  {
44
77
  "name" => "localvault_whoami",
@@ -47,7 +80,8 @@ module LocalVault
47
80
  "type" => "object",
48
81
  "properties" => { **VAULT_PARAM },
49
82
  "required" => []
50
- }
83
+ },
84
+ "annotations" => { "readOnlyHint" => true, "openWorldHint" => false }
51
85
  },
52
86
  {
53
87
  "name" => "set_secret",
@@ -60,6 +94,12 @@ module LocalVault
60
94
  **VAULT_PARAM
61
95
  },
62
96
  "required" => ["key", "value"]
97
+ },
98
+ "annotations" => {
99
+ "readOnlyHint" => false,
100
+ "destructiveHint" => true,
101
+ "idempotentHint" => false,
102
+ "openWorldHint" => false
63
103
  }
64
104
  },
65
105
  {
@@ -72,6 +112,12 @@ module LocalVault
72
112
  **VAULT_PARAM
73
113
  },
74
114
  "required" => ["key"]
115
+ },
116
+ "annotations" => {
117
+ "readOnlyHint" => false,
118
+ "destructiveHint" => true,
119
+ "idempotentHint" => true,
120
+ "openWorldHint" => false
75
121
  }
76
122
  }
77
123
  ].freeze
@@ -96,12 +142,21 @@ module LocalVault
96
142
 
97
143
  vault_name = arguments["vault"]
98
144
  return whoami(status_resolver.call(vault_name)) if name == "localvault_whoami"
145
+ return build_exec(arguments) if name == "localvault_build_exec"
146
+ return required_argument_error("key") if name == "get_secret" && !present_string?(arguments["key"])
147
+ if name == "get_secret" && arguments["allow_plaintext"] != true
148
+ return error_result(
149
+ "Plaintext retrieval is blocked by default. For authentication or an external command, call " \
150
+ "localvault_build_exec instead. Retry get_secret with allow_plaintext=true only when the user " \
151
+ "explicitly needs the secret text or injection cannot complete the task."
152
+ )
153
+ end
99
154
 
100
155
  vault = vault_resolver.call(vault_name)
101
156
 
102
157
  unless vault
103
158
  hint = vault_name ? "localvault show -v #{vault_name}" : "localvault show"
104
- return error_result("No unlocked vault session. Run: #{hint}")
159
+ return error_result("No unlocked vault session. Run `localvault mcp --check`, then unlock with: #{hint}")
105
160
  end
106
161
 
107
162
  case name
@@ -138,7 +193,12 @@ module LocalVault
138
193
  keys = vault.list
139
194
  keys = keys.select { |key| key.start_with?(prefix) } if prefix && !prefix.empty?
140
195
  keys = keys.select { |key| key.downcase.include?(query.downcase) } if query && !query.empty?
141
- keys.empty? ? text_result("No secrets stored") : text_result(keys.join("\n"))
196
+ result = keys.empty? ? text_result("No secrets stored") : text_result(keys.join("\n"))
197
+ result["content"] << {
198
+ "type" => "text",
199
+ "text" => "Next: call localvault_build_exec with command argv and optional only/project mappings. Do not copy secret values."
200
+ }
201
+ result
142
202
  end
143
203
 
144
204
  def self.set_secret(key, value, vault)
@@ -180,6 +240,23 @@ module LocalVault
180
240
  text_result(text).merge("structuredContent" => structured)
181
241
  end
182
242
 
243
+ def self.build_exec(arguments)
244
+ result = ExecCommandBuilder.new(
245
+ command: arguments["command"],
246
+ vault: arguments["vault"],
247
+ project: arguments["project"],
248
+ only: arguments["only"],
249
+ except: arguments["except"],
250
+ map: arguments["map"],
251
+ profile: arguments["profile"]
252
+ ).build
253
+ text_result(
254
+ "Built command (not executed):\n#{result["command"]}\n\n#{result["next_action"]}"
255
+ ).merge("structuredContent" => result)
256
+ rescue InputValidation::InvalidInput => e
257
+ error_result(e.message)
258
+ end
259
+
183
260
  def self.candidate_message(header, matches)
184
261
  ([header] + matches.map { |match| " #{match}" }).join("\n")
185
262
  end
@@ -200,7 +277,7 @@ module LocalVault
200
277
  error_result("Argument '#{name}' must be a string")
201
278
  end
202
279
 
203
- private_class_method :get_secret, :list_secrets, :set_secret, :delete_secret,
280
+ private_class_method :get_secret, :list_secrets, :set_secret, :delete_secret, :build_exec,
204
281
  :text_result, :error_result, :whoami, :candidate_message,
205
282
  :present_string?, :optional_string?, :required_argument_error, :string_argument_error
206
283
  end
@@ -1,6 +1,6 @@
1
1
  require "base64"
2
2
  require "fileutils"
3
- require "shellwords"
3
+ require "open3"
4
4
 
5
5
  module LocalVault
6
6
  # Caches derived master keys to avoid re-prompting passphrase on every command.
@@ -95,8 +95,11 @@ module LocalVault
95
95
 
96
96
  def self.keychain_get(vault_name)
97
97
  if macos?
98
- out = `security find-generic-password -a #{Shellwords.escape(vault_name)} -s #{Shellwords.escape(KEYCHAIN_SERVICE)} -w 2>/dev/null`.chomp
99
- return out if $?.success? && !out.empty?
98
+ out = run_command(
99
+ ["security", "find-generic-password", "-a", vault_name, "-s", KEYCHAIN_SERVICE, "-w"],
100
+ timeout: 2
101
+ )
102
+ return out unless out.nil? || out.empty?
100
103
  end
101
104
  # File fallback (Linux, or macOS when Keychain unavailable)
102
105
  file = session_file(vault_name)
@@ -143,5 +146,28 @@ module LocalVault
143
146
  FileUtils.rm_f(session_file(vault_name))
144
147
  end
145
148
 
149
+ def self.run_command(argv, timeout:)
150
+ output = nil
151
+ Open3.popen3(*argv) do |stdin, stdout, stderr, wait_thread|
152
+ stdin.close
153
+ unless wait_thread.join(timeout)
154
+ Process.kill("TERM", wait_thread.pid)
155
+ unless wait_thread.join(0.1)
156
+ Process.kill("KILL", wait_thread.pid)
157
+ wait_thread.join
158
+ end
159
+ return nil
160
+ end
161
+
162
+ output = stdout.read.chomp if wait_thread.value.success?
163
+ stderr.close
164
+ rescue Errno::ESRCH
165
+ return nil
166
+ end
167
+ output
168
+ rescue Errno::ENOENT
169
+ nil
170
+ end
171
+
146
172
  end
147
173
  end
@@ -0,0 +1,22 @@
1
+ module LocalVault
2
+ class StdinSecretInput
3
+ class InteractiveInput < StandardError; end
4
+ class InvalidEncoding < StandardError; end
5
+
6
+ PIPE_EXAMPLE = %(printf '%s' "$SECRET" | localvault set KEY --stdin).freeze
7
+
8
+ def self.read(input = $stdin)
9
+ if input.respond_to?(:tty?) && input.tty?
10
+ raise InteractiveInput, "Refusing to read secret from interactive stdin. " \
11
+ "Pipe it instead: #{PIPE_EXAMPLE}"
12
+ end
13
+
14
+ input.binmode if input.respond_to?(:binmode)
15
+ value = input.read || ""
16
+ value = value.dup.force_encoding(Encoding::UTF_8)
17
+ raise InvalidEncoding, "Secret value must be valid UTF-8" unless value.valid_encoding?
18
+
19
+ value
20
+ end
21
+ end
22
+ end
@@ -33,6 +33,17 @@ module LocalVault
33
33
  }
34
34
  end
35
35
 
36
+ def self.readiness_status(name = nil)
37
+ result = resolve(name)
38
+ {
39
+ "localvault_home" => Config.root_path,
40
+ "active_vault" => result.active_vault,
41
+ "active_vault_source" => result.active_vault_source,
42
+ "session_vault" => result.session_vault,
43
+ "active_vault_unlocked" => !result.vault.nil?
44
+ }
45
+ end
46
+
36
47
  def self.active_vault_name(name = nil)
37
48
  return [name, "argument"] if name && !name.empty?
38
49
  return [ENV["LOCALVAULT_VAULT"], "env"] if ENV["LOCALVAULT_VAULT"] && !ENV["LOCALVAULT_VAULT"].empty?
@@ -1,3 +1,3 @@
1
1
  module LocalVault
2
- VERSION = "1.7.0"
2
+ VERSION = "1.8.1"
3
3
  end
data/lib/localvault.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "localvault/store"
5
5
  require_relative "localvault/env_projection"
6
6
  require_relative "localvault/key_lookup"
7
7
  require_relative "localvault/vault_resolver"
8
+ require_relative "localvault/stdin_secret_input"
8
9
  require_relative "localvault/vault"
9
10
  require_relative "localvault/identity"
10
11
  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.7.0
4
+ version: 1.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq
@@ -108,6 +108,7 @@ files:
108
108
  - lib/localvault.rb
109
109
  - lib/localvault/api_client.rb
110
110
  - lib/localvault/cli.rb
111
+ - lib/localvault/cli/error_presenter.rb
111
112
  - lib/localvault/cli/keys.rb
112
113
  - lib/localvault/cli/sync.rb
113
114
  - lib/localvault/cli/team.rb
@@ -115,13 +116,17 @@ files:
115
116
  - lib/localvault/config.rb
116
117
  - lib/localvault/crypto.rb
117
118
  - lib/localvault/env_projection.rb
119
+ - lib/localvault/group_catalog.rb
118
120
  - lib/localvault/identity.rb
121
+ - lib/localvault/input_validation.rb
119
122
  - lib/localvault/key_lookup.rb
120
123
  - lib/localvault/key_slot.rb
124
+ - lib/localvault/mcp/exec_command_builder.rb
121
125
  - lib/localvault/mcp/server.rb
122
126
  - lib/localvault/mcp/tools.rb
123
127
  - lib/localvault/session_cache.rb
124
128
  - lib/localvault/share_crypto.rb
129
+ - lib/localvault/stdin_secret_input.rb
125
130
  - lib/localvault/store.rb
126
131
  - lib/localvault/sync_bundle.rb
127
132
  - lib/localvault/sync_state.rb