diff --git a/crates/tinyinference-core/src/sanitize.rs b/crates/tinyinference-core/src/sanitize.rs index cbf26c6b..0f563399 100644 --- a/crates/tinyinference-core/src/sanitize.rs +++ b/crates/tinyinference-core/src/sanitize.rs @@ -135,6 +135,22 @@ static NON_SECRET_NUMBER_REGEX: LazyLock = LazyLock::new(|| { .unwrap() }); +/// Lowercase built-in type names that follow `:` in an annotation +/// (TypeScript primitives and Python builtins), as in `accessToken: string`. +const LOWERCASE_TYPE_NAMES: [&str; 11] = [ + "string", + "number", + "boolean", + "null", + "undefined", + "any", + "unknown", + "str", + "int", + "float", + "bool", +]; + /// Identifiers and dotted member paths with no digit (`None`, `self.vocab`, /// `os.environ`): references to a value in code, not the value itself. static DIGITLESS_IDENTIFIER_REGEX: LazyLock = @@ -179,6 +195,18 @@ fn looks_like_secret(value: &str, quoted: bool, rest: &str, key: &str) -> bool { } _ => {} } + // A bare dotted member path (`action.payload`, `self.vocab`) reads a + // value in code; it is not the value itself. Not under a password or + // secret key, where `correct.horse` is as likely a value as a path. + let lower_key = key.to_ascii_lowercase(); + if !quoted + && value.contains('.') + && DIGITLESS_IDENTIFIER_REGEX.is_match(value) + && !lower_key.ends_with("password") + && !lower_key.ends_with("secret") + { + return false; + } // A string literal is data, so `"short"` stays redacted. A bare word that // is exactly the sensitive key is a common source-code reference // (`api_key=api_key`); other identifier-shaped values under a sensitive @@ -272,14 +300,17 @@ pub fn scrub_credentials(input: &str) -> String { // `api_key: String` is a type annotation, not a credential // assignment. Uppercase type names make this unambiguous while // lowercase alphabetic values such as `password: correcthorse` - // remain protected. + // remain protected. Lowercase built-in type names (`accessToken: + // string | null`) are annotations too, but only bare: a quoted + // `"string"` is data. if &caps[2] == ":" - && value + && ((value .as_str() .chars() .next() .is_some_and(char::is_uppercase) - && DIGITLESS_IDENTIFIER_REGEX.is_match(value.as_str()) + && DIGITLESS_IDENTIFIER_REGEX.is_match(value.as_str())) + || (!quoted && LOWERCASE_TYPE_NAMES.contains(&value.as_str()))) { return full_match.to_string(); } diff --git a/crates/tinyinference-core/src/sanitize_tests.rs b/crates/tinyinference-core/src/sanitize_tests.rs index 0fc706e4..31868030 100644 --- a/crates/tinyinference-core/src/sanitize_tests.rs +++ b/crates/tinyinference-core/src/sanitize_tests.rs @@ -299,3 +299,37 @@ fn references_and_type_annotations_are_not_secrets() { assert_unchanged(" api_key: String,"); assert_unchanged(r#"token = os.environ["GITHUB_TOKEN"]"#); } + +// openhuman#7713: TypeScript auth code came back redacted after the +// alphabetic-password fix, so the agent could not read its own source. + +#[test] +fn member_paths_and_lowercase_type_annotations_are_not_secrets() { + for source in [ + " accessToken: string | null;", + "def login(password: str, api_key: str) -> bool:", + " state.accessToken = action.payload;", + "const apiKey = config.apiKey;", + "request.credential = session.credential", + ] { + assert_unchanged(source); + } +} + +#[test] +fn values_that_only_resemble_code_references_stay_redacted() { + for (input, value) in [ + // A quoted type name is a string literal, so it is data. + (r#"password: "string""#, "string"), + // A plain word after `:` is not a type name. + ("password: correcthorse", "correcthorse"), + // Under a password or secret key a dotted word may be the value. + ("password=correct.horse", "correct.horse"), + ("secret: a.b", "a.b"), + // A dotted value with digits is not a member path. + ("token=abc123.def456", "abc123.def456"), + ] { + let out = scrub_credentials(input); + assert!(!out.contains(value), "{input} -> {out}"); + } +}