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 +4 -4
- data/README.md +16 -10
- data/lib/localvault/cli.rb +55 -26
- data/lib/localvault/env_projection.rb +131 -0
- data/lib/localvault/key_lookup.rb +30 -0
- data/lib/localvault/mcp/server.rb +8 -68
- data/lib/localvault/mcp/tools.rb +87 -8
- data/lib/localvault/vault.rb +20 -72
- data/lib/localvault/vault_resolver.rb +81 -0
- data/lib/localvault/version.rb +1 -1
- data/lib/localvault.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2ff1cbda409eb9a922e301dfb73866fde96fa06dc7ace9e670df15ef587dd336
|
|
4
|
+
data.tar.gz: 8f1b78e9c49e3f6f99a37cd97bbca36054e75c15d99d126699beb61f00a07840
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
-
#
|
|
229
|
-
#
|
|
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?)
|
|
232
|
+
# delete_secret(key, vault?) — remove a secret
|
|
232
233
|
```
|
|
233
234
|
|
|
234
|
-
|
|
235
|
+
Command injection stays in the CLI. Use selectors, mappings, and profiles to keep subprocess envs scoped:
|
|
235
236
|
|
|
236
237
|
```bash
|
|
237
|
-
localvault
|
|
238
|
-
|
|
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
|
-
|
|
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
|
|
333
|
+
bundle exec rake test
|
|
328
334
|
```
|
|
329
335
|
|
|
330
336
|
## Used by
|
data/lib/localvault/cli.rb
CHANGED
|
@@ -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
|
-
|
|
150
|
-
|
|
151
|
-
if
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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(
|
|
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(
|
|
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
|
|
470
|
-
|
|
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]
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
104
|
+
def vault_status(name = nil)
|
|
105
|
+
VaultResolver.status(name)
|
|
166
106
|
end
|
|
167
107
|
|
|
168
108
|
def success_response(id, result)
|
data/lib/localvault/mcp/tools.rb
CHANGED
|
@@ -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
|
-
|
|
98
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
data/lib/localvault/vault.rb
CHANGED
|
@@ -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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
data/lib/localvault/version.rb
CHANGED
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.
|
|
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:
|