You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
pubtraitBiometricBackend:Send + Sync{fnauthenticate(&self,reason:&str) -> Result<(),BiometricError>;}pubstructLAContextBackend;// macOS realpubstructStdinBackend;// non-macOS fallback (unchanged from #27)#[cfg(test)]pubstructMockBackend;// 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
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
Problem
PR #27 introduced a biometric gate for
approve/revoke/teardown, but the macOS path is a stub that logs a prompt and returnsOk(()). 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.evaluatePolicycall viaobjc2+objc2-local-authentication+block2so 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.rsmodule with a trait-seam design:cmd_approve/cmd_revoke/cmd_teardowntake&dyn BiometricBackend(viaCommandContext)LAContextBackend: unsafe FFI to-[LAContext evaluatePolicy:localizedReason:reply:]RcBlockwraps the completion callbackstd::sync::mpsc::channelbridges async → syncrx.recv_timeoutso the CLI can't deadlockLAErrorUserCancel,LAErrorSystemCancel,LAErrorBiometryNotAvailable,LAErrorBiometryLockout,LAErrorPasscodeNotSet,LAErrorAppCancel,LAErrorInvalidContext) to a typedBiometricErrorenumAGENTKEYS_BIOMETRIC=off, redaction of session tokens from the prompt reasonDependencies
objc2 = "0.6"objc2-foundation = "0.3"objc2-local-authentication = "0.3"(or feature onobjc2-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 coderedact_prompt_reason(raw: &str) -> String— strip anything that looks like a session-token prefixpolicy_selection(has_biometry, has_passcode) -> LAPolicy— table-drivenL2 — FFI boundary (
#[cfg(target_os = "macos")], runs on macOS CI):la_context_constructs_and_drops— catches library-load / linker issuescan_evaluate_policy_is_synchronous— callscanEvaluatePolicy:error:which returns synchronously without prompting; on a CI runner with no Touch ID it returnsLAErrorBiometryNotAvailable, which is the most useful FFI-level validationerror_struct_layout— dereference an NSError, readcode, asserti64widthL3 — Behavioral contract (via
MockBackend, runs everywhere):cmd_approve_proceeds_on_auth_successcmd_approve_aborts_on_user_cancelcmd_revoke_redacts_session_token_from_prompt_reason— assert the string passed toauthenticatedoesn't contain the raw argcmd_teardown_aborts_on_biometry_lockoutcmd_*_bypasses_gate_when_env_is_offL4 — Manual QA (
docs/manual-test-issue-<N>.md, documented, not automated):AGENTKEYS_BIOMETRIC=off→ bypasses entirelyagentkeys revoke <raw-session-token>→ prompt reason does NOT echo the tokenunsafesurface~4-5 blocks, each 2-5 lines, each with a
// SAFETY:comment. Known sources:LAContext::new()— unsafe message-send (all objc2 sends are unsafe)*mut NSErrorderef inside the completion block — Apple's contract guarantees non-null on failure; we null-check defensivelyevaluatePolicy:localizedReason:reply:— typed wrapper from objc2-local-authentication, but stillunsafe fnRcBlockwith'static + Send + Synccaptures only (channelSenderqualifies)Deadlock / leak protections:
recv_timeouton the channel (CLI can never deadlock)Retained<LAContext>capture inside the block (avoids retain cycle)Acceptance criteria
LAContextBackend::authenticatecalls the real LAContext API on macOScargo test -p agentkeys-cli -- --test-threads=1passes on Linux (stdin + mock paths)cargo test -p agentkeys-cli -- --test-threads=1passes on macOS (adds FFI boundary tests)unsafeblock without a// SAFETY:commentcargo clippy -p agentkeys-cli -- -D warningscleanbiometric.rsmodule is replaced by the trait-seam designReferences
Out of scope
init --forceand recovery flow (separate PR)