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
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,28 +217,41 @@ localvault remove @alice -v production --rotate
|
|
|
214
217
|
|
|
215
218
|
## MCP Server (AI Agents)
|
|
216
219
|
|
|
217
|
-
Give AI agents
|
|
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
|
|
221
226
|
localvault install-mcp claude-code
|
|
222
|
-
# Also supports: cursor, windsurf
|
|
227
|
+
# Also supports: cursor, windsurf
|
|
223
228
|
|
|
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
|
-
#
|
|
229
|
-
# list_secrets(vault?, prefix?) — list key names
|
|
236
|
+
# localvault_whoami — diagnose active vault/session state
|
|
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
|
|
230
240
|
# set_secret(key, value, vault?) — store a secret
|
|
231
|
-
# delete_secret(key, vault?)
|
|
241
|
+
# delete_secret(key, vault?) — remove a secret
|
|
232
242
|
```
|
|
233
243
|
|
|
234
|
-
|
|
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:
|
|
235
250
|
|
|
236
251
|
```bash
|
|
237
|
-
localvault
|
|
238
|
-
|
|
252
|
+
localvault exec --profile aws -- aws sts get-caller-identity
|
|
253
|
+
localvault exec --only AWS_IAM.*,AWS_SES.* --except AWS_SES.smtp_password -- your-script
|
|
254
|
+
localvault env --map AWS_IAM.access_key_id=AWS_ACCESS_KEY_ID
|
|
239
255
|
```
|
|
240
256
|
|
|
241
257
|
## Multi-Project Vaults
|
|
@@ -247,15 +263,28 @@ One vault, many projects. Dot-notation keeps secrets organized:
|
|
|
247
263
|
localvault set myapp.DATABASE_URL postgres://localhost/myapp -v work
|
|
248
264
|
localvault set api.DATABASE_URL postgres://localhost/api -v work
|
|
249
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
|
+
|
|
250
272
|
# View grouped by project
|
|
251
273
|
localvault show --group -v work
|
|
252
274
|
|
|
275
|
+
# Show one group by exact or unique prefix
|
|
276
|
+
localvault show --group my -v work
|
|
277
|
+
|
|
253
278
|
# Filter to one project
|
|
254
279
|
localvault show -p myapp -v work
|
|
255
280
|
|
|
256
281
|
# Export one project
|
|
257
282
|
eval $(localvault env -p myapp -v work)
|
|
258
283
|
|
|
284
|
+
# Export all projects with project.key transformed to PROJECT__key
|
|
285
|
+
localvault env -v work
|
|
286
|
+
# → MYAPP__DATABASE_URL, API__DATABASE_URL
|
|
287
|
+
|
|
259
288
|
# Bulk import
|
|
260
289
|
localvault import .env --prefix myapp -v work
|
|
261
290
|
```
|
|
@@ -272,7 +301,7 @@ localvault get API_KEY
|
|
|
272
301
|
localvault exec -- rails server
|
|
273
302
|
```
|
|
274
303
|
|
|
275
|
-
|
|
304
|
+
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
305
|
|
|
277
306
|
## Security
|
|
278
307
|
|
|
@@ -324,7 +353,7 @@ localvault login --server https://vaulthost.example
|
|
|
324
353
|
git clone https://github.com/inventlist/localvault.git
|
|
325
354
|
cd localvault
|
|
326
355
|
bundle install
|
|
327
|
-
bundle exec rake test
|
|
356
|
+
bundle exec rake test
|
|
328
357
|
```
|
|
329
358
|
|
|
330
359
|
## Used by
|
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
|