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.
- checksums.yaml +4 -4
- data/README.md +36 -8
- data/bin/localvault +2 -1
- data/lib/localvault/cli/error_presenter.rb +189 -0
- data/lib/localvault/cli.rb +334 -15
- data/lib/localvault/env_projection.rb +8 -1
- data/lib/localvault/group_catalog.rb +67 -0
- data/lib/localvault/input_validation.rb +80 -0
- data/lib/localvault/mcp/exec_command_builder.rb +77 -0
- data/lib/localvault/mcp/server.rb +4 -1
- data/lib/localvault/mcp/tools.rb +87 -10
- data/lib/localvault/session_cache.rb +29 -3
- data/lib/localvault/stdin_secret_input.rb +22 -0
- data/lib/localvault/vault_resolver.rb +11 -0
- data/lib/localvault/version.rb +1 -1
- data/lib/localvault.rb +1 -0
- metadata +6 -1
|
@@ -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 })
|
data/lib/localvault/mcp/tools.rb
CHANGED
|
@@ -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" => "
|
|
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
|
|
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" => "
|
|
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 "
|
|
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 =
|
|
99
|
-
|
|
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?
|
data/lib/localvault/version.rb
CHANGED
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.
|
|
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
|