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.
- checksums.yaml +4 -4
- data/README.md +39 -10
- data/bin/localvault +2 -1
- data/lib/localvault/cli/error_presenter.rb +177 -0
- data/lib/localvault/cli.rb +266 -36
- data/lib/localvault/env_projection.rb +138 -0
- data/lib/localvault/group_catalog.rb +67 -0
- data/lib/localvault/input_validation.rb +80 -0
- data/lib/localvault/key_lookup.rb +30 -0
- data/lib/localvault/mcp/exec_command_builder.rb +77 -0
- data/lib/localvault/mcp/server.rb +12 -69
- data/lib/localvault/mcp/tools.rb +172 -16
- data/lib/localvault/session_cache.rb +29 -3
- data/lib/localvault/vault.rb +20 -72
- data/lib/localvault/vault_resolver.rb +92 -0
- data/lib/localvault/version.rb +1 -1
- data/lib/localvault.rb +3 -0
- metadata +8 -1
|
@@ -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
|
|
20
|
-
@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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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)
|
data/lib/localvault/mcp/tools.rb
CHANGED
|
@@ -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" => "
|
|
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
|
|
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" => "
|
|
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
|
-
|
|
98
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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 "
|
|
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
|