232 lines
9.2 KiB
Go
232 lines
9.2 KiB
Go
package secrets
|
|
|
|
import (
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync/atomic"
|
|
|
|
"reasonix/internal/provider"
|
|
)
|
|
|
|
var (
|
|
// secretKeyNamePattern matches environment-variable / key names that are
|
|
// likely to carry credentials. Bare "pwd" is intentionally excluded: it
|
|
// only counts with a leading separator (DB_PWD, MYSQL-PWD), so the POSIX
|
|
// PWD / OLDPWD working-directory variables never match.
|
|
secretKeyNamePattern = regexp.MustCompile(`(?i)((^|[_-])(api[_-]?key|access[_-]?key|private[_-]?key|secret|token|password|passwd)([_-]|$)|[_-]pwd([_-]|$))`)
|
|
// keyValuePattern mirrors secretKeyNamePattern for KEY=value / key: value
|
|
// text: PWD requires a prefixed separator so "PWD=/home/user" stays intact.
|
|
// The optional auth-scheme group keeps schemes like "Basic"/"Digest" out of
|
|
// the value capture, so the credential after the scheme word is what gets
|
|
// masked (an uncaptured scheme would itself be swallowed as the value,
|
|
// leaving the real credential in the clear right behind it). The separator
|
|
// group tolerates quotes around the key and before the value so JSON bodies
|
|
// ("access_token":"...") match, not just env/header text.
|
|
keyValuePattern = regexp.MustCompile(`(?i)\b([A-Z0-9_.-]*(?:API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD)[A-Z0-9_.-]*|[A-Z0-9_.-]+[_-]PWD[A-Z0-9_.-]*|AUTHORIZATION)\b(['"]?\s*[:=]\s*['"]?)((?:Bearer|Basic|Digest|Negotiate|NTLM|Token|Bot|ApiKey)\s+)?(['"]?)([^'"\s,;]+)(['"]?)`)
|
|
// cookieHeaderPattern captures Cookie/Set-Cookie header values so every
|
|
// name=value pair gets its value masked; attribute flags without a value
|
|
// (HttpOnly, Secure) pass through untouched.
|
|
cookieHeaderPattern = regexp.MustCompile(`(?i)\b((?:set-)?cookie)(\s*[:=]\s*)([^=;\s]+=[^;\s]*(?:;\s*[^=;\s]+(?:=[^;\s]*)?)*)`)
|
|
cookiePairPattern = regexp.MustCompile(`([^=;\s]+)=([^;\s]*)`)
|
|
bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+([A-Za-z0-9._~+/=-]{16,})`)
|
|
openAIKeyPattern = regexp.MustCompile(`\b((?:sk|rk)-(?:proj-)?[A-Za-z0-9_-]{12,})\b`)
|
|
githubTokenPattern = regexp.MustCompile(`\b(gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b`)
|
|
slackTokenPattern = regexp.MustCompile(`\b(xox[baprs]-[A-Za-z0-9-]{16,})\b`)
|
|
awsAccessKeyPattern = regexp.MustCompile(`\b(AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16})\b`)
|
|
jwtPattern = regexp.MustCompile(`\b(eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b`)
|
|
)
|
|
|
|
const redactedValue = "[redacted]"
|
|
|
|
// Runtime toggles for the opt-in protection layers, set once by the
|
|
// composition root from the user-global [secrets] config section. Package
|
|
// globals are safe here because [secrets] cannot be overridden per-project:
|
|
// every concurrent workspace in one process shares the same user setting.
|
|
var (
|
|
redactToolOutputEnabled atomic.Bool
|
|
filterSubprocessEnvEnabled atomic.Bool
|
|
protectSensitiveFilesEnabled atomic.Bool
|
|
)
|
|
|
|
func init() {
|
|
// Tool-output redaction defaults on; subprocess env filtering defaults
|
|
// off because it breaks legitimate token-based workflows (gh, git push
|
|
// over HTTPS, npm publish) and needs an explicit user opt-in.
|
|
redactToolOutputEnabled.Store(true)
|
|
}
|
|
|
|
// SetRedactToolOutput enables or disables masking of tool output before it
|
|
// enters model context and UI events ([secrets] redact_tool_output). Durable
|
|
// surfaces — session transcripts and background-job artifacts — are always
|
|
// redacted regardless of this toggle.
|
|
func SetRedactToolOutput(enabled bool) { redactToolOutputEnabled.Store(enabled) }
|
|
|
|
// SetFilterSubprocessEnv enables or disables stripping credential-like
|
|
// variables from tool subprocess environments ([secrets]
|
|
// filter_subprocess_env).
|
|
func SetFilterSubprocessEnv(enabled bool) { filterSubprocessEnvEnabled.Store(enabled) }
|
|
|
|
// FilterSubprocessEnv reports whether credential-like variables are stripped
|
|
// from tool subprocess environments. Callers that would launch a command in an
|
|
// environment they cannot filter (a host-owned terminal, say) must check this
|
|
// and keep execution local.
|
|
func FilterSubprocessEnv() bool { return filterSubprocessEnvEnabled.Load() }
|
|
|
|
// SetProtectSensitiveFiles enables or disables the built-in credential-path
|
|
// read denylist for read/list/search tools ([secrets] protect_sensitive_files).
|
|
func SetProtectSensitiveFiles(enabled bool) { protectSensitiveFilesEnabled.Store(enabled) }
|
|
|
|
// ProtectSensitiveFiles reports whether the built-in credential-path read
|
|
// denylist is active.
|
|
func ProtectSensitiveFiles() bool { return protectSensitiveFilesEnabled.Load() }
|
|
|
|
// EnvKeySensitive reports whether an environment variable name is likely to
|
|
// carry credentials. It intentionally keys off the name, not the value, so child
|
|
// processes do not inherit saved provider secrets when filtering is enabled.
|
|
func EnvKeySensitive(key string) bool {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
return false
|
|
}
|
|
return secretKeyNamePattern.MatchString(key)
|
|
}
|
|
|
|
// FilterEnv removes sensitive KEY=value assignments from an environment vector.
|
|
func FilterEnv(env []string) []string {
|
|
out := env[:0]
|
|
for _, item := range env {
|
|
key, _, ok := strings.Cut(item, "=")
|
|
if !ok || EnvKeySensitive(key) {
|
|
continue
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ProcessEnv returns the environment for shell/tool subprocesses: the current
|
|
// process environment as-is by default, with credential-like assignments
|
|
// removed when the user opted into [secrets] filter_subprocess_env.
|
|
func ProcessEnv() []string {
|
|
if !filterSubprocessEnvEnabled.Load() {
|
|
return os.Environ()
|
|
}
|
|
return FilterEnv(os.Environ())
|
|
}
|
|
|
|
// RedactToolOutput masks credential-like values in live tool output (model
|
|
// context, UI events) unless the user disabled [secrets] redact_tool_output.
|
|
// Durable writers (session save, job artifacts) call Redact directly instead:
|
|
// disk logs stay redacted even when live output is not.
|
|
func RedactToolOutput(s string) string {
|
|
if !redactToolOutputEnabled.Load() {
|
|
return s
|
|
}
|
|
return Redact(s)
|
|
}
|
|
|
|
// Redact masks credential-like values in text before the text enters durable
|
|
// transcripts, job artifacts, or diagnostic records. It is deterministic and
|
|
// idempotent: redacting already-redacted text is a byte-for-byte no-op, which
|
|
// the session save path relies on for digest stability across load/save cycles
|
|
// (see Session.save).
|
|
func Redact(s string) string {
|
|
if s == "" {
|
|
return s
|
|
}
|
|
s = keyValuePattern.ReplaceAllStringFunc(s, func(match string) string {
|
|
parts := keyValuePattern.FindStringSubmatch(match)
|
|
if len(parts) != 7 {
|
|
return redactedValue
|
|
}
|
|
key := parts[1]
|
|
sep := parts[2]
|
|
scheme := parts[3]
|
|
quote := parts[4]
|
|
value := parts[5]
|
|
endQuote := parts[6]
|
|
if strings.EqualFold(key, "authorization") {
|
|
return key + sep + scheme + quote + redactedValue + endQuote
|
|
}
|
|
return key + sep + scheme + quote + mask(value) + endQuote
|
|
})
|
|
s = cookieHeaderPattern.ReplaceAllStringFunc(s, func(match string) string {
|
|
parts := cookieHeaderPattern.FindStringSubmatch(match)
|
|
if len(parts) != 4 {
|
|
return redactedValue
|
|
}
|
|
return parts[1] + parts[2] + cookiePairPattern.ReplaceAllString(parts[3], "$1="+redactedValue)
|
|
})
|
|
s = bearerTokenPattern.ReplaceAllStringFunc(s, func(match string) string {
|
|
token := strings.TrimSpace(strings.TrimPrefix(match, "Bearer"))
|
|
if len(token) == len(match) {
|
|
return "Bearer " + redactedValue
|
|
}
|
|
return "Bearer " + mask(token)
|
|
})
|
|
for _, rx := range []*regexp.Regexp{openAIKeyPattern, githubTokenPattern, slackTokenPattern, awsAccessKeyPattern, jwtPattern} {
|
|
s = rx.ReplaceAllStringFunc(s, mask)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func mask(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return redactedValue
|
|
}
|
|
if len(value) <= 12 {
|
|
return redactedValue
|
|
}
|
|
head := 4
|
|
tail := 4
|
|
if strings.HasPrefix(value, "sk-") || strings.HasPrefix(value, "rk-") {
|
|
head = 6
|
|
}
|
|
if len(value) <= head+tail {
|
|
return redactedValue
|
|
}
|
|
return value[:head] + strings.Repeat("*", len(value)-head-tail) + value[len(value)-tail:]
|
|
}
|
|
|
|
// RedactMessage returns a storage-safe copy of m with textual secret surfaces
|
|
// masked. Images are left untouched because they are opaque data URLs.
|
|
// ToolCalls and MemoryCitations are cloned before masking: m is passed by
|
|
// value but its slices share backing arrays with the caller, and the save
|
|
// path hands in live session messages — writing through would silently mutate
|
|
// the model-visible history mid-conversation and churn the prompt cache.
|
|
func RedactMessage(m provider.Message) provider.Message {
|
|
m.Content = Redact(m.Content)
|
|
m.ReasoningContent = Redact(m.ReasoningContent)
|
|
m.Original = Redact(m.Original)
|
|
if len(m.ToolCalls) > 0 {
|
|
calls := make([]provider.ToolCall, len(m.ToolCalls))
|
|
copy(calls, m.ToolCalls)
|
|
for i := range calls {
|
|
calls[i].Arguments = Redact(calls[i].Arguments)
|
|
calls[i].Diff = Redact(calls[i].Diff)
|
|
}
|
|
m.ToolCalls = calls
|
|
}
|
|
if len(m.MemoryCitations) > 0 {
|
|
cites := make([]provider.MemoryCitation, len(m.MemoryCitations))
|
|
copy(cites, m.MemoryCitations)
|
|
for i := range cites {
|
|
cites[i].Note = Redact(cites[i].Note)
|
|
}
|
|
m.MemoryCitations = cites
|
|
}
|
|
return m
|
|
}
|
|
|
|
// RedactMessages returns a redacted copy of msgs. The input slice and its
|
|
// messages are never mutated.
|
|
func RedactMessages(msgs []provider.Message) []provider.Message {
|
|
out := make([]provider.Message, len(msgs))
|
|
for i, m := range msgs {
|
|
out[i] = RedactMessage(m)
|
|
}
|
|
return out
|
|
}
|