openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
//! The AI-provider account behind this session (I-1 D-07 / D-08).
//!
//! Claude Code only, deliberately. A second adapter is a second set of file
//! shapes to track and a second way to be wrong about who is at the keyboard,
//! and nothing consumes the attribute yet — breadth is I-3's problem, not this
//! module's.
//!
//! # The one line that must never change
//!
//! **The credential store is never opened.** Not `.credentials.json`, not the
//! macOS Keychain. The account email lives in a non-secret state file, so there
//! is no reason to go near the secrets — and reading the Claude Code Keychain
//! item through `/usr/bin/security` is a documented credential-theft primitive
//! that an EDR will flag as exactly that. This attribute carries an account
//! email or the literal `shared-key`, and nothing else. `tests/credential_
//! invariant.rs` makes the credential file unreadable and asserts this resolver
//! still answers identically, which it can only do by never having opened it.
//!
//! # Resolution
//!
//! | # | Source | Answer |
//! |---|--------|--------|
//! | 1 | `$ANTHROPIC_API_KEY` present and non-empty | `shared-key` |
//! | 2 | `$ANTHROPIC_AUTH_TOKEN` present and non-empty | `shared-key` |
//! | 3 | `settings.json` carries an `apiKeyHelper` key | `shared-key` |
//! | 4 | `settings.json` `env` block seeds either variable, non-empty | `shared-key` |
//! | 5 | state file's `oauthAccount.emailAddress` | that address |
//! | 6 | anything else | absent |
//!
//! Only the *presence* of the first four is ever read — never a character of
//! their values — and they outrank the cached account, which is the point of
//! ordering them first: the state file is a cache, not live truth, so a box
//! repurposed for CI keeps the last human's address in it long after a service
//! credential took over. Attributing that machine's actions to a person who has
//! not touched it in months is the exact misattribution D-08 exists to prevent.
//!
//! An absent `oauthAccount` is **not** evidence of a service key — a Claude
//! Desktop / SSO login leaves the field absent while authentication works fine —
//! so step 5 omits the attribute rather than guessing `shared-key`.
//!
//! # Where the files are
//!
//! `$CLAUDE_CONFIG_DIR` relocates Claude Code's state, and when it is set it is
//! **exclusive**: a missing or unreadable state file under it means absent, with
//! no fallback to the home directory. A fresh profile must not inherit another
//! profile's human. An empty value is read as unset, the conventional reading —
//! it would resolve to nonsense paths anyway.
//!
//! # Invariant discipline on the error paths
//!
//! No log line here may carry file content, a byte offset, or a `serde_json`
//! error's `Display` — that type quotes the input it failed on, which is how a
//! parse failure turns into a leak. Failures log a fixed reason word at debug
//! and nothing else.

use std::path::PathBuf;

/// Presence of either of these means a credential with no human behind it.
const ENV_API_KEY: &str = "ANTHROPIC_API_KEY";
const ENV_AUTH_TOKEN: &str = "ANTHROPIC_AUTH_TOKEN";

/// Relocates Claude Code's configuration directory (verified on 2.1.220).
/// Imported from `hooks::claude_code` rather than redeclared: this module and
/// that one resolve the *same* `settings.json`, and two copies of the literal is
/// how they came to disagree about it in the first place. Only the tests name it
/// now — both resolvers here reach the variable through that module.
#[cfg(test)]
use crate::hooks::claude_code::CONFIG_DIR_ENV;

/// Test seams (D-11), honored unconditionally.
const SEAM_STATE_FILE: &str = "OPENLATCH_TEST_CLAUDE_STATE_FILE";
const SEAM_SETTINGS_FILE: &str = "OPENLATCH_TEST_CLAUDE_SETTINGS_FILE";

/// The value that stands in for "authenticated, but by no particular person".
const SHARED_KEY: &str = "shared-key";

/// The state file accumulates per-project history and can get large; a read
/// this size already means the shape assumption is wrong.
const MAX_STATE_BYTES: usize = 8 * 1024 * 1024;

/// `settings.json` is hand-edited configuration and never approaches this.
const MAX_SETTINGS_BYTES: usize = 1024 * 1024;

/// The provider account identifier, or `None` when this host cannot name one.
///
/// Takes no arguments by design: the credential that authenticates the agent is
/// a property of the host and the daemon's environment, not of a session's
/// working directory.
pub async fn resolve() -> Option<String> {
    if shared_key_in_env() || shared_key_in_settings().await {
        return Some(SHARED_KEY.to_string());
    }
    account_email().await
}

/// Presence only. Neither value is read, compared, hashed or logged.
fn shared_key_in_env() -> bool {
    [ENV_API_KEY, ENV_AUTH_TOKEN]
        .iter()
        .any(|key| std::env::var_os(key).is_some_and(|v| !v.is_empty()))
}

/// An `apiKeyHelper` key in `settings.json` means Claude Code mints its
/// credential from a command — again a machine credential, again presence only.
///
/// The `env` block is the same story one level down: `settings.json` can seed
/// environment variables into Claude Code's own process, so a key configured
/// there never appears in the *daemon's* environment and `shared_key_in_env`
/// cannot see it. Non-emptiness is checked, never the value itself.
async fn shared_key_in_settings() -> bool {
    let Some(path) = settings_path() else {
        return false;
    };
    let Some(value) = read_json(&path, MAX_SETTINGS_BYTES).await else {
        return false;
    };
    let Some(obj) = value.as_object() else {
        return false;
    };
    if obj.contains_key("apiKeyHelper") {
        return true;
    }
    obj.get("env")
        .and_then(|e| e.as_object())
        .is_some_and(|env| {
            [ENV_API_KEY, ENV_AUTH_TOKEN].iter().any(|key| {
                env.get(*key)
                    .and_then(|v| v.as_str())
                    .is_some_and(|v| !v.is_empty())
            })
        })
}

/// `oauthAccount.emailAddress` from the non-secret state file.
async fn account_email() -> Option<String> {
    let path = state_path()?;
    let value = read_json(&path, MAX_STATE_BYTES).await?;

    // Exactly one path, indexed case-sensitively. The file holds keys that
    // differ only by case and partial key material under other fields, so a
    // case-folding lookup or a search would be a way to read something this
    // module has no business reading.
    let email = value
        .get("oauthAccount")
        .and_then(|account| account.get("emailAddress"))
        .and_then(serde_json::Value::as_str)
        .filter(|email| !email.is_empty());

    if email.is_none() {
        debug_absent("no_account");
    }
    email.map(str::to_owned)
}

/// `$OPENLATCH_TEST_CLAUDE_STATE_FILE`, else `<config dir>/.claude.json`.
fn state_path() -> Option<PathBuf> {
    if let Some(seam) = env_path(SEAM_STATE_FILE) {
        return Some(seam);
    }
    Some(crate::hooks::claude_code::state_dir()?.join(".claude.json"))
}

/// `$OPENLATCH_TEST_CLAUDE_SETTINGS_FILE`, else `<config dir>/settings.json`.
///
/// With `$CLAUDE_CONFIG_DIR` unset the settings live in `~/.claude/`, one level
/// deeper than the state file — the two are not siblings by default, which is
/// why [`state_path`] reaches for `state_dir` and this one for `config_dir`.
/// Both now live in `hooks::claude_code`, so the asymmetry is stated once and
/// the config monitor's manifest reads the same two answers.
fn settings_path() -> Option<PathBuf> {
    if let Some(seam) = env_path(SEAM_SETTINGS_FILE) {
        return Some(seam);
    }
    Some(crate::hooks::claude_code::config_dir()?.join("settings.json"))
}

fn env_path(key: &str) -> Option<PathBuf> {
    std::env::var_os(key)
        .filter(|value| !value.is_empty())
        .map(PathBuf::from)
}

/// Read and parse, or `None` with a fixed reason word.
///
/// The size check happens before the read so an unexpectedly large file costs a
/// `stat` rather than the allocation.
async fn read_json(path: &std::path::Path, max_bytes: usize) -> Option<serde_json::Value> {
    let Ok(meta) = tokio::fs::metadata(path).await else {
        debug_absent("absent");
        return None;
    };
    if meta.len() > max_bytes as u64 {
        debug_absent("unreadable");
        return None;
    }
    let Ok(bytes) = tokio::fs::read(path).await else {
        debug_absent("unreadable");
        return None;
    };
    // `.ok()` and not `.map_err(…)`: a serde_json error's Display quotes the
    // input around the failure, so the error value must not survive this line.
    match serde_json::from_slice(&bytes) {
        Ok(value) => Some(value),
        Err(_) => {
            debug_absent("unreadable");
            None
        }
    }
}

fn debug_absent(reason: &'static str) {
    tracing::debug!(target: "identity", reason, "provideracct not resolved");
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::daemon::identity::test_support::EnvGuard;
    use std::path::Path;

    fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
        let path = dir.join(name);
        std::fs::write(&path, body).expect("write fixture");
        path
    }

    /// The whole matrix in one function: these cases mutate process-global
    /// environment variables, so they run sequentially, under the identity
    /// module's lock.
    #[tokio::test]
    async fn provider_account_matrix() {
        let _lock = crate::daemon::identity::ENV_LOCK.lock().await;
        let env = EnvGuard::clear();
        let dir = tempfile::tempdir().expect("tempdir");

        let state = write(
            dir.path(),
            "state.json",
            r#"{"oauthAccount":{"emailAddress":"alice@fixture.test"}}"#,
        );
        env.set(SEAM_STATE_FILE, &state);

        // 1. The ordinary case: a subscription login names a human.
        assert_eq!(resolve().await.as_deref(), Some("alice@fixture.test"));

        // 2. Either service-credential variable outranks that cached address —
        //    the machine is authenticated, but not by Alice.
        for key in [ENV_API_KEY, ENV_AUTH_TOKEN] {
            env.set(key, "sk-ant-fixture-value-never-read");
            assert_eq!(
                resolve().await.as_deref(),
                Some(SHARED_KEY),
                "{key} must win over a cached oauthAccount"
            );
            env.unset(key);
        }

        // 3. Set-but-empty is not a credential.
        env.set(ENV_API_KEY, "");
        assert_eq!(resolve().await.as_deref(), Some("alice@fixture.test"));
        env.unset(ENV_API_KEY);

        // 4. `apiKeyHelper` means the credential is minted by a command.
        let helper = write(
            dir.path(),
            "settings-helper.json",
            r#"{"apiKeyHelper":"x"}"#,
        );
        env.set(SEAM_SETTINGS_FILE, &helper);
        assert_eq!(resolve().await.as_deref(), Some(SHARED_KEY));

        // 4b. A key seeded through the settings `env` block never reaches the
        //     daemon's environment — it must still read as a machine credential.
        let env_block = write(
            dir.path(),
            "settings-env-block.json",
            r#"{"env":{"ANTHROPIC_API_KEY":"sk-ant-fixture-value-never-read"}}"#,
        );
        env.set(SEAM_SETTINGS_FILE, &env_block);
        assert_eq!(
            resolve().await.as_deref(),
            Some(SHARED_KEY),
            "settings env.ANTHROPIC_API_KEY must win over a cached oauthAccount"
        );
        let env_block_empty = write(
            dir.path(),
            "settings-env-empty.json",
            r#"{"env":{"ANTHROPIC_API_KEY":""}}"#,
        );
        env.set(SEAM_SETTINGS_FILE, &env_block_empty);
        assert_eq!(
            resolve().await.as_deref(),
            Some("alice@fixture.test"),
            "an empty env-block value is not a credential"
        );

        // 5. A settings file without it changes nothing.
        let plain = write(dir.path(), "settings-plain.json", "{}");
        env.set(SEAM_SETTINGS_FILE, &plain);
        assert_eq!(resolve().await.as_deref(), Some("alice@fixture.test"));

        // 6. Every shape of "the file cannot tell us who this is" is absent,
        //    never `shared-key`: an unhydrated store is not a service key.
        for (name, body) in [
            ("malformed.json", "{not json"),
            ("no-account.json", r#"{"someOtherKey":true}"#),
            (
                "empty-email.json",
                r#"{"oauthAccount":{"emailAddress":""}}"#,
            ),
            ("wrong-type.json", r#"{"oauthAccount":{"emailAddress":42}}"#),
        ] {
            let path = write(dir.path(), name, body);
            env.set(SEAM_STATE_FILE, &path);
            assert_eq!(resolve().await, None, "{name} must resolve to absent");
        }
        env.set(SEAM_STATE_FILE, dir.path().join("does-not-exist.json"));
        assert_eq!(
            resolve().await,
            None,
            "an absent file is an absent attribute"
        );

        // 7. The real file holds keys that differ only by case; the parse must
        //    keep them apart and index the exact one.
        let collision = write(
            dir.path(),
            "case.json",
            r#"{"oauthaccount":{"emailAddress":"wrong@fixture.test"},
                "oauthAccount":{"emailAddress":"alice@fixture.test"}}"#,
        );
        env.set(SEAM_STATE_FILE, &collision);
        assert_eq!(resolve().await.as_deref(), Some("alice@fixture.test"));
    }

    /// `$CLAUDE_CONFIG_DIR` is exclusive: a profile pointed at an empty
    /// directory reports no account rather than borrowing the home
    /// directory's.
    #[tokio::test]
    async fn a_relocated_config_dir_never_falls_back_to_home() {
        let _lock = crate::daemon::identity::ENV_LOCK.lock().await;
        let env = EnvGuard::clear();
        let dir = tempfile::tempdir().expect("tempdir");

        env.set(CONFIG_DIR_ENV, dir.path());
        assert_eq!(
            resolve().await,
            None,
            "an empty relocated config dir must not inherit another profile's human"
        );

        // And it is genuinely read from there, not ignored.
        write(
            dir.path(),
            ".claude.json",
            r#"{"oauthAccount":{"emailAddress":"relocated@fixture.test"}}"#,
        );
        assert_eq!(resolve().await.as_deref(), Some("relocated@fixture.test"));

        // Empty is unset — the ladder falls through to the home directory
        // rather than resolving a path made of nothing.
        env.set(CONFIG_DIR_ENV, "");
        assert_ne!(
            resolve().await.as_deref(),
            Some("relocated@fixture.test"),
            "an empty value must not keep pointing at the relocated dir"
        );
    }
}