feat(claudewatcher): client-name guard via RegisterRule + env
Pre-rollout guard. Source code stays clean — client identities come from CLAUDE_INGEST_CLIENT_BLOCK env (sourced from a SOPS-encrypted k8s secret in infra repo). Env value is a regex alternation; main wraps it with `(?i)\b(...)\b` so word-boundary matching avoids false hits inside longer identifiers (e.g. "Sebastian" doesn't trigger on "SEB"). DefaultRules (credential shapes) still take precedence so any leak that's BOTH a client mention AND a credential shape logs as the credential — strictly more dangerous, points triage at the right thing. Tests cover precedence + case variations + word-boundary respect + invalid-pattern rejection. Refs: infra#73 Track E.1 pre-rollout grill (option B). Bump-Type: minor
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
package claudewatcher
|
||||
|
||||
import "regexp"
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Scrubber drops any turn whose content matches a known-bad pattern.
|
||||
// Fail-closed by design: we'd rather lose signal than ingest credentials
|
||||
@@ -45,10 +49,51 @@ var DefaultRules = []Rule{
|
||||
{Name: "sops-encrypted-marker", RE: regexp.MustCompile(`ENC\[AES256_GCM,data:[A-Za-z0-9+/=]{8,}`)},
|
||||
}
|
||||
|
||||
// extraRules is appended to DefaultRules at process startup via
|
||||
// RegisterRule. The mutex guards concurrent RegisterRule calls (rare)
|
||||
// against concurrent Scrub reads (hot path). Scrub takes a read lock
|
||||
// only when extraRules is non-empty, so steady-state cost is zero
|
||||
// when no client-name guard is configured.
|
||||
var (
|
||||
extraRulesMu sync.RWMutex
|
||||
extraRules []Rule
|
||||
)
|
||||
|
||||
// RegisterRule appends a runtime-configured regex to the scrubber's
|
||||
// rule set. Used by main to inject client-name guards from
|
||||
// CLAUDE_INGEST_CLIENT_BLOCK env var (or equivalent SOPS-encrypted
|
||||
// secret) without baking client identities into source code.
|
||||
//
|
||||
// pattern is compiled as-is — callers wrap with `\b...\b` and case
|
||||
// flags as needed. Duplicate names are accepted (rules are positional);
|
||||
// the second registration just fires after the first.
|
||||
func RegisterRule(name, pattern string) error {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compile rule %q: %w", name, err)
|
||||
}
|
||||
extraRulesMu.Lock()
|
||||
extraRules = append(extraRules, Rule{Name: name, RE: re})
|
||||
extraRulesMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetExtraRules clears every RegisterRule-added rule. Test-only.
|
||||
func ResetExtraRules() {
|
||||
extraRulesMu.Lock()
|
||||
extraRules = nil
|
||||
extraRulesMu.Unlock()
|
||||
}
|
||||
|
||||
// Scrub reports the first matching rule, or empty when content is clean.
|
||||
// Empty string is treated as clean. Caller decides what to do on a hit;
|
||||
// the convention in claudewatcher is to drop the turn entirely and emit
|
||||
// a slog.Warn naming the rule.
|
||||
//
|
||||
// Rule order: DefaultRules first (credential shapes), then runtime
|
||||
// RegisterRule additions (client-name guards). Credential leaks
|
||||
// outrank client-name hits in the log because they're strictly more
|
||||
// dangerous.
|
||||
func Scrub(content string) string {
|
||||
if content == "" {
|
||||
return ""
|
||||
@@ -58,5 +103,12 @@ func Scrub(content string) string {
|
||||
return r.Name
|
||||
}
|
||||
}
|
||||
extraRulesMu.RLock()
|
||||
defer extraRulesMu.RUnlock()
|
||||
for _, r := range extraRules {
|
||||
if r.RE.MatchString(content) {
|
||||
return r.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user