Skip to main content

act_runtime/
credentials.rs

1//! Host side of `act:credentials/store`.
2//!
3//! Session handling lives here rather than in the transports so both MCP and
4//! HTTP get identical behaviour; `runtime/sessions.rs` is the single point both
5//! already pass through.
6//!
7//! ## Two layers, on purpose
8//!
9//! [`CredentialHost`] is synchronous, owns no wasmtime types, and is the whole
10//! of the credential logic: compartment projection, session liveness, the audit
11//! record. It is unit-testable against a real store with no engine, no linker
12//! and no guest.
13//!
14//! Around it sits the generated-trait bridge. `act:credentials/store`'s two
15//! functions are `async func` in WIT, so bindgen lowers them through
16//! `func_wrap_concurrent`: the generated `HostWithStore<T>` methods are
17//! **associated functions taking a [`wasmtime::component::Accessor`]**, not methods on `&self`, and
18//! the impl target is the `HasData` marker rather than `HostState`. The bridge
19//! reaches host state through the accessor, resolves the capability decision
20//! (which may await a human), and then calls the synchronous host. Nothing
21//! about the credential logic itself has to be written twice, or written async.
22
23use std::collections::{HashMap, HashSet};
24use std::path::{Path, PathBuf};
25use std::sync::{Arc, Mutex};
26
27use act_credentials::backend::BackendChoice;
28use act_credentials::record::{Secret, SecretInfo};
29use act_credentials::store::CredentialStore;
30use act_policy::providers::credentials::CAP_CREDENTIALS;
31use wasmtime::component::{HasSelf, Linker};
32
33use super::HostState;
34use super::bindings::act::credentials::{store, types};
35use crate::consent::{sanitize_hint, truncate_field};
36
37/// Why the host refused, in the terms `CredentialHost` can decide in. Maps
38/// onto the WIT `secret-error` variants and nothing else, through this
39/// type's private `to_wit`.
40#[derive(Debug, PartialEq, Eq)]
41pub enum HostError {
42    NotFound,
43    Denied,
44    InvalidSession,
45    Unavailable(String),
46}
47
48/// What the guest is told when the store could not be read.
49///
50/// Host-authored and constant on purpose. `StoreError::Encoding` is built from
51/// `serde_json`'s message, and serde's `invalid type` text embeds the offending
52/// JSON scalar — so forwarding the detail hands stored credential material to
53/// the guest, which puts it in a tool result. Externally-materialised store
54/// files are a first-class source (design §5.6), and the pipelines that render
55/// them emit JSON numbers without being asked, so a numeric PIN or account id
56/// is exactly the shape that reaches this path. The detail goes to the host's
57/// log at `warn`, where the operator can see it and the agent cannot.
58const STORE_UNREADABLE: &str = "the credential store could not be read";
59
60impl HostError {
61    fn to_wit(&self) -> store::SecretError {
62        match self {
63            HostError::NotFound => store::SecretError::NotFound,
64            HostError::Denied => store::SecretError::Denied,
65            HostError::InvalidSession => store::SecretError::InvalidSession,
66            HostError::Unavailable(d) => store::SecretError::Unavailable(d.clone()),
67        }
68    }
69}
70
71/// How a credential too close to expiry is renewed.
72///
73/// A seam, and deliberately a narrow one. Renewing means speaking OAuth to an
74/// authorization server — discovery, a client registration, a token endpoint —
75/// and this runtime holds no opinion about protocols: it knows only that a
76/// stored value carries an expiry and an issuer, and that something outside can
77/// turn those into a fresh value. `act-cli` implements it over
78/// `act:credentials`' OAuth flow; an embedder with a different upstream
79/// implements it differently, or passes none and gets no renewal.
80///
81/// Nothing here mentions OAuth for that reason. What crosses is what any
82/// renewal must produce.
83#[async_trait::async_trait]
84pub trait CredentialRefresher: Send + Sync {
85    /// Renew one credential. The error is a diagnostic for the host's log — it
86    /// reaches no component, and MUST NOT carry credential material.
87    async fn refresh(&self, req: RefreshRequest<'_>) -> Result<Refreshed, String>;
88}
89
90/// What the runtime knows and a refresher needs.
91pub struct RefreshRequest<'a> {
92    /// The authorization server the credential was acquired from, as recorded
93    /// in the host-only compartment when it was acquired.
94    pub issuer: &'a str,
95    /// The host-only refresh token. It never leaves the host by any other path.
96    pub refresh_token: &'a str,
97    /// Unix seconds, read once by the caller so a whole decision shares one
98    /// clock reading.
99    pub now: u64,
100}
101
102/// What a renewal produced.
103pub struct Refreshed {
104    pub access_token: String,
105    pub expires_at: Option<u64>,
106    pub scopes: Vec<String>,
107    /// `None` means the server rotated nothing and the stored one still works.
108    /// `Some` always replaces: a rotating server invalidates the old, and
109    /// keeping it is how a credential dies at the *next* refresh instead of
110    /// this one.
111    pub refresh_token: Option<String>,
112}
113
114/// Unix seconds, or 0 if the clock is before the epoch — which makes every
115/// credential look far from expiry rather than expired, so a broken clock
116/// cannot stampede every session into refreshing at once.
117fn now_unix() -> u64 {
118    std::time::SystemTime::now()
119        .duration_since(std::time::UNIX_EPOCH)
120        .map_or(0, |d| d.as_secs())
121}
122
123/// The fields of this record that are too close to expiry to serve.
124///
125/// Only ever the `std:oauth2`-shaped ones: `needs_refresh` reads
126/// `std:expires-at` out of an object, and a `std:string` field has no object to
127/// read it from. That is the whole of why refresh cannot touch a sibling.
128fn due_fields(rec: &act_credentials::record::SecretRecord, now: u64) -> Vec<String> {
129    rec.fields
130        .iter()
131        .filter(|(_, v)| act_credentials::expiry::needs_refresh(v.expose(), now))
132        .map(|(k, _)| k.clone())
133        .collect()
134}
135
136/// Write a renewal into a record: one field's value, and its host-only half.
137///
138/// Every sibling is untouched because none is named here. The design calls this
139/// the structural payoff of typing fields rather than credentials — "refresh
140/// dropped the tenant id" is a bug this shape cannot express.
141fn apply_refresh(rec: &mut act_credentials::record::SecretRecord, field: &str, r: &Refreshed) {
142    let mut value = serde_json::Map::new();
143    value.insert(
144        "std:access-token".into(),
145        serde_json::Value::String(r.access_token.clone()),
146    );
147    if let Some(exp) = r.expires_at {
148        value.insert("std:expires-at".into(), serde_json::Value::from(exp));
149    }
150    if !r.scopes.is_empty() {
151        value.insert(
152            "std:scopes".into(),
153            serde_json::Value::from(r.scopes.clone()),
154        );
155    }
156    rec.fields.insert(
157        field.to_string(),
158        act_credentials::record::SecretValue::new(serde_json::Value::Object(value)),
159    );
160
161    // A rotated token replaces; an absent one leaves what was there. Both the
162    // record's expiry and the field's move together, so `act secret list` does
163    // not go on showing the old one.
164    if let Some(new_refresh) = &r.refresh_token {
165        rec.host_only.insert(
166            refresh_token_slot(field),
167            act_credentials::record::SecretValue::new(new_refresh.clone()),
168        );
169    }
170    rec.expires_at = r.expires_at.map(|e| i64::try_from(e).unwrap_or(i64::MAX));
171}
172
173/// Where the issuer of a `std:oauth2` field is kept, alongside its refresh
174/// token: `<field key>:std:issuer` and `<field key>:std:refresh-token`.
175///
176/// Namespaced by field key because a credential may hold an OAuth token per
177/// upstream, and a compartment keyed by member name alone would have the second
178/// overwrite the first — a loss that surfaces only at the first refresh.
179pub fn issuer_slot(field: &str) -> String {
180    format!("{field}:std:issuer")
181}
182
183pub fn refresh_token_slot(field: &str) -> String {
184    format!("{field}:std:refresh-token")
185}
186
187pub struct CredentialHost {
188    store: Arc<dyn CredentialStore>,
189    component: String,
190    live_sessions: Mutex<HashSet<String>>,
191    refresher: Option<Arc<dyn CredentialRefresher>>,
192    /// One lock per credential key, so two sessions renewing the same
193    /// credential inside this process take turns. The store's advisory lock
194    /// covers other processes; this covers the far more common case of two
195    /// live sessions against one upstream, where a rotation would otherwise
196    /// have each invalidate the other's token.
197    refresh_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
198}
199
200impl CredentialHost {
201    pub fn new(store: Arc<dyn CredentialStore>, component: String) -> Self {
202        Self {
203            store,
204            component,
205            live_sessions: Mutex::new(HashSet::new()),
206            refresher: None,
207            refresh_locks: Mutex::new(HashMap::new()),
208        }
209    }
210
211    /// Give this host a way to renew credentials. Without one, a near-expiry
212    /// credential is served as it is: the alternative — refusing it — would
213    /// break every component whose upstream issues short-lived tokens, and the
214    /// host has nothing better to offer.
215    pub fn with_refresher(mut self, refresher: Arc<dyn CredentialRefresher>) -> Self {
216        self.refresher = Some(refresher);
217        self
218    }
219
220    /// The component reference this host serves — `resolve::profile_key` of
221    /// the reference the operator wrote, not the literal spelling. It is the
222    /// profile namespace — the boundary the whole design rests on (design
223    /// §2.1) — and it is what a consent prompt must name (design §5.5).
224    pub fn component(&self) -> &str {
225        &self.component
226    }
227
228    pub fn note_session_opened(&self, id: &str) {
229        self.live_sessions
230            .lock()
231            .unwrap_or_else(std::sync::PoisonError::into_inner)
232            .insert(id.to_string());
233    }
234
235    pub fn note_session_closed(&self, id: &str) {
236        self.live_sessions
237            .lock()
238            .unwrap_or_else(std::sync::PoisonError::into_inner)
239            .remove(id);
240    }
241
242    fn live(&self, id: &str) -> bool {
243        self.live_sessions
244            .lock()
245            .unwrap_or_else(std::sync::PoisonError::into_inner)
246            .contains(id)
247    }
248
249    /// A hit returns the projection only. The host-only compartment — refresh
250    /// tokens, issuer binding — never crosses the boundary (design D4).
251    ///
252    /// The caller must have resolved the `act:credentials` capability decision
253    /// *before* getting here: a denial must not depend on whether the key
254    /// exists, or `denied` becomes a probing channel (design §3.4).
255    pub fn get_secret(&self, session: &str, key: &str) -> Result<Secret, HostError> {
256        if !self.live(session) {
257            return Err(HostError::InvalidSession);
258        }
259        match self.store.get(&self.component, key) {
260            Ok(Some(rec)) => {
261                // The one record that says material left the host. Emitted
262                // on the audit target, which `RUST_LOG` cannot silence.
263                crate::audit::emit_credential_issue(&crate::audit::CredentialIssueRecord {
264                    component_ref: self.component.clone(),
265                    session_id: session.to_string(),
266                    key: key.to_string(),
267                    kind: rec.kind.clone(),
268                });
269                Ok(rec.project())
270            }
271            Ok(None) => Err(HostError::NotFound),
272            Err(e) => {
273                tracing::warn!(error = %e, "credential store read failed");
274                Err(HostError::Unavailable(STORE_UNREADABLE.into()))
275            }
276        }
277    }
278
279    /// Renew any field of this credential that is too close to expiry, before
280    /// it is served.
281    ///
282    /// Silent by design (design §5.4): the component asked for a credential and
283    /// gets one that works. It never sees a refresh token and never re-opens a
284    /// session because one expired.
285    ///
286    /// **Failure is not fatal.** A renewal that cannot happen — no refresher,
287    /// no issuer recorded, the server refusing — leaves the stored value alone
288    /// and lets it be served. It may still work: the skew is a margin, not an
289    /// expiry, and a token inside it is usually still valid. Refusing here
290    /// would turn a renewal problem into a failed tool call for a credential
291    /// that was very likely fine, and the component's own upstream is the thing
292    /// that actually knows. The attempt is logged; the reason never reaches the
293    /// guest.
294    pub async fn refresh_if_due(&self, key: &str, now: u64) {
295        let Some(refresher) = self.refresher.clone() else {
296            return;
297        };
298        // Cheap check first, outside the lock: the overwhelming majority of
299        // calls are nowhere near expiry and must not queue behind anything.
300        let Ok(Some(rec)) = self.store.get(&self.component, key) else {
301            return;
302        };
303        if due_fields(&rec, now).is_empty() {
304            return;
305        }
306
307        let lock = self.lock_for(key);
308        let _held = lock.lock().await;
309
310        // Re-read and re-decide **after** acquiring the lock. Whoever held it
311        // first may have already renewed this very credential, and renewing
312        // again would spend a rotation to replace a token that is fresh.
313        let Ok(Some(rec)) = self.store.get(&self.component, key) else {
314            return;
315        };
316        for field in due_fields(&rec, now) {
317            let (Some(issuer), Some(refresh_token)) = (
318                rec.host_only
319                    .get(&issuer_slot(&field))
320                    .and_then(|v| v.expose_str())
321                    .map(str::to_string),
322                rec.host_only
323                    .get(&refresh_token_slot(&field))
324                    .and_then(|v| v.expose_str())
325                    .map(str::to_string),
326            ) else {
327                // Acquired before the host recorded either, or provisioned by
328                // hand from a token someone else obtained. Nothing to renew
329                // with; the value stands.
330                tracing::debug!(
331                    field = %field,
332                    "credential is near expiry but carries no issuer and refresh token"
333                );
334                continue;
335            };
336
337            let refreshed = match refresher
338                .refresh(RefreshRequest {
339                    issuer: &issuer,
340                    refresh_token: &refresh_token,
341                    now,
342                })
343                .await
344            {
345                Ok(r) => r,
346                Err(e) => {
347                    tracing::warn!(field = %field, error = %e, "credential refresh failed");
348                    continue;
349                }
350            };
351
352            let applied = self.store.update(&self.component, key, &mut |rec| {
353                apply_refresh(rec, &field, &refreshed);
354            });
355            match applied {
356                Ok(_) => tracing::info!(
357                    component = %self.component,
358                    key = %key,
359                    field = %field,
360                    "credential refreshed"
361                ),
362                Err(e) => tracing::warn!(error = %e, "storing a refreshed credential failed"),
363            }
364        }
365    }
366
367    fn lock_for(&self, key: &str) -> Arc<tokio::sync::Mutex<()>> {
368        self.refresh_locks
369            .lock()
370            .unwrap_or_else(std::sync::PoisonError::into_inner)
371            .entry(key.to_string())
372            .or_default()
373            .clone()
374    }
375
376    /// Metadata only — no value can reach this path, because `SecretInfo` has
377    /// no field that could hold one. Deliberately unaudited (design §9): a
378    /// listing hands over nothing, and recording it would bury the issue
379    /// records that matter.
380    pub fn list_secrets(&self, session: Option<&str>) -> Result<Vec<SecretInfo>, HostError> {
381        if let Some(id) = session
382            && !self.live(id)
383        {
384            return Err(HostError::InvalidSession);
385        }
386        self.store.list(Some(&self.component)).map_err(|e| {
387            tracing::warn!(error = %e, "credential store list failed");
388            HostError::Unavailable(STORE_UNREADABLE.into())
389        })
390    }
391}
392
393/// Where the file backend lives when the operator has not named one.
394///
395/// This is the path the writer (`act secret`) and the reader (`act run` /
396/// `act call`) have to agree on when neither was given
397/// `--credentials-backend`, so it lives next to the reader rather than in
398/// either caller. `None` on a platform with no data directory.
399pub fn default_store_root() -> Option<PathBuf> {
400    dirs::data_dir().map(|d| d.join("act").join("credentials"))
401}
402
403/// Parse `--credentials-backend`, or fall back to [`default_store_root`].
404///
405/// The one parser for that flag: `act secret set/list/rm` and the runtime's
406/// the credential host [`crate::ComponentRuntime::load`] builds both come
407/// through here, so a store named on the
408/// write is the store read on the run. There is no inferred backend — an
409/// unrecognised value is an error, never a silent fall back to plaintext
410/// (design D13/§7.4).
411///
412/// `Ok(None)` means only "no store location exists on this platform, and none
413/// was named": the runtime treats that as no credential host, while `act
414/// secret` turns it into an error naming the flag. Neither decision belongs
415/// here.
416pub fn resolve_backend(explicit: Option<&str>) -> anyhow::Result<Option<BackendChoice>> {
417    match explicit {
418        Some(s) => {
419            let path = s.strip_prefix("file:").ok_or_else(|| {
420                anyhow::anyhow!("unknown --credentials-backend '{s}'; expected file:<path>")
421            })?;
422            anyhow::ensure!(
423                !path.is_empty(),
424                "--credentials-backend 'file:' needs a path, e.g. file:/path/to/store"
425            );
426            Ok(Some(BackendChoice::File(PathBuf::from(path))))
427        }
428        None => Ok(default_store_root().map(BackendChoice::File)),
429    }
430}
431
432/// The directory a [`BackendChoice`] lives in. A `match` rather than an
433/// irrefutable `let`, so a second variant is a compile error here instead of
434/// a silent assumption at every call site.
435pub fn backend_root(choice: &BackendChoice) -> &Path {
436    match choice {
437        BackendChoice::File(p) => p,
438    }
439}
440
441// ── WIT bridge ─────────────────────────────────────────────────────────────
442
443/// `Host` is implemented for `&mut HostState` on *both* interfaces:
444/// `skip_mut_forwarding_impls` suppresses bindgen's blanket `&mut T`
445/// forwarding impls, while both `store::add_to_linker` and
446/// `types::add_to_linker` require `for<'a> D::Data<'a>: Host` — which is
447/// `&'a mut HostState` under `HasSelf<HostState>`.
448impl store::Host for HostState {}
449impl store::Host for &mut HostState {}
450impl types::Host for &mut HostState {}
451
452/// Register both `act:credentials` instances in the linker.
453///
454/// Both, not one: `store` uses types from `types`, so the elaborated world
455/// imports both instances, and a guest importing `act:credentials/store`
456/// fails instantiation on an unregistered `act:credentials/types@0.1.0`. The
457/// interface carries no functions, but the instance must still exist.
458pub fn add_to_linker(linker: &mut Linker<HostState>) -> anyhow::Result<()> {
459    types::add_to_linker::<HostState, HasSelf<HostState>>(linker, |s| s)
460        .map_err(|e| anyhow::anyhow!("failed to add act:credentials/types to linker: {e}"))?;
461    store::add_to_linker::<HostState, HasSelf<HostState>>(linker, |s| s)
462        .map_err(|e| anyhow::anyhow!("failed to add act:credentials/store to linker: {e}"))?;
463    Ok(())
464}
465
466/// Everything the gate below needs, cloned out of the store in one
467/// `Accessor::with` so nothing borrows host state across an await.
468struct GateContext {
469    host: Option<Arc<CredentialHost>>,
470    ceiling: Arc<dyn act_policy::provider::CompiledCeiling>,
471    prompter: Arc<dyn act_policy::consent::ConsentPrompter>,
472    cache: Arc<act_policy::consent::DecisionCache>,
473}
474
475impl GateContext {
476    fn from(accessor: &wasmtime::component::Accessor<HostState, HasSelf<HostState>>) -> Self {
477        accessor.with(|mut access| {
478            let state: &mut HostState = access.get();
479            Self {
480                host: state.credentials.clone(),
481                ceiling: state.credentials_ceiling.clone(),
482                prompter: state.consent_prompter.clone(),
483                cache: state.consent_cache.clone(),
484            }
485        })
486    }
487
488    /// Resolve the `act:credentials` decision for one operation, exactly as
489    /// the fs / http / sockets gates do: classify against the compiled
490    /// ceiling, emit the typed decision record, and let `ask` reach the
491    /// operator through the shared prompter and per-run cache.
492    ///
493    /// Runs *before* the store is touched, so a refusal never depends on
494    /// whether the key exists (design §3.4).
495    async fn allows(&self, key: &str, action: &str, hint: Option<&str>) -> bool {
496        let component = self.host.as_ref().map(|h| h.component().to_string());
497        use crate::audit::{CapDecisionRecord, Decision4, emit_cap_decision};
498
499        let op = act_policy::provider::ResourceOp {
500            cap_id: CAP_CREDENTIALS.to_string(),
501            key: key.to_string(),
502            action: action.to_string(),
503            attrs: serde_json::Value::Null,
504        };
505        let explained = self.ceiling.classify_explained(&op);
506        let mode = self.ceiling.effective_mode().to_string();
507        match explained.decision {
508            act_policy::Decision::Allow => {
509                emit_cap_decision(&CapDecisionRecord::statik(
510                    CAP_CREDENTIALS,
511                    key,
512                    action,
513                    Decision4::Allow,
514                    &mode,
515                    explained.rule,
516                ));
517                true
518            }
519            act_policy::Decision::Deny => {
520                emit_cap_decision(&CapDecisionRecord::statik(
521                    CAP_CREDENTIALS,
522                    key,
523                    action,
524                    Decision4::Deny,
525                    &mode,
526                    explained.rule,
527                ));
528                false
529            }
530            // Deliberately silent until the verdict exists; the record is
531            // emitted below, mirroring `fs_policy::resolve_ask`.
532            act_policy::Decision::Ask => {
533                let has_channel = self.prompter.has_channel();
534                let allowed = self
535                    .cache
536                    .decide_cached(
537                        &*self.prompter,
538                        act_policy::consent::ConsentAsk {
539                            cap_id: CAP_CREDENTIALS.to_string(),
540                            key: key.to_string(),
541                            summary: consent_summary(component.as_deref(), action, key, hint),
542                        },
543                    )
544                    .await;
545                emit_cap_decision(&CapDecisionRecord::answered(
546                    CAP_CREDENTIALS,
547                    key,
548                    allowed,
549                    has_channel,
550                ));
551                allowed
552            }
553        }
554    }
555}
556
557/// Build the one line a human is asked to approve.
558///
559/// Everything but the hint is host-derived (design §5.5: "a descriptor signals
560/// that something is needed; it never instructs the host where to go, what to
561/// run, or what to say"). `component` leads, because §5.5 requires the
562/// component reference be displayed prominently: the whole question is
563/// *which* artifact is asking, and a prompt that only named the key would let
564/// any component borrow another's reputation. It is the reference the
565/// operator themselves passed on the command line, threaded through
566/// `CredentialHost::component`, never anything the guest chose.
567///
568/// The hint is the component's own words, so it is attributed, stripped of
569/// control and bidi-override characters, and truncated — by the shared
570/// [`crate::consent::sanitize_hint`], which `act:consent`'s
571/// [`crate::consent::prompt_line`] also calls, because a security helper with
572/// two copies is two helpers that drift. The prompters escape the finished
573/// line as well (`crate::consent::consent_line`) — this is the inner of two
574/// layers, and it is the one that keeps the guest's text *readable* rather
575/// than exploded into `\u{...}` escapes.
576///
577/// The component **digest** is still missing, and cannot be added from here:
578/// `ConsentAsk` carries no digest for any capability class.
579fn consent_summary(component: Option<&str>, action: &str, key: &str, hint: Option<&str>) -> String {
580    // Same reasoning as `consent::prompt_line`: `key` is the guest's own
581    // store-lookup descriptor and unbounded, so it is truncated here too —
582    // escaping (later, whole-line) stops forgery; this stops a flood.
583    let key = truncate_field(key);
584    let base = match component {
585        Some(c) => format!("{c} requests credential {action}: {key}"),
586        None => format!("credential {action}: {key}"),
587    };
588    match hint.map(sanitize_hint) {
589        Some(h) if !h.is_empty() => format!("{base} — component says: \"{h}\""),
590        _ => base,
591    }
592}
593
594/// Encode a stored field for the guest. WIT types the value as `cbor`
595/// (`list<u8>`), so it is the field's *encoding* that must match its
596/// declared type (design §3.2), not one fixed shape for every field.
597///
598/// `ciborium::into_writer` over the stored `serde_json::Value` does exactly
599/// that with no per-kind branching: `serde_json::Value`'s own `Serialize`
600/// impl calls `serialize_str` for a `std:string` field and `serialize_map`
601/// for a `std:oauth2` one, so ciborium emits CBOR text or a CBOR map to
602/// match — the two encodings §3.2's table names, and nothing else.
603///
604/// A field that is neither a string nor an object is refused rather than
605/// encoded: it is not a shape the design promises the guest, and it is
606/// exactly the case `STORE_UNREADABLE` documents — an externally-materialised
607/// store file (design §5.6) can hold a bare JSON number — so it is reported the
608/// same way, not turned into CBOR the guest has no reason to expect.
609fn to_wit_secret(secret: Secret) -> Result<store::Secret, HostError> {
610    let mut fields = Vec::with_capacity(secret.fields.len());
611    for (name, value) in secret.fields {
612        let json = value.expose();
613        if !(json.is_string() || json.is_object()) {
614            tracing::warn!(field = %name, "credential field is neither string- nor object-shaped");
615            return Err(HostError::Unavailable(STORE_UNREADABLE.into()));
616        }
617        fields.push((name, act_types::cbor::to_cbor(json)));
618    }
619    Ok(store::Secret {
620        kind: secret.kind,
621        fields,
622    })
623}
624
625fn to_wit_info(info: SecretInfo) -> store::SecretInfo {
626    store::SecretInfo {
627        key: info.key,
628        kind: info.kind,
629        description: info.description,
630        // WIT types expiry as `u64`; the record allows any `i64`. A negative
631        // timestamp is not representable, so it is reported as "no expiry"
632        // rather than wrapping into a date centuries away.
633        expires_at: info.expires_at.and_then(|e| u64::try_from(e).ok()),
634    }
635}
636
637/// A run with no credential store configured at all. Distinct from a denial:
638/// nothing was refused, there was simply nowhere to look.
639const NO_STORE: &str = "no credential store is configured for this run";
640
641/// Serve one `list-secrets`, gate included.
642///
643/// Split out of the trait impl so the gate is reachable from a test: the impl
644/// can only be entered with a live `Accessor`, which needs an engine, a linker
645/// and a guest, while a `GateContext` is four `Arc`s anyone can build. The
646/// trait method below is then nothing but "get the context, call this".
647async fn serve_list(
648    ctx: &GateContext,
649    session: Option<&str>,
650) -> Result<Vec<store::SecretInfo>, store::SecretError> {
651    // The listing is scoped to the whole profile, not to one key, so the
652    // gate is asked about the profile — `*` is the only honest key here.
653    if !ctx.allows("*", "list", None).await {
654        return Err(HostError::Denied.to_wit());
655    }
656    let Some(host) = &ctx.host else {
657        return Err(store::SecretError::Unavailable(NO_STORE.into()));
658    };
659    host.list_secrets(session)
660        .map(|infos| infos.into_iter().map(to_wit_info).collect())
661        .map_err(|e| e.to_wit())
662}
663
664/// Serve one `get-secret`, gate included. See [`serve_list`] for why it is a
665/// free function.
666///
667/// The order is load-bearing: the gate resolves *before* the store is touched,
668/// so a refusal cannot depend on whether the key exists (design §3.4 — otherwise
669/// `denied` becomes a probing channel).
670async fn serve_get(
671    ctx: &GateContext,
672    session: &str,
673    want: &store::SecretRequest,
674) -> Result<store::Secret, store::SecretError> {
675    if !ctx.allows(&want.key, "get", want.hint.as_deref()).await {
676        return Err(HostError::Denied.to_wit());
677    }
678    let Some(host) = &ctx.host else {
679        return Err(store::SecretError::Unavailable(NO_STORE.into()));
680    };
681    // Renew before serving, so what crosses is valid *now* (design §5.4). It
682    // happens after the gate: a component refused the class must not be able to
683    // make this host talk to an authorization server.
684    host.refresh_if_due(&want.key, now_unix()).await;
685
686    // `want.kind` is not consulted, and there is nothing it could select
687    // between: retrieval is never filtered by shape (ACT-AUTH §1.1.6). The
688    // component reads the fields it knows by name.
689    let secret = host
690        .get_secret(session, &want.key)
691        .map_err(|e| e.to_wit())?;
692    to_wit_secret(secret).map_err(|e| e.to_wit())
693}
694
695impl store::HostWithStore<HostState> for HasSelf<HostState> {
696    async fn list_secrets(
697        accessor: &wasmtime::component::Accessor<HostState, Self>,
698        session: Option<String>,
699    ) -> Result<Vec<store::SecretInfo>, store::SecretError> {
700        let ctx = GateContext::from(accessor);
701        serve_list(&ctx, session.as_deref()).await
702    }
703
704    async fn get_secret(
705        accessor: &wasmtime::component::Accessor<HostState, Self>,
706        session: String,
707        want: store::SecretRequest,
708    ) -> Result<store::Secret, store::SecretError> {
709        let ctx = GateContext::from(accessor);
710        serve_get(&ctx, &session, &want).await
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717    use act_credentials::backend::file::FileStore;
718    use act_credentials::record::{SecretRecord, SecretValue};
719    use act_credentials::store::CredentialStore;
720    use std::collections::BTreeMap;
721
722    fn host(dir: &std::path::Path) -> CredentialHost {
723        let store = FileStore::new(dir.to_path_buf());
724        let mut fields = BTreeMap::new();
725        fields.insert("acme:token".to_string(), SecretValue::new("tok"));
726        let mut host_only = BTreeMap::new();
727        host_only.insert("std:refresh-token".to_string(), SecretValue::new("rt"));
728        store
729            .put(
730                "comp",
731                "notion",
732                &SecretRecord {
733                    kind: "std:fields".into(),
734                    fields,
735                    host_only,
736                    description: None,
737                    expires_at: None,
738                },
739            )
740            .unwrap();
741        CredentialHost::new(Arc::new(store), "comp".to_string())
742    }
743
744    // ── the capability gate ───────────────────────────────────────────────
745    //
746    // `GateContext` is four `Arc`s and `serve_get` / `serve_list` are free
747    // functions, so the decision path is reachable here without an engine, a
748    // linker or a guest. These are the tests that hold the gate itself; the
749    // ones above hold `CredentialHost` underneath it.
750
751    use act_credentials::store::StoreError;
752    use act_policy::consent::{ConsentAsk, ConsentPrompter, DecisionCache, DenyPrompter};
753    use act_policy::grant::{CapabilityGrant, PolicyMode};
754    use act_policy::provider::CapabilityProvider;
755    use act_policy::providers::credentials::CredentialsProvider;
756    use std::sync::atomic::{AtomicUsize, Ordering};
757
758    /// Wraps a real store and counts reads, so a test can assert that a
759    /// refusal happened *before* the lookup rather than after it.
760    struct CountingStore {
761        inner: FileStore,
762        gets: AtomicUsize,
763        lists: AtomicUsize,
764    }
765
766    impl CredentialStore for CountingStore {
767        fn get(&self, component: &str, key: &str) -> Result<Option<SecretRecord>, StoreError> {
768            self.gets.fetch_add(1, Ordering::SeqCst);
769            self.inner.get(component, key)
770        }
771        fn put(&self, component: &str, key: &str, rec: &SecretRecord) -> Result<(), StoreError> {
772            self.inner.put(component, key, rec)
773        }
774        fn erase(&self, component: &str, key: &str) -> Result<(), StoreError> {
775            self.inner.erase(component, key)
776        }
777        fn list(&self, component: Option<&str>) -> Result<Vec<SecretInfo>, StoreError> {
778            self.lists.fetch_add(1, Ordering::SeqCst);
779            self.inner.list(component)
780        }
781        fn components(&self) -> Result<Vec<String>, StoreError> {
782            self.inner.components()
783        }
784        fn update(
785            &self,
786            component: &str,
787            key: &str,
788            mutate: &mut dyn FnMut(&mut act_credentials::record::SecretRecord),
789        ) -> Result<Option<act_credentials::record::SecretRecord>, StoreError> {
790            self.inner.update(component, key, mutate)
791        }
792    }
793
794    /// Says yes to everything and counts how often it was asked, so a test
795    /// can tell "cached" from "prompted twice".
796    struct AllowPrompter(AtomicUsize);
797
798    #[async_trait::async_trait]
799    impl ConsentPrompter for AllowPrompter {
800        async fn decide(&self, _ask: &ConsentAsk) -> bool {
801            self.0.fetch_add(1, Ordering::SeqCst);
802            true
803        }
804    }
805
806    async fn ceiling(
807        declared: bool,
808        mode: PolicyMode,
809    ) -> Arc<dyn act_policy::provider::CompiledCeiling> {
810        // `Some(&[])` is what `runtime::declared_constraints` returns for a
811        // manifest that carries the class (even bare); `None` is what it
812        // returns for one that does not.
813        let declared: Option<Vec<serde_json::Value>> =
814            if declared { Some(Vec::new()) } else { None };
815        Arc::from(
816            CredentialsProvider
817                .resolve(
818                    CAP_CREDENTIALS,
819                    declared.as_deref(),
820                    &CapabilityGrant {
821                        mode,
822                        allow: vec![],
823                        deny: vec![],
824                    },
825                )
826                .await
827                .expect("resolve"),
828        )
829    }
830
831    /// A gate context over a store that has the `notion` key, so any failure
832    /// below is the gate's doing and not a missing record.
833    fn gate_ctx(
834        dir: &std::path::Path,
835        ceiling: Arc<dyn act_policy::provider::CompiledCeiling>,
836        prompter: Arc<dyn ConsentPrompter>,
837    ) -> (GateContext, Arc<CountingStore>) {
838        let seeded = host(dir); // writes `comp` / `notion` through a FileStore
839        drop(seeded);
840        let store = Arc::new(CountingStore {
841            inner: FileStore::new(dir.to_path_buf()),
842            gets: AtomicUsize::new(0),
843            lists: AtomicUsize::new(0),
844        });
845        let h = Arc::new(CredentialHost::new(store.clone(), "comp".to_string()));
846        h.note_session_opened("s1");
847        (
848            GateContext {
849                host: Some(h),
850                ceiling,
851                prompter,
852                cache: Arc::new(DecisionCache::new()),
853            },
854            store,
855        )
856    }
857
858    fn want(key: &str) -> store::SecretRequest {
859        store::SecretRequest {
860            key: key.to_string(),
861            kind: None,
862            resource: None,
863            scopes: vec![],
864            hint: None,
865        }
866    }
867
868    #[tokio::test(flavor = "current_thread")]
869    async fn an_undeclared_class_is_refused_no_matter_what_was_granted() {
870        // The whole premise of `act:credentials`: a grant cannot widen a
871        // class the artifact never declared.
872        for mode in [PolicyMode::Open, PolicyMode::Allowlist, PolicyMode::Ask] {
873            let c = ceiling(false, mode).await;
874            let dir = tempfile::tempdir().unwrap();
875            let (ctx, store) = gate_ctx(dir.path(), c, Arc::new(DenyPrompter));
876            assert!(!ctx.allows("notion", "get", None).await, "mode {mode:?}");
877            assert!(
878                matches!(
879                    serve_get(&ctx, "s1", &want("notion")).await,
880                    Err(store::SecretError::Denied)
881                ),
882                "mode {mode:?}"
883            );
884            assert_eq!(
885                store.gets.load(Ordering::SeqCst),
886                0,
887                "a refusal must not reach the store — otherwise `denied` timing \
888                 leaks whether the key exists (design §3.4), mode {mode:?}"
889            );
890        }
891    }
892
893    #[tokio::test(flavor = "current_thread")]
894    async fn a_denied_grant_is_refused_even_though_the_class_was_declared() {
895        let dir = tempfile::tempdir().unwrap();
896        let (ctx, store) = gate_ctx(
897            dir.path(),
898            ceiling(true, PolicyMode::Deny).await,
899            Arc::new(DenyPrompter),
900        );
901        assert!(!ctx.allows("notion", "get", None).await);
902        assert!(matches!(
903            serve_get(&ctx, "s1", &want("notion")).await,
904            Err(store::SecretError::Denied)
905        ));
906        assert_eq!(store.gets.load(Ordering::SeqCst), 0);
907    }
908
909    #[tokio::test(flavor = "current_thread")]
910    async fn ask_with_no_prompt_channel_degrades_to_deny() {
911        // `ask` is the default mode and headless runs get `DenyPrompter`, so
912        // this is what an unattended `act call` actually does.
913        let dir = tempfile::tempdir().unwrap();
914        let (ctx, store) = gate_ctx(
915            dir.path(),
916            ceiling(true, PolicyMode::Ask).await,
917            Arc::new(DenyPrompter),
918        );
919        assert!(!ctx.allows("notion", "get", None).await);
920        assert!(matches!(
921            serve_get(&ctx, "s1", &want("notion")).await,
922            Err(store::SecretError::Denied)
923        ));
924        assert_eq!(store.gets.load(Ordering::SeqCst), 0);
925    }
926
927    #[tokio::test(flavor = "current_thread")]
928    async fn an_approved_ask_serves_the_credential_and_is_not_asked_twice() {
929        let dir = tempfile::tempdir().unwrap();
930        let prompter = Arc::new(AllowPrompter(AtomicUsize::new(0)));
931        let (ctx, store) = gate_ctx(
932            dir.path(),
933            ceiling(true, PolicyMode::Ask).await,
934            prompter.clone(),
935        );
936
937        let got = serve_get(&ctx, "s1", &want("notion"))
938            .await
939            .expect("served");
940        assert_eq!(got.kind, "std:fields");
941        assert_eq!(store.gets.load(Ordering::SeqCst), 1);
942
943        // Second request for the same key: the per-run cache answers, so the
944        // human is not re-prompted for a decision they already made.
945        assert!(serve_get(&ctx, "s1", &want("notion")).await.is_ok());
946        assert_eq!(
947            prompter.0.load(Ordering::SeqCst),
948            1,
949            "one prompt per (class, key) per run"
950        );
951    }
952
953    #[tokio::test(flavor = "current_thread")]
954    async fn an_open_grant_on_a_declared_class_needs_no_prompt_at_all() {
955        let dir = tempfile::tempdir().unwrap();
956        let prompter = Arc::new(AllowPrompter(AtomicUsize::new(0)));
957        let (ctx, _store) = gate_ctx(
958            dir.path(),
959            ceiling(true, PolicyMode::Open).await,
960            prompter.clone(),
961        );
962        assert!(serve_get(&ctx, "s1", &want("notion")).await.is_ok());
963        assert_eq!(prompter.0.load(Ordering::SeqCst), 0, "static allow");
964    }
965
966    #[tokio::test(flavor = "current_thread")]
967    async fn a_listing_is_gated_too_and_a_refusal_never_reaches_the_index() {
968        let dir = tempfile::tempdir().unwrap();
969        let (ctx, store) = gate_ctx(
970            dir.path(),
971            ceiling(false, PolicyMode::Open).await,
972            Arc::new(DenyPrompter),
973        );
974        assert!(matches!(
975            serve_list(&ctx, Some("s1")).await,
976            Err(store::SecretError::Denied)
977        ));
978        assert_eq!(store.lists.load(Ordering::SeqCst), 0);
979    }
980
981    #[tokio::test(flavor = "current_thread")]
982    async fn a_run_with_no_store_reports_unavailable_rather_than_denied() {
983        // Nothing was refused — there was simply nowhere to look. Reporting
984        // `denied` would send the operator to fix a grant that is fine.
985        let ctx = GateContext {
986            host: None,
987            ceiling: ceiling(true, PolicyMode::Open).await,
988            prompter: Arc::new(DenyPrompter),
989            cache: Arc::new(DecisionCache::new()),
990        };
991        assert!(matches!(
992            serve_get(&ctx, "s1", &want("notion")).await,
993            Err(store::SecretError::Unavailable(_))
994        ));
995    }
996
997    /// The stored value a corrupt-store test must never see leave the host.
998    const MATERIAL: &str = "987654321";
999
1000    /// A `secrets.json` the way an external secret-materialiser writes one:
1001    /// the field's value as a JSON number, which the CLI's own `set` cannot
1002    /// produce but `jq` / `op` / `kubectl get secret -o json` do without being
1003    /// asked.
1004    fn seed_numeric_value(dir: &std::path::Path) {
1005        std::fs::create_dir_all(dir).unwrap();
1006        std::fs::write(
1007            act_credentials::backend::file::secrets_path(dir),
1008            format!(
1009                r#"{{"entries":{{"comp":{{"notion":{{"kind":"std:fields","fields":{{"acme:token":{MATERIAL}}},"host_only":{{}},"description":null,"expires_at":null}}}}}}}}"#
1010            ),
1011        )
1012        .unwrap();
1013    }
1014
1015    fn ctx_over(
1016        dir: &std::path::Path,
1017        ceiling: Arc<dyn act_policy::provider::CompiledCeiling>,
1018    ) -> GateContext {
1019        let h = Arc::new(CredentialHost::new(
1020            Arc::new(FileStore::new(dir.to_path_buf())),
1021            "comp".to_string(),
1022        ));
1023        h.note_session_opened("s1");
1024        GateContext {
1025            host: Some(h),
1026            ceiling,
1027            prompter: Arc::new(DenyPrompter),
1028            cache: Arc::new(DecisionCache::new()),
1029        }
1030    }
1031
1032    /// The contract `act_sdk::credentials::Secret::as_oauth2` reads, pinned on
1033    /// the host side.
1034    ///
1035    /// This is the property the whole field-type migration exists to establish —
1036    /// that what `act secret set` writes is what the SDK reads — and it is the
1037    /// one no single task tested, because the two ends live in different repos.
1038    /// A literal round trip is not available yet: act-sdk is a sibling checkout
1039    /// and the published 0.14.0 predates its credentials module, so a path
1040    /// dev-dependency would break a lone clone of this repo. So both ends are
1041    /// pinned against the written registry instead — `ACT-CONSTANTS.md` §8.3 —
1042    /// and this is the host half.
1043    ///
1044    /// The encodings are load-bearing in a way that fails **silently**: the SDK
1045    /// treats a member of the wrong CBOR type as absent, so a float expiry reads
1046    /// as "never expires" and a non-array scopes list as "grants nothing",
1047    /// neither of which raises anything anywhere.
1048    #[test]
1049    fn an_oauth2_field_encodes_to_the_map_the_sdk_reads() {
1050        use ciborium::Value;
1051
1052        let secret = Secret {
1053            kind: "std:oauth2".into(),
1054            fields: BTreeMap::from([(
1055                "std:token".to_string(),
1056                SecretValue::new(serde_json::json!({
1057                    "std:access-token": "at",
1058                    "std:expires-at": 1_760_000_000u64,
1059                    "std:scopes": ["repo", "read:org"],
1060                })),
1061            )]),
1062        };
1063
1064        let wit = to_wit_secret(secret).expect("an object field is encodable");
1065        let (name, bytes) = &wit.fields[0];
1066        assert_eq!(name, "std:token");
1067
1068        let decoded: Value = ciborium::from_reader(bytes.as_slice()).expect("valid CBOR");
1069        let Value::Map(members) = decoded else {
1070            panic!("ACT-CONSTANTS 8.1: a std:oauth2 value is a CBOR map, got {decoded:?}");
1071        };
1072        let member = |want: &str| {
1073            members
1074                .iter()
1075                .find(|(k, _)| matches!(k, Value::Text(s) if s == want))
1076                .map_or_else(|| panic!("8.3 registers {want}"), |(_, v)| v.clone())
1077        };
1078
1079        assert!(
1080            matches!(member("std:access-token"), Value::Text(s) if s == "at"),
1081            "8.3: std:access-token is CBOR text"
1082        );
1083        assert!(
1084            matches!(member("std:expires-at"), Value::Integer(i) if u64::try_from(i) == Ok(1_760_000_000)),
1085            "8.3: std:expires-at is a CBOR unsigned integer — a float here reads as 'never expires'"
1086        );
1087        let Value::Array(scopes) = member("std:scopes") else {
1088            panic!("8.3: std:scopes is a CBOR array — anything else reads as 'grants nothing'");
1089        };
1090        assert!(
1091            scopes
1092                .iter()
1093                .all(|s| matches!(s, Value::Text(t) if t == "repo" || t == "read:org")),
1094            "8.3: std:scopes members are CBOR text"
1095        );
1096    }
1097
1098    #[tokio::test(flavor = "current_thread")]
1099    async fn a_store_decode_error_does_not_carry_stored_material_to_the_guest() {
1100        // The phase's central claim, on the one path that used to break it:
1101        // `StoreError::Encoding` wraps serde's message, and serde's
1102        // `invalid type` text quotes the offending scalar. Forwarding it put
1103        // the stored credential in the guest's `unavailable` payload, and the
1104        // guest puts that in a tool result — so the agent read the value.
1105        // Spec §5.6 makes an externally-materialised store file a first-class
1106        // source, which is how a record this shape gets on disk at all.
1107        let dir = tempfile::tempdir().unwrap();
1108        seed_numeric_value(dir.path());
1109
1110        let ctx = ctx_over(dir.path(), ceiling(true, PolicyMode::Open).await);
1111        let Err(store::SecretError::Unavailable(msg)) =
1112            serve_get(&ctx, "s1", &want("notion")).await
1113        else {
1114            panic!("a store that cannot be decoded must report `unavailable`");
1115        };
1116
1117        assert!(
1118            !msg.contains(MATERIAL),
1119            "stored material reached the guest inside the error: {msg}"
1120        );
1121        assert_eq!(
1122            msg, STORE_UNREADABLE,
1123            "the guest gets a host-authored constant, never the store's own words"
1124        );
1125    }
1126
1127    #[tokio::test(flavor = "current_thread")]
1128    async fn a_listing_over_an_undecodable_store_is_host_authored_too() {
1129        // `list` reads the index, which has no field that could hold a value,
1130        // so this is not today's leak — it is what keeps the two error sites
1131        // uniform, so a backend that ever lists from the records cannot
1132        // reintroduce it by inheriting the old `e.to_string()`.
1133        let dir = tempfile::tempdir().unwrap();
1134        std::fs::create_dir_all(dir.path()).unwrap();
1135        std::fs::write(dir.path().join("index.json"), r#"{"version":"one"}"#).unwrap();
1136
1137        let ctx = ctx_over(dir.path(), ceiling(true, PolicyMode::Open).await);
1138        let Err(store::SecretError::Unavailable(msg)) = serve_list(&ctx, Some("s1")).await else {
1139            panic!("an index that cannot be decoded must report `unavailable`");
1140        };
1141        assert_eq!(msg, STORE_UNREADABLE);
1142    }
1143
1144    #[test]
1145    fn a_hit_returns_only_the_revealable_compartment() {
1146        let dir = tempfile::tempdir().unwrap();
1147        let h = host(dir.path());
1148        h.note_session_opened("s1");
1149
1150        let got = h.get_secret("s1", "notion").expect("found");
1151        assert_eq!(got.kind, "std:fields");
1152        let keys: Vec<&String> = got.fields.keys().collect();
1153        assert_eq!(keys, vec!["acme:token"]);
1154    }
1155
1156    #[test]
1157    fn a_miss_is_not_found() {
1158        let dir = tempfile::tempdir().unwrap();
1159        let h = host(dir.path());
1160        h.note_session_opened("s1");
1161        assert!(matches!(
1162            h.get_secret("s1", "absent"),
1163            Err(HostError::NotFound)
1164        ));
1165    }
1166
1167    #[test]
1168    fn a_closed_session_stops_being_served() {
1169        let dir = tempfile::tempdir().unwrap();
1170        let h = host(dir.path());
1171        h.note_session_opened("s1");
1172        h.note_session_closed("s1");
1173        assert!(matches!(
1174            h.get_secret("s1", "notion"),
1175            Err(HostError::InvalidSession)
1176        ));
1177    }
1178
1179    #[test]
1180    fn an_unknown_session_is_rejected() {
1181        let dir = tempfile::tempdir().unwrap();
1182        let h = host(dir.path());
1183        assert!(matches!(
1184            h.get_secret("nope", "notion"),
1185            Err(HostError::InvalidSession)
1186        ));
1187    }
1188
1189    #[test]
1190    fn closing_one_session_does_not_close_another() {
1191        // The bridges keep several sessions open at once (design §8.2), so a
1192        // close must be keyed, not a flush.
1193        let dir = tempfile::tempdir().unwrap();
1194        let h = host(dir.path());
1195        h.note_session_opened("s1");
1196        h.note_session_opened("s2");
1197        h.note_session_closed("s1");
1198        assert!(h.get_secret("s2", "notion").is_ok());
1199        assert!(matches!(
1200            h.get_secret("s1", "notion"),
1201            Err(HostError::InvalidSession)
1202        ));
1203    }
1204
1205    #[test]
1206    fn a_listing_carries_metadata_and_has_no_field_that_could_hold_a_value() {
1207        let dir = tempfile::tempdir().unwrap();
1208        let h = host(dir.path());
1209        h.note_session_opened("s1");
1210
1211        let listed = h.list_secrets(Some("s1")).expect("listed");
1212        assert_eq!(listed.len(), 1);
1213        assert_eq!(listed[0].key, "notion");
1214        assert_eq!(listed[0].kind, "std:fields");
1215        // The whole rendering, not just the fields we assert on: a value
1216        // reaching a listing at all is the failure this guards.
1217        assert!(!format!("{listed:?}").contains("tok"));
1218    }
1219
1220    #[test]
1221    fn a_listing_outside_any_session_is_allowed() {
1222        // `list-secrets` takes `option<string>` precisely so a component can
1223        // inspect its profile before any session exists (design §3.3).
1224        let dir = tempfile::tempdir().unwrap();
1225        let h = host(dir.path());
1226        assert_eq!(h.list_secrets(None).expect("listed").len(), 1);
1227    }
1228
1229    #[test]
1230    fn a_listing_under_a_dead_session_is_rejected() {
1231        let dir = tempfile::tempdir().unwrap();
1232        let h = host(dir.path());
1233        assert!(matches!(
1234            h.list_secrets(Some("nope")),
1235            Err(HostError::InvalidSession)
1236        ));
1237    }
1238
1239    #[test]
1240    fn another_components_profile_is_not_visible() {
1241        // The profile is the boundary (design §2.1): the component name is
1242        // fixed at construction and no argument can reach past it.
1243        let dir = tempfile::tempdir().unwrap();
1244        let h = host(dir.path());
1245        let mut fields = BTreeMap::new();
1246        fields.insert("acme:token".to_string(), SecretValue::new("other"));
1247        FileStore::new(dir.path().to_path_buf())
1248            .put(
1249                "someone-else",
1250                "notion",
1251                &SecretRecord {
1252                    kind: "std:fields".into(),
1253                    fields,
1254                    host_only: BTreeMap::new(),
1255                    description: None,
1256                    expires_at: None,
1257                },
1258            )
1259            .unwrap();
1260
1261        h.note_session_opened("s1");
1262        let got = h.get_secret("s1", "notion").expect("own key still found");
1263        assert_eq!(got.fields["acme:token"].expose_str(), Some("tok"));
1264        assert_eq!(h.list_secrets(Some("s1")).unwrap().len(), 1);
1265    }
1266
1267    #[test]
1268    fn a_hint_cannot_forge_a_second_prompt_line() {
1269        // The hint is guest-authored (design §5.5) and lands in a prompt a
1270        // human is about to answer.
1271        let s = consent_summary(
1272            Some("comp"),
1273            "get",
1274            "notion",
1275            Some("looks fine\nAllow? [y/N] y"),
1276        );
1277        assert!(!s.contains('\n'), "got {s}");
1278        assert!(
1279            s.contains("component says"),
1280            "the guest's words must be attributed, got {s}"
1281        );
1282    }
1283
1284    #[test]
1285    fn a_bidi_override_in_a_hint_is_blanked_not_merely_control_stripped() {
1286        // `char::is_control` is category Cc only and would let every one of
1287        // these through, leaving a terminal displaying a different string
1288        // than the component actually supplied.
1289        for sneaky in ['\u{202e}', '\u{2066}', '\u{200f}', '\u{2028}'] {
1290            let s = consent_summary(
1291                Some("comp"),
1292                "get",
1293                "notion",
1294                Some(&format!("ok{sneaky}reversed")),
1295            );
1296            assert!(!s.contains(sneaky), "U+{:04X} survived: {s}", sneaky as u32);
1297        }
1298    }
1299
1300    #[test]
1301    fn a_long_hint_is_truncated_rather_than_flooding_the_prompt() {
1302        let s = consent_summary(Some("comp"), "get", "notion", Some(&"a".repeat(500)));
1303        assert!(s.chars().count() < 220, "got {} chars", s.chars().count());
1304        assert!(s.contains('…'));
1305    }
1306
1307    #[test]
1308    fn the_prompt_names_the_component_asking_not_only_the_key() {
1309        // Spec §5.5: the component reference must be displayed prominently.
1310        // Without it a human is approving "some component wants notion-work".
1311        let s = consent_summary(
1312            Some("ghcr.io/actpkg/notion@0.1.0"),
1313            "get",
1314            "notion-work",
1315            None,
1316        );
1317        assert!(s.starts_with("ghcr.io/actpkg/notion@0.1.0"), "got {s}");
1318        assert!(s.contains("notion-work"), "got {s}");
1319    }
1320
1321    #[test]
1322    fn no_hint_leaves_the_prompt_host_authored_end_to_end() {
1323        let s = consent_summary(None, "get", "notion", None);
1324        assert_eq!(s, "credential get: notion");
1325    }
1326
1327    #[test]
1328    fn a_megabyte_long_key_is_truncated_rather_than_flooding_the_prompt() {
1329        // M5: `key` is the store lookup key as the guest asked for it — the
1330        // same unbounded, guest-authored shape as `consent::prompt_line`'s
1331        // `key`, and fixed by the same shared `consent::truncate_field`.
1332        let huge_key = "x".repeat(1_000_000);
1333        let s = consent_summary(Some("comp"), "get", &huge_key, None);
1334        assert!(
1335            s.chars().count() < 200,
1336            "expected the key to be truncated, got {} chars",
1337            s.chars().count()
1338        );
1339        assert!(s.contains('…'), "got {s}");
1340        assert!(
1341            s.contains("comp requests credential get:"),
1342            "the rest of the line must still render normally, got {s}"
1343        );
1344    }
1345
1346    #[test]
1347    fn a_value_crosses_the_boundary_as_cbor_not_as_a_bare_string() {
1348        // The guest decodes `secret-fields` with the same dCBOR reader it
1349        // uses for tool arguments; a raw string would decode to garbage.
1350        let dir = tempfile::tempdir().unwrap();
1351        let h = host(dir.path());
1352        h.note_session_opened("s1");
1353        let wit = to_wit_secret(h.get_secret("s1", "notion").unwrap()).unwrap();
1354
1355        assert_eq!(wit.kind, "std:fields");
1356        assert_eq!(wit.fields.len(), 1);
1357        let (name, bytes) = &wit.fields[0];
1358        assert_eq!(name, "acme:token");
1359        let decoded: String = act_types::cbor::from_cbor(bytes).expect("dCBOR text string");
1360        assert_eq!(decoded, "tok");
1361    }
1362
1363    #[test]
1364    fn an_object_field_crosses_the_boundary_as_a_cbor_map() {
1365        // The other half of the mapping the test above pins for a string:
1366        // `std:oauth2`'s field value is itself a JSON object, so the guest
1367        // must see a CBOR map for it, not text.
1368        let dir = tempfile::tempdir().unwrap();
1369        let store = FileStore::new(dir.path().to_path_buf());
1370        let mut fields = BTreeMap::new();
1371        fields.insert(
1372            "std:token".to_string(),
1373            SecretValue::new(serde_json::json!({
1374                "std:access-token": "at",
1375                "std:scopes": ["repo"],
1376            })),
1377        );
1378        store
1379            .put(
1380                "comp",
1381                "gh",
1382                &SecretRecord {
1383                    kind: "std:oauth2".into(),
1384                    fields,
1385                    host_only: BTreeMap::new(),
1386                    description: None,
1387                    expires_at: None,
1388                },
1389            )
1390            .unwrap();
1391        let h = CredentialHost::new(Arc::new(store), "comp".to_string());
1392        h.note_session_opened("s1");
1393
1394        let wit = to_wit_secret(h.get_secret("s1", "gh").unwrap()).unwrap();
1395        assert_eq!(wit.kind, "std:oauth2");
1396        assert_eq!(wit.fields.len(), 1);
1397        let (name, bytes) = &wit.fields[0];
1398        assert_eq!(name, "std:token");
1399
1400        let decoded = act_types::cbor::cbor_to_json(bytes).expect("dCBOR map");
1401        assert!(decoded.is_object(), "expected a CBOR map, got {decoded:?}");
1402        assert_eq!(decoded["std:access-token"], "at");
1403    }
1404
1405    #[test]
1406    fn a_field_that_is_neither_string_nor_object_is_refused_not_encoded() {
1407        // A `std:string` field holding a bare number — the shape an external
1408        // secret-materialiser writes without being asked (design §5.6), and
1409        // exactly the case `STORE_UNREADABLE` exists to name. The guard still
1410        // refuses it; only the object case above is new.
1411        let mut fields = BTreeMap::new();
1412        fields.insert("acme:token".to_string(), SecretValue::new(987654321));
1413        let secret = Secret {
1414            kind: "std:string".into(),
1415            fields,
1416        };
1417        let Err(HostError::Unavailable(msg)) = to_wit_secret(secret) else {
1418            panic!("a non-string, non-object field must be refused, not encoded");
1419        };
1420        assert_eq!(msg, STORE_UNREADABLE);
1421    }
1422
1423    #[test]
1424    fn every_host_error_has_a_distinct_wit_variant() {
1425        // A collapse here would report a missing credential as a policy
1426        // refusal, sending the user to fix the wrong thing (design §3.4).
1427        assert!(matches!(
1428            HostError::NotFound.to_wit(),
1429            store::SecretError::NotFound
1430        ));
1431        assert!(matches!(
1432            HostError::Denied.to_wit(),
1433            store::SecretError::Denied
1434        ));
1435        assert!(matches!(
1436            HostError::InvalidSession.to_wit(),
1437            store::SecretError::InvalidSession
1438        ));
1439        match HostError::Unavailable("disk gone".into()).to_wit() {
1440            store::SecretError::Unavailable(d) => assert_eq!(d, "disk gone"),
1441            other => panic!("got {other:?}"),
1442        }
1443    }
1444
1445    #[test]
1446    fn a_negative_expiry_reads_as_no_expiry_rather_than_a_far_future_date() {
1447        let info = to_wit_info(SecretInfo {
1448            key: "k".into(),
1449            kind: "std:fields".into(),
1450            description: None,
1451            expires_at: Some(-1),
1452        });
1453        assert_eq!(info.expires_at, None);
1454
1455        let ok = to_wit_info(SecretInfo {
1456            key: "k".into(),
1457            kind: "std:fields".into(),
1458            description: Some("note".into()),
1459            expires_at: Some(1_800_000_000),
1460        });
1461        assert_eq!(ok.expires_at, Some(1_800_000_000));
1462        assert_eq!(ok.description.as_deref(), Some("note"));
1463    }
1464}
1465
1466/// Silent refresh, driven through `CredentialHost` with a refresher that
1467/// answers from memory.
1468///
1469/// No network and no OAuth: what is under test is the runtime's half — when it
1470/// decides to renew, what it writes, what it leaves alone, and what it does
1471/// when renewal is impossible. The protocol half lives in `act-cli` and is
1472/// tested there against a mock authorization server.
1473#[cfg(test)]
1474mod refresh_tests {
1475    use super::*;
1476    use act_credentials::backend::file::FileStore;
1477    use act_credentials::record::{SecretRecord, SecretValue};
1478    use std::sync::atomic::{AtomicUsize, Ordering};
1479
1480    const NOW: u64 = 1_700_000_000;
1481
1482    /// Hands back a fixed token and counts how often it was asked.
1483    struct Canned {
1484        calls: AtomicUsize,
1485        rotates: bool,
1486    }
1487
1488    #[async_trait::async_trait]
1489    impl CredentialRefresher for Canned {
1490        async fn refresh(&self, req: RefreshRequest<'_>) -> Result<Refreshed, String> {
1491            // Yield before counting, so a caller that is mid-refresh lets every
1492            // other one run. Without this the first task finishes without ever
1493            // suspending, the rest find a fresh credential at the cheap check
1494            // outside the lock, and a test of the lock proves nothing about it.
1495            tokio::task::yield_now().await;
1496            self.calls.fetch_add(1, Ordering::SeqCst);
1497            assert_eq!(req.issuer, "https://as.example.com");
1498            assert_eq!(req.refresh_token, "old-refresh");
1499            Ok(Refreshed {
1500                access_token: "new-access".into(),
1501                expires_at: Some(req.now + 3600),
1502                scopes: vec!["read".into()],
1503                refresh_token: self.rotates.then(|| "new-refresh".to_string()),
1504            })
1505        }
1506    }
1507
1508    struct Refusing;
1509
1510    #[async_trait::async_trait]
1511    impl CredentialRefresher for Refusing {
1512        async fn refresh(&self, _: RefreshRequest<'_>) -> Result<Refreshed, String> {
1513            Err("the authorization server refused".into())
1514        }
1515    }
1516
1517    fn record(expires_at: u64, with_host_only: bool) -> SecretRecord {
1518        let mut fields = std::collections::BTreeMap::new();
1519        fields.insert(
1520            "acme:token".to_string(),
1521            SecretValue::new(serde_json::json!({
1522                "std:access-token": "old-access",
1523                "std:expires-at": expires_at,
1524                "std:scopes": ["read"],
1525            })),
1526        );
1527        // A sibling, so "refresh touches one field" is a claim with something
1528        // to be false about.
1529        fields.insert("acme:tenant".to_string(), SecretValue::new("tenant-42"));
1530        let mut host_only = std::collections::BTreeMap::new();
1531        if with_host_only {
1532            host_only.insert(
1533                issuer_slot("acme:token"),
1534                SecretValue::new("https://as.example.com"),
1535            );
1536            host_only.insert(
1537                refresh_token_slot("acme:token"),
1538                SecretValue::new("old-refresh"),
1539            );
1540        }
1541        SecretRecord {
1542            kind: "std:fields".into(),
1543            fields,
1544            host_only,
1545            description: None,
1546            expires_at: Some(expires_at as i64),
1547        }
1548    }
1549
1550    fn host_with(
1551        dir: &std::path::Path,
1552        rec: SecretRecord,
1553        refresher: Arc<dyn CredentialRefresher>,
1554    ) -> CredentialHost {
1555        let store = FileStore::new(dir.to_path_buf());
1556        store.put("comp", "default", &rec).unwrap();
1557        CredentialHost::new(Arc::new(store), "comp".to_string()).with_refresher(refresher)
1558    }
1559
1560    #[tokio::test]
1561    async fn a_near_expiry_field_is_renewed_and_its_siblings_are_not() {
1562        let dir = tempfile::tempdir().unwrap();
1563        let canned = Arc::new(Canned {
1564            calls: AtomicUsize::new(0),
1565            rotates: true,
1566        });
1567        let host = host_with(dir.path(), record(NOW + 10, true), canned.clone());
1568
1569        host.refresh_if_due("default", NOW).await;
1570
1571        host.note_session_opened("s1");
1572        let served = host.get_secret("s1", "default").unwrap();
1573        let token = served.fields["acme:token"].expose().clone();
1574        assert_eq!(token["std:access-token"], "new-access");
1575        assert_eq!(token["std:expires-at"], serde_json::json!(NOW + 3600));
1576        assert_eq!(
1577            served.fields["acme:tenant"].expose_str(),
1578            Some("tenant-42"),
1579            "a sibling was never in scope"
1580        );
1581        assert_eq!(canned.calls.load(Ordering::SeqCst), 1);
1582    }
1583
1584    #[tokio::test]
1585    async fn a_rotated_refresh_token_replaces_the_stored_one_and_never_leaves() {
1586        let dir = tempfile::tempdir().unwrap();
1587        let host = host_with(
1588            dir.path(),
1589            record(NOW + 10, true),
1590            Arc::new(Canned {
1591                calls: AtomicUsize::new(0),
1592                rotates: true,
1593            }),
1594        );
1595
1596        host.refresh_if_due("default", NOW).await;
1597
1598        let stored = FileStore::new(dir.path().to_path_buf())
1599            .get("comp", "default")
1600            .unwrap()
1601            .unwrap();
1602        assert_eq!(
1603            stored.host_only[&refresh_token_slot("acme:token")].expose_str(),
1604            Some("new-refresh"),
1605            "a rotating server invalidates the old; keeping it kills the next refresh"
1606        );
1607        // And the compartment still does not cross to a component.
1608        let projected = serde_json::to_string(
1609            &stored
1610                .project()
1611                .fields
1612                .iter()
1613                .map(|(k, v)| (k.clone(), v.expose().clone()))
1614                .collect::<std::collections::BTreeMap<_, _>>(),
1615        )
1616        .unwrap();
1617        assert!(!projected.contains("new-refresh"), "{projected}");
1618        assert!(!projected.contains("old-refresh"), "{projected}");
1619    }
1620
1621    #[tokio::test]
1622    async fn a_server_that_rotates_nothing_leaves_the_stored_refresh_token() {
1623        let dir = tempfile::tempdir().unwrap();
1624        let host = host_with(
1625            dir.path(),
1626            record(NOW + 10, true),
1627            Arc::new(Canned {
1628                calls: AtomicUsize::new(0),
1629                rotates: false,
1630            }),
1631        );
1632        host.refresh_if_due("default", NOW).await;
1633
1634        let stored = FileStore::new(dir.path().to_path_buf())
1635            .get("comp", "default")
1636            .unwrap()
1637            .unwrap();
1638        assert_eq!(
1639            stored.host_only[&refresh_token_slot("acme:token")].expose_str(),
1640            Some("old-refresh"),
1641            "absent means keep, not clear"
1642        );
1643    }
1644
1645    #[tokio::test]
1646    async fn a_credential_with_life_left_is_not_touched() {
1647        let dir = tempfile::tempdir().unwrap();
1648        let canned = Arc::new(Canned {
1649            calls: AtomicUsize::new(0),
1650            rotates: true,
1651        });
1652        let host = host_with(dir.path(), record(NOW + 86_400, true), canned.clone());
1653
1654        host.refresh_if_due("default", NOW).await;
1655
1656        assert_eq!(
1657            canned.calls.load(Ordering::SeqCst),
1658            0,
1659            "renewing a healthy token spends a rotation for nothing"
1660        );
1661        host.note_session_opened("s1");
1662        let served = host.get_secret("s1", "default").unwrap();
1663        assert_eq!(
1664            served.fields["acme:token"].expose()["std:access-token"],
1665            "old-access"
1666        );
1667    }
1668
1669    #[tokio::test]
1670    async fn a_refusal_leaves_the_credential_and_serves_it() {
1671        // A renewal that cannot happen must not become a failed tool call: the
1672        // skew is a margin, not an expiry, and the token is usually still good.
1673        let dir = tempfile::tempdir().unwrap();
1674        let host = host_with(dir.path(), record(NOW + 10, true), Arc::new(Refusing));
1675
1676        host.refresh_if_due("default", NOW).await;
1677
1678        host.note_session_opened("s1");
1679        let served = host.get_secret("s1", "default").unwrap();
1680        assert_eq!(
1681            served.fields["acme:token"].expose()["std:access-token"],
1682            "old-access",
1683            "the stored value stands"
1684        );
1685    }
1686
1687    #[tokio::test]
1688    async fn a_credential_with_no_issuer_recorded_is_served_as_it_is() {
1689        // Provisioned by hand from a token someone else obtained: there is
1690        // nothing to renew with, and that is not an error.
1691        let dir = tempfile::tempdir().unwrap();
1692        let canned = Arc::new(Canned {
1693            calls: AtomicUsize::new(0),
1694            rotates: true,
1695        });
1696        let host = host_with(dir.path(), record(NOW + 10, false), canned.clone());
1697
1698        host.refresh_if_due("default", NOW).await;
1699
1700        assert_eq!(canned.calls.load(Ordering::SeqCst), 0);
1701        host.note_session_opened("s1");
1702        assert!(host.get_secret("s1", "default").is_ok());
1703    }
1704
1705    #[tokio::test]
1706    async fn concurrent_calls_renew_once() {
1707        // Two live sessions against one upstream is routine. Without the
1708        // per-key lock and the re-read after it, both refresh; with rotation
1709        // the second invalidates the first and the user is sent back to
1710        // `act login` with nothing to explain it.
1711        let dir = tempfile::tempdir().unwrap();
1712        let canned = Arc::new(Canned {
1713            calls: AtomicUsize::new(0),
1714            rotates: true,
1715        });
1716        let host = Arc::new(host_with(
1717            dir.path(),
1718            record(NOW + 10, true),
1719            canned.clone(),
1720        ));
1721
1722        let mut tasks = Vec::new();
1723        for _ in 0..6 {
1724            let host = host.clone();
1725            tasks.push(tokio::spawn(async move {
1726                host.refresh_if_due("default", NOW).await;
1727            }));
1728        }
1729        for t in tasks {
1730            t.await.unwrap();
1731        }
1732
1733        assert_eq!(
1734            canned.calls.load(Ordering::SeqCst),
1735            1,
1736            "every task passed the cheap check before the first write landed, so \
1737             it is the re-read after the lock that makes the rest no-ops"
1738        );
1739    }
1740}