| 1 | package secrets |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "regexp" |
| 6 | "strings" |
| 7 | "sync" |
| 8 | "sync/atomic" |
| 9 | |
| 10 | "reasonix/internal/provider" |
| 11 | ) |
| 12 | |
| 13 | var ( |
| 14 | // secretKeyNamePattern matches environment-variable / key names that are |
| 15 | // likely to carry credentials. Bare "pwd" is intentionally excluded: it |
| 16 | // only counts with a leading separator (DB_PWD, MYSQL-PWD), so the POSIX |
| 17 | // PWD / OLDPWD working-directory variables never match. |
| 18 | secretKeyNamePattern = regexp.MustCompile(`(?i)((^|[_-])(api[_-]?key|access[_-]?key|private[_-]?key|secret|token|password|passwd)([_-]|$)|[_-]pwd([_-]|$))`) |
| 19 | // cookieHeaderPattern captures Cookie/Set-Cookie header values so every |
| 20 | // name=value pair gets its value masked; attribute flags without a value |
| 21 | // (HttpOnly, Secure) pass through untouched. |
| 22 | cookieHeaderPattern = regexp.MustCompile(`(?i)\b((?:set-)?cookie)(\s*[:=]\s*)([^=;\s]+=[^;\s]*(?:;\s*[^=;\s]+(?:=[^;\s]*)?)*)`) |
| 23 | cookiePairPattern = regexp.MustCompile(`([^=;\s]+)=([^;\s]*)`) |
| 24 | bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+([A-Za-z0-9._~+/=-]{16,})`) |
| 25 | openAIKeyPattern = regexp.MustCompile(`\b((?:sk|rk)-(?:proj-)?[A-Za-z0-9_-]{12,})\b`) |
| 26 | githubTokenPattern = regexp.MustCompile(`\b(gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b`) |
| 27 | slackTokenPattern = regexp.MustCompile(`\b(xox[baprs]-[A-Za-z0-9-]{16,})\b`) |
| 28 | awsAccessKeyPattern = regexp.MustCompile(`\b(AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16})\b`) |
| 29 | jwtPattern = regexp.MustCompile(`\b(eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b`) |
| 30 | // Match through the final @ before a path/whitespace so raw @ characters |
| 31 | // inside userinfo cannot leave a password suffix visible. |
| 32 | urlUserInfoPattern = regexp.MustCompile(`(?i)\b([a-z][a-z0-9+.-]*://)([^/\s]+)@`) |
| 33 | |
| 34 | // maskedCredentialPattern collapses partially masked credentials and any |
| 35 | // visible prefix/suffix around the stars ("****ae54", "sk-ab****"). |
| 36 | maskedCredentialPattern = regexp.MustCompile(`[A-Za-z0-9._-]*\*{2,}[A-Za-z0-9._-]*`) |
| 37 | // credentialContextPattern catches prose forms such as |
| 38 | // "api key: relaykey..." that are not KEY=value pairs. |
| 39 | credentialContextPattern = regexp.MustCompile(`(?i)\b(api[ _-]?key|access[ _-]?key|secret|token|authorization|bearer|credential)s?\b(['"]?\s*[:=]?\s*['"]?)([A-Za-z0-9._~+/-]{12,})`) |
| 40 | // credentialTokenPattern is a conservative fallback for opaque key-shaped |
| 41 | // runs. Single-case, digit-free identifiers remain readable. |
| 42 | credentialTokenPattern = regexp.MustCompile(`[A-Za-z0-9_-]{16,}`) |
| 43 | digitPattern = regexp.MustCompile(`[0-9]`) |
| 44 | ) |
| 45 | |
| 46 | const redactedValue = "[redacted]" |
| 47 | |
| 48 | // Runtime toggles for the opt-in protection layers, set once by the |
| 49 | // composition root from the user-global [secrets] config section. Package |
| 50 | // globals are safe here because [secrets] cannot be overridden per-project: |
| 51 | // every concurrent workspace in one process shares the same user setting. |
| 52 | var ( |
| 53 | filterSubprocessEnvEnabled atomic.Bool |
| 54 | protectSensitiveFilesEnabled atomic.Bool |
| 55 | credentialEnvKeys = struct { |
| 56 | sync.RWMutex |
| 57 | keys map[string]struct{} |
| 58 | }{keys: map[string]struct{}{}} |
| 59 | ) |
| 60 | |
| 61 | // SetFilterSubprocessEnv enables or disables stripping credential-like |
| 62 | // variables from tool subprocess environments ([secrets] |
| 63 | // filter_subprocess_env). |
| 64 | func SetFilterSubprocessEnv(enabled bool) { filterSubprocessEnvEnabled.Store(enabled) } |
| 65 | |
| 66 | // FilterSubprocessEnv reports whether credential-like variables are stripped |
| 67 | // from tool subprocess environments. Callers that would launch a command in an |
| 68 | // environment they cannot filter (a host-owned terminal, say) must check this |
| 69 | // and keep execution local. |
| 70 | func FilterSubprocessEnv() bool { return filterSubprocessEnvEnabled.Load() } |
| 71 | |
| 72 | // SetProtectSensitiveFiles enables or disables the built-in credential-path |
| 73 | // read denylist for read/list/search tools ([secrets] protect_sensitive_files). |
| 74 | func SetProtectSensitiveFiles(enabled bool) { protectSensitiveFilesEnabled.Store(enabled) } |
| 75 | |
| 76 | // ProtectSensitiveFiles reports whether the built-in credential-path read |
| 77 | // denylist is active. |
| 78 | func ProtectSensitiveFiles() bool { return protectSensitiveFilesEnabled.Load() } |
| 79 | |
| 80 | // RegisterCredentialEnvKeys permanently marks names whose values came from |
| 81 | // Reasonix's credential store. Registration is a process-lifetime union so two |
| 82 | // concurrent workspaces with different custom providers cannot make each |
| 83 | // other's saved keys visible to tools. Explicit per-tool/plugin env config may |
| 84 | // still add a value back after ProcessEnv has produced the safe base env. |
| 85 | func RegisterCredentialEnvKeys(keys []string) { |
| 86 | credentialEnvKeys.Lock() |
| 87 | defer credentialEnvKeys.Unlock() |
| 88 | for _, key := range keys { |
| 89 | if key = credentialEnvKey(key); key != "" { |
| 90 | credentialEnvKeys.keys[key] = struct{}{} |
| 91 | } |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | func credentialEnvKey(key string) string { |
| 96 | return strings.ToUpper(strings.TrimSpace(key)) |
| 97 | } |
| 98 | |
| 99 | func registeredCredentialEnvKey(key string) bool { |
| 100 | credentialEnvKeys.RLock() |
| 101 | defer credentialEnvKeys.RUnlock() |
| 102 | _, ok := credentialEnvKeys.keys[credentialEnvKey(key)] |
| 103 | return ok |
| 104 | } |
| 105 | |
| 106 | // EnvKeySensitive reports whether an environment variable name is likely to |
| 107 | // carry credentials. It intentionally keys off the name, not the value, so child |
| 108 | // processes do not inherit saved provider secrets when filtering is enabled. |
| 109 | func EnvKeySensitive(key string) bool { |
| 110 | key = strings.TrimSpace(key) |
| 111 | if key == "" { |
| 112 | return false |
| 113 | } |
| 114 | return secretKeyNamePattern.MatchString(key) |
| 115 | } |
| 116 | |
| 117 | // FilterEnv removes sensitive KEY=value assignments from an environment vector. |
| 118 | func FilterEnv(env []string) []string { |
| 119 | out := env[:0] |
| 120 | for _, item := range env { |
| 121 | key, _, ok := strings.Cut(item, "=") |
| 122 | if !ok || EnvKeySensitive(key) || registeredCredentialEnvKey(key) { |
| 123 | continue |
| 124 | } |
| 125 | out = append(out, item) |
| 126 | } |
| 127 | return out |
| 128 | } |
| 129 | |
| 130 | func filterRegisteredCredentialEnv(env []string) []string { |
| 131 | out := env[:0] |
| 132 | for _, item := range env { |
| 133 | key, _, ok := strings.Cut(item, "=") |
| 134 | if !ok || registeredCredentialEnvKey(key) { |
| 135 | continue |
| 136 | } |
| 137 | out = append(out, item) |
| 138 | } |
| 139 | return out |
| 140 | } |
| 141 | |
| 142 | // ProcessEnv returns the environment for shell/tool subprocesses. Values loaded |
| 143 | // from Reasonix's credential store are always removed. Other credential-like |
| 144 | // inherited variables are removed only when the user opted into [secrets] |
| 145 | // filter_subprocess_env, preserving existing gh/git/npm workflows by default. |
| 146 | func ProcessEnv() []string { |
| 147 | if !filterSubprocessEnvEnabled.Load() { |
| 148 | return filterRegisteredCredentialEnv(os.Environ()) |
| 149 | } |
| 150 | return FilterEnv(os.Environ()) |
| 151 | } |
| 152 | |
| 153 | // Redact masks credential-like values for explicit diagnostic, export, and |
| 154 | // cleanup paths. Normal model content, tool output, session transcripts, and |
| 155 | // background-job artifacts deliberately bypass this helper to retain v0.53's |
| 156 | // byte-preserving behavior. |
| 157 | func Redact(s string) string { |
| 158 | if s == "" { |
| 159 | return s |
| 160 | } |
| 161 | s = urlUserInfoPattern.ReplaceAllString(s, "$1"+redactedValue+"@") |
| 162 | s = redactKeyValues(s) |
| 163 | s = cookieHeaderPattern.ReplaceAllStringFunc(s, func(match string) string { |
| 164 | parts := cookieHeaderPattern.FindStringSubmatch(match) |
| 165 | if len(parts) != 4 { |
| 166 | return redactedValue |
| 167 | } |
| 168 | return parts[1] + parts[2] + cookiePairPattern.ReplaceAllString(parts[3], "$1="+redactedValue) |
| 169 | }) |
| 170 | s = bearerTokenPattern.ReplaceAllStringFunc(s, func(match string) string { |
| 171 | token := strings.TrimSpace(strings.TrimPrefix(match, "Bearer")) |
| 172 | if len(token) == len(match) { |
| 173 | return "Bearer " + redactedValue |
| 174 | } |
| 175 | return "Bearer " + mask(token) |
| 176 | }) |
| 177 | for _, rx := range []*regexp.Regexp{openAIKeyPattern, githubTokenPattern, slackTokenPattern, awsAccessKeyPattern, jwtPattern} { |
| 178 | s = rx.ReplaceAllStringFunc(s, mask) |
| 179 | } |
| 180 | return s |
| 181 | } |
| 182 | |
| 183 | // RedactCredentials applies the stronger credential scrub used at external |
| 184 | // error and logging boundaries. In addition to known key shapes, it removes |
| 185 | // partially masked credentials, prose-form credentials, and opaque tokens that |
| 186 | // carry a digit or mixed case. |
| 187 | func RedactCredentials(s string) string { |
| 188 | if s == "" { |
| 189 | return s |
| 190 | } |
| 191 | s = Redact(s) |
| 192 | s = credentialContextPattern.ReplaceAllString(s, "${1}${2}****") |
| 193 | s = maskedCredentialPattern.ReplaceAllString(s, "****") |
| 194 | return credentialTokenPattern.ReplaceAllStringFunc(s, func(token string) string { |
| 195 | mixedCase := strings.ToLower(token) != token && strings.ToUpper(token) != token |
| 196 | if digitPattern.MatchString(token) || mixedCase { |
| 197 | return "****" |
| 198 | } |
| 199 | return token |
| 200 | }) |
| 201 | } |
| 202 | |
| 203 | // RedactError returns an error string safe for an external log or diagnostic |
| 204 | // boundary. A nil error produces an empty string. |
| 205 | func RedactError(err error) string { |
| 206 | if err == nil { |
| 207 | return "" |
| 208 | } |
| 209 | return RedactCredentials(err.Error()) |
| 210 | } |
| 211 | |
| 212 | func redactKeyValues(s string) string { |
| 213 | var out strings.Builder |
| 214 | last := 0 |
| 215 | for sep := 0; sep < len(s); sep++ { |
| 216 | if s[sep] != ':' && s[sep] != '=' { |
| 217 | continue |
| 218 | } |
| 219 | keyEnd := sep |
| 220 | for keyEnd > 0 && asciiSpace(s[keyEnd-1]) { |
| 221 | keyEnd-- |
| 222 | } |
| 223 | if keyEnd > 0 && (s[keyEnd-1] == '\'' || s[keyEnd-1] == '"') { |
| 224 | keyEnd-- |
| 225 | } |
| 226 | keyStart := keyEnd |
| 227 | for keyStart > 0 && credentialKeyByte(s[keyStart-1]) { |
| 228 | keyStart-- |
| 229 | } |
| 230 | key := s[keyStart:keyEnd] |
| 231 | if !credentialTextKeySensitive(key) { |
| 232 | continue |
| 233 | } |
| 234 | |
| 235 | valueStart := sep + 1 |
| 236 | for valueStart < len(s) && asciiSpace(s[valueStart]) { |
| 237 | valueStart++ |
| 238 | } |
| 239 | if valueStart < len(s) && (s[valueStart] == '\'' || s[valueStart] == '"') { |
| 240 | valueStart++ |
| 241 | } |
| 242 | schemeStart := valueStart |
| 243 | for valueStart < len(s) && credentialKeyByte(s[valueStart]) { |
| 244 | valueStart++ |
| 245 | } |
| 246 | if valueStart < len(s) && asciiSpace(s[valueStart]) && authorizationScheme(s[schemeStart:valueStart]) { |
| 247 | for valueStart < len(s) && asciiSpace(s[valueStart]) { |
| 248 | valueStart++ |
| 249 | } |
| 250 | if valueStart < len(s) && (s[valueStart] == '\'' || s[valueStart] == '"') { |
| 251 | valueStart++ |
| 252 | } |
| 253 | } else { |
| 254 | valueStart = schemeStart |
| 255 | } |
| 256 | |
| 257 | valueEnd := valueStart |
| 258 | for valueEnd < len(s) && !asciiSpace(s[valueEnd]) && s[valueEnd] != '\'' && s[valueEnd] != '"' && s[valueEnd] != ',' && s[valueEnd] != ';' { |
| 259 | valueEnd++ |
| 260 | } |
| 261 | if valueEnd == valueStart { |
| 262 | continue |
| 263 | } |
| 264 | if last == 0 { |
| 265 | out.Grow(len(s)) |
| 266 | } |
| 267 | out.WriteString(s[last:valueStart]) |
| 268 | value := s[valueStart:valueEnd] |
| 269 | if authorizationKey(key) { |
| 270 | out.WriteString(redactedValue) |
| 271 | } else if value == "****" || value == redactedValue { |
| 272 | out.WriteString(value) |
| 273 | } else { |
| 274 | out.WriteString(mask(value)) |
| 275 | } |
| 276 | last = valueEnd |
| 277 | sep = valueEnd - 1 |
| 278 | } |
| 279 | if last == 0 { |
| 280 | return s |
| 281 | } |
| 282 | out.WriteString(s[last:]) |
| 283 | return out.String() |
| 284 | } |
| 285 | |
| 286 | func credentialKeyByte(b byte) bool { |
| 287 | return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b == '_' || b == '-' || b == '.' |
| 288 | } |
| 289 | |
| 290 | func asciiSpace(b byte) bool { |
| 291 | return b == ' ' || b == '\t' || b == '\n' || b == '\r' || b == '\f' |
| 292 | } |
| 293 | |
| 294 | func authorizationKey(key string) bool { |
| 295 | upper := strings.ToUpper(key) |
| 296 | return upper == "AUTHORIZATION" || strings.HasSuffix(upper, "-AUTHORIZATION") || strings.HasSuffix(upper, "_AUTHORIZATION") || strings.HasSuffix(upper, ".AUTHORIZATION") |
| 297 | } |
| 298 | |
| 299 | func credentialTextKeySensitive(key string) bool { |
| 300 | upper := strings.ToUpper(key) |
| 301 | compact := strings.NewReplacer("_", "", "-", "").Replace(upper) |
| 302 | return authorizationKey(key) || |
| 303 | strings.Contains(compact, "APIKEY") || |
| 304 | strings.Contains(compact, "ACCESSKEY") || |
| 305 | strings.Contains(compact, "PRIVATEKEY") || |
| 306 | strings.Contains(upper, "SECRET") || |
| 307 | strings.Contains(upper, "TOKEN") || |
| 308 | strings.Contains(upper, "PASSWORD") || |
| 309 | strings.Contains(upper, "PASSWD") || |
| 310 | strings.Contains(upper, "_PWD") || |
| 311 | strings.Contains(upper, "-PWD") |
| 312 | } |
| 313 | |
| 314 | func authorizationScheme(s string) bool { |
| 315 | switch strings.ToLower(s) { |
| 316 | case "bearer", "basic", "digest", "negotiate", "ntlm", "token", "bot", "apikey": |
| 317 | return true |
| 318 | default: |
| 319 | return false |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | func mask(value string) string { |
| 324 | value = strings.TrimSpace(value) |
| 325 | if value == "" { |
| 326 | return redactedValue |
| 327 | } |
| 328 | if len(value) <= 12 { |
| 329 | return redactedValue |
| 330 | } |
| 331 | head := 4 |
| 332 | tail := 4 |
| 333 | if strings.HasPrefix(value, "sk-") || strings.HasPrefix(value, "rk-") { |
| 334 | head = 6 |
| 335 | } |
| 336 | if len(value) <= head+tail { |
| 337 | return redactedValue |
| 338 | } |
| 339 | return value[:head] + strings.Repeat("*", len(value)-head-tail) + value[len(value)-tail:] |
| 340 | } |
| 341 | |
| 342 | // RedactMessage returns a storage-safe copy of m with textual secret surfaces |
| 343 | // masked. Images are left untouched because they are opaque data URLs. |
| 344 | // ToolCalls and MemoryCitations are cloned before masking: m is passed by |
| 345 | // value but its slices share backing arrays with the caller, and the save |
| 346 | // path hands in live session messages — writing through would silently mutate |
| 347 | // the model-visible history mid-conversation and churn the prompt cache. |
| 348 | func RedactMessage(m provider.Message) provider.Message { |
| 349 | m.Content = Redact(m.Content) |
| 350 | m.ReasoningContent = Redact(m.ReasoningContent) |
| 351 | m.Original = Redact(m.Original) |
| 352 | if len(m.ToolCalls) > 0 { |
| 353 | calls := make([]provider.ToolCall, len(m.ToolCalls)) |
| 354 | copy(calls, m.ToolCalls) |
| 355 | for i := range calls { |
| 356 | calls[i].Arguments = Redact(calls[i].Arguments) |
| 357 | calls[i].Diff = Redact(calls[i].Diff) |
| 358 | } |
| 359 | m.ToolCalls = calls |
| 360 | } |
| 361 | if len(m.MemoryCitations) > 0 { |
| 362 | cites := make([]provider.MemoryCitation, len(m.MemoryCitations)) |
| 363 | copy(cites, m.MemoryCitations) |
| 364 | for i := range cites { |
| 365 | cites[i].Note = Redact(cites[i].Note) |
| 366 | } |
| 367 | m.MemoryCitations = cites |
| 368 | } |
| 369 | return m |
| 370 | } |
| 371 | |
| 372 | // RedactMessages returns a redacted copy of msgs. The input slice and its |
| 373 | // messages are never mutated. |
| 374 | func RedactMessages(msgs []provider.Message) []provider.Message { |
| 375 | out := make([]provider.Message, len(msgs)) |
| 376 | for i, m := range msgs { |
| 377 | out[i] = RedactMessage(m) |
| 378 | } |
| 379 | return out |
| 380 | } |
| 381 |