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

    area/cliagentkeys CLI, operator workstationneeds-arch-reviewNeeds explicit arch.md compatibility review before mergestatus/deprecatedNo longer relevant; flagged for close after review

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions