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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 86be5d86ef86c74bcfc777561088264d1b908eaca92a2b501d5e23d937e5a906
4
- data.tar.gz: d3d079c65ff54ca198907957ae3193a3a73520aa84169cf0067bc254967fea05
3
+ metadata.gz: f49d7a925b997780d6d03a2b071117e20da0827d24331d28c9cd5043a70b0056
4
+ data.tar.gz: 27b2be6fa5bb90d3df68eec4a27fcba483a175b574afd96f7aeba7dd0e175412
5
5
  SHA512:
6
- metadata.gz: f0d5b4b8d1575b8b91fbd742afd0aef2f71401e72882149cf0b83e68cd233636592c5e0af85512007b4cff604d466ff38d5ac085ea7eea60e9b9446a3d7247a5
7
- data.tar.gz: 98a51ac12f4857e0f381fa4b4449a63687d402fe3bf3d97e693f75bfc526440efbc0fd9df14f397c99771c4c871c9a8f164b51e29cca56a232bfa78c099ca8c9
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 "sk-proj-..."
46
- localvault set STRIPE_SECRET_KEY "sk_live_..."
47
- localvault set DATABASE_URL "postgres://localhost/myapp"
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 VALUE` | Store a secret (supports dot-notation: `project.KEY`) |
73
- | `set --group GROUP KEY VALUE` | Store a secret in a named group |
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 ["localvault set KEY VALUE", *group_save_suggestions] if context.command&.name == "set"
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
@@ -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 VALUE Store a secret"
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
- def set(key, value)
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
- $stdout.puts lookup.value
296
+ print_plaintext(key, lookup.value)
267
297
  elsif lookup.single_match?
268
- $stdout.puts vault.get(lookup.matches.first)
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: options[: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: options[: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: options[:reveal])
559
+ render_grouped_table(secrets, vault.name, reveal: reveal)
520
560
  else
521
- render_table(secrets.sort.to_h, vault.name, reveal: options[: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
- path = `which #{name} 2>/dev/null`.strip
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
@@ -1,3 +1,3 @@
1
1
  module LocalVault
2
- VERSION = "1.8.0"
2
+ VERSION = "1.9.0"
3
3
  end
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.8.0
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