localvault 1.8.1 → 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 +36 -0
- data/lib/localvault/cli/guard.rb +87 -0
- data/lib/localvault/cli.rb +42 -6
- data/lib/localvault/guard.rb +148 -0
- data/lib/localvault/plaintext_output.rb +48 -0
- data/lib/localvault/version.rb +1 -1
- data/lib/localvault.rb +2 -0
- metadata +4 -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
|
@@ -145,6 +145,9 @@ backward compatibility but the top-level forms are preferred.
|
|
|
145
145
|
| `install-mcp [CLIENT]` | Configure MCP server in claude-code, cursor, or windsurf |
|
|
146
146
|
| `mcp` | Start MCP server (stdio transport) |
|
|
147
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) |
|
|
148
151
|
|
|
149
152
|
All commands accept `--vault NAME` (or `-v NAME`) to target a specific vault. Default vault is `default`.
|
|
150
153
|
|
|
@@ -310,6 +313,39 @@ Unlocking writes a derived key to `LOCALVAULT_SESSION` and also caches it with a
|
|
|
310
313
|
|
|
311
314
|
## Security
|
|
312
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
|
+
|
|
313
349
|
### Crypto Stack
|
|
314
350
|
|
|
315
351
|
| Layer | Algorithm | Purpose |
|
|
@@ -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,6 +4,7 @@ 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"
|
|
8
9
|
require_relative "stdin_secret_input"
|
|
9
10
|
require_relative "vault_resolver"
|
|
@@ -292,9 +293,9 @@ module LocalVault
|
|
|
292
293
|
lookup = KeyLookup.lookup(vault, key)
|
|
293
294
|
|
|
294
295
|
if lookup.exact?
|
|
295
|
-
|
|
296
|
+
print_plaintext(key, lookup.value)
|
|
296
297
|
elsif lookup.single_match?
|
|
297
|
-
|
|
298
|
+
print_plaintext(lookup.matches.first, vault.get(lookup.matches.first))
|
|
298
299
|
elsif lookup.multiple_matches?
|
|
299
300
|
$stderr.puts "Error: Multiple keys match '#{key}'. Be more specific:"
|
|
300
301
|
lookup.matches.each { |k| $stderr.puts " #{k}" }
|
|
@@ -403,6 +404,15 @@ module LocalVault
|
|
|
403
404
|
method_option :profile, type: :string, desc: "Apply a built-in env mapping profile (aws)"
|
|
404
405
|
def env
|
|
405
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
|
|
406
416
|
skip_warn = ->(k) { $stderr.puts "Warning: skipping unsafe key '#{k}'" }
|
|
407
417
|
$stdout.puts vault.export_env(**env_projection_options(on_skip: skip_warn))
|
|
408
418
|
rescue EnvProjection::InvalidMapping, EnvProjection::UnknownProfile => e
|
|
@@ -520,6 +530,7 @@ module LocalVault
|
|
|
520
530
|
def show
|
|
521
531
|
vault = open_vault!
|
|
522
532
|
secrets = vault.all
|
|
533
|
+
reveal = options[:reveal] && reveal_permitted?
|
|
523
534
|
|
|
524
535
|
named_group_query = options[:group] && ![GROUP_ALL_SENTINEL, GROUP_OFF_SENTINEL].include?(options[:group])
|
|
525
536
|
if secrets.empty? && !named_group_query
|
|
@@ -533,21 +544,21 @@ module LocalVault
|
|
|
533
544
|
abort_with "No project '#{options[:project]}' in vault '#{vault.name}'"
|
|
534
545
|
return
|
|
535
546
|
end
|
|
536
|
-
render_table(group.sort.to_h, "#{vault.name}/#{options[:project]}", reveal:
|
|
547
|
+
render_table(group.sort.to_h, "#{vault.name}/#{options[:project]}", reveal: reveal)
|
|
537
548
|
elsif options[:group] && ![GROUP_ALL_SENTINEL, GROUP_OFF_SENTINEL].include?(options[:group])
|
|
538
549
|
match = GroupCatalog.new(secrets).resolve(options[:group])
|
|
539
550
|
if match.group
|
|
540
551
|
entries = match.group.entries.to_h { |entry| [entry.label, entry.value] }
|
|
541
|
-
render_table(entries, "#{vault.name}/#{match.group.name}", reveal:
|
|
552
|
+
render_table(entries, "#{vault.name}/#{match.group.name}", reveal: reveal)
|
|
542
553
|
elsif match.kind == :ambiguous
|
|
543
554
|
raise GroupSelectionError.new(:ambiguous, query: options[:group], candidates: match.groups.map(&:name))
|
|
544
555
|
else
|
|
545
556
|
raise GroupSelectionError.new(:absent, query: options[:group])
|
|
546
557
|
end
|
|
547
558
|
elsif options[:group] != GROUP_OFF_SENTINEL && (options[:group] || secrets.values.any? { |v| v.is_a?(Hash) })
|
|
548
|
-
render_grouped_table(secrets, vault.name, reveal:
|
|
559
|
+
render_grouped_table(secrets, vault.name, reveal: reveal)
|
|
549
560
|
else
|
|
550
|
-
render_table(secrets.sort.to_h, vault.name, reveal:
|
|
561
|
+
render_table(secrets.sort.to_h, vault.name, reveal: reveal)
|
|
551
562
|
end
|
|
552
563
|
end
|
|
553
564
|
|
|
@@ -754,7 +765,9 @@ module LocalVault
|
|
|
754
765
|
require_relative "cli/keys"
|
|
755
766
|
require_relative "cli/team"
|
|
756
767
|
require_relative "cli/sync"
|
|
768
|
+
require_relative "cli/guard"
|
|
757
769
|
|
|
770
|
+
register(Guard, "guard", "guard SUBCOMMAND", "Block plaintext secrets in agent tool traffic (Claude Code hooks)")
|
|
758
771
|
register(Keys, "keys", "keys SUBCOMMAND", "Manage your X25519 keypair for vault sharing")
|
|
759
772
|
register(Team, "team", "team SUBCOMMAND", "Manage vault team access")
|
|
760
773
|
register(Sync, "sync", "sync SUBCOMMAND", "Sync vaults to InventList cloud")
|
|
@@ -1915,6 +1928,29 @@ module LocalVault
|
|
|
1915
1928
|
$stderr.puts "Error: #{message}"
|
|
1916
1929
|
end
|
|
1917
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
|
+
|
|
1918
1954
|
# --- install-mcp helpers ---
|
|
1919
1955
|
|
|
1920
1956
|
# Claude Code: use `claude mcp add --scope user` so the server is
|
|
@@ -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
|
data/lib/localvault/version.rb
CHANGED
data/lib/localvault.rb
CHANGED
|
@@ -6,6 +6,8 @@ require_relative "localvault/env_projection"
|
|
|
6
6
|
require_relative "localvault/key_lookup"
|
|
7
7
|
require_relative "localvault/vault_resolver"
|
|
8
8
|
require_relative "localvault/stdin_secret_input"
|
|
9
|
+
require_relative "localvault/plaintext_output"
|
|
10
|
+
require_relative "localvault/guard"
|
|
9
11
|
require_relative "localvault/vault"
|
|
10
12
|
require_relative "localvault/identity"
|
|
11
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,6 +126,7 @@ 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
|
|
129
132
|
- lib/localvault/stdin_secret_input.rb
|