Module: LocalVault::Guard
- Defined in:
- lib/localvault/guard.rb
Overview
Scans agent tool traffic (Claude Code hook events) for stored plaintext secret values, so a value already in an agent's context — retrieved or freshly generated, once stored — can never pass through a command line unnoticed.
Failure posture is fail-open: locked vaults, unreadable stores, and malformed events all allow the tool call. A locked vault cannot have fed values into the session, and a guard that blocks all work when it cannot check gets uninstalled.
Defined Under Namespace
Classes: Match
Constant Summary collapse
- MIN_VALUE_LENGTH =
8- HOOK_ENTRYPOINT =
"localvault guard hook".freeze
- HOOK_COMMAND =
The installed command must fail open on machines where the binary is old, missing, or broken — otherwise every Bash call errors for users whose settings outlive their localvault install. Only a genuine deny (exit 2) is allowed through; every other exit becomes a silent allow.
%(sh -c 'out=$(#{HOOK_ENTRYPOINT} 2>&1); s=$?; if [ $s -eq 2 ]; then echo "$out" >&2; exit 2; fi; exit 0').freeze
- HOOK_EVENTS =
%w[PreToolUse PostToolUse].freeze
- ALLOW =
{ exit: 0, message: nil }.freeze
Class Method Summary collapse
- .deny_message(matches) ⇒ Object
-
.evaluate(event, secrets = unlocked_secrets) ⇒ Hash
Evaluate a parsed Claude Code hook event.
- .exposure_message(matches) ⇒ Object
- .fingerprint(value) ⇒ Object
- .flatten(hash, prefix = nil) ⇒ Object
- .installed?(settings) ⇒ Boolean
-
.merge_hooks!(settings) ⇒ Boolean
Idempotently add the guard hook entries to a Claude Code settings hash.
- .name_list(matches) ⇒ Object
-
.scan(text, secrets = unlocked_secrets) ⇒ Array<Match>
Stored secret values appearing in
text. - .strings_in(node) ⇒ Object
-
.unlocked_secrets ⇒ Array<Hash>
Plaintext values from every session-unlocked vault.
Class Method Details
.deny_message(matches) ⇒ Object
102 103 104 105 106 107 108 109 110 111 |
# File 'lib/localvault/guard.rb', line 102 def self.(matches) first = matches.first env_name = first.key.split(".").last.upcase <<~MSG.strip LocalVault guard: blocked — this tool input contains the plaintext value of #{name_list(matches)}. Never place secret values in commands or arguments. Inject them instead: localvault exec --map #{first.key}=#{env_name} -- your-command or pipe a new value with: printf '%s' "$VALUE" | localvault set KEY --stdin MSG end |
.evaluate(event, secrets = unlocked_secrets) ⇒ Hash
Evaluate a parsed Claude Code hook event.
87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/localvault/guard.rb', line 87 def self.evaluate(event, secrets = unlocked_secrets) case event["hook_event_name"] when "PreToolUse" matches = scan(strings_in(event["tool_input"]).join("\n"), secrets) matches.empty? ? ALLOW : { exit: 2, message: (matches) } when "PostToolUse" matches = scan(strings_in(event["tool_response"]).join("\n"), secrets) matches.empty? ? ALLOW : { exit: 2, message: (matches) } else ALLOW end rescue StandardError ALLOW end |
.exposure_message(matches) ⇒ Object
113 114 115 116 117 118 119 |
# File 'lib/localvault/guard.rb', line 113 def self.(matches) <<~MSG.strip LocalVault guard: this command's output contained the plaintext value of #{name_list(matches)} and has entered the transcript. Treat the value as exposed: rotate it, then store the replacement via --stdin. Avoid commands that print secrets; use scoped injection (localvault exec --only/--map). MSG end |
.fingerprint(value) ⇒ Object
70 71 72 |
# File 'lib/localvault/guard.rb', line 70 def self.fingerprint(value) Digest::SHA256.hexdigest(value)[0, 12] end |
.flatten(hash, prefix = nil) ⇒ Object
46 47 48 49 50 51 52 53 54 55 |
# File 'lib/localvault/guard.rb', line 46 def self.flatten(hash, prefix = nil) hash.each_with_object({}) do |(k, v), out| key = prefix ? "#{prefix}.#{k}" : k.to_s if v.is_a?(Hash) out.merge!(flatten(v, key)) else out[key] = v.to_s end end end |
.installed?(settings) ⇒ Boolean
141 142 143 144 145 146 |
# File 'lib/localvault/guard.rb', line 141 def self.installed?(settings) hooks = settings["hooks"] || {} HOOK_EVENTS.all? do |event| (hooks[event] || []).any? { |e| (e["hooks"] || []).any? { |h| h["command"].to_s.include?(HOOK_ENTRYPOINT) } } end end |
.merge_hooks!(settings) ⇒ Boolean
Idempotently add the guard hook entries to a Claude Code settings hash.
128 129 130 131 132 133 134 135 136 137 138 139 |
# File 'lib/localvault/guard.rb', line 128 def self.merge_hooks!(settings) changed = false hooks = settings["hooks"] ||= {} HOOK_EVENTS.each do |event| entries = hooks[event] ||= [] next if entries.any? { |e| (e["hooks"] || []).any? { |h| h["command"].to_s.include?(HOOK_ENTRYPOINT) } } entries << { "matcher" => "Bash", "hooks" => [{ "type" => "command", "command" => HOOK_COMMAND }] } changed = true end changed end |
.name_list(matches) ⇒ Object
121 122 123 |
# File 'lib/localvault/guard.rb', line 121 def self.name_list(matches) matches.map { |m| "#{m.vault}/#{m.key} (sha256:#{m.fingerprint})" }.join(", ") end |
.scan(text, secrets = unlocked_secrets) ⇒ Array<Match>
Returns stored secret values appearing in text.
58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/localvault/guard.rb', line 58 def self.scan(text, secrets = unlocked_secrets) return [] if text.nil? || text.empty? secrets.filter_map do |entry| value = entry[:value] next if value.nil? || value.length < MIN_VALUE_LENGTH next unless text.include?(value) Match.new(vault: entry[:vault], key: entry[:key], fingerprint: fingerprint(value)) end end |
.strings_in(node) ⇒ Object
74 75 76 77 78 79 80 81 |
# File 'lib/localvault/guard.rb', line 74 def self.strings_in(node) case node when String then [node] when Hash then node.values.flat_map { |v| strings_in(v) } when Array then node.flat_map { |v| strings_in(v) } else [] end end |
.unlocked_secrets ⇒ Array<Hash>
Plaintext values from every session-unlocked vault.
32 33 34 35 36 37 38 39 40 41 42 43 44 |
# File 'lib/localvault/guard.rb', line 32 def self.unlocked_secrets Store.list_vaults.flat_map do |name| master_key = SessionCache.get(name) next [] unless master_key begin vault = Vault.new(name: name, master_key: master_key) flatten(vault.all).map { |key, value| { vault: name, key: key, value: value } } rescue StandardError [] end end end |