aperion-shield 1.0.1

Aperion Shield -- a local MCP guardrail for AI coding agents with optional biometric identity gates (ID.me). Standalone, free, open source.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
//! Aperion Shield -- identity-gated tool calls.
//!
//! When a rule in `shieldset.yaml` carries an `identity:` block and would
//! otherwise resolve to **Approval** or **Block**, the engine emits a
//! [`crate::Decision::IdentityVerification`] instead. The MCP middleman
//! then:
//!
//!   1. Looks up a fresh, cryptographically-signed proof in the local
//!      cache (`~/.aperion-shield/identity-cache.json`). A "fresh" proof
//!      is one whose `provider`, `scope`, allowed-subject set, and
//!      level-of-assurance all satisfy the rule's [`Requirement`], and
//!      whose `expires_at` is in the future.
//!   2. **Cache hit** -- the call is released and we log
//!      `identity_satisfied` to the audit stream.
//!   3. **Cache miss** -- Shield lazily starts a tiny localhost HTTP
//!      server (random port), mints a [`Challenge`], hands the user a
//!      `verify_url`, and holds the tool call server-side for at most
//!      `hold_seconds` (default 120). If the user completes the
//!      provider's flow (e.g. ID.me OAuth + biometric) within that
//!      window, Shield mints a [`Proof`], persists it, and releases the
//!      held call. If the window elapses, Shield returns a structured
//!      `shield_identity_required` error to the agent; the user can
//!      verify out-of-band and retry the call.
//!
//! All identity work is gated by the YAML schema -- a rule with no
//! `identity:` block behaves exactly as it did pre-v0.4. The free
//! standalone build ships both a fully-working `mock` provider (for
//! integration tests and demos) and a stubbed `id_me` provider whose
//! OAuth client is ready to activate the moment we receive sandbox
//! credentials from the ID.me partner program.
//!
//! Module layout:
//!
//! ```text
//! identity/
//!   mod.rs       -- public surface: types, trait, [`IdentityGate`]
//!   config.rs    -- `identity.yaml` schema + loader
//!   proof.rs     -- Ed25519 sign / verify, on-disk keypair
//!   cache.rs     -- file-backed proof cache with TTL eviction
//!   server.rs    -- hyper localhost callback server
//!   providers/
//!     mock.rs    -- always-verify provider (tests, demos)
//!     idme.rs    -- ID.me OAuth provider (creds not yet activated)
//! ```

#![allow(clippy::module_inception)]

pub mod cache;
pub mod config;
pub mod proof;
pub mod providers;
pub mod server;

use std::collections::HashSet;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

pub use cache::ProofCache;
pub use config::{IdentityConfig, ProviderConfig, ProviderKind};
pub use proof::{Proof, ProofSigner};
pub use providers::{idme::IdMeProvider, mock::MockProvider};
pub use server::{CallbackServer, Inflight, ServerHandle};

// ────────────────────────────────────────────────────────────────────
// Public types
// ────────────────────────────────────────────────────────────────────

/// A single requirement attached to a rule. Authored in YAML as the
/// `identity:` block under a rule; compiled into this struct once at
/// rule-load time and reused on every match.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Requirement {
    /// `id` of the provider in `identity.yaml` to invoke. Must resolve
    /// to a known [`ProviderConfig`].
    pub provider: String,

    /// Logical scope name (free-form). Doubles as the cache partition
    /// key, so a proof for `scm.commit_to_main` does **not** satisfy a
    /// rule asking for `db.production_apply`.
    pub scope: String,

    /// Set of subjects allowed to satisfy this gate. Each entry is
    /// matched against [`VerifiedIdentity::subject`] AND
    /// [`VerifiedIdentity::email`]; ANY hit passes.
    ///
    /// Accepted forms:
    ///   * Email      -- `[email protected]`
    ///   * Subject    -- `idme|550e8400-e29b-41d4-a716-446655440000`
    ///   * Wildcard   -- `*` (any user verifying is enough; useful
    ///                  for single-operator laptops)
    pub allowed_subjects: Vec<String>,

    /// Maximum acceptable age (seconds) for a cached proof. Older
    /// proofs are ignored and the user is re-prompted.
    #[serde(default = "default_max_age")]
    pub max_proof_age_seconds: u64,

    /// Required ID.me level-of-assurance (0/1/2/3). A proof issued at
    /// a lower LOA does not satisfy the gate.
    #[serde(default)]
    pub loa: u8,
}

fn default_max_age() -> u64 {
    900 // 15 minutes
}

impl Requirement {
    /// Does this allow-list include the given identity?
    pub fn allows(&self, vi: &VerifiedIdentity) -> bool {
        let want: HashSet<&str> = self.allowed_subjects.iter().map(String::as_str).collect();
        if want.contains("*") {
            return true;
        }
        let prefixed = format!("{}|{}", vi.provider, vi.subject);
        if want.contains(prefixed.as_str()) {
            return true;
        }
        if want.contains(vi.subject.as_str()) {
            return true;
        }
        if let Some(email) = vi.email.as_deref() {
            if want.contains(email) {
                return true;
            }
        }
        false
    }

    /// True if the given (already-decoded, signature-verified) proof
    /// satisfies all of: provider, scope, allow-list, LOA, freshness.
    pub fn is_satisfied_by(&self, proof: &Proof, now_secs: u64) -> bool {
        if proof.provider != self.provider {
            return false;
        }
        if proof.scope != self.scope {
            return false;
        }
        if proof.loa < self.loa {
            return false;
        }
        if proof.expires_at <= now_secs {
            return false;
        }
        if now_secs.saturating_sub(proof.verified_at) > self.max_proof_age_seconds {
            return false;
        }
        // Subject / email check.
        let vi = VerifiedIdentity {
            provider: proof.provider.clone(),
            subject: proof.subject.clone(),
            email: proof.email.clone(),
            loa: proof.loa,
            raw: serde_json::Value::Null,
        };
        self.allows(&vi)
    }
}

/// The "begin verification" input. Produced by the engine, consumed by
/// a provider's [`IdentityProvider::begin`] implementation.
#[derive(Debug, Clone)]
pub struct ChallengeRequest {
    pub rule_id: String,
    pub requirement: Requirement,
    /// The URL the provider should redirect back to once the user
    /// completes the flow. Always `http://127.0.0.1:<port>/callback`
    /// where `<port>` is the OS-assigned port the callback server is
    /// listening on.
    pub callback_url: String,
    /// Opaque, unguessable challenge id (also the cache key for the
    /// in-flight challenge state held by the callback server).
    pub challenge_id: String,
}

/// A provider-issued challenge. The `verify_url` is what we surface to
/// the user; the optional state-bag (`pkce_verifier`, `nonce`) is what
/// we hold privately so we can validate the callback.
#[derive(Debug, Clone)]
pub struct Challenge {
    pub challenge_id: String,
    /// The URL the user opens in a browser to complete verification.
    /// For real OAuth providers this is the authorize URL with a
    /// `state=<challenge_id>` query param. For the mock provider it's
    /// the local server's `/verify/<id>` endpoint.
    pub verify_url: String,
    /// PKCE verifier (for OAuth 2.0 + PKCE flows). Stored alongside
    /// the challenge so the callback handler can complete the token
    /// exchange.
    pub pkce_verifier: Option<String>,
    /// Anti-replay nonce embedded in the authorize URL and validated
    /// on callback.
    pub nonce: String,
    /// When this challenge expires; the callback server reaps any
    /// challenge older than this.
    pub expires_at: u64,
}

/// The result of a successful provider exchange. Translated by Shield
/// into a signed [`Proof`] before being cached / returned.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifiedIdentity {
    pub provider: String,
    /// Stable provider-side subject id (e.g. ID.me's `sub` claim). This
    /// is the identity key -- emails can change, subjects do not.
    pub subject: String,
    /// Email at the time of verification, for human-readable logs and
    /// allow-list matching by address.
    pub email: Option<String>,
    /// Level of assurance achieved (ID.me IAL/LOA tier).
    pub loa: u8,
    /// Provider-specific raw response payload, retained for audit but
    /// NEVER persisted to the proof cache (PII surface).
    #[serde(skip_serializing_if = "serde_json::Value::is_null", default)]
    pub raw: serde_json::Value,
}

/// Provider trait. Every concrete provider (mock, ID.me, future Okta /
/// Auth0 / etc.) implements this. Designed to be `Send + Sync + 'static`
/// so providers can live inside an `Arc` for the whole process.
#[async_trait]
pub trait IdentityProvider: Send + Sync + 'static {
    /// Stable identifier matching the `id:` field in `identity.yaml`.
    fn id(&self) -> &str;

    /// True if this provider has the credentials it needs to operate.
    /// The ID.me adapter returns `false` until sandbox credentials are
    /// supplied; the mock provider always returns `true`.
    fn is_ready(&self) -> bool;

    /// Begin a verification flow. Returns the URL the user should open
    /// plus the bookkeeping needed to validate the callback.
    async fn begin(&self, req: ChallengeRequest) -> anyhow::Result<Challenge>;

    /// Complete the verification flow given the provider's response.
    /// For OAuth providers, `code` is the authorization code; for the
    /// mock provider it's a synthetic token. `state` must match the
    /// challenge id we minted in [`begin`].
    async fn exchange(
        &self,
        challenge_id: &str,
        code: &str,
        state: &str,
        pkce_verifier: Option<&str>,
    ) -> anyhow::Result<VerifiedIdentity>;
}

// ────────────────────────────────────────────────────────────────────
// Gate -- the public composition object
// ────────────────────────────────────────────────────────────────────

/// The [`IdentityGate`] is the single object `main.rs` interacts with.
/// It owns the provider registry, the signed proof cache, and (lazily)
/// the local callback server.
///
/// Construction is cheap (loads config, opens cache file, generates or
/// loads the signing key). The callback server is **not** spun up until
/// the first [`IdentityGate::start_challenge`] call -- shields that
/// never hit an identity-gated rule pay zero runtime cost.
pub struct IdentityGate {
    config: IdentityConfig,
    providers: Vec<Arc<dyn IdentityProvider>>,
    cache: Arc<ProofCache>,
    signer: Arc<ProofSigner>,
    server: tokio::sync::OnceCell<ServerHandle>,
}

impl IdentityGate {
    /// Build a gate from a config and a list of provider implementations.
    /// Cache key + signing keypair live in `<state_dir>` (typically
    /// `~/.aperion-shield`).
    pub fn new(
        config: IdentityConfig,
        providers: Vec<Arc<dyn IdentityProvider>>,
        state_dir: std::path::PathBuf,
    ) -> anyhow::Result<Self> {
        let signer = Arc::new(ProofSigner::load_or_create(&state_dir)?);
        let cache = Arc::new(ProofCache::open(state_dir.join("identity-cache.json"), &signer)?);
        Ok(Self {
            config,
            providers,
            cache,
            signer,
            server: tokio::sync::OnceCell::new(),
        })
    }

    /// Look up a cached proof satisfying `req`. None means we have to
    /// prompt the user.
    pub fn cached_proof_for(&self, req: &Requirement) -> Option<Proof> {
        let now = unix_now();
        self.cache.find_satisfying(req, now)
    }

    /// Provider with the given id, or None.
    pub fn provider(&self, id: &str) -> Option<Arc<dyn IdentityProvider>> {
        self.providers.iter().find(|p| p.id() == id).cloned()
    }

    /// Lazily start the callback server, sharing the same provider /
    /// cache / signer instances the gate already holds. Subsequent calls
    /// return the cached handle.
    async fn ensure_server(&self) -> anyhow::Result<&ServerHandle> {
        self.server
            .get_or_try_init(|| async {
                CallbackServer::spawn(
                    &self.config.callback_host,
                    self.config.callback_port,
                    self.providers.clone(),
                    self.cache.clone(),
                    self.signer.clone(),
                )
                .await
            })
            .await
    }

    /// Ensure the callback server is running and return its base URL
    /// (e.g. `http://127.0.0.1:53201`).
    pub async fn callback_base(&self) -> anyhow::Result<String> {
        let h = self.ensure_server().await?;
        Ok(h.base_url())
    }

    /// Hand the gate a freshly-minted challenge so the callback server
    /// can correlate the user's redirect back to its in-flight state.
    pub async fn register_inflight(
        &self,
        challenge: &Challenge,
        requirement: Requirement,
        provider: String,
        rule_id: String,
    ) -> anyhow::Result<()> {
        let h = self.ensure_server().await?;
        h.register(
            challenge.clone(),
            server::Inflight { rule_id, provider, requirement },
        )
        .await;
        Ok(())
    }

    /// Block up to `hold_seconds` waiting for a proof to land in the
    /// cache for `req`. Returns the proof if one arrives; None on
    /// timeout. Callers should treat None as "tell the agent to retry".
    pub async fn wait_for_proof(
        &self,
        req: &Requirement,
        hold_seconds: u64,
    ) -> Option<Proof> {
        let deadline = std::time::Instant::now()
            + std::time::Duration::from_secs(hold_seconds);
        loop {
            if let Some(p) = self.cached_proof_for(req) {
                return Some(p);
            }
            if std::time::Instant::now() >= deadline {
                return None;
            }
            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
        }
    }

    /// Persist a freshly-verified identity as a signed proof.
    pub fn mint_and_cache(
        &self,
        vi: &VerifiedIdentity,
        req: &Requirement,
    ) -> anyhow::Result<Proof> {
        let now = unix_now();
        let proof = Proof {
            v: 1,
            provider: vi.provider.clone(),
            subject: vi.subject.clone(),
            email: vi.email.clone(),
            loa: vi.loa,
            scope: req.scope.clone(),
            verified_at: now,
            expires_at: now.saturating_add(req.max_proof_age_seconds),
            nonce: hex::encode(rand::random::<[u8; 16]>()),
            sig: String::new(),
        };
        let signed = self.signer.sign(proof)?;
        self.cache.insert(signed.clone())?;
        Ok(signed)
    }

    /// Number of valid (signature-verified, non-expired) proofs cached.
    pub fn cached_count(&self) -> usize {
        self.cache.count_valid(unix_now())
    }

    /// Drop every cached proof. Returns how many were evicted.
    pub fn flush(&self) -> anyhow::Result<usize> {
        self.cache.flush()
    }

    /// Hold seconds configured for this gate.
    pub fn hold_seconds(&self) -> u64 {
        self.config.hold_seconds
    }

    /// True if at least one provider is registered AND ready.
    pub fn has_ready_provider(&self) -> bool {
        self.providers.iter().any(|p| p.is_ready())
    }

    /// Read-only access to the loaded config.
    pub fn config(&self) -> &IdentityConfig {
        &self.config
    }
}

pub(crate) fn unix_now() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn vi(provider: &str, subject: &str, email: Option<&str>, loa: u8) -> VerifiedIdentity {
        VerifiedIdentity {
            provider: provider.into(),
            subject: subject.into(),
            email: email.map(str::to_string),
            loa,
            raw: serde_json::Value::Null,
        }
    }

    #[test]
    fn allow_list_matches_email() {
        // Compose the email literals at runtime so the source contains
        // no `name@domain` token. Some doc/display layers normalise
        // such tokens to a placeholder which made earlier versions of
        // this test compare two identical strings without us noticing.
        let allowed = format!("{}@{}", "ace", "aperion.ai");
        let denied  = format!("{}@{}", "bee", "other.com");
        let req = Requirement {
            provider: "id_me".into(),
            scope: "scm.commit".into(),
            allowed_subjects: vec![allowed.clone()],
            max_proof_age_seconds: 900,
            loa: 2,
        };
        // Email matches the allow list -> allowed.
        assert!(req.allows(&vi("id_me", "sub-1", Some(allowed.as_str()), 2)));
        // Different email, no other identifier on the allow list -> denied.
        assert!(!req.allows(&vi("id_me", "sub-2", Some(denied.as_str()), 2)));
        // No email at all -> denied.
        assert!(!req.allows(&vi("id_me", "sub-3", None, 2)));
    }

    #[test]
    fn allow_list_matches_subject_with_prefix() {
        let req = Requirement {
            provider: "id_me".into(),
            scope: "x".into(),
            allowed_subjects: vec!["id_me|sub-1".into()],
            max_proof_age_seconds: 900,
            loa: 0,
        };
        assert!(req.allows(&vi("id_me", "sub-1", None, 0)));
    }

    #[test]
    fn wildcard_lets_anyone_pass() {
        let req = Requirement {
            provider: "id_me".into(),
            scope: "x".into(),
            allowed_subjects: vec!["*".into()],
            max_proof_age_seconds: 900,
            loa: 0,
        };
        assert!(req.allows(&vi("id_me", "sub-99", Some("[email protected]"), 0)));
    }

    #[test]
    fn requirement_freshness_check() {
        let req = Requirement {
            provider: "id_me".into(),
            scope: "x".into(),
            allowed_subjects: vec!["*".into()],
            max_proof_age_seconds: 60,
            loa: 0,
        };
        let now = unix_now();
        let fresh = Proof {
            v: 1, provider: "id_me".into(), subject: "s".into(), email: None,
            loa: 0, scope: "x".into(), verified_at: now, expires_at: now + 60,
            nonce: "".into(), sig: "".into(),
        };
        let stale = Proof { verified_at: now - 120, expires_at: now + 60, ..fresh.clone() };
        let wrong_scope = Proof { scope: "y".into(), ..fresh.clone() };
        let low_loa = Proof { loa: 0, ..fresh.clone() };
        let high_loa_req = Requirement { loa: 2, ..req.clone() };

        assert!(req.is_satisfied_by(&fresh, now));
        assert!(!req.is_satisfied_by(&stale, now));
        assert!(!req.is_satisfied_by(&wrong_scope, now));
        assert!(!high_loa_req.is_satisfied_by(&low_loa, now));
    }
}