Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/agentkeys-cli/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod biometric;
pub mod session_store;

use std::sync::Arc;
Expand Down Expand Up @@ -201,6 +202,7 @@ pub async fn cmd_run(ctx: &CommandContext, agent: &str, cmd: &[String]) -> Resul
}

pub async fn cmd_revoke(ctx: &CommandContext, agent: &str) -> Result<String> {
biometric::require_biometric(&format!("Revoke session(s) for {}", agent))?;
let session = ctx.load_session().context("load session (run `agentkeys init` first)")?;

let target_session = Session {
Expand Down Expand Up @@ -229,6 +231,7 @@ pub async fn cmd_revoke(ctx: &CommandContext, agent: &str) -> Result<String> {
}

pub async fn cmd_teardown(ctx: &CommandContext, agent: &str) -> Result<String> {
biometric::require_biometric(&format!("Tear down agent {} (deletes all credentials)", agent))?;
let session = ctx.load_session().context("load session (run `agentkeys init` first)")?;
let agent_id = WalletAddress(agent.to_string());

Expand Down Expand Up @@ -401,6 +404,7 @@ pub async fn cmd_recover(ctx: &CommandContext, identity: &str, method: &str) ->
}

pub async fn cmd_approve(ctx: &CommandContext, pair_code: &str, auto_yes: bool) -> Result<String> {
biometric::require_biometric("Approve pair request (creates a new child session)")?;
let session = ctx.load_session().context("load session (run `agentkeys init` first)")?;

if ctx.verbose {
Expand Down
58 changes: 57 additions & 1 deletion crates/agentkeys-cli/tests/cli_tests.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
use std::sync::Arc;

use agentkeys_cli::{cmd_init, cmd_link, cmd_read, cmd_revoke, cmd_store, cmd_teardown, cmd_usage, CommandContext};
use agentkeys_cli::{cmd_approve, cmd_init, cmd_link, cmd_read, cmd_revoke, cmd_store, cmd_teardown, cmd_usage, CommandContext};
use agentkeys_core::backend::CredentialBackend;
use agentkeys_mock_server::test_client::InProcessBackend;
use agentkeys_types::Session;

fn disable_biometric() {
unsafe { std::env::set_var("AGENTKEYS_BIOMETRIC", "off") };
}

fn create_test_backend() -> Arc<InProcessBackend> {
Arc::new(InProcessBackend::new())
}
Expand Down Expand Up @@ -94,6 +98,7 @@ async fn cli_run_injects_env() {
// Test 5: revoke then read — exercises the revoke path without blocking on keychain
#[tokio::test(flavor = "multi_thread")]
async fn cli_revoke_then_read() {
disable_biometric();
let backend = create_test_backend();
let (wallet, session) = init_session_direct(&backend).await;
let context = ctx_with_session(backend, session);
Expand All @@ -112,6 +117,7 @@ async fn cli_revoke_then_read() {
// Test 6: teardown then read returns error
#[tokio::test(flavor = "multi_thread")]
async fn cli_teardown_deletes_all() {
disable_biometric();
let backend = create_test_backend();
let (wallet, session) = init_session_direct(&backend).await;
let context = ctx_with_session(backend, session);
Expand Down Expand Up @@ -267,3 +273,53 @@ async fn cli_error_format_unreachable() {
"unexpected error: {err}"
);
}

// Test 15: biometric gate is skipped when AGENTKEYS_BIOMETRIC=off
#[tokio::test(flavor = "multi_thread")]
async fn cmd_approve_skips_biometric_in_test_mode() {
disable_biometric();
let backend = create_test_backend();
let (_wallet, session) = init_session_direct(&backend).await;
let context = ctx_with_session(backend, session);

// approve with a non-existent pair code — biometric gate must not block it;
// expect an error from the backend (not a biometric error).
let result = cmd_approve(&context, "NONEXISTENT-PAIR-CODE", true).await;
assert!(result.is_err(), "expected backend error for unknown pair code");
let err = result.unwrap_err().to_string();
assert!(
!err.contains("biometric") && !err.contains("Biometric") && !err.contains("cancelled"),
"unexpected biometric error leaked: {err}"
);
}

// Test 16: cmd_revoke skips biometric when AGENTKEYS_BIOMETRIC=off
#[tokio::test(flavor = "multi_thread")]
async fn cmd_revoke_skips_biometric_in_test_mode() {
disable_biometric();
let backend = create_test_backend();
let (wallet, session) = init_session_direct(&backend).await;
let context = ctx_with_session(backend, session);

// revoke proceeds past the biometric gate regardless of result
let result = cmd_revoke(&context, &wallet).await;
// accept ok or backend error — just not a biometric gate error
if let Err(ref err) = result {
assert!(
!err.to_string().contains("biometric") && !err.to_string().contains("cancelled"),
"unexpected biometric error: {err}"
);
}
}

// Test 17: cmd_teardown skips biometric when AGENTKEYS_BIOMETRIC=off
#[tokio::test(flavor = "multi_thread")]
async fn cmd_teardown_skips_biometric_in_test_mode() {
disable_biometric();
let backend = create_test_backend();
let (wallet, session) = init_session_direct(&backend).await;
let context = ctx_with_session(backend, session);

let result = cmd_teardown(&context, &wallet).await;
assert!(result.is_ok(), "teardown should succeed: {:?}", result.err());
}
6 changes: 3 additions & 3 deletions docs/spec/1-step-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,10 @@ AgentKeys' answer is structurally different from 1Password: **we don't hand user
> **Correction (2026-04-12, verified against Heima source `tee-worker/omni-executor/core/src/auth/auth_token.rs`):** The original table below described session "keys" (keypairs) stored client-side. Heima's actual implementation uses **JWT-based stateless auth tokens**: the TEE signs a JWT with its RSA private key, and the client holds the JWT string as a bearer token — NOT a private key. The security properties and storage requirements are different from what the original table assumed. OS keychain is no longer required (a JWT can go in a plain file); `keyring-rs` complexity drops significantly. The table is preserved below as the original design-time thinking, with the correction noted.


| Tier | Lifetime | Storage (original spec) | Storage (corrected, JWT model) | Usage |
| Tier | Lifetime | Storage (original spec) | Storage (corrected, session-token model) | Usage |
| --------------------- | ---------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| **Master auth token** | Short (15 min – 24 h, configurable via `AuthOptions.expires_at`) | OS keychain | Plain file or env var (JWT string, not a private key) | Management commands: `agentkeys init`, `store`, `usage`, `teardown`, `approve`. Never used by running agents. |
| **Agent auth token** | Long (hours to days) | Sandbox filesystem (`~/.agentkeys/session`, 0600) | Same (JWT string in file, 0600) | MCP Credential Server authentication. Scoped to specific credentials for a specific agent. |
| **Master auth token** | Short (15 min – 24 h, configurable via `AuthOptions.expires_at`) | OS keychain | Plain file or env var (session-token string, not a private key) | Management commands: `agentkeys init`, `store`, `usage`, `teardown`, `approve`. Never used by running agents. |
| **Agent auth token** | Long (hours to days) | Sandbox filesystem (`~/.agentkeys/session`, 0600) | Same (session-token string in file, 0600) | MCP Credential Server authentication. Scoped to specific credentials for a specific agent. |


### 3.3 Storage choices (Rounds 5–6)
Expand Down
5 changes: 3 additions & 2 deletions docs/spec/tech-brief.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,12 @@ User (Mac) Mock Backend / Heima Agent Sandbox
│ │ generate master keypair │
│ │ generate mock wallet addr │
│◄─────────────────────────│ │
JWT auth token → file │ (NOTE: original spec said│
session token → file │ (NOTE: original spec said│
│ (was: session key → │ "session key → OS │
│ OS keychain; corrected │ keychain"; corrected │
│ 2026-04-12 per Heima │ after verifying Heima │
│ source verification) │ uses JWT auth tokens) │
│ source verification) │ uses JWT-format session │
│ │ tokens) │
```

### Flow B — Pair a daemon (universal, works for Docker / cloud VM / cloud LLM)
Expand Down