localvault 1.7.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +36 -8
- data/bin/localvault +2 -1
- data/lib/localvault/cli/error_presenter.rb +189 -0
- data/lib/localvault/cli.rb +334 -15
- data/lib/localvault/env_projection.rb +8 -1
- data/lib/localvault/group_catalog.rb +67 -0
- data/lib/localvault/input_validation.rb +80 -0
- data/lib/localvault/mcp/exec_command_builder.rb +77 -0
- data/lib/localvault/mcp/server.rb +4 -1
- data/lib/localvault/mcp/tools.rb +87 -10
- data/lib/localvault/session_cache.rb +29 -3
- data/lib/localvault/stdin_secret_input.rb +22 -0
- data/lib/localvault/vault_resolver.rb +11 -0
- data/lib/localvault/version.rb +1 -1
- data/lib/localvault.rb +1 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a7cdca7447f0efeb125773a3385a6e3beee17cb03d4c05e04b91dc59254215dd
|
|
4
|
+
data.tar.gz: b3378c85ad59e00561d5eb5f590e203469d86f8c88925e7b547721717175f5a4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2e4409c50f9aa0350b86541e905703ed24520cd583498d49de11a320cda8b6c2ebf86bc14c377bed52cd83438ba2254b0e243d91ffcf5521f7f84d9445072e1c
|
|
7
|
+
data.tar.gz: 97ff4555610dc166e82d01b6f11200ce516d0d36acb701625519c73b6e2f31d9096ae53e19d863128246d240d54eb82ebbd6f60ed51309ef4a8ec45675b144fd
|
data/README.md
CHANGED
|
@@ -41,10 +41,10 @@ sudo dnf install libsodium-devel
|
|
|
41
41
|
# Create a vault (prompts for passphrase)
|
|
42
42
|
localvault init
|
|
43
43
|
|
|
44
|
-
# Store secrets
|
|
45
|
-
localvault set OPENAI_API_KEY
|
|
46
|
-
localvault set STRIPE_SECRET_KEY
|
|
47
|
-
localvault set DATABASE_URL
|
|
44
|
+
# Store secrets without putting values in shell history / process args
|
|
45
|
+
printf '%s' "$OPENAI_API_KEY" | localvault set OPENAI_API_KEY --stdin
|
|
46
|
+
printf '%s' "$STRIPE_SECRET_KEY" | localvault set STRIPE_SECRET_KEY --stdin
|
|
47
|
+
printf '%s' "$DATABASE_URL" | localvault set DATABASE_URL --stdin
|
|
48
48
|
|
|
49
49
|
# Retrieve a secret (raw, pipeable)
|
|
50
50
|
localvault get OPENAI_API_KEY
|
|
@@ -69,11 +69,15 @@ localvault exec -- rails server
|
|
|
69
69
|
| Command | Description |
|
|
70
70
|
|---------|-------------|
|
|
71
71
|
| `init [NAME]` | Create a vault (Argon2id key derivation) |
|
|
72
|
-
| `set KEY
|
|
72
|
+
| `set KEY --stdin` | Store a secret from stdin without argv/history exposure |
|
|
73
|
+
| `set KEY [VALUE]` | Store a secret (positional value kept for compatibility) |
|
|
74
|
+
| `set --group GROUP KEY --stdin` | Store a secret in a named group from stdin |
|
|
73
75
|
| `get KEY` | Retrieve a secret (raw, pipeable) |
|
|
74
76
|
| `show` | Display all secrets in a table (masked by default) |
|
|
75
77
|
| `show --reveal` | Display with values visible |
|
|
76
78
|
| `show --group` | Group by dot-notation prefix (one table per project) |
|
|
79
|
+
| `show --group QUERY` | Show one exact or uniquely matching group |
|
|
80
|
+
| `groups [QUERY]` | List/search group names and key counts without values |
|
|
77
81
|
| `list` | List key names only |
|
|
78
82
|
| `delete KEY` | Remove a secret |
|
|
79
83
|
| `rename OLD NEW` | Rename a secret key |
|
|
@@ -140,6 +144,7 @@ backward compatibility but the top-level forms are preferred.
|
|
|
140
144
|
|---------|-------------|
|
|
141
145
|
| `install-mcp [CLIENT]` | Configure MCP server in claude-code, cursor, or windsurf |
|
|
142
146
|
| `mcp` | Start MCP server (stdio transport) |
|
|
147
|
+
| `doctor` | Check install and PATH readiness, including brew/asdf shadowing |
|
|
143
148
|
|
|
144
149
|
All commands accept `--vault NAME` (or `-v NAME`) to target a specific vault. Default vault is `default`.
|
|
145
150
|
|
|
@@ -214,7 +219,9 @@ localvault remove @alice -v production --rotate
|
|
|
214
219
|
|
|
215
220
|
## MCP Server (AI Agents)
|
|
216
221
|
|
|
217
|
-
Give AI agents controlled secret access without hardcoding credentials in MCP
|
|
222
|
+
Give AI agents controlled secret access without hardcoding credentials in MCP
|
|
223
|
+
config. The default workflow keeps values out of model context: discover names,
|
|
224
|
+
build a `localvault exec` command, then run it with process-scoped injection.
|
|
218
225
|
|
|
219
226
|
```bash
|
|
220
227
|
# One-command install for Claude Code
|
|
@@ -224,15 +231,27 @@ localvault install-mcp claude-code
|
|
|
224
231
|
# Unlock your vault for the session
|
|
225
232
|
localvault unlock
|
|
226
233
|
|
|
234
|
+
# Verify setup without starting the blocking stdio server
|
|
235
|
+
localvault mcp --check
|
|
236
|
+
|
|
237
|
+
# Diagnose brew/asdf PATH shadowing after upgrades
|
|
238
|
+
localvault doctor
|
|
239
|
+
|
|
227
240
|
# MCP tools available to the agent:
|
|
228
241
|
# localvault_whoami — diagnose active vault/session state
|
|
229
|
-
# get_secret(key, vault?) — read an exact secret key
|
|
230
242
|
# list_secrets(vault?, prefix?, query?) — list/search key names
|
|
243
|
+
# localvault_build_exec(command, ...) — build safe injection (does not execute)
|
|
244
|
+
# get_secret(key, allow_plaintext: true, vault?) — explicit plaintext reveal
|
|
231
245
|
# set_secret(key, value, vault?) — store a secret
|
|
232
246
|
# delete_secret(key, vault?) — remove a secret
|
|
233
247
|
```
|
|
234
248
|
|
|
235
|
-
|
|
249
|
+
Agents should prefer `localvault_build_exec` for commands, evaluation, API calls,
|
|
250
|
+
and configuration checks. It returns both argv and a shell-safe command without
|
|
251
|
+
opening a vault or reading a value. `get_secret` rejects calls unless
|
|
252
|
+
`allow_plaintext: true` is explicit.
|
|
253
|
+
|
|
254
|
+
Use selectors, mappings, and profiles to keep subprocess envs scoped:
|
|
236
255
|
|
|
237
256
|
```bash
|
|
238
257
|
localvault exec --profile aws -- aws sts get-caller-identity
|
|
@@ -249,9 +268,18 @@ One vault, many projects. Dot-notation keeps secrets organized:
|
|
|
249
268
|
localvault set myapp.DATABASE_URL postgres://localhost/myapp -v work
|
|
250
269
|
localvault set api.DATABASE_URL postgres://localhost/api -v work
|
|
251
270
|
|
|
271
|
+
# Or use the guided group form
|
|
272
|
+
localvault set --group myapp DATABASE_URL postgres://localhost/myapp -v work
|
|
273
|
+
|
|
274
|
+
# Search groups without revealing values
|
|
275
|
+
localvault groups app -v work
|
|
276
|
+
|
|
252
277
|
# View grouped by project
|
|
253
278
|
localvault show --group -v work
|
|
254
279
|
|
|
280
|
+
# Show one group by exact or unique prefix
|
|
281
|
+
localvault show --group my -v work
|
|
282
|
+
|
|
255
283
|
# Filter to one project
|
|
256
284
|
localvault show -p myapp -v work
|
|
257
285
|
|
data/bin/localvault
CHANGED
|
@@ -0,0 +1,189 @@
|
|
|
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 @error.is_a?(CLI::SetValueSourceError) && @error.kind == :multiple
|
|
64
|
+
"Error: #{@error.message}"
|
|
65
|
+
elsif group_save_attempt?
|
|
66
|
+
"Error: saving in a group needs GROUP, KEY, and VALUE."
|
|
67
|
+
elsif @error.is_a?(CLI::SetValueSourceError)
|
|
68
|
+
"Error: #{@error.message}"
|
|
69
|
+
elsif unknown_option
|
|
70
|
+
"Error: unknown option `#{unknown_option}`."
|
|
71
|
+
elsif context.command.nil?
|
|
72
|
+
"Error: unknown or ambiguous command."
|
|
73
|
+
else
|
|
74
|
+
"Error: this command needs a different combination of arguments."
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def usage_for(context)
|
|
79
|
+
if @error.is_a?(CLI::GroupSelectionError)
|
|
80
|
+
"localvault show --group GROUP"
|
|
81
|
+
elsif group_save_attempt?
|
|
82
|
+
"localvault set --group GROUP KEY VALUE"
|
|
83
|
+
elsif context.command
|
|
84
|
+
["localvault", *context.namespace, context.command.usage].join(" ")
|
|
85
|
+
else
|
|
86
|
+
["localvault", *context.namespace, "COMMAND"].join(" ")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def render_option_hint(context, io)
|
|
91
|
+
return unless unknown_option
|
|
92
|
+
|
|
93
|
+
correction = DidYouMean::SpellChecker.new(dictionary: option_names(context)).correct(unknown_option).first
|
|
94
|
+
io.puts "\nDid you mean `#{correction}`?" if correction
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def suggestions_for(context)
|
|
98
|
+
if @error.is_a?(CLI::GroupSelectionError)
|
|
99
|
+
return @error.candidates.map { |name| "localvault show --group #{name}" } if @error.kind == :ambiguous
|
|
100
|
+
return ["localvault groups #{@error.query}", "localvault groups"]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
return group_save_suggestions if group_save_attempt?
|
|
104
|
+
return set_suggestions if context.command&.name == "set"
|
|
105
|
+
return show_group_suggestions if context.command&.name == "show" || unknown_option == "--group-by"
|
|
106
|
+
return ["localvault add HANDLE", "localvault team add HANDLE"] if context.namespace == ["team"] && context.command&.name == "add"
|
|
107
|
+
|
|
108
|
+
if context.command
|
|
109
|
+
examples = curated_examples(context.command)
|
|
110
|
+
examples.empty? ? [usage_for(context)] : examples
|
|
111
|
+
else
|
|
112
|
+
command_suggestions(context)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def group_save_suggestions
|
|
117
|
+
[
|
|
118
|
+
"localvault set --group GROUP KEY VALUE",
|
|
119
|
+
"localvault set GROUP.KEY VALUE",
|
|
120
|
+
"localvault groups [QUERY]"
|
|
121
|
+
]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def set_suggestions
|
|
125
|
+
[
|
|
126
|
+
%(printf '%s' "$SECRET" | localvault set KEY --stdin),
|
|
127
|
+
"localvault set KEY VALUE",
|
|
128
|
+
*group_save_suggestions
|
|
129
|
+
]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def show_group_suggestions
|
|
133
|
+
[
|
|
134
|
+
"localvault show --group GROUP",
|
|
135
|
+
"localvault groups [QUERY]",
|
|
136
|
+
"localvault show --project PROJECT"
|
|
137
|
+
]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def command_suggestions(context)
|
|
141
|
+
names = context.command_class.all_commands.keys.reject { |name| name == "help" }
|
|
142
|
+
matches = prefix_or_spelling_matches(names, context.token.to_s)
|
|
143
|
+
items = matches.map do |name|
|
|
144
|
+
command = context.command_class.all_commands.fetch(name)
|
|
145
|
+
["localvault", *context.namespace, command.usage, " # #{command.description}"].join(" ")
|
|
146
|
+
end
|
|
147
|
+
items.empty? ? ["localvault help"] : items
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def prefix_or_spelling_matches(names, token)
|
|
151
|
+
prefix = names.select { |name| name.start_with?(token) }
|
|
152
|
+
return prefix.sort unless prefix.empty?
|
|
153
|
+
|
|
154
|
+
DidYouMean::SpellChecker.new(dictionary: names).correct(token).first(5)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def curated_examples(command)
|
|
158
|
+
command.long_description.to_s.lines.filter_map do |line|
|
|
159
|
+
example = line.delete("\u0005").strip
|
|
160
|
+
example if example.start_with?("localvault ")
|
|
161
|
+
end.uniq
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def option_names(context)
|
|
165
|
+
options = context.command_class.class_options.values
|
|
166
|
+
options += context.command.options.values if context.command
|
|
167
|
+
|
|
168
|
+
options.flat_map do |option|
|
|
169
|
+
["--#{option.name.to_s.tr("_", "-")}", *Array(option.aliases)]
|
|
170
|
+
end.uniq
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def unknown_option
|
|
174
|
+
return @unknown_option if defined?(@unknown_option)
|
|
175
|
+
|
|
176
|
+
known = option_names(@context)
|
|
177
|
+
@unknown_option = @argv.find do |argument|
|
|
178
|
+
next false unless argument.start_with?("--") && argument != "--"
|
|
179
|
+
|
|
180
|
+
!known.include?(argument.split("=", 2).first)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def group_save_attempt?
|
|
185
|
+
@argv.first == "set" && @argv.include?("--group")
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|