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

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.deny_message(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.

Returns:

  • (Hash)

    {exit: Integer, message: String|nil} — exit 2 blocks a PreToolUse call / surfaces a PostToolUse warning to the agent



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: deny_message(matches) }
  when "PostToolUse"
    matches = scan(strings_in(event["tool_response"]).join("\n"), secrets)
    matches.empty? ? ALLOW : { exit: 2, message: exposure_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.exposure_message(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

Returns:

  • (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.

Returns:

  • (Boolean)

    whether the settings were modified



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.

Returns:

  • (Array<Match>)

    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_secretsArray<Hash>

Plaintext values from every session-unlocked vault.

Returns:

  • (Array<Hash>)

    entries with :vault, :key, :value



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