localvault 1.8.0 → 1.9.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 +47 -6
- data/lib/localvault/cli/error_presenter.rb +13 -1
- data/lib/localvault/cli/guard.rb +87 -0
- data/lib/localvault/cli.rb +165 -11
- data/lib/localvault/guard.rb +148 -0
- data/lib/localvault/plaintext_output.rb +48 -0
- data/lib/localvault/stdin_secret_input.rb +22 -0
- data/lib/localvault/version.rb +1 -1
- data/lib/localvault.rb +3 -0
- 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: f49d7a925b997780d6d03a2b071117e20da0827d24331d28c9cd5043a70b0056
|
|
4
|
+
data.tar.gz: 27b2be6fa5bb90d3df68eec4a27fcba483a175b574afd96f7aeba7dd0e175412
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7863c066d383db0a2ad4a98ae9665fe1fafc04de61b1fdbfec590d4af0ea04e47f31f1635a4c63d5886f0f71780a5f9f0ebd143348af23d404415991487dd214
|
|
7
|
+
data.tar.gz: b61cb7b5096ad2250560a1462f87ecfd81e538ed823971c46fe4820be3bb50443574055e40d0e080c7f4e5f7b7ce93d2d2f9cceedd03ea4f39a9a13141f35c44
|
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,8 +69,9 @@ localvault exec -- rails server
|
|
|
69
69
|
| Command | Description |
|
|
70
70
|
|---------|-------------|
|
|
71
71
|
| `init [NAME]` | Create a vault (Argon2id key derivation) |
|
|
72
|
-
| `set KEY
|
|
73
|
-
| `set
|
|
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 |
|
|
74
75
|
| `get KEY` | Retrieve a secret (raw, pipeable) |
|
|
75
76
|
| `show` | Display all secrets in a table (masked by default) |
|
|
76
77
|
| `show --reveal` | Display with values visible |
|
|
@@ -143,6 +144,10 @@ backward compatibility but the top-level forms are preferred.
|
|
|
143
144
|
|---------|-------------|
|
|
144
145
|
| `install-mcp [CLIENT]` | Configure MCP server in claude-code, cursor, or windsurf |
|
|
145
146
|
| `mcp` | Start MCP server (stdio transport) |
|
|
147
|
+
| `doctor` | Check install and PATH readiness, including brew/asdf shadowing |
|
|
148
|
+
| `guard install` | Install Claude Code hooks that block secrets in agent commands (v1.9.0) |
|
|
149
|
+
| `guard status` | Show guard hook installation state |
|
|
150
|
+
| `guard hook` | Hook entrypoint (reads hook JSON on stdin; not run by hand) |
|
|
146
151
|
|
|
147
152
|
All commands accept `--vault NAME` (or `-v NAME`) to target a specific vault. Default vault is `default`.
|
|
148
153
|
|
|
@@ -232,6 +237,9 @@ localvault unlock
|
|
|
232
237
|
# Verify setup without starting the blocking stdio server
|
|
233
238
|
localvault mcp --check
|
|
234
239
|
|
|
240
|
+
# Diagnose brew/asdf PATH shadowing after upgrades
|
|
241
|
+
localvault doctor
|
|
242
|
+
|
|
235
243
|
# MCP tools available to the agent:
|
|
236
244
|
# localvault_whoami — diagnose active vault/session state
|
|
237
245
|
# list_secrets(vault?, prefix?, query?) — list/search key names
|
|
@@ -305,6 +313,39 @@ Unlocking writes a derived key to `LOCALVAULT_SESSION` and also caches it with a
|
|
|
305
313
|
|
|
306
314
|
## Security
|
|
307
315
|
|
|
316
|
+
### Agent Plaintext Containment (v1.9.0)
|
|
317
|
+
|
|
318
|
+
Two layers keep secrets out of AI agent context and transcripts:
|
|
319
|
+
|
|
320
|
+
**1. Plaintext refuses captured streams.** `get`, `env`, and `show --reveal`
|
|
321
|
+
print values to an interactive terminal as always. When stdout is captured
|
|
322
|
+
(a pipe or an agent's shell), a human confirms with one keypress on `/dev/tty`;
|
|
323
|
+
an agent's shell has no `/dev/tty`, so it is refused and pointed at injection:
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
localvault get STRIPE_KEY # human at a terminal: prints
|
|
327
|
+
localvault get STRIPE_KEY | pbcopy # human piping: "Print plaintext? [y/N]"
|
|
328
|
+
# agent shell: refused → use localvault exec --map STRIPE_KEY=STRIPE_KEY -- CMD
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
There is deliberately no flag or environment variable to bypass this — the only
|
|
332
|
+
override is a keypress on a real terminal. Headless automation uses
|
|
333
|
+
`localvault exec` injection.
|
|
334
|
+
|
|
335
|
+
**2. Guard hooks block secrets in agent commands.** `localvault guard install`
|
|
336
|
+
wires Claude Code hooks that scan every Bash tool call against your unlocked
|
|
337
|
+
vaults. A command containing a stored secret value is blocked before it runs,
|
|
338
|
+
naming the key by fingerprint — never by value:
|
|
339
|
+
|
|
340
|
+
```text
|
|
341
|
+
LocalVault guard: blocked — this tool input contains the plaintext value of
|
|
342
|
+
default/STRIPE.private_key (sha256:1a2b3c4d5e6f). Inject it instead:
|
|
343
|
+
localvault exec --map STRIPE.private_key=PRIVATE_KEY -- your-command
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
The installed hook fails open: locked vaults, an old binary, or a missing
|
|
347
|
+
install allow the call rather than breaking your session.
|
|
348
|
+
|
|
308
349
|
### Crypto Stack
|
|
309
350
|
|
|
310
351
|
| Layer | Algorithm | Purpose |
|
|
@@ -60,8 +60,12 @@ module LocalVault
|
|
|
60
60
|
when :ambiguous then "Error: group name is ambiguous. Existing groups: #{@error.candidates.join(", ")}."
|
|
61
61
|
else "Error: group and key names may contain letters, digits, and underscores only."
|
|
62
62
|
end
|
|
63
|
+
elsif @error.is_a?(CLI::SetValueSourceError) && @error.kind == :multiple
|
|
64
|
+
"Error: #{@error.message}"
|
|
63
65
|
elsif group_save_attempt?
|
|
64
66
|
"Error: saving in a group needs GROUP, KEY, and VALUE."
|
|
67
|
+
elsif @error.is_a?(CLI::SetValueSourceError)
|
|
68
|
+
"Error: #{@error.message}"
|
|
65
69
|
elsif unknown_option
|
|
66
70
|
"Error: unknown option `#{unknown_option}`."
|
|
67
71
|
elsif context.command.nil?
|
|
@@ -97,7 +101,7 @@ module LocalVault
|
|
|
97
101
|
end
|
|
98
102
|
|
|
99
103
|
return group_save_suggestions if group_save_attempt?
|
|
100
|
-
return
|
|
104
|
+
return set_suggestions if context.command&.name == "set"
|
|
101
105
|
return show_group_suggestions if context.command&.name == "show" || unknown_option == "--group-by"
|
|
102
106
|
return ["localvault add HANDLE", "localvault team add HANDLE"] if context.namespace == ["team"] && context.command&.name == "add"
|
|
103
107
|
|
|
@@ -117,6 +121,14 @@ module LocalVault
|
|
|
117
121
|
]
|
|
118
122
|
end
|
|
119
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
|
+
|
|
120
132
|
def show_group_suggestions
|
|
121
133
|
[
|
|
122
134
|
"localvault show --group GROUP",
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require "thor"
|
|
2
|
+
require "json"
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "../guard"
|
|
5
|
+
|
|
6
|
+
module LocalVault
|
|
7
|
+
class CLI
|
|
8
|
+
class Guard < Thor
|
|
9
|
+
desc "hook", "Claude Code hook entrypoint (reads hook JSON on stdin)"
|
|
10
|
+
long_desc <<~DESC
|
|
11
|
+
Reads a Claude Code PreToolUse/PostToolUse event on stdin. Blocks tool
|
|
12
|
+
calls whose input contains a stored plaintext secret value from any
|
|
13
|
+
session-unlocked vault, naming the key by fingerprint — never by value.
|
|
14
|
+
Fails open on any error so a broken guard cannot block all work.
|
|
15
|
+
DESC
|
|
16
|
+
def hook
|
|
17
|
+
event = JSON.parse($stdin.read)
|
|
18
|
+
result = LocalVault::Guard.evaluate(event)
|
|
19
|
+
if result[:exit] != 0
|
|
20
|
+
$stderr.puts result[:message]
|
|
21
|
+
exit result[:exit]
|
|
22
|
+
end
|
|
23
|
+
rescue StandardError
|
|
24
|
+
# fail open
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
desc "install", "Install the guard hooks into Claude Code settings"
|
|
28
|
+
method_option :project, type: :boolean, default: false,
|
|
29
|
+
desc: "Install into ./.claude/settings.json instead of ~/.claude/settings.json"
|
|
30
|
+
def install
|
|
31
|
+
path = settings_path(options[:project])
|
|
32
|
+
settings = File.exist?(path) ? JSON.parse(File.read(path)) : {}
|
|
33
|
+
if LocalVault::Guard.merge_hooks!(settings)
|
|
34
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
35
|
+
File.write(path, JSON.pretty_generate(settings) + "\n")
|
|
36
|
+
$stdout.puts "Installed LocalVault guard hooks in #{path}"
|
|
37
|
+
$stdout.puts "Restart Claude Code sessions to pick up hook changes."
|
|
38
|
+
else
|
|
39
|
+
$stdout.puts "LocalVault guard hooks already installed in #{path}"
|
|
40
|
+
end
|
|
41
|
+
warn_if_path_binary_lacks_guard
|
|
42
|
+
rescue JSON::ParserError
|
|
43
|
+
$stderr.puts "Error: #{path} is not valid JSON; fix it before installing."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
desc "status", "Show guard hook installation status"
|
|
47
|
+
def status
|
|
48
|
+
{ "user" => settings_path(false), "project" => settings_path(true) }.each do |label, path|
|
|
49
|
+
state = if !File.exist?(path)
|
|
50
|
+
"not installed"
|
|
51
|
+
else
|
|
52
|
+
begin
|
|
53
|
+
LocalVault::Guard.installed?(JSON.parse(File.read(path))) ? "installed" : "not installed"
|
|
54
|
+
rescue JSON::ParserError
|
|
55
|
+
"unreadable JSON"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
$stdout.puts "#{label} (#{path}): #{state}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
no_commands do
|
|
63
|
+
# The installed hook wraps the entrypoint to fail open, so an old or
|
|
64
|
+
# missing PATH binary never breaks the user's sessions — but it also
|
|
65
|
+
# silently guards nothing, which deserves a loud note at install time.
|
|
66
|
+
def warn_if_path_binary_lacks_guard
|
|
67
|
+
help_output = `localvault help 2>/dev/null`
|
|
68
|
+
return if help_output.include?("guard")
|
|
69
|
+
|
|
70
|
+
$stderr.puts "Note: the `localvault` on your PATH does not support `guard hook` " \
|
|
71
|
+
"(old version or different install). The hook fails open and guards " \
|
|
72
|
+
"nothing until you upgrade: brew upgrade localvault"
|
|
73
|
+
rescue StandardError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def settings_path(project)
|
|
78
|
+
if project
|
|
79
|
+
File.join(Dir.pwd, ".claude", "settings.json")
|
|
80
|
+
else
|
|
81
|
+
File.join(Dir.home, ".claude", "settings.json")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/localvault/cli.rb
CHANGED
|
@@ -4,7 +4,9 @@ require "base64"
|
|
|
4
4
|
require "lipgloss"
|
|
5
5
|
require_relative "env_projection"
|
|
6
6
|
require_relative "key_lookup"
|
|
7
|
+
require_relative "plaintext_output"
|
|
7
8
|
require_relative "session_cache"
|
|
9
|
+
require_relative "stdin_secret_input"
|
|
8
10
|
require_relative "vault_resolver"
|
|
9
11
|
require_relative "group_catalog"
|
|
10
12
|
|
|
@@ -49,6 +51,18 @@ module LocalVault
|
|
|
49
51
|
1
|
|
50
52
|
end
|
|
51
53
|
end
|
|
54
|
+
class SetValueSourceError < Thor::Error
|
|
55
|
+
attr_reader :kind
|
|
56
|
+
|
|
57
|
+
def initialize(kind, message)
|
|
58
|
+
@kind = kind
|
|
59
|
+
super(message)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def exit_status
|
|
63
|
+
1
|
|
64
|
+
end
|
|
65
|
+
end
|
|
52
66
|
|
|
53
67
|
def self.start(given_args = ARGV, config = {})
|
|
54
68
|
require_relative "cli/error_presenter"
|
|
@@ -105,7 +119,9 @@ module LocalVault
|
|
|
105
119
|
shell.say " localvault demo Create a demo vault to explore commands"
|
|
106
120
|
shell.say ""
|
|
107
121
|
shell.say "SECRETS"
|
|
108
|
-
shell.say " localvault set KEY
|
|
122
|
+
shell.say " printf '%s' \"$SECRET\" | localvault set KEY --stdin"
|
|
123
|
+
shell.say " Store a secret without argv/history exposure"
|
|
124
|
+
shell.say " localvault set KEY VALUE Store a secret (compatibility path)"
|
|
109
125
|
shell.say " localvault set --group G K V Store a secret inside group G"
|
|
110
126
|
shell.say " localvault get KEY Retrieve a secret"
|
|
111
127
|
shell.say " localvault show Display all secrets (masked by default)"
|
|
@@ -169,6 +185,7 @@ module LocalVault
|
|
|
169
185
|
shell.say " localvault login --status Show current login status"
|
|
170
186
|
shell.say " localvault logout Log out"
|
|
171
187
|
shell.say " localvault version Print version"
|
|
188
|
+
shell.say " localvault doctor Check install and PATH readiness"
|
|
172
189
|
shell.say " localvault help [COMMAND] Full help for any command"
|
|
173
190
|
shell.say ""
|
|
174
191
|
end
|
|
@@ -197,10 +214,14 @@ module LocalVault
|
|
|
197
214
|
abort_with e.message
|
|
198
215
|
end
|
|
199
216
|
|
|
200
|
-
desc "set KEY VALUE", "Store a secret (supports dot-notation for nested keys)"
|
|
217
|
+
desc "set KEY [VALUE]", "Store a secret (supports dot-notation for nested keys)"
|
|
201
218
|
long_desc <<~DESC
|
|
202
219
|
Store a secret in the current vault.
|
|
203
220
|
|
|
221
|
+
SAFE INPUT (preferred):
|
|
222
|
+
\x05 printf '%s' "$SECRET" | localvault set KEY --stdin
|
|
223
|
+
\x05 printf '%s' "$SECRET" | localvault set --group GROUP KEY --stdin
|
|
224
|
+
|
|
204
225
|
FLAT KEY (simple):
|
|
205
226
|
\x05 localvault set DATABASE_URL postgres://localhost/myapp
|
|
206
227
|
\x05 localvault set STRIPE_KEY sk_live_abc123
|
|
@@ -215,33 +236,42 @@ module LocalVault
|
|
|
215
236
|
\x05 localvault set GROUP.KEY VALUE
|
|
216
237
|
\x05 localvault groups [QUERY]
|
|
217
238
|
|
|
239
|
+
Positional values may be visible in process lists and shell history.
|
|
218
240
|
The dot separates project from key name. One vault can hold many projects.
|
|
219
241
|
Use `localvault show -p platepose -v vault` to view a single project.
|
|
220
242
|
Use `localvault import` to bulk-load from a .env, .json, or .yml file.
|
|
221
243
|
DESC
|
|
222
244
|
method_option :group, type: :string, desc: "Store KEY and VALUE inside this named group"
|
|
223
|
-
|
|
245
|
+
method_option :stdin, type: :boolean, default: false, desc: "Read VALUE from stdin instead of argv"
|
|
246
|
+
def set(key, value = nil)
|
|
247
|
+
validate_secret_value_source!(value)
|
|
224
248
|
vault = open_vault!
|
|
225
249
|
if options[:group]
|
|
226
250
|
group = canonical_group_name(vault, options[:group])
|
|
227
251
|
validate_group_segment!(group)
|
|
228
252
|
validate_group_segment!(key)
|
|
229
253
|
raise GroupSaveError, :collision if vault.all.key?(group) && !vault.all[group].is_a?(Hash)
|
|
254
|
+
value = read_secret_value(value)
|
|
230
255
|
vault.set("#{group}.#{key}", value)
|
|
231
256
|
$stdout.puts "Set #{key} in group `#{group}` in vault `#{vault.name}`."
|
|
232
257
|
$stdout.puts
|
|
233
258
|
$stdout.puts "Stored as:"
|
|
234
259
|
$stdout.puts " #{group}.#{key}"
|
|
235
260
|
else
|
|
261
|
+
value = read_secret_value(value)
|
|
236
262
|
vault.set(key, value)
|
|
237
263
|
$stdout.puts "Set #{key} in vault '#{vault.name}'"
|
|
238
264
|
end
|
|
265
|
+
rescue StdinSecretInput::InteractiveInput, StdinSecretInput::InvalidEncoding => e
|
|
266
|
+
raise SetValueSourceError.new(:stdin, e.message)
|
|
239
267
|
rescue Vault::InvalidKeyName => e
|
|
240
268
|
raise GroupSaveError, :invalid if options[:group]
|
|
241
269
|
abort_with e.message
|
|
270
|
+
CommandStatus.error
|
|
242
271
|
rescue RuntimeError => e
|
|
243
272
|
raise GroupSaveError, :collision if options[:group]
|
|
244
273
|
abort_with e.message
|
|
274
|
+
CommandStatus.error
|
|
245
275
|
end
|
|
246
276
|
|
|
247
277
|
desc "get KEY", "Retrieve a secret value by key"
|
|
@@ -263,9 +293,9 @@ module LocalVault
|
|
|
263
293
|
lookup = KeyLookup.lookup(vault, key)
|
|
264
294
|
|
|
265
295
|
if lookup.exact?
|
|
266
|
-
|
|
296
|
+
print_plaintext(key, lookup.value)
|
|
267
297
|
elsif lookup.single_match?
|
|
268
|
-
|
|
298
|
+
print_plaintext(lookup.matches.first, vault.get(lookup.matches.first))
|
|
269
299
|
elsif lookup.multiple_matches?
|
|
270
300
|
$stderr.puts "Error: Multiple keys match '#{key}'. Be more specific:"
|
|
271
301
|
lookup.matches.each { |k| $stderr.puts " #{k}" }
|
|
@@ -374,6 +404,15 @@ module LocalVault
|
|
|
374
404
|
method_option :profile, type: :string, desc: "Apply a built-in env mapping profile (aws)"
|
|
375
405
|
def env
|
|
376
406
|
vault = open_vault!
|
|
407
|
+
unless PlaintextOutput.permitted?(purpose: "Export plaintext values")
|
|
408
|
+
abort_with <<~MSG.strip
|
|
409
|
+
refusing to print plaintext env exports: stdout is a captured stream, not an interactive terminal.
|
|
410
|
+
Use process-scoped injection instead:
|
|
411
|
+
localvault exec [--only KEYS|--map KEY=ENV_NAME|--profile aws] -- your-command
|
|
412
|
+
A human at a terminal is asked to confirm; agents and CI must use injection.
|
|
413
|
+
MSG
|
|
414
|
+
return
|
|
415
|
+
end
|
|
377
416
|
skip_warn = ->(k) { $stderr.puts "Warning: skipping unsafe key '#{k}'" }
|
|
378
417
|
$stdout.puts vault.export_env(**env_projection_options(on_skip: skip_warn))
|
|
379
418
|
rescue EnvProjection::InvalidMapping, EnvProjection::UnknownProfile => e
|
|
@@ -491,6 +530,7 @@ module LocalVault
|
|
|
491
530
|
def show
|
|
492
531
|
vault = open_vault!
|
|
493
532
|
secrets = vault.all
|
|
533
|
+
reveal = options[:reveal] && reveal_permitted?
|
|
494
534
|
|
|
495
535
|
named_group_query = options[:group] && ![GROUP_ALL_SENTINEL, GROUP_OFF_SENTINEL].include?(options[:group])
|
|
496
536
|
if secrets.empty? && !named_group_query
|
|
@@ -504,21 +544,21 @@ module LocalVault
|
|
|
504
544
|
abort_with "No project '#{options[:project]}' in vault '#{vault.name}'"
|
|
505
545
|
return
|
|
506
546
|
end
|
|
507
|
-
render_table(group.sort.to_h, "#{vault.name}/#{options[:project]}", reveal:
|
|
547
|
+
render_table(group.sort.to_h, "#{vault.name}/#{options[:project]}", reveal: reveal)
|
|
508
548
|
elsif options[:group] && ![GROUP_ALL_SENTINEL, GROUP_OFF_SENTINEL].include?(options[:group])
|
|
509
549
|
match = GroupCatalog.new(secrets).resolve(options[:group])
|
|
510
550
|
if match.group
|
|
511
551
|
entries = match.group.entries.to_h { |entry| [entry.label, entry.value] }
|
|
512
|
-
render_table(entries, "#{vault.name}/#{match.group.name}", reveal:
|
|
552
|
+
render_table(entries, "#{vault.name}/#{match.group.name}", reveal: reveal)
|
|
513
553
|
elsif match.kind == :ambiguous
|
|
514
554
|
raise GroupSelectionError.new(:ambiguous, query: options[:group], candidates: match.groups.map(&:name))
|
|
515
555
|
else
|
|
516
556
|
raise GroupSelectionError.new(:absent, query: options[:group])
|
|
517
557
|
end
|
|
518
558
|
elsif options[:group] != GROUP_OFF_SENTINEL && (options[:group] || secrets.values.any? { |v| v.is_a?(Hash) })
|
|
519
|
-
render_grouped_table(secrets, vault.name, reveal:
|
|
559
|
+
render_grouped_table(secrets, vault.name, reveal: reveal)
|
|
520
560
|
else
|
|
521
|
-
render_table(secrets.sort.to_h, vault.name, reveal:
|
|
561
|
+
render_table(secrets.sort.to_h, vault.name, reveal: reveal)
|
|
522
562
|
end
|
|
523
563
|
end
|
|
524
564
|
|
|
@@ -725,7 +765,9 @@ module LocalVault
|
|
|
725
765
|
require_relative "cli/keys"
|
|
726
766
|
require_relative "cli/team"
|
|
727
767
|
require_relative "cli/sync"
|
|
768
|
+
require_relative "cli/guard"
|
|
728
769
|
|
|
770
|
+
register(Guard, "guard", "guard SUBCOMMAND", "Block plaintext secrets in agent tool traffic (Claude Code hooks)")
|
|
729
771
|
register(Keys, "keys", "keys SUBCOMMAND", "Manage your X25519 keypair for vault sharing")
|
|
730
772
|
register(Team, "team", "team SUBCOMMAND", "Manage vault team access")
|
|
731
773
|
register(Sync, "sync", "sync SUBCOMMAND", "Sync vaults to InventList cloud")
|
|
@@ -1463,6 +1505,46 @@ module LocalVault
|
|
|
1463
1505
|
$stdout.puts "localvault #{VERSION}"
|
|
1464
1506
|
end
|
|
1465
1507
|
|
|
1508
|
+
desc "doctor", "Check install and PATH readiness"
|
|
1509
|
+
long_desc <<~DESC
|
|
1510
|
+
Check whether the localvault executable selected by PATH matches the
|
|
1511
|
+
install you expect.
|
|
1512
|
+
|
|
1513
|
+
This catches common Homebrew/asdf shadowing issues after upgrades:
|
|
1514
|
+
\x05 localvault doctor
|
|
1515
|
+
\x05 which -a localvault
|
|
1516
|
+
DESC
|
|
1517
|
+
def doctor
|
|
1518
|
+
paths = localvault_paths
|
|
1519
|
+
warnings = localvault_path_warnings(paths)
|
|
1520
|
+
|
|
1521
|
+
$stdout.puts "LocalVault doctor"
|
|
1522
|
+
$stdout.puts "Version: localvault #{VERSION}"
|
|
1523
|
+
$stdout.puts "Home: #{Config.root_path}"
|
|
1524
|
+
|
|
1525
|
+
if paths.empty?
|
|
1526
|
+
$stdout.puts "Executable selected by PATH: not found"
|
|
1527
|
+
else
|
|
1528
|
+
$stdout.puts "Executable selected by PATH: #{paths.first}"
|
|
1529
|
+
$stdout.puts "All localvault executables on PATH:"
|
|
1530
|
+
paths.each_with_index { |path, index| $stdout.puts " #{index + 1}. #{path}" }
|
|
1531
|
+
end
|
|
1532
|
+
|
|
1533
|
+
if warnings.empty?
|
|
1534
|
+
$stdout.puts "PATH: ok"
|
|
1535
|
+
CommandStatus.ok
|
|
1536
|
+
else
|
|
1537
|
+
$stdout.puts
|
|
1538
|
+
warnings.each { |warning| $stdout.puts "Warning: #{warning}" }
|
|
1539
|
+
$stdout.puts
|
|
1540
|
+
$stdout.puts "Suggested checks:"
|
|
1541
|
+
$stdout.puts " asdf reshim ruby"
|
|
1542
|
+
$stdout.puts " hash -r"
|
|
1543
|
+
$stdout.puts " which -a localvault"
|
|
1544
|
+
CommandStatus.error
|
|
1545
|
+
end
|
|
1546
|
+
end
|
|
1547
|
+
|
|
1466
1548
|
def self.exit_on_failure?
|
|
1467
1549
|
false
|
|
1468
1550
|
end
|
|
@@ -1489,6 +1571,22 @@ module LocalVault
|
|
|
1489
1571
|
|
|
1490
1572
|
private
|
|
1491
1573
|
|
|
1574
|
+
def validate_secret_value_source!(value)
|
|
1575
|
+
if options[:stdin] && !value.nil?
|
|
1576
|
+
raise SetValueSourceError.new(:multiple, "Use either a positional VALUE or --stdin, not both.")
|
|
1577
|
+
end
|
|
1578
|
+
|
|
1579
|
+
return if options[:stdin] || !value.nil?
|
|
1580
|
+
|
|
1581
|
+
raise SetValueSourceError.new(:missing, "Provide a VALUE or read one with --stdin.")
|
|
1582
|
+
end
|
|
1583
|
+
|
|
1584
|
+
def read_secret_value(value)
|
|
1585
|
+
return value unless options[:stdin]
|
|
1586
|
+
|
|
1587
|
+
StdinSecretInput.read($stdin)
|
|
1588
|
+
end
|
|
1589
|
+
|
|
1492
1590
|
def canonical_group_name(vault, supplied)
|
|
1493
1591
|
groups = GroupCatalog.new(vault.all).groups
|
|
1494
1592
|
return supplied if groups.any? { |group| group.name == supplied }
|
|
@@ -1830,6 +1928,29 @@ module LocalVault
|
|
|
1830
1928
|
$stderr.puts "Error: #{message}"
|
|
1831
1929
|
end
|
|
1832
1930
|
|
|
1931
|
+
# --- plaintext gating (see docs/plans/07-agent-plaintext-containment.md) ---
|
|
1932
|
+
|
|
1933
|
+
def print_plaintext(key, value)
|
|
1934
|
+
unless PlaintextOutput.permitted?(purpose: "Print plaintext value of '#{key}'")
|
|
1935
|
+
env_name = key.split(".").last.upcase
|
|
1936
|
+
abort_with <<~MSG.strip
|
|
1937
|
+
refusing to print plaintext for '#{key}': stdout is a captured stream, not an interactive terminal.
|
|
1938
|
+
Use process-scoped injection instead:
|
|
1939
|
+
localvault exec --map #{key}=#{env_name} -- your-command
|
|
1940
|
+
A human at a terminal is asked to confirm; agents and CI must use injection.
|
|
1941
|
+
MSG
|
|
1942
|
+
return
|
|
1943
|
+
end
|
|
1944
|
+
$stdout.puts value
|
|
1945
|
+
end
|
|
1946
|
+
|
|
1947
|
+
def reveal_permitted?
|
|
1948
|
+
return true if PlaintextOutput.permitted?(purpose: "Reveal plaintext values")
|
|
1949
|
+
|
|
1950
|
+
$stderr.puts "Masking values: stdout is a captured stream. Use `localvault exec` for injection."
|
|
1951
|
+
false
|
|
1952
|
+
end
|
|
1953
|
+
|
|
1833
1954
|
# --- install-mcp helpers ---
|
|
1834
1955
|
|
|
1835
1956
|
# Claude Code: use `claude mcp add --scope user` so the server is
|
|
@@ -1903,14 +2024,47 @@ module LocalVault
|
|
|
1903
2024
|
|
|
1904
2025
|
no_commands do
|
|
1905
2026
|
def find_binary(name)
|
|
1906
|
-
|
|
1907
|
-
path.empty? ? nil : path
|
|
2027
|
+
executable_paths_for(name).first
|
|
1908
2028
|
end
|
|
1909
2029
|
|
|
1910
2030
|
def system_command_exists?(cmd)
|
|
1911
2031
|
!find_binary(cmd).nil?
|
|
1912
2032
|
end
|
|
1913
2033
|
|
|
2034
|
+
def localvault_paths
|
|
2035
|
+
executable_paths_for("localvault")
|
|
2036
|
+
end
|
|
2037
|
+
|
|
2038
|
+
def executable_paths_for(name)
|
|
2039
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).filter_map do |directory|
|
|
2040
|
+
next if directory.empty?
|
|
2041
|
+
|
|
2042
|
+
path = File.join(directory, name)
|
|
2043
|
+
path if File.executable?(path) && !File.directory?(path)
|
|
2044
|
+
end.uniq
|
|
2045
|
+
end
|
|
2046
|
+
|
|
2047
|
+
def localvault_path_warnings(paths)
|
|
2048
|
+
warnings = []
|
|
2049
|
+
if paths.empty?
|
|
2050
|
+
warnings << "localvault is not on PATH. Brew upgrades may be installed but unreachable."
|
|
2051
|
+
return warnings
|
|
2052
|
+
end
|
|
2053
|
+
|
|
2054
|
+
if paths.first.include?("/.asdf/shims/") && paths.any? { |path| homebrew_localvault_path?(path) }
|
|
2055
|
+
warnings << "PATH selects an asdf shim before Homebrew localvault. " \
|
|
2056
|
+
"A stale shim can hide the upgraded brew executable."
|
|
2057
|
+
elsif paths.length > 1
|
|
2058
|
+
warnings << "Multiple localvault executables are on PATH. Confirm the first entry is the one you intend."
|
|
2059
|
+
end
|
|
2060
|
+
|
|
2061
|
+
warnings
|
|
2062
|
+
end
|
|
2063
|
+
|
|
2064
|
+
def homebrew_localvault_path?(path)
|
|
2065
|
+
path.start_with?("/opt/homebrew/bin/", "/usr/local/bin/")
|
|
2066
|
+
end
|
|
2067
|
+
|
|
1914
2068
|
def cursor_settings_path
|
|
1915
2069
|
File.expand_path("~/.cursor/mcp.json")
|
|
1916
2070
|
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "digest"
|
|
3
|
+
require_relative "store"
|
|
4
|
+
require_relative "session_cache"
|
|
5
|
+
|
|
6
|
+
module LocalVault
|
|
7
|
+
# Scans agent tool traffic (Claude Code hook events) for stored plaintext
|
|
8
|
+
# secret values, so a value already in an agent's context — retrieved or
|
|
9
|
+
# freshly generated, once stored — can never pass through a command line
|
|
10
|
+
# unnoticed.
|
|
11
|
+
#
|
|
12
|
+
# Failure posture is fail-open: locked vaults, unreadable stores, and
|
|
13
|
+
# malformed events all allow the tool call. A locked vault cannot have fed
|
|
14
|
+
# values into the session, and a guard that blocks all work when it cannot
|
|
15
|
+
# check gets uninstalled.
|
|
16
|
+
module Guard
|
|
17
|
+
MIN_VALUE_LENGTH = 8
|
|
18
|
+
HOOK_ENTRYPOINT = "localvault guard hook".freeze
|
|
19
|
+
# The installed command must fail open on machines where the binary is
|
|
20
|
+
# old, missing, or broken — otherwise every Bash call errors for users
|
|
21
|
+
# whose settings outlive their localvault install. Only a genuine deny
|
|
22
|
+
# (exit 2) is allowed through; every other exit becomes a silent allow.
|
|
23
|
+
HOOK_COMMAND = %(sh -c 'out=$(#{HOOK_ENTRYPOINT} 2>&1); s=$?; if [ $s -eq 2 ]; then echo "$out" >&2; exit 2; fi; exit 0').freeze
|
|
24
|
+
HOOK_EVENTS = %w[PreToolUse PostToolUse].freeze
|
|
25
|
+
ALLOW = { exit: 0, message: nil }.freeze
|
|
26
|
+
|
|
27
|
+
Match = Struct.new(:vault, :key, :fingerprint, keyword_init: true)
|
|
28
|
+
|
|
29
|
+
# Plaintext values from every session-unlocked vault.
|
|
30
|
+
#
|
|
31
|
+
# @return [Array<Hash>] entries with :vault, :key, :value
|
|
32
|
+
def self.unlocked_secrets
|
|
33
|
+
Store.list_vaults.flat_map do |name|
|
|
34
|
+
master_key = SessionCache.get(name)
|
|
35
|
+
next [] unless master_key
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
vault = Vault.new(name: name, master_key: master_key)
|
|
39
|
+
flatten(vault.all).map { |key, value| { vault: name, key: key, value: value } }
|
|
40
|
+
rescue StandardError
|
|
41
|
+
[]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.flatten(hash, prefix = nil)
|
|
47
|
+
hash.each_with_object({}) do |(k, v), out|
|
|
48
|
+
key = prefix ? "#{prefix}.#{k}" : k.to_s
|
|
49
|
+
if v.is_a?(Hash)
|
|
50
|
+
out.merge!(flatten(v, key))
|
|
51
|
+
else
|
|
52
|
+
out[key] = v.to_s
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Array<Match>] stored secret values appearing in +text+
|
|
58
|
+
def self.scan(text, secrets = unlocked_secrets)
|
|
59
|
+
return [] if text.nil? || text.empty?
|
|
60
|
+
|
|
61
|
+
secrets.filter_map do |entry|
|
|
62
|
+
value = entry[:value]
|
|
63
|
+
next if value.nil? || value.length < MIN_VALUE_LENGTH
|
|
64
|
+
next unless text.include?(value)
|
|
65
|
+
|
|
66
|
+
Match.new(vault: entry[:vault], key: entry[:key], fingerprint: fingerprint(value))
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.fingerprint(value)
|
|
71
|
+
Digest::SHA256.hexdigest(value)[0, 12]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.strings_in(node)
|
|
75
|
+
case node
|
|
76
|
+
when String then [node]
|
|
77
|
+
when Hash then node.values.flat_map { |v| strings_in(v) }
|
|
78
|
+
when Array then node.flat_map { |v| strings_in(v) }
|
|
79
|
+
else []
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Evaluate a parsed Claude Code hook event.
|
|
84
|
+
#
|
|
85
|
+
# @return [Hash] +{exit: Integer, message: String|nil}+ — exit 2 blocks a
|
|
86
|
+
# PreToolUse call / surfaces a PostToolUse warning to the agent
|
|
87
|
+
def self.evaluate(event, secrets = unlocked_secrets)
|
|
88
|
+
case event["hook_event_name"]
|
|
89
|
+
when "PreToolUse"
|
|
90
|
+
matches = scan(strings_in(event["tool_input"]).join("\n"), secrets)
|
|
91
|
+
matches.empty? ? ALLOW : { exit: 2, message: deny_message(matches) }
|
|
92
|
+
when "PostToolUse"
|
|
93
|
+
matches = scan(strings_in(event["tool_response"]).join("\n"), secrets)
|
|
94
|
+
matches.empty? ? ALLOW : { exit: 2, message: exposure_message(matches) }
|
|
95
|
+
else
|
|
96
|
+
ALLOW
|
|
97
|
+
end
|
|
98
|
+
rescue StandardError
|
|
99
|
+
ALLOW
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.deny_message(matches)
|
|
103
|
+
first = matches.first
|
|
104
|
+
env_name = first.key.split(".").last.upcase
|
|
105
|
+
<<~MSG.strip
|
|
106
|
+
LocalVault guard: blocked — this tool input contains the plaintext value of #{name_list(matches)}.
|
|
107
|
+
Never place secret values in commands or arguments. Inject them instead:
|
|
108
|
+
localvault exec --map #{first.key}=#{env_name} -- your-command
|
|
109
|
+
or pipe a new value with: printf '%s' "$VALUE" | localvault set KEY --stdin
|
|
110
|
+
MSG
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.exposure_message(matches)
|
|
114
|
+
<<~MSG.strip
|
|
115
|
+
LocalVault guard: this command's output contained the plaintext value of #{name_list(matches)} and has entered the transcript.
|
|
116
|
+
Treat the value as exposed: rotate it, then store the replacement via --stdin.
|
|
117
|
+
Avoid commands that print secrets; use scoped injection (localvault exec --only/--map).
|
|
118
|
+
MSG
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.name_list(matches)
|
|
122
|
+
matches.map { |m| "#{m.vault}/#{m.key} (sha256:#{m.fingerprint})" }.join(", ")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Idempotently add the guard hook entries to a Claude Code settings hash.
|
|
126
|
+
#
|
|
127
|
+
# @return [Boolean] whether the settings were modified
|
|
128
|
+
def self.merge_hooks!(settings)
|
|
129
|
+
changed = false
|
|
130
|
+
hooks = settings["hooks"] ||= {}
|
|
131
|
+
HOOK_EVENTS.each do |event|
|
|
132
|
+
entries = hooks[event] ||= []
|
|
133
|
+
next if entries.any? { |e| (e["hooks"] || []).any? { |h| h["command"].to_s.include?(HOOK_ENTRYPOINT) } }
|
|
134
|
+
|
|
135
|
+
entries << { "matcher" => "Bash", "hooks" => [{ "type" => "command", "command" => HOOK_COMMAND }] }
|
|
136
|
+
changed = true
|
|
137
|
+
end
|
|
138
|
+
changed
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def self.installed?(settings)
|
|
142
|
+
hooks = settings["hooks"] || {}
|
|
143
|
+
HOOK_EVENTS.all? do |event|
|
|
144
|
+
(hooks[event] || []).any? { |e| (e["hooks"] || []).any? { |h| h["command"].to_s.include?(HOOK_ENTRYPOINT) } }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module LocalVault
|
|
2
|
+
# Gate for printing plaintext secret values.
|
|
3
|
+
#
|
|
4
|
+
# Plaintext may flow to an interactive terminal (a human is watching). When
|
|
5
|
+
# stdout is captured (pipe, command substitution, agent shell), a human can
|
|
6
|
+
# confirm with one keystroke on /dev/tty. An agent's shell has no /dev/tty,
|
|
7
|
+
# and there is deliberately no flag, environment variable, or config bypass:
|
|
8
|
+
# anything discoverable from the binary would be discovered by an agent.
|
|
9
|
+
module PlaintextOutput
|
|
10
|
+
TTY_PATH = "/dev/tty".freeze
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# In-process overrides for tests only — unreachable from the installed
|
|
14
|
+
# binary, unlike an env var or CLI flag would be.
|
|
15
|
+
attr_accessor :assume_tty
|
|
16
|
+
attr_accessor :tty_override # {input: IO, output: IO}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param out [IO] the stream plaintext would be written to
|
|
20
|
+
# @param purpose [String] short description used in the /dev/tty prompt
|
|
21
|
+
# @return [Boolean] whether plaintext may be printed to +out+
|
|
22
|
+
def self.permitted?(out: $stdout, purpose: "Print plaintext")
|
|
23
|
+
return true if assume_tty
|
|
24
|
+
return true if out.respond_to?(:tty?) && out.tty?
|
|
25
|
+
|
|
26
|
+
confirm("#{purpose}? [y/N] ")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.confirm(prompt)
|
|
30
|
+
with_tty do |input, output|
|
|
31
|
+
output.write(prompt)
|
|
32
|
+
output.flush
|
|
33
|
+
answer = input.gets
|
|
34
|
+
!answer.nil? && %w[y yes].include?(answer.strip.downcase)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.with_tty(&block)
|
|
39
|
+
if tty_override
|
|
40
|
+
return yield(tty_override[:input], tty_override[:output])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
File.open(TTY_PATH, "r+") { |tty| yield(tty, tty) }
|
|
44
|
+
rescue SystemCallError, IOError
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module LocalVault
|
|
2
|
+
class StdinSecretInput
|
|
3
|
+
class InteractiveInput < StandardError; end
|
|
4
|
+
class InvalidEncoding < StandardError; end
|
|
5
|
+
|
|
6
|
+
PIPE_EXAMPLE = %(printf '%s' "$SECRET" | localvault set KEY --stdin).freeze
|
|
7
|
+
|
|
8
|
+
def self.read(input = $stdin)
|
|
9
|
+
if input.respond_to?(:tty?) && input.tty?
|
|
10
|
+
raise InteractiveInput, "Refusing to read secret from interactive stdin. " \
|
|
11
|
+
"Pipe it instead: #{PIPE_EXAMPLE}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
input.binmode if input.respond_to?(:binmode)
|
|
15
|
+
value = input.read || ""
|
|
16
|
+
value = value.dup.force_encoding(Encoding::UTF_8)
|
|
17
|
+
raise InvalidEncoding, "Secret value must be valid UTF-8" unless value.valid_encoding?
|
|
18
|
+
|
|
19
|
+
value
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/localvault/version.rb
CHANGED
data/lib/localvault.rb
CHANGED
|
@@ -5,6 +5,9 @@ require_relative "localvault/store"
|
|
|
5
5
|
require_relative "localvault/env_projection"
|
|
6
6
|
require_relative "localvault/key_lookup"
|
|
7
7
|
require_relative "localvault/vault_resolver"
|
|
8
|
+
require_relative "localvault/stdin_secret_input"
|
|
9
|
+
require_relative "localvault/plaintext_output"
|
|
10
|
+
require_relative "localvault/guard"
|
|
8
11
|
require_relative "localvault/vault"
|
|
9
12
|
require_relative "localvault/identity"
|
|
10
13
|
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.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nauman Tariq
|
|
@@ -109,6 +109,7 @@ files:
|
|
|
109
109
|
- lib/localvault/api_client.rb
|
|
110
110
|
- lib/localvault/cli.rb
|
|
111
111
|
- lib/localvault/cli/error_presenter.rb
|
|
112
|
+
- lib/localvault/cli/guard.rb
|
|
112
113
|
- lib/localvault/cli/keys.rb
|
|
113
114
|
- lib/localvault/cli/sync.rb
|
|
114
115
|
- lib/localvault/cli/team.rb
|
|
@@ -117,6 +118,7 @@ files:
|
|
|
117
118
|
- lib/localvault/crypto.rb
|
|
118
119
|
- lib/localvault/env_projection.rb
|
|
119
120
|
- lib/localvault/group_catalog.rb
|
|
121
|
+
- lib/localvault/guard.rb
|
|
120
122
|
- lib/localvault/identity.rb
|
|
121
123
|
- lib/localvault/input_validation.rb
|
|
122
124
|
- lib/localvault/key_lookup.rb
|
|
@@ -124,8 +126,10 @@ files:
|
|
|
124
126
|
- lib/localvault/mcp/exec_command_builder.rb
|
|
125
127
|
- lib/localvault/mcp/server.rb
|
|
126
128
|
- lib/localvault/mcp/tools.rb
|
|
129
|
+
- lib/localvault/plaintext_output.rb
|
|
127
130
|
- lib/localvault/session_cache.rb
|
|
128
131
|
- lib/localvault/share_crypto.rb
|
|
132
|
+
- lib/localvault/stdin_secret_input.rb
|
|
129
133
|
- lib/localvault/store.rb
|
|
130
134
|
- lib/localvault/sync_bundle.rb
|
|
131
135
|
- lib/localvault/sync_state.rb
|