Skip to content

Biometric gate: wire real macOS LAContext.evaluatePolicy (PR #27 follow-up) #37

@hanwencheng

Description

@hanwencheng

Problem

PR #27 introduced a biometric gate for approve / revoke / teardown, but the macOS path is a stub that logs a prompt and returns Ok(()). Real Touch ID / Face ID protection on macOS is therefore not active. The non-macOS stdin fallback works; all the call-site plumbing, env escape hatches, and redaction are in place.

What this issue tracks

Wire the real LAContext.evaluatePolicy call via objc2 + objc2-local-authentication + block2 so master-CLI actions are actually gated by Touch ID / Face ID on macOS.

Scope

Code (crates/agentkeys-cli/src/biometric/)

Replace the single-function biometric.rs module with a trait-seam design:

pub trait BiometricBackend: Send + Sync {
    fn authenticate(&self, reason: &str) -> Result<(), BiometricError>;
}

pub struct LAContextBackend;          // macOS real
pub struct StdinBackend;              // non-macOS fallback (unchanged from #27)
#[cfg(test)] pub struct MockBackend;  // scripted results for unit tests
  • cmd_approve / cmd_revoke / cmd_teardown take &dyn BiometricBackend (via CommandContext)
  • LAContextBackend: unsafe FFI to -[LAContext evaluatePolicy:localizedReason:reply:]
    • RcBlock wraps the completion callback
    • std::sync::mpsc::channel bridges async → sync
    • 60-second timeout via rx.recv_timeout so the CLI can't deadlock
    • Maps the full NSError code matrix (LAErrorUserCancel, LAErrorSystemCancel, LAErrorBiometryNotAvailable, LAErrorBiometryLockout, LAErrorPasscodeNotSet, LAErrorAppCancel, LAErrorInvalidContext) to a typed BiometricError enum
  • Preserve all PR fix(cli): #11 biometric gate for high-security master CLI actions (macOS) #27 escape hatches: AGENTKEYS_BIOMETRIC=off, redaction of session tokens from the prompt reason

Dependencies

  • objc2 = "0.6"
  • objc2-foundation = "0.3"
  • objc2-local-authentication = "0.3" (or feature on objc2-frameworks)
  • block2 = "0.6"

All gated by [target.'cfg(target_os = "macos")'.dependencies] — zero cost on Linux/Windows builds.

Tests — 4 layers

L1 — Pure logic (#[cfg(test)], runs everywhere):

  • parse_la_error(code: i64) -> BiometricError — one test per documented NSError code
  • redact_prompt_reason(raw: &str) -> String — strip anything that looks like a session-token prefix
  • policy_selection(has_biometry, has_passcode) -> LAPolicy — table-driven

L2 — FFI boundary (#[cfg(target_os = "macos")], runs on macOS CI):

  • la_context_constructs_and_drops — catches library-load / linker issues
  • can_evaluate_policy_is_synchronous — calls canEvaluatePolicy:error: which returns synchronously without prompting; on a CI runner with no Touch ID it returns LAErrorBiometryNotAvailable, which is the most useful FFI-level validation
  • error_struct_layout — dereference an NSError, read code, assert i64 width

L3 — Behavioral contract (via MockBackend, runs everywhere):

  • cmd_approve_proceeds_on_auth_success
  • cmd_approve_aborts_on_user_cancel
  • cmd_revoke_redacts_session_token_from_prompt_reason — assert the string passed to authenticate doesn't contain the raw arg
  • cmd_teardown_aborts_on_biometry_lockout
  • cmd_*_bypasses_gate_when_env_is_off

L4 — Manual QA (docs/manual-test-issue-<N>.md, documented, not automated):

  1. Touch ID succeeds → action proceeds
  2. User cancels → action aborts with clean message
  3. Failed finger × 3 → biometry lockout → passcode fallback
  4. Device with no biometry (e.g., Mac mini) → falls back to passcode or errors
  5. AGENTKEYS_BIOMETRIC=off → bypasses entirely
  6. agentkeys revoke <raw-session-token> → prompt reason does NOT echo the token

unsafe surface

~4-5 blocks, each 2-5 lines, each with a // SAFETY: comment. Known sources:

  1. LAContext::new() — unsafe message-send (all objc2 sends are unsafe)
  2. *mut NSError deref inside the completion block — Apple's contract guarantees non-null on failure; we null-check defensively
  3. evaluatePolicy:localizedReason:reply: — typed wrapper from objc2-local-authentication, but still unsafe fn
  4. Block lifetime: RcBlock with 'static + Send + Sync captures only (channel Sender qualifies)

Deadlock / leak protections:

  • 60-second recv_timeout on the channel (CLI can never deadlock)
  • No Retained<LAContext> capture inside the block (avoids retain cycle)
  • Single-shot: each action creates its own LAContext

Acceptance criteria

  • LAContextBackend::authenticate calls the real LAContext API on macOS
  • cargo test -p agentkeys-cli -- --test-threads=1 passes on Linux (stdin + mock paths)
  • cargo test -p agentkeys-cli -- --test-threads=1 passes on macOS (adds FFI boundary tests)
  • Manual QA scenarios 1-6 pass on a macOS host with Touch ID
  • Zero unsafe block without a // SAFETY: comment
  • cargo clippy -p agentkeys-cli -- -D warnings clean
  • PR fix(cli): #11 biometric gate for high-security master CLI actions (macOS) #27's current biometric.rs module is replaced by the trait-seam design

References

Out of scope

  • Linux fprintd / polkit gate (separate issue)
  • Windows Hello gate (separate issue)
  • Biometric gate on init --force and recovery flow (separate PR)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions