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
- .default_config_path ⇒ Object
- .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
-
.ignored_keys(config_path = default_config_path) ⇒ Object
Keys the operator has explicitly declared non-secret, from ~/.localvault/config.yml:.
- .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, ignore: ignored_keys) ⇒ 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
.default_config_path ⇒ Object
53 54 55 |
# File 'lib/localvault/guard.rb', line 53 def self.default_config_path File.join(Dir.home, ".localvault", "config.yml") end |
.deny_message(matches) ⇒ Object
146 147 148 149 150 151 152 153 154 155 |
# File 'lib/localvault/guard.rb', line 146 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.
131 132 133 134 135 136 137 138 139 140 141 142 143 144 |
# File 'lib/localvault/guard.rb', line 131 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
157 158 159 160 161 162 163 |
# File 'lib/localvault/guard.rb', line 157 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
114 115 116 |
# File 'lib/localvault/guard.rb', line 114 def self.fingerprint(value) Digest::SHA256.hexdigest(value)[0, 12] end |
.flatten(hash, prefix = nil) ⇒ Object
85 86 87 88 89 90 91 92 93 94 |
# File 'lib/localvault/guard.rb', line 85 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 |
.ignored_keys(config_path = default_config_path) ⇒ Object
Keys the operator has explicitly declared non-secret, from ~/.localvault/config.yml:
guard_ignore:
- CLOUDFLARE_ASSETS.r2_bucket
WHY EXPLICIT, AND NOT INFERRED FROM THE KEY NAME.
The first attempt exempted keys whose name looked public — anything
ending in bucket / region / _id — with a veto list of credential-ish
words. An audit killed it in one line: a value stored under AWS.bucket
would be exempt regardless of what it actually contained, so the fix
traded a false positive for a false NEGATIVE in a security tool. The veto
list was also unbounded (pem, jwk, seed, mnemonic, salt, otp, bearer,
sas, …) and every omission is a silent bypass.
A key's name cannot describe its value. Only the operator can say "this one is public", so only the operator may — by naming the exact full key, in a file they control, which is greppable and auditable.
The problem being solved is still real: a public identifier stored beside real secrets (a bucket name that happens to be a substring of a project path) denies every command mentioning that path, and a guard that cries wolf gets uninstalled, which protects nothing. The answer is an explicit allowlist, not a clever one.
44 45 46 47 48 49 50 51 |
# File 'lib/localvault/guard.rb', line 44 def self.ignored_keys(config_path = default_config_path) return [] unless File.exist?(config_path) raw = YAML.safe_load(File.read(config_path)) || {} Array(raw["guard_ignore"]).map(&:to_s) rescue StandardError [] # an unreadable config must never widen the exemption end |
.installed?(settings) ⇒ Boolean
185 186 187 188 189 190 |
# File 'lib/localvault/guard.rb', line 185 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.
172 173 174 175 176 177 178 179 180 181 182 183 |
# File 'lib/localvault/guard.rb', line 172 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
165 166 167 |
# File 'lib/localvault/guard.rb', line 165 def self.name_list(matches) matches.map { |m| "#{m.vault}/#{m.key} (sha256:#{m.fingerprint})" }.join(", ") end |
.scan(text, secrets = unlocked_secrets, ignore: ignored_keys) ⇒ Array<Match>
Returns stored secret values appearing in text.
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 |
# File 'lib/localvault/guard.rb', line 97 def self.scan(text, secrets = unlocked_secrets, ignore: ignored_keys) return [] if text.nil? || text.empty? ignored = Array(ignore).map(&:to_s) secrets.filter_map do |entry| value = entry[:value] next if value.nil? || value.length < MIN_VALUE_LENGTH # Exact full-key match only. No prefixes, no suffix inference — an # operator opting one key out must never silently opt out its siblings. next if ignored.include?(entry[:key].to_s) next unless text.include?(value) Match.new(vault: entry[:vault], key: entry[:key], fingerprint: fingerprint(value)) end end |
.strings_in(node) ⇒ Object
118 119 120 121 122 123 124 125 |
# File 'lib/localvault/guard.rb', line 118 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.
71 72 73 74 75 76 77 78 79 80 81 82 83 |
# File 'lib/localvault/guard.rb', line 71 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 |