localvault 1.7.0 → 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 +26 -3
- data/bin/localvault +2 -1
- data/lib/localvault/cli/error_presenter.rb +177 -0
- data/lib/localvault/cli.rb +211 -10
- 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/vault_resolver.rb +11 -0
- data/lib/localvault/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 86be5d86ef86c74bcfc777561088264d1b908eaca92a2b501d5e23d937e5a906
|
|
4
|
+
data.tar.gz: d3d079c65ff54ca198907957ae3193a3a73520aa84169cf0067bc254967fea05
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f0d5b4b8d1575b8b91fbd742afd0aef2f71401e72882149cf0b83e68cd233636592c5e0af85512007b4cff604d466ff38d5ac085ea7eea60e9b9446a3d7247a5
|
|
7
|
+
data.tar.gz: 98a51ac12f4857e0f381fa4b4449a63687d402fe3bf3d97e693f75bfc526440efbc0fd9df14f397c99771c4c871c9a8f164b51e29cca56a232bfa78c099ca8c9
|
data/README.md
CHANGED
|
@@ -70,10 +70,13 @@ localvault exec -- rails server
|
|
|
70
70
|
|---------|-------------|
|
|
71
71
|
| `init [NAME]` | Create a vault (Argon2id key derivation) |
|
|
72
72
|
| `set KEY VALUE` | Store a secret (supports dot-notation: `project.KEY`) |
|
|
73
|
+
| `set --group GROUP KEY VALUE` | Store a secret in a named group |
|
|
73
74
|
| `get KEY` | Retrieve a secret (raw, pipeable) |
|
|
74
75
|
| `show` | Display all secrets in a table (masked by default) |
|
|
75
76
|
| `show --reveal` | Display with values visible |
|
|
76
77
|
| `show --group` | Group by dot-notation prefix (one table per project) |
|
|
78
|
+
| `show --group QUERY` | Show one exact or uniquely matching group |
|
|
79
|
+
| `groups [QUERY]` | List/search group names and key counts without values |
|
|
77
80
|
| `list` | List key names only |
|
|
78
81
|
| `delete KEY` | Remove a secret |
|
|
79
82
|
| `rename OLD NEW` | Rename a secret key |
|
|
@@ -214,7 +217,9 @@ localvault remove @alice -v production --rotate
|
|
|
214
217
|
|
|
215
218
|
## MCP Server (AI Agents)
|
|
216
219
|
|
|
217
|
-
Give AI agents controlled secret access without hardcoding credentials in MCP
|
|
220
|
+
Give AI agents controlled secret access without hardcoding credentials in MCP
|
|
221
|
+
config. The default workflow keeps values out of model context: discover names,
|
|
222
|
+
build a `localvault exec` command, then run it with process-scoped injection.
|
|
218
223
|
|
|
219
224
|
```bash
|
|
220
225
|
# One-command install for Claude Code
|
|
@@ -224,15 +229,24 @@ localvault install-mcp claude-code
|
|
|
224
229
|
# Unlock your vault for the session
|
|
225
230
|
localvault unlock
|
|
226
231
|
|
|
232
|
+
# Verify setup without starting the blocking stdio server
|
|
233
|
+
localvault mcp --check
|
|
234
|
+
|
|
227
235
|
# MCP tools available to the agent:
|
|
228
236
|
# localvault_whoami — diagnose active vault/session state
|
|
229
|
-
# get_secret(key, vault?) — read an exact secret key
|
|
230
237
|
# list_secrets(vault?, prefix?, query?) — list/search key names
|
|
238
|
+
# localvault_build_exec(command, ...) — build safe injection (does not execute)
|
|
239
|
+
# get_secret(key, allow_plaintext: true, vault?) — explicit plaintext reveal
|
|
231
240
|
# set_secret(key, value, vault?) — store a secret
|
|
232
241
|
# delete_secret(key, vault?) — remove a secret
|
|
233
242
|
```
|
|
234
243
|
|
|
235
|
-
|
|
244
|
+
Agents should prefer `localvault_build_exec` for commands, evaluation, API calls,
|
|
245
|
+
and configuration checks. It returns both argv and a shell-safe command without
|
|
246
|
+
opening a vault or reading a value. `get_secret` rejects calls unless
|
|
247
|
+
`allow_plaintext: true` is explicit.
|
|
248
|
+
|
|
249
|
+
Use selectors, mappings, and profiles to keep subprocess envs scoped:
|
|
236
250
|
|
|
237
251
|
```bash
|
|
238
252
|
localvault exec --profile aws -- aws sts get-caller-identity
|
|
@@ -249,9 +263,18 @@ One vault, many projects. Dot-notation keeps secrets organized:
|
|
|
249
263
|
localvault set myapp.DATABASE_URL postgres://localhost/myapp -v work
|
|
250
264
|
localvault set api.DATABASE_URL postgres://localhost/api -v work
|
|
251
265
|
|
|
266
|
+
# Or use the guided group form
|
|
267
|
+
localvault set --group myapp DATABASE_URL postgres://localhost/myapp -v work
|
|
268
|
+
|
|
269
|
+
# Search groups without revealing values
|
|
270
|
+
localvault groups app -v work
|
|
271
|
+
|
|
252
272
|
# View grouped by project
|
|
253
273
|
localvault show --group -v work
|
|
254
274
|
|
|
275
|
+
# Show one group by exact or unique prefix
|
|
276
|
+
localvault show --group my -v work
|
|
277
|
+
|
|
255
278
|
# Filter to one project
|
|
256
279
|
localvault show -p myapp -v work
|
|
257
280
|
|
data/bin/localvault
CHANGED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
require "did_you_mean"
|
|
2
|
+
|
|
3
|
+
module LocalVault
|
|
4
|
+
class CLI
|
|
5
|
+
class ErrorPresenter
|
|
6
|
+
Context = Data.define(:command_class, :namespace, :token, :command)
|
|
7
|
+
|
|
8
|
+
def initialize(root_class, argv)
|
|
9
|
+
@root_class = root_class
|
|
10
|
+
@argv = argv.map(&:to_s)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render(error, io: $stderr)
|
|
14
|
+
@error = error
|
|
15
|
+
context = @context = resolve_context
|
|
16
|
+
io.puts error_heading(context)
|
|
17
|
+
io.puts "Usage: #{usage_for(context)}"
|
|
18
|
+
render_option_hint(context, io)
|
|
19
|
+
io.puts
|
|
20
|
+
io.puts "Try:"
|
|
21
|
+
suggestions_for(context).first(5).each { |suggestion| io.puts " #{suggestion}" }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def resolve_context
|
|
27
|
+
command_class = @root_class
|
|
28
|
+
namespace = []
|
|
29
|
+
token = @argv.first
|
|
30
|
+
command = resolve_command(command_class, token)
|
|
31
|
+
|
|
32
|
+
if command && command_class.subcommand_classes.key?(command.name)
|
|
33
|
+
namespace << command.name
|
|
34
|
+
command_class = command_class.subcommand_classes.fetch(command.name)
|
|
35
|
+
token = @argv[1]
|
|
36
|
+
command = resolve_command(command_class, token)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Context.new(command_class, namespace, token, command)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resolve_command(command_class, token)
|
|
43
|
+
return nil unless token && !token.start_with?("-")
|
|
44
|
+
return command_class.all_commands[token] if command_class.all_commands.key?(token)
|
|
45
|
+
|
|
46
|
+
matches = command_class.all_commands.keys.select { |name| name.start_with?(token) }
|
|
47
|
+
matches.one? ? command_class.all_commands[matches.first] : nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def error_heading(context)
|
|
51
|
+
if @error.is_a?(CLI::GroupSelectionError)
|
|
52
|
+
if @error.kind == :ambiguous
|
|
53
|
+
"Error: Multiple groups match `#{@error.query}`."
|
|
54
|
+
else
|
|
55
|
+
"Error: No group matches `#{@error.query}`."
|
|
56
|
+
end
|
|
57
|
+
elsif @error.is_a?(CLI::GroupSaveError)
|
|
58
|
+
case @error.kind
|
|
59
|
+
when :collision then "Error: cannot create group: that name is already used by a secret key."
|
|
60
|
+
when :ambiguous then "Error: group name is ambiguous. Existing groups: #{@error.candidates.join(", ")}."
|
|
61
|
+
else "Error: group and key names may contain letters, digits, and underscores only."
|
|
62
|
+
end
|
|
63
|
+
elsif group_save_attempt?
|
|
64
|
+
"Error: saving in a group needs GROUP, KEY, and VALUE."
|
|
65
|
+
elsif unknown_option
|
|
66
|
+
"Error: unknown option `#{unknown_option}`."
|
|
67
|
+
elsif context.command.nil?
|
|
68
|
+
"Error: unknown or ambiguous command."
|
|
69
|
+
else
|
|
70
|
+
"Error: this command needs a different combination of arguments."
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def usage_for(context)
|
|
75
|
+
if @error.is_a?(CLI::GroupSelectionError)
|
|
76
|
+
"localvault show --group GROUP"
|
|
77
|
+
elsif group_save_attempt?
|
|
78
|
+
"localvault set --group GROUP KEY VALUE"
|
|
79
|
+
elsif context.command
|
|
80
|
+
["localvault", *context.namespace, context.command.usage].join(" ")
|
|
81
|
+
else
|
|
82
|
+
["localvault", *context.namespace, "COMMAND"].join(" ")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def render_option_hint(context, io)
|
|
87
|
+
return unless unknown_option
|
|
88
|
+
|
|
89
|
+
correction = DidYouMean::SpellChecker.new(dictionary: option_names(context)).correct(unknown_option).first
|
|
90
|
+
io.puts "\nDid you mean `#{correction}`?" if correction
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def suggestions_for(context)
|
|
94
|
+
if @error.is_a?(CLI::GroupSelectionError)
|
|
95
|
+
return @error.candidates.map { |name| "localvault show --group #{name}" } if @error.kind == :ambiguous
|
|
96
|
+
return ["localvault groups #{@error.query}", "localvault groups"]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
return group_save_suggestions if group_save_attempt?
|
|
100
|
+
return ["localvault set KEY VALUE", *group_save_suggestions] if context.command&.name == "set"
|
|
101
|
+
return show_group_suggestions if context.command&.name == "show" || unknown_option == "--group-by"
|
|
102
|
+
return ["localvault add HANDLE", "localvault team add HANDLE"] if context.namespace == ["team"] && context.command&.name == "add"
|
|
103
|
+
|
|
104
|
+
if context.command
|
|
105
|
+
examples = curated_examples(context.command)
|
|
106
|
+
examples.empty? ? [usage_for(context)] : examples
|
|
107
|
+
else
|
|
108
|
+
command_suggestions(context)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def group_save_suggestions
|
|
113
|
+
[
|
|
114
|
+
"localvault set --group GROUP KEY VALUE",
|
|
115
|
+
"localvault set GROUP.KEY VALUE",
|
|
116
|
+
"localvault groups [QUERY]"
|
|
117
|
+
]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def show_group_suggestions
|
|
121
|
+
[
|
|
122
|
+
"localvault show --group GROUP",
|
|
123
|
+
"localvault groups [QUERY]",
|
|
124
|
+
"localvault show --project PROJECT"
|
|
125
|
+
]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def command_suggestions(context)
|
|
129
|
+
names = context.command_class.all_commands.keys.reject { |name| name == "help" }
|
|
130
|
+
matches = prefix_or_spelling_matches(names, context.token.to_s)
|
|
131
|
+
items = matches.map do |name|
|
|
132
|
+
command = context.command_class.all_commands.fetch(name)
|
|
133
|
+
["localvault", *context.namespace, command.usage, " # #{command.description}"].join(" ")
|
|
134
|
+
end
|
|
135
|
+
items.empty? ? ["localvault help"] : items
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def prefix_or_spelling_matches(names, token)
|
|
139
|
+
prefix = names.select { |name| name.start_with?(token) }
|
|
140
|
+
return prefix.sort unless prefix.empty?
|
|
141
|
+
|
|
142
|
+
DidYouMean::SpellChecker.new(dictionary: names).correct(token).first(5)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def curated_examples(command)
|
|
146
|
+
command.long_description.to_s.lines.filter_map do |line|
|
|
147
|
+
example = line.delete("\u0005").strip
|
|
148
|
+
example if example.start_with?("localvault ")
|
|
149
|
+
end.uniq
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def option_names(context)
|
|
153
|
+
options = context.command_class.class_options.values
|
|
154
|
+
options += context.command.options.values if context.command
|
|
155
|
+
|
|
156
|
+
options.flat_map do |option|
|
|
157
|
+
["--#{option.name.to_s.tr("_", "-")}", *Array(option.aliases)]
|
|
158
|
+
end.uniq
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def unknown_option
|
|
162
|
+
return @unknown_option if defined?(@unknown_option)
|
|
163
|
+
|
|
164
|
+
known = option_names(@context)
|
|
165
|
+
@unknown_option = @argv.find do |argument|
|
|
166
|
+
next false unless argument.start_with?("--") && argument != "--"
|
|
167
|
+
|
|
168
|
+
!known.include?(argument.split("=", 2).first)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def group_save_attempt?
|
|
173
|
+
@argv.first == "set" && @argv.include?("--group")
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
data/lib/localvault/cli.rb
CHANGED
|
@@ -6,9 +6,92 @@ require_relative "env_projection"
|
|
|
6
6
|
require_relative "key_lookup"
|
|
7
7
|
require_relative "session_cache"
|
|
8
8
|
require_relative "vault_resolver"
|
|
9
|
+
require_relative "group_catalog"
|
|
9
10
|
|
|
10
11
|
module LocalVault
|
|
11
12
|
class CLI < Thor
|
|
13
|
+
USAGE_EXIT_STATUS = 1
|
|
14
|
+
GROUP_ALL_SENTINEL = "\0localvault-all-groups"
|
|
15
|
+
GROUP_OFF_SENTINEL = "\0localvault-groups-off"
|
|
16
|
+
CommandStatus = Data.define(:code) do
|
|
17
|
+
def self.ok
|
|
18
|
+
new(0)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.error
|
|
22
|
+
new(1)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
class GroupSaveError < Thor::Error
|
|
26
|
+
attr_reader :kind, :candidates
|
|
27
|
+
|
|
28
|
+
def initialize(kind, candidates: [])
|
|
29
|
+
@kind = kind
|
|
30
|
+
@candidates = candidates
|
|
31
|
+
super("group save failed")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def exit_status
|
|
35
|
+
1
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
class GroupSelectionError < Thor::Error
|
|
39
|
+
attr_reader :kind, :query, :candidates
|
|
40
|
+
|
|
41
|
+
def initialize(kind, query:, candidates: [])
|
|
42
|
+
@kind = kind
|
|
43
|
+
@query = query
|
|
44
|
+
@candidates = candidates
|
|
45
|
+
super("group selection failed")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def exit_status
|
|
49
|
+
1
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.start(given_args = ARGV, config = {})
|
|
54
|
+
require_relative "cli/error_presenter"
|
|
55
|
+
config[:shell] ||= Thor::Base.shell.new
|
|
56
|
+
result = dispatch(nil, normalize_legacy_group_option(given_args.dup), nil, config)
|
|
57
|
+
result.is_a?(CommandStatus) ? result.code : 0
|
|
58
|
+
rescue Thor::Error => error
|
|
59
|
+
ErrorPresenter.new(self, given_args).render(error)
|
|
60
|
+
error.respond_to?(:exit_status) ? error.exit_status : USAGE_EXIT_STATUS
|
|
61
|
+
rescue Errno::EPIPE
|
|
62
|
+
0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.normalize_legacy_group_option(arguments)
|
|
66
|
+
command = arguments.first
|
|
67
|
+
matches = all_commands.keys.select { |name| name.start_with?(command.to_s) }
|
|
68
|
+
return arguments unless command == "show" || matches == ["show"]
|
|
69
|
+
|
|
70
|
+
normalized = []
|
|
71
|
+
index = 0
|
|
72
|
+
while index < arguments.length
|
|
73
|
+
argument = arguments[index]
|
|
74
|
+
if argument == "--"
|
|
75
|
+
normalized.concat(arguments[index..])
|
|
76
|
+
break
|
|
77
|
+
end
|
|
78
|
+
if argument == "--group" && arguments[index + 1]&.match?(/\A(?:true|false|t|f)\z/i)
|
|
79
|
+
enabled = arguments[index + 1].match?(/\A(?:true|t)\z/i)
|
|
80
|
+
normalized << "--group=#{enabled ? GROUP_ALL_SENTINEL : GROUP_OFF_SENTINEL}"
|
|
81
|
+
index += 2
|
|
82
|
+
next
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
case argument
|
|
86
|
+
when /\A--group=(?:true|t)\z/i then normalized << "--group=#{GROUP_ALL_SENTINEL}"
|
|
87
|
+
when /\A--group=(?:false|f)\z/i, "--no-group", "--skip-group" then normalized << "--group=#{GROUP_OFF_SENTINEL}"
|
|
88
|
+
else normalized << argument
|
|
89
|
+
end
|
|
90
|
+
index += 1
|
|
91
|
+
end
|
|
92
|
+
normalized
|
|
93
|
+
end
|
|
94
|
+
|
|
12
95
|
class_option :vault, aliases: "-v", type: :string, desc: "Vault name"
|
|
13
96
|
|
|
14
97
|
def self.help(shell, subcommand = false)
|
|
@@ -23,8 +106,10 @@ module LocalVault
|
|
|
23
106
|
shell.say ""
|
|
24
107
|
shell.say "SECRETS"
|
|
25
108
|
shell.say " localvault set KEY VALUE Store a secret"
|
|
109
|
+
shell.say " localvault set --group G K V Store a secret inside group G"
|
|
26
110
|
shell.say " localvault get KEY Retrieve a secret"
|
|
27
111
|
shell.say " localvault show Display all secrets (masked by default)"
|
|
112
|
+
shell.say " localvault groups [QUERY] List or search stored groups (names only)"
|
|
28
113
|
shell.say " localvault list List secret key names"
|
|
29
114
|
shell.say " localvault delete KEY Remove a secret"
|
|
30
115
|
shell.say " localvault import FILE Bulk-import from .env / .json / .yml"
|
|
@@ -71,6 +156,8 @@ module LocalVault
|
|
|
71
156
|
shell.say "AI / MCP"
|
|
72
157
|
shell.say " localvault install-mcp Configure MCP server in your AI tool"
|
|
73
158
|
shell.say " localvault mcp Start MCP server (stdio)"
|
|
159
|
+
shell.say " localvault mcp --check Check setup and active-vault readiness"
|
|
160
|
+
shell.say " Agents: list names, then use localvault_build_exec for safe injection"
|
|
74
161
|
shell.say ""
|
|
75
162
|
shell.say "LEGACY SHARING (pre-v1.2 direct share, still works as fallback)"
|
|
76
163
|
shell.say " localvault keygen Generate X25519 keypair (same as `keys generate`)"
|
|
@@ -123,14 +210,38 @@ module LocalVault
|
|
|
123
210
|
\x05 localvault set platepose.SECRET_KEY_BASE abc123 -v intellectaco
|
|
124
211
|
\x05 localvault set inventlist.STRIPE_KEY sk_live_abc123 -v intellectaco
|
|
125
212
|
|
|
213
|
+
GUIDED GROUP SAVE (same storage, easier to discover):
|
|
214
|
+
\x05 localvault set --group GROUP KEY VALUE
|
|
215
|
+
\x05 localvault set GROUP.KEY VALUE
|
|
216
|
+
\x05 localvault groups [QUERY]
|
|
217
|
+
|
|
126
218
|
The dot separates project from key name. One vault can hold many projects.
|
|
127
219
|
Use `localvault show -p platepose -v vault` to view a single project.
|
|
128
220
|
Use `localvault import` to bulk-load from a .env, .json, or .yml file.
|
|
129
221
|
DESC
|
|
222
|
+
method_option :group, type: :string, desc: "Store KEY and VALUE inside this named group"
|
|
130
223
|
def set(key, value)
|
|
131
224
|
vault = open_vault!
|
|
132
|
-
|
|
133
|
-
|
|
225
|
+
if options[:group]
|
|
226
|
+
group = canonical_group_name(vault, options[:group])
|
|
227
|
+
validate_group_segment!(group)
|
|
228
|
+
validate_group_segment!(key)
|
|
229
|
+
raise GroupSaveError, :collision if vault.all.key?(group) && !vault.all[group].is_a?(Hash)
|
|
230
|
+
vault.set("#{group}.#{key}", value)
|
|
231
|
+
$stdout.puts "Set #{key} in group `#{group}` in vault `#{vault.name}`."
|
|
232
|
+
$stdout.puts
|
|
233
|
+
$stdout.puts "Stored as:"
|
|
234
|
+
$stdout.puts " #{group}.#{key}"
|
|
235
|
+
else
|
|
236
|
+
vault.set(key, value)
|
|
237
|
+
$stdout.puts "Set #{key} in vault '#{vault.name}'"
|
|
238
|
+
end
|
|
239
|
+
rescue Vault::InvalidKeyName => e
|
|
240
|
+
raise GroupSaveError, :invalid if options[:group]
|
|
241
|
+
abort_with e.message
|
|
242
|
+
rescue RuntimeError => e
|
|
243
|
+
raise GroupSaveError, :collision if options[:group]
|
|
244
|
+
abort_with e.message
|
|
134
245
|
end
|
|
135
246
|
|
|
136
247
|
desc "get KEY", "Retrieve a secret value by key"
|
|
@@ -185,6 +296,30 @@ module LocalVault
|
|
|
185
296
|
vault.list.each { |key| $stdout.puts key }
|
|
186
297
|
end
|
|
187
298
|
|
|
299
|
+
desc "groups [QUERY]", "List or search stored secret groups without revealing values"
|
|
300
|
+
long_desc <<~DESC
|
|
301
|
+
Discover dot-notation namespaces and flat-key prefix groups.
|
|
302
|
+
|
|
303
|
+
\x05 localvault groups
|
|
304
|
+
\x05 localvault groups str
|
|
305
|
+
\x05 localvault show --group STRIPE
|
|
306
|
+
\x05 localvault set --group STRIPE API_KEY VALUE
|
|
307
|
+
DESC
|
|
308
|
+
def groups(query = nil)
|
|
309
|
+
vault = open_vault!
|
|
310
|
+
matches = GroupCatalog.new(vault.all).search(query)
|
|
311
|
+
if matches.empty?
|
|
312
|
+
$stdout.puts "No groups match #{query}"
|
|
313
|
+
return
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
heading = query ? "Groups matching `#{query}`" : "Groups"
|
|
317
|
+
$stdout.puts "#{heading} in vault `#{vault.name}`:"
|
|
318
|
+
$stdout.puts
|
|
319
|
+
$stdout.printf(" %-20s %-6s %s\n", "Group", "Keys", "Kind")
|
|
320
|
+
matches.each { |group| $stdout.printf(" %-20s %-6d %s\n", group.name, group.count, group.kind) }
|
|
321
|
+
end
|
|
322
|
+
|
|
188
323
|
desc "delete KEY", "Remove a secret or entire project group"
|
|
189
324
|
long_desc <<~DESC
|
|
190
325
|
Delete a single key or an entire project group.
|
|
@@ -350,14 +485,15 @@ module LocalVault
|
|
|
350
485
|
\x05 localvault show -p platepose -v intellectaco # one project only
|
|
351
486
|
\x05 localvault show -p platepose -v intellectaco --reveal
|
|
352
487
|
DESC
|
|
353
|
-
method_option :group,
|
|
488
|
+
method_option :group, type: :string, lazy_default: GROUP_ALL_SENTINEL, desc: "Show all groups or one group by name"
|
|
354
489
|
method_option :reveal, type: :boolean, default: false, desc: "Show full values instead of masking"
|
|
355
490
|
method_option :project, aliases: "-p", type: :string, desc: "Show only this project group"
|
|
356
491
|
def show
|
|
357
492
|
vault = open_vault!
|
|
358
493
|
secrets = vault.all
|
|
359
494
|
|
|
360
|
-
|
|
495
|
+
named_group_query = options[:group] && ![GROUP_ALL_SENTINEL, GROUP_OFF_SENTINEL].include?(options[:group])
|
|
496
|
+
if secrets.empty? && !named_group_query
|
|
361
497
|
$stdout.puts "No secrets in vault '#{vault.name}'."
|
|
362
498
|
return
|
|
363
499
|
end
|
|
@@ -369,7 +505,17 @@ module LocalVault
|
|
|
369
505
|
return
|
|
370
506
|
end
|
|
371
507
|
render_table(group.sort.to_h, "#{vault.name}/#{options[:project]}", reveal: options[:reveal])
|
|
372
|
-
elsif options[:group]
|
|
508
|
+
elsif options[:group] && ![GROUP_ALL_SENTINEL, GROUP_OFF_SENTINEL].include?(options[:group])
|
|
509
|
+
match = GroupCatalog.new(secrets).resolve(options[:group])
|
|
510
|
+
if match.group
|
|
511
|
+
entries = match.group.entries.to_h { |entry| [entry.label, entry.value] }
|
|
512
|
+
render_table(entries, "#{vault.name}/#{match.group.name}", reveal: options[:reveal])
|
|
513
|
+
elsif match.kind == :ambiguous
|
|
514
|
+
raise GroupSelectionError.new(:ambiguous, query: options[:group], candidates: match.groups.map(&:name))
|
|
515
|
+
else
|
|
516
|
+
raise GroupSelectionError.new(:absent, query: options[:group])
|
|
517
|
+
end
|
|
518
|
+
elsif options[:group] != GROUP_OFF_SENTINEL && (options[:group] || secrets.values.any? { |v| v.is_a?(Hash) })
|
|
373
519
|
render_grouped_table(secrets, vault.name, reveal: options[:reveal])
|
|
374
520
|
else
|
|
375
521
|
render_table(secrets.sort.to_h, vault.name, reveal: options[:reveal])
|
|
@@ -468,8 +614,44 @@ module LocalVault
|
|
|
468
614
|
end
|
|
469
615
|
|
|
470
616
|
desc "mcp", "Start MCP server (stdio)"
|
|
617
|
+
long_desc <<~DESC
|
|
618
|
+
Start LocalVault's stdio MCP server for an AI client.
|
|
619
|
+
|
|
620
|
+
Install and verify:
|
|
621
|
+
\x05 localvault install-mcp [claude-code|cursor|windsurf]
|
|
622
|
+
\x05 localvault mcp --check
|
|
623
|
+
\x05 localvault show
|
|
624
|
+
|
|
625
|
+
Do not run `localvault mcp` directly to test it: stdio servers wait for
|
|
626
|
+
JSON-RPC input and therefore appear idle. Use `--check`, then restart the
|
|
627
|
+
configured AI client.
|
|
628
|
+
|
|
629
|
+
Agents should call `list_secrets`, then `localvault_build_exec` to inject
|
|
630
|
+
secrets into a subprocess. Plaintext `get_secret` requires the explicit
|
|
631
|
+
`allow_plaintext: true` acknowledgement.
|
|
632
|
+
DESC
|
|
633
|
+
method_option :check, type: :boolean, default: false, desc: "Check installation and active-vault readiness, then exit"
|
|
471
634
|
def mcp
|
|
472
|
-
|
|
635
|
+
if options[:check]
|
|
636
|
+
require_relative "mcp/tools"
|
|
637
|
+
status = VaultResolver.readiness_status(options[:vault])
|
|
638
|
+
ready = status["active_vault_unlocked"]
|
|
639
|
+
tool_names = MCP::Tools::DEFINITIONS.map { |definition| definition.fetch("name") }
|
|
640
|
+
$stdout.puts "LocalVault #{VERSION}"
|
|
641
|
+
$stdout.puts "Home: #{Config.root_path}"
|
|
642
|
+
$stdout.puts "MCP readiness: #{ready ? "ready" : "locked"}"
|
|
643
|
+
$stdout.puts "Active vault: #{status["active_vault"]} (#{status["active_vault_source"]})"
|
|
644
|
+
$stdout.puts "Vault session: #{ready ? "available" : "unlock with `localvault show`"}"
|
|
645
|
+
$stdout.puts "MCP tools: #{tool_names.join(", ")}"
|
|
646
|
+
$stdout.puts "Plaintext gate: enabled"
|
|
647
|
+
$stdout.puts "Server instructions: enabled"
|
|
648
|
+
$stdout.puts
|
|
649
|
+
$stdout.puts "Safe agent workflow: list_secrets → localvault_build_exec → run the generated command"
|
|
650
|
+
$stdout.puts "Plaintext retrieval is opt-in with allow_plaintext: true."
|
|
651
|
+
return ready ? CommandStatus.ok : CommandStatus.error
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
require_relative "mcp/server"
|
|
473
655
|
MCP::Server.new.start
|
|
474
656
|
end
|
|
475
657
|
|
|
@@ -1282,7 +1464,7 @@ module LocalVault
|
|
|
1282
1464
|
end
|
|
1283
1465
|
|
|
1284
1466
|
def self.exit_on_failure?
|
|
1285
|
-
|
|
1467
|
+
false
|
|
1286
1468
|
end
|
|
1287
1469
|
|
|
1288
1470
|
no_commands do
|
|
@@ -1307,6 +1489,23 @@ module LocalVault
|
|
|
1307
1489
|
|
|
1308
1490
|
private
|
|
1309
1491
|
|
|
1492
|
+
def canonical_group_name(vault, supplied)
|
|
1493
|
+
groups = GroupCatalog.new(vault.all).groups
|
|
1494
|
+
return supplied if groups.any? { |group| group.name == supplied }
|
|
1495
|
+
|
|
1496
|
+
insensitive = groups.select { |group| group.name.casecmp?(supplied) }
|
|
1497
|
+
return insensitive.first.name if insensitive.one?
|
|
1498
|
+
raise GroupSaveError.new(:ambiguous, candidates: insensitive.map(&:name)) if insensitive.length > 1
|
|
1499
|
+
|
|
1500
|
+
supplied
|
|
1501
|
+
end
|
|
1502
|
+
|
|
1503
|
+
def validate_group_segment!(segment)
|
|
1504
|
+
unless segment.match?(Vault::KEY_SEGMENT_PATTERN) && segment.length <= 128
|
|
1505
|
+
raise GroupSaveError, :invalid
|
|
1506
|
+
end
|
|
1507
|
+
end
|
|
1508
|
+
|
|
1310
1509
|
# ── Demo data ──────────────────────────────────────────────────
|
|
1311
1510
|
DEMO_DATA = {
|
|
1312
1511
|
"default" => {
|
|
@@ -1694,9 +1893,11 @@ module LocalVault
|
|
|
1694
1893
|
def print_next_steps(client_name)
|
|
1695
1894
|
$stdout.puts ""
|
|
1696
1895
|
$stdout.puts "Next steps:"
|
|
1697
|
-
$stdout.puts " 1.
|
|
1698
|
-
$stdout.puts " 2.
|
|
1699
|
-
$stdout.puts " 3.
|
|
1896
|
+
$stdout.puts " 1. Unlock your vault once: localvault show"
|
|
1897
|
+
$stdout.puts " 2. Verify readiness: localvault mcp --check"
|
|
1898
|
+
$stdout.puts " 3. Restart #{client_name}"
|
|
1899
|
+
$stdout.puts " 4. Ask the agent to list names, then use localvault_build_exec"
|
|
1900
|
+
$stdout.puts " for process injection. Plaintext get_secret is explicit opt-in."
|
|
1700
1901
|
$stdout.puts " Switch vaults: localvault switch <vault>"
|
|
1701
1902
|
end
|
|
1702
1903
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative "input_validation"
|
|
2
|
+
|
|
1
3
|
module LocalVault
|
|
2
4
|
module EnvProjection
|
|
3
5
|
class InvalidMapping < StandardError; end
|
|
@@ -34,7 +36,10 @@ module LocalVault
|
|
|
34
36
|
def self.parse_selectors(value)
|
|
35
37
|
values = Array(value).compact.flat_map { |v| v.to_s.split(",") }
|
|
36
38
|
selectors = values.map(&:strip).reject(&:empty?)
|
|
39
|
+
selectors.each { |selector| InputValidation.selector!(selector) }
|
|
37
40
|
selectors.empty? ? nil : selectors
|
|
41
|
+
rescue InputValidation::InvalidInput => e
|
|
42
|
+
raise InvalidMapping, e.message
|
|
38
43
|
end
|
|
39
44
|
|
|
40
45
|
def self.parse_map(value)
|
|
@@ -43,10 +48,12 @@ module LocalVault
|
|
|
43
48
|
|
|
44
49
|
key, env_name = pair.split("=", 2).map(&:strip)
|
|
45
50
|
raise InvalidMapping, "Invalid map '#{pair}'. Use KEY=ENV_NAME" if key.to_s.empty? || env_name.to_s.empty?
|
|
46
|
-
|
|
51
|
+
InputValidation.mapping!(key, env_name)
|
|
47
52
|
|
|
48
53
|
hash[key] = env_name
|
|
49
54
|
end
|
|
55
|
+
rescue InputValidation::InvalidInput => e
|
|
56
|
+
raise InvalidMapping, e.message
|
|
50
57
|
end
|
|
51
58
|
|
|
52
59
|
def self.profile_config(profile)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module LocalVault
|
|
2
|
+
class GroupCatalog
|
|
3
|
+
Entry = Data.define(:label, :value, :source_kind)
|
|
4
|
+
Group = Data.define(:name, :kind, :entries) do
|
|
5
|
+
def count
|
|
6
|
+
entries.length
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
Match = Data.define(:kind, :query, :groups) do
|
|
10
|
+
def group
|
|
11
|
+
groups.one? ? groups.first : nil
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :groups
|
|
16
|
+
|
|
17
|
+
def initialize(secrets)
|
|
18
|
+
grouped = {}
|
|
19
|
+
|
|
20
|
+
secrets.each do |name, value|
|
|
21
|
+
if value.is_a?(Hash)
|
|
22
|
+
append_namespace(grouped, name, value)
|
|
23
|
+
elsif name.include?("_")
|
|
24
|
+
append_entry(grouped, name.split("_", 2).first, Entry.new(name, value, :prefix))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@groups = grouped.map do |name, entries|
|
|
29
|
+
kinds = entries.map(&:source_kind).uniq
|
|
30
|
+
kind = kinds.length > 1 ? :mixed : kinds.first
|
|
31
|
+
Group.new(name, kind, entries.sort_by(&:label).freeze)
|
|
32
|
+
end.sort_by { |group| [group.name.downcase, group.name] }.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def search(query = nil)
|
|
36
|
+
return groups if query.nil? || query.empty?
|
|
37
|
+
|
|
38
|
+
needle = query.downcase
|
|
39
|
+
groups.select { |group| group.name.downcase.start_with?(needle) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resolve(query)
|
|
43
|
+
exact = groups.select { |group| group.name == query }
|
|
44
|
+
return Match.new(:exact, query, exact) if exact.one?
|
|
45
|
+
|
|
46
|
+
insensitive = groups.select { |group| group.name.casecmp?(query) }
|
|
47
|
+
return Match.new(:unique, query, insensitive) if insensitive.one?
|
|
48
|
+
return Match.new(:ambiguous, query, insensitive) if insensitive.length > 1
|
|
49
|
+
|
|
50
|
+
matches = search(query)
|
|
51
|
+
kind = matches.one? ? :unique : (matches.empty? ? :absent : :ambiguous)
|
|
52
|
+
Match.new(kind, query, matches)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def append_namespace(grouped, name, values)
|
|
58
|
+
values.each do |label, value|
|
|
59
|
+
append_entry(grouped, name, Entry.new(label, value, :namespace))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def append_entry(grouped, name, entry)
|
|
64
|
+
(grouped[name] ||= []) << entry
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -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,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
|
|
@@ -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
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.0
|
|
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,9 +116,12 @@ 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
|