Skip to main content

act_runtime/consent/
gate.rs

1//! Host side of `act:consent/consent-authority` — the gate a component asks
2//! before taking an action the host cannot see it taking.
3//!
4//! ## Two layers, the same shape as `act:credentials`
5//!
6//! [`ConsentGate`] is the whole of the decision logic. It owns no wasmtime
7//! types, so ACT-CONSENT.md §4's procedure is unit-testable against real
8//! compiled ceilings with no engine, no linker and no guest.
9//!
10//! Around it sits the generated-trait bridge. `request` is an `async func` in
11//! WIT, so bindgen lowers it through `func_wrap_concurrent`: the generated
12//! [`consent_authority::HostWithStore`] method is an **associated function
13//! taking a [`wasmtime::component::Accessor`]**, not a method on `&self`, and
14//! the impl target is the `HasData` marker rather than `HostState`. The
15//! bridge reaches host state through the accessor, decodes the request, and
16//! calls the gate. `credentials.rs`'s module header explains the same shape at
17//! greater length.
18//!
19//! ## Why refusals are indistinguishable
20//!
21//! Every path here returns `deny` — undeclared, denied by grant, refused by a
22//! human, no channel to ask on. That is ACT-CONSENT.md §8.4: a component that
23//! could tell those apart could map the operator's policy by varying its
24//! requests. The distinction lives in the audit trail, which the operator
25//! reads and the component cannot.
26
27use std::collections::BTreeMap;
28use std::sync::Arc;
29
30use act_policy::provider::CompiledCeiling;
31use wasmtime::component::{HasSelf, Linker};
32
33use crate::bindings::act::consent::{consent_authority, types};
34use crate::store::HostState;
35
36/// The host's answer, straight from the WIT enum. Not a host-side copy: one
37/// type means the bridge cannot lower a verdict into the wrong variant, and
38/// §8.4's "every refusal is the same value" is a property of the type rather
39/// than of a conversion someone has to keep right.
40pub(crate) use types::Decision;
41
42/// The sub-operation recorded for a consent decision. Consent has no
43/// sub-operations — the class *is* the action — so this is a constant, and it
44/// exists only to fill the audit's action column with something.
45///
46/// It reaches the record on the two statically-decided paths. The `ask` path
47/// goes through `CapDecisionRecord::answered`, which hardcodes
48/// `action: String::new()` for every capability class, so a consent decision
49/// taken by a human is recorded with an empty action like any other. That is
50/// pre-existing shared behaviour, not something this class chose.
51const ACTION: &str = "request";
52
53/// Every consent decision is a semantic-class decision, and must never fold
54/// into the tool call's rollup — see `CapDecisionRecord::never_rollup`'s doc.
55/// A free function rather than inlining `never_rollup: true` at each of the
56/// five call sites in `decide`: it also documents, in one place, why this
57/// gate never leaves the field at its default.
58///
59/// `never_rollup` is a plain field, not a constructor parameter on `statik` /
60/// `statik_with_reason` / `answered` — those are shared with every physical
61/// provider, which must keep defaulting to `false`, so this crate sets it
62/// after construction instead of widening the shared constructors' surface
63/// for one caller.
64fn mark_never_rollup(
65    mut record: crate::audit::CapDecisionRecord,
66) -> crate::audit::CapDecisionRecord {
67    record.never_rollup = true;
68    record
69}
70
71/// One component run's semantic-authorization gate.
72///
73/// Assembled per call from [`HostState`], which is why the shared pieces are
74/// `Arc`s rather than owned: `cache` in particular must be the run's one
75/// cache, or §5's "remember the decision for at least the component run"
76/// would hold only for the length of a single call, and a component could
77/// wear a human down by asking again.
78pub(crate) struct ConsentGate {
79    /// Every declared class the host does not wire interception for. **A miss
80    /// here is what "undeclared" means** — see [`ConsentGate::decide`].
81    ///
82    /// `create_store` builds it as every resolved ceiling minus
83    /// `PHYSICALLY_INTERCEPTED`, so the four classes the host enforces by
84    /// interception are absent and a consent request naming one of them is
85    /// refused. That is deliberate: those already have a gate on the
86    /// boundary, and a second door that could answer "allow" for them would
87    /// be either redundant or a way around the first.
88    semantic_ceilings: Arc<BTreeMap<String, Arc<dyn CompiledCeiling>>>,
89    prompter: Arc<dyn act_policy::consent::ConsentPrompter>,
90    cache: Arc<act_policy::consent::DecisionCache>,
91    /// The reference the operator supplied for this component, for the prompt
92    /// line. Never a name the guest chose (ACT-CONSENT.md §5).
93    component: String,
94}
95
96impl ConsentGate {
97    fn from_accessor(
98        accessor: &wasmtime::component::Accessor<HostState, HasSelf<HostState>>,
99    ) -> Self {
100        accessor.with(|mut access| {
101            let state: &mut HostState = access.get();
102            Self {
103                semantic_ceilings: state.semantic_ceilings.clone(),
104                prompter: state.consent_prompter.clone(),
105                cache: state.consent_cache.clone(),
106                component: state.component_ref.clone(),
107            }
108        })
109    }
110
111    /// The decision procedure of ACT-CONSENT.md §4, in order.
112    ///
113    /// Step 1 has two clauses — the class is empty, **or** it is absent from
114    /// the component's declared capabilities — and both run before anything
115    /// else and before the prompter exists as a possibility, so a refusal
116    /// depends on the manifest and on nothing else.
117    ///
118    /// A class the component never declared has no ceiling in
119    /// `semantic_ceilings` at all: that deny is on the **map miss**, not on a
120    /// fallback `resolve(class, None, ..)`. Resolving a ceiling for it would
121    /// reach the same verdict and lose the reason, which §7.2 requires the audit
122    /// to carry. The empty class gets its own check because the map miss does
123    /// not reliably cover it — see the comment on that check.
124    ///
125    /// Steps 2 to 4 are the compiled ceiling's own `classify_explained`: deny
126    /// constraints first, then the declaration, then the grant mode. Consent
127    /// adds nothing to that order — it is the same intersection every physical
128    /// class is decided by, which is the whole point of routing semantic
129    /// classes through the ordinary policy surface.
130    pub(crate) async fn decide(
131        &self,
132        class: &str,
133        key: &str,
134        summary: &str,
135        args: &serde_json::Value,
136    ) -> Decision {
137        use crate::audit::{CapDecisionRecord, Decision4, emit_cap_decision};
138
139        // §4 step 1, first clause: an empty class. Checked rather than left to
140        // the map lookup below, which would catch it only for as long as `""`
141        // happens to be absent. It need not be: §3.3 forbids declaring a
142        // non-concrete class, but nothing enforces §3.3, so a manifest with
143        // `[std.capabilities.""]` gets a real ceiling row from
144        // `resolve_ceilings` and would then run the ordinary grant path — up
145        // to and including asking a human a question that names no class.
146        if class.is_empty() {
147            emit_cap_decision(&mark_never_rollup(CapDecisionRecord::statik_with_reason(
148                class,
149                key,
150                ACTION,
151                Decision4::Deny,
152                "deny",
153                None,
154                Some("empty capability class"),
155            )));
156            return Decision::Deny;
157        }
158
159        let Some(ceiling) = self.semantic_ceilings.get(class) else {
160            // Two different reasons produce the identical map miss.
161            // `PHYSICALLY_INTERCEPTED` classes (wasi:http and friends) are
162            // never in `semantic_ceilings` at all, declared or not — see that
163            // constant's doc — so a request naming one lands here even when
164            // the manifest genuinely declares it. "class not declared" would
165            // be false in that case: the class is very much declared, it is
166            // just enforced somewhere else. §7.2 requires the audit reason to
167            // be true, so the two causes get two reasons; the decision itself
168            // (deny, without consulting anyone) is identical either way.
169            let reason = if act_policy::ceilings::PHYSICALLY_INTERCEPTED.contains(&class) {
170                "class is enforced on the boundary, not through consent"
171            } else {
172                "class not declared in act:component"
173            };
174            emit_cap_decision(&mark_never_rollup(CapDecisionRecord::statik_with_reason(
175                class,
176                key,
177                ACTION,
178                Decision4::Deny,
179                "deny",
180                None,
181                Some(reason),
182            )));
183            return Decision::Deny;
184        };
185
186        let op = act_policy::provider::ResourceOp {
187            cap_id: class.to_string(),
188            key: key.to_string(),
189            action: ACTION.to_string(),
190            attrs: args.clone(),
191        };
192        let explained = ceiling.classify_explained(&op);
193        let mode = ceiling.effective_mode().to_string();
194
195        match explained.decision {
196            act_policy::Decision::Allow => {
197                emit_cap_decision(&mark_never_rollup(CapDecisionRecord::statik(
198                    class,
199                    key,
200                    ACTION,
201                    Decision4::Allow,
202                    &mode,
203                    explained.rule,
204                )));
205                Decision::Allow
206            }
207            act_policy::Decision::Deny => {
208                emit_cap_decision(&mark_never_rollup(CapDecisionRecord::statik(
209                    class,
210                    key,
211                    ACTION,
212                    Decision4::Deny,
213                    &mode,
214                    explained.rule,
215                )));
216                Decision::Deny
217            }
218            // Deliberately silent until the verdict exists; the record is emitted
219            // below, mirroring `fs_policy::resolve_ask`.
220            act_policy::Decision::Ask => {
221                let has_channel = self.prompter.has_channel();
222                let allowed = self
223                    .cache
224                    .decide_cached(
225                        &*self.prompter,
226                        act_policy::consent::ConsentAsk {
227                            cap_id: class.to_string(),
228                            key: key.to_string(),
229                            summary: crate::consent::prompt_line(
230                                Some(&self.component),
231                                class,
232                                key,
233                                summary,
234                            ),
235                        },
236                    )
237                    .await;
238                emit_cap_decision(&mark_never_rollup(CapDecisionRecord::answered(
239                    class,
240                    key,
241                    allowed,
242                    has_channel,
243                )));
244                if allowed {
245                    Decision::Allow
246                } else {
247                    Decision::Deny
248                }
249            }
250        }
251    }
252
253    #[cfg(test)]
254    fn for_test(
255        semantic_ceilings: BTreeMap<String, Arc<dyn CompiledCeiling>>,
256        prompter: Arc<dyn act_policy::consent::ConsentPrompter>,
257    ) -> Self {
258        Self {
259            semantic_ceilings: Arc::new(semantic_ceilings),
260            prompter,
261            cache: Arc::new(act_policy::consent::DecisionCache::new()),
262            component: "./test.wasm".to_string(),
263        }
264    }
265}
266
267/// Read the request's `args` as policy dimensions.
268///
269/// Anything that is not a CBOR map carries no dimensions rather than being an
270/// error (ACT-CONSENT.md §2.2): `key` still matches, and the guest learns
271/// nothing from a malformed blob that it would not have learned from an empty
272/// one. Undecodable bytes take the same path — refusing here would turn a
273/// component's encoding bug into a distinguishable outcome, which §8.4
274/// forbids.
275fn args_to_attrs(args: &[u8]) -> serde_json::Value {
276    match act_types::cbor::cbor_to_json(args) {
277        Ok(v @ serde_json::Value::Object(_)) => v,
278        _ => serde_json::Value::Null,
279    }
280}
281
282// ── WIT bridge ─────────────────────────────────────────────────────────────
283
284/// Both interfaces get a `Host` impl on `&mut HostState`, and on nothing
285/// else, for the same reason `act:credentials` does: `skip_mut_forwarding_impls`
286/// suppresses bindgen's blanket `&mut T` forwarding impls, and `add_to_linker`
287/// requires `for<'a> D::Data<'a>: Host` — which is `&'a mut HostState` under
288/// `HasSelf<HostState>`, and that is the whole of the requirement.
289impl consent_authority::Host for &mut HostState {}
290impl types::Host for &mut HostState {}
291
292/// Register both `act:consent` instances in the linker.
293///
294/// Both, not one: `consent-authority` uses types from `types`, so the
295/// elaborated world imports both instances, and a guest importing
296/// `act:consent/consent-authority` fails instantiation on an unregistered
297/// `act:consent/types@0.1.0`. The interface carries no functions, but the
298/// instance must still exist.
299pub(crate) fn add_to_linker(linker: &mut Linker<HostState>) -> anyhow::Result<()> {
300    types::add_to_linker::<HostState, HasSelf<HostState>>(linker, |s| s)
301        .map_err(|e| anyhow::anyhow!("failed to add act:consent/types to linker: {e}"))?;
302    consent_authority::add_to_linker::<HostState, HasSelf<HostState>>(linker, |s| s).map_err(
303        |e| anyhow::anyhow!("failed to add act:consent/consent-authority to linker: {e}"),
304    )?;
305    Ok(())
306}
307
308impl consent_authority::HostWithStore<HostState> for HasSelf<HostState> {
309    /// `meta` is accepted and not read. Its job in ACT-CONSENT.md §7.1 is to
310    /// anchor the decision to a session, and the record this emits is already
311    /// anchored: `emit_cap_decision` writes an event inside the in-flight
312    /// `act.tool_call` span, which carries `act.session.id`. It never selects
313    /// policy — that is keyed on the class and the key alone — so reading it
314    /// here could only widen what a guest-supplied value can influence.
315    async fn request(
316        accessor: &wasmtime::component::Accessor<HostState, Self>,
317        req: consent_authority::ConsentRequest,
318        _meta: consent_authority::Metadata,
319    ) -> Decision {
320        let gate = ConsentGate::from_accessor(accessor);
321        let attrs = args_to_attrs(&req.args);
322        gate.decide(&req.class, &req.key, &req.summary, &attrs)
323            .await
324    }
325}
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use act_policy::consent::{ConsentAsk, ConsentPrompter};
330    use act_policy::grant::PolicyMode;
331    use serde_json::json;
332    use std::collections::BTreeMap;
333    use std::sync::Arc;
334    use std::sync::atomic::{AtomicUsize, Ordering};
335
336    /// A prompter that must never run. Its `decide` panics, and that panic is
337    /// the assertion: a test using it asserts the operator was not consulted.
338    struct PanickingPrompter;
339
340    #[async_trait::async_trait]
341    impl ConsentPrompter for PanickingPrompter {
342        async fn decide(&self, ask: &ConsentAsk) -> bool {
343            panic!("the operator must not be consulted, but was asked: {ask:?}");
344        }
345    }
346
347    struct CountingPrompter {
348        allow: bool,
349        calls: AtomicUsize,
350    }
351
352    impl CountingPrompter {
353        fn allowing() -> Self {
354            Self {
355                allow: true,
356                calls: AtomicUsize::new(0),
357            }
358        }
359
360        fn calls(&self) -> usize {
361            self.calls.load(Ordering::SeqCst)
362        }
363    }
364
365    #[async_trait::async_trait]
366    impl ConsentPrompter for CountingPrompter {
367        async fn decide(&self, _ask: &ConsentAsk) -> bool {
368            self.calls.fetch_add(1, Ordering::SeqCst);
369            self.allow
370        }
371    }
372
373    /// One declared semantic class under a bare grant of `mode`.
374    async fn ceilings_declaring(
375        class: &str,
376        declared: &[serde_json::Value],
377        mode: PolicyMode,
378    ) -> BTreeMap<String, Arc<dyn act_policy::provider::CompiledCeiling>> {
379        ceilings_granted(
380            class,
381            declared,
382            act_policy::grant::CapabilityGrant {
383                mode,
384                allow: Vec::new(),
385                deny: Vec::new(),
386            },
387        )
388        .await
389    }
390
391    /// One declared semantic class, resolved through the real provider
392    /// registry against `grant` — the same call `create_store` makes, so
393    /// these tests hold the ceiling the host would actually build rather
394    /// than a stand-in.
395    ///
396    /// The `PHYSICALLY_INTERCEPTED` filter below deliberately mirrors
397    /// `create_store`, which is the copy that actually enforces "a
398    /// physically-enforced class is not reachable through consent". This one
399    /// only reproduces the shape of the map the gate is handed.
400    async fn ceilings_granted(
401        class: &str,
402        declared: &[serde_json::Value],
403        grant: act_policy::grant::CapabilityGrant,
404    ) -> BTreeMap<String, Arc<dyn act_policy::provider::CompiledCeiling>> {
405        use act_policy::grant::{GrantPolicy, PolicyMode};
406
407        let policy = GrantPolicy {
408            // Deliberately the tighter fixture: `PolicyMode::Deny`, not
409            // `grant.mode`. This helper's job is to hand back one class
410            // resolved under `grant` — the physical classes (fs/http/
411            // sockets/credentials) are filtered out below regardless, so
412            // resolving them under the test's own `grant.mode` too was
413            // configuring more than any test here describes or depends on.
414            default: PolicyMode::Deny,
415            entries: BTreeMap::from([(class.to_string(), grant)]),
416        };
417        let declared = BTreeMap::from([(class.to_string(), declared.to_vec())]);
418        let all = act_policy::ceilings::resolve_ceilings(
419            &act_policy::provider::ProviderRegistry::with_builtins(),
420            &declared,
421            &policy,
422        )
423        .await
424        .expect("resolve");
425        all.into_iter()
426            .filter(|(id, _)| !act_policy::ceilings::PHYSICALLY_INTERCEPTED.contains(&id.as_str()))
427            .collect()
428    }
429
430    #[tokio::test]
431    async fn an_undeclared_class_denies_without_reaching_the_prompter() {
432        // ACT-CONSENT.md §4 step 1: the refusal must not depend on anything but
433        // the manifest, and the operator must not be consulted. A prompter that
434        // panics proves it was never called.
435        let gate = ConsentGate::for_test(BTreeMap::new(), Arc::new(PanickingPrompter));
436        let decision = gate
437            .decide(
438                "db:drop",
439                "analytics",
440                "Drop database \"analytics\"",
441                &json!({}),
442            )
443            .await;
444        assert_eq!(decision, Decision::Deny);
445    }
446
447    #[tokio::test]
448    async fn a_declared_class_outside_its_ceiling_denies() {
449        let gate = ConsentGate::for_test(
450            ceilings_declaring("db:drop", &[json!({"key": "test_*"})], PolicyMode::Open).await,
451            Arc::new(PanickingPrompter),
452        );
453        assert_eq!(
454            gate.decide("db:drop", "production", "Drop production", &json!({}))
455                .await,
456            Decision::Deny
457        );
458    }
459
460    // NOTE: this pins the caching *policy* (same gate, same `(class, key)`,
461    // one prompt) but not the *sharing* it depends on: `ConsentGate::for_test`
462    // hands every gate its own fresh `DecisionCache`, so this test cannot
463    // distinguish "the cache is the run's one cache" from "`from_accessor`
464    // builds a new cache per gate" — both would pass it identically, because
465    // it only ever calls `.decide()` on one already-constructed `gate`. That
466    // property — whether two *separately constructed* gates (as two real
467    // `request` calls produce) still share one cache — is proven end to end
468    // in `act-cli/tests/consent_e2e.rs`'s
469    // `a_repeated_question_is_asked_once_not_per_call` and
470    // `a_different_key_is_still_asked_about`, against a real MCP elicitation
471    // round-trip. Reproducing that here would mean rebuilding `create_store`'s
472    // full `HostState` (WASI, HTTP client, every ceiling) inside a unit test —
473    // a second copy of exactly the kind of construction this module's own
474    // docs warn drifts.
475    #[tokio::test]
476    async fn ask_reaches_the_prompter_once_per_key_and_is_remembered() {
477        let prompter = Arc::new(CountingPrompter::allowing());
478        let gate = ConsentGate::for_test(
479            ceilings_declaring("db:drop", &[], PolicyMode::Ask).await,
480            prompter.clone(),
481        );
482        assert_eq!(
483            gate.decide("db:drop", "a", "s", &json!({})).await,
484            Decision::Allow
485        );
486        assert_eq!(
487            gate.decide("db:drop", "a", "s", &json!({})).await,
488            Decision::Allow
489        );
490        assert_eq!(
491            prompter.calls(),
492            1,
493            "the same (class, key) must not re-prompt"
494        );
495        assert_eq!(
496            gate.decide("db:drop", "b", "s", &json!({})).await,
497            Decision::Allow
498        );
499        assert_eq!(
500            prompter.calls(),
501            2,
502            "a different key is a different question"
503        );
504    }
505
506    #[tokio::test]
507    async fn a_physically_enforced_class_is_not_reachable_through_consent() {
508        // wasi:http is declared here and still refused: it is enforced on the
509        // boundary, and consent must not become a second door that can answer
510        // "allow" for it.
511        //
512        // Note what actually holds this in production: `create_store`'s own
513        // `PHYSICALLY_INTERCEPTED` filter, which `ceilings_declaring`
514        // deliberately mirrors so these tests see the map the host would
515        // build. Deleting the filter in `store.rs` would not turn this test
516        // red — the e2e tests are the layer that can see that. What this
517        // pins is that the gate adds no bypass of its own on top of the
518        // filtered map.
519        let gate = ConsentGate::for_test(
520            ceilings_declaring("wasi:http", &[], PolicyMode::Open).await,
521            Arc::new(PanickingPrompter),
522        );
523        assert_eq!(
524            gate.decide("wasi:http", "api.example.com", "s", &json!({}))
525                .await,
526            Decision::Deny
527        );
528    }
529
530    #[tokio::test]
531    async fn a_declared_physical_class_denies_with_the_true_reason_not_undeclared() {
532        // M2: the test above pins the decision (Deny); this pins the audit
533        // *reason*. `wasi:http` is declared here — same fixture as above —
534        // and still hits the map miss, because it is physically intercepted
535        // and therefore never in `semantic_ceilings`. Before this fix, that
536        // miss was unconditionally audited as "class not declared in
537        // act:component", which is false: the manifest genuinely declares
538        // this class, it's just enforced somewhere else.
539        use crate::audit::layer::AuditWriter;
540        use std::sync::{Arc as StdArc, Mutex};
541        use tracing_subscriber::layer::SubscriberExt;
542
543        struct CapturingWriter(StdArc<Mutex<Vec<String>>>);
544        impl AuditWriter for CapturingWriter {
545            fn write_line(&self, line: &str) {
546                self.0.lock().unwrap().push(line.to_string());
547            }
548        }
549
550        let sink = StdArc::new(Mutex::new(Vec::new()));
551        let layer = crate::audit::AuditLayer::new(
552            CapturingWriter(sink.clone()),
553            crate::audit::Detail::Full,
554        );
555        let sub = tracing_subscriber::registry().with(layer);
556        let _guard = tracing::subscriber::set_default(sub);
557
558        let gate = ConsentGate::for_test(
559            ceilings_declaring("wasi:http", &[], PolicyMode::Open).await,
560            Arc::new(PanickingPrompter),
561        );
562        let decision = gate
563            .decide("wasi:http", "api.example.com", "s", &json!({}))
564            .await;
565        drop(_guard);
566
567        assert_eq!(decision, Decision::Deny);
568        let lines = sink.lock().unwrap().clone();
569        let line = lines
570            .iter()
571            .find(|l| l.contains("wasi:http"))
572            .unwrap_or_else(|| panic!("no audit line naming wasi:http, got {lines:?}"));
573        assert!(
574            line.contains("enforced on the boundary"),
575            "must carry the true reason, got: {line}"
576        );
577        assert!(
578            !line.contains("not declared"),
579            "must not claim undeclared when the manifest declares it, got: {line}"
580        );
581    }
582
583    #[tokio::test]
584    async fn an_empty_class_denies_even_when_the_manifest_declares_it() {
585        // ACT-CONSENT.md §4 step 1 has two clauses: absent from the declared
586        // capabilities, *or empty*. The map miss covers the first. This is the
587        // second, and it needs a check of its own: §3.3 forbids declaring such
588        // a class, but nothing enforces §3.3 -- `act-build validate` checks
589        // only `name` and `version` -- so a manifest carrying
590        // `[std.capabilities.""]` reaches `resolve_ceilings`, which hands the
591        // empty key a real ceiling row like any other.
592        //
593        // The ceilings below therefore *declare* `""`. Under a map-miss-only
594        // implementation the `ask` case would put the request to a human as
595        // `./test.wasm requests : analytics` -- a question naming no class at
596        // all -- and the `open` case would allow it outright.
597        for mode in [PolicyMode::Ask, PolicyMode::Open] {
598            let ceilings = ceilings_declaring("", &[], mode).await;
599            assert!(
600                ceilings.contains_key(""),
601                "the fixture must declare the empty class, or this test is \
602                 just the map-miss case again"
603            );
604            let gate = ConsentGate::for_test(ceilings, Arc::new(PanickingPrompter));
605            assert_eq!(
606                gate.decide("", "analytics", "s", &json!({})).await,
607                Decision::Deny,
608                "an empty class must be refused under {mode:?}"
609            );
610        }
611    }
612
613    #[tokio::test]
614    async fn a_deny_constraint_beats_an_otherwise_open_grant() {
615        // §4 step 2: a deny constraint in the effective grant wins, and it
616        // wins before the mode is reached -- so `open` does not rescue a key
617        // the operator named in `deny`.
618        let ceilings = ceilings_granted(
619            "db:drop",
620            &[],
621            act_policy::grant::CapabilityGrant {
622                mode: PolicyMode::Open,
623                allow: Vec::new(),
624                deny: vec![json!({"key": "production"})],
625            },
626        )
627        .await;
628        let gate = ConsentGate::for_test(ceilings, Arc::new(PanickingPrompter));
629        assert_eq!(
630            gate.decide("db:drop", "production", "s", &json!({})).await,
631            Decision::Deny
632        );
633        assert_eq!(
634            gate.decide("db:drop", "analytics", "s", &json!({})).await,
635            Decision::Allow,
636            "the deny constraint must bound the key it names and nothing else"
637        );
638    }
639
640    #[tokio::test]
641    async fn a_deny_mode_grant_refuses_a_declared_class_without_asking() {
642        // §4 step 4, first bullet: mode `deny` refuses, and refuses without
643        // consulting anyone.
644        let gate = ConsentGate::for_test(
645            ceilings_declaring("db:drop", &[], PolicyMode::Deny).await,
646            Arc::new(PanickingPrompter),
647        );
648        assert_eq!(
649            gate.decide("db:drop", "analytics", "s", &json!({})).await,
650            Decision::Deny
651        );
652    }
653
654    #[tokio::test]
655    async fn an_allowlist_grant_bounds_the_key_without_asking() {
656        // §4 step 4: allowlist allows a matching request and denies the rest,
657        // and neither outcome consults a human.
658        let ceilings = ceilings_granted(
659            "db:drop",
660            &[],
661            act_policy::grant::CapabilityGrant {
662                mode: PolicyMode::Allowlist,
663                allow: vec![json!({"key": "test_*"})],
664                deny: Vec::new(),
665            },
666        )
667        .await;
668        let gate = ConsentGate::for_test(ceilings, Arc::new(PanickingPrompter));
669        assert_eq!(
670            gate.decide("db:drop", "test_scratch", "s", &json!({}))
671                .await,
672            Decision::Allow
673        );
674        assert_eq!(
675            gate.decide("db:drop", "production", "s", &json!({})).await,
676            Decision::Deny
677        );
678    }
679
680    #[tokio::test]
681    async fn an_ask_grant_carrying_an_allowlist_refuses_outside_it_rather_than_prompting() {
682        // §4 step 4, last sentence: a single approval must not be able to
683        // authorize what the operator's own allowlist excluded, so a request
684        // outside it is refused rather than put to a human.
685        let ceilings = ceilings_granted(
686            "db:drop",
687            &[],
688            act_policy::grant::CapabilityGrant {
689                mode: PolicyMode::Ask,
690                allow: vec![json!({"key": "test_*"})],
691                deny: Vec::new(),
692            },
693        )
694        .await;
695        let gate = ConsentGate::for_test(ceilings, Arc::new(PanickingPrompter));
696        assert_eq!(
697            gate.decide("db:drop", "production", "s", &json!({})).await,
698            Decision::Deny
699        );
700    }
701
702    #[tokio::test]
703    async fn a_key_hidden_in_args_cannot_shadow_the_one_that_was_shown() {
704        // §8.1: there is exactly one key, and it is the one a human was shown
705        // and the audit recorded. The gate must build `ResourceOp::key` from
706        // the request and `attrs` from `args` -- swapped, a component would
707        // pass an in-ceiling key in `args` and act on a different subject.
708        let gate = ConsentGate::for_test(
709            ceilings_declaring("db:drop", &[json!({"key": "test_*"})], PolicyMode::Open).await,
710            Arc::new(PanickingPrompter),
711        );
712        assert_eq!(
713            gate.decide(
714                "db:drop",
715                "production",
716                "s",
717                &json!({"key": "test_scratch"})
718            )
719            .await,
720            Decision::Deny
721        );
722    }
723
724    #[test]
725    fn args_that_are_not_a_cbor_map_carry_no_dimensions() {
726        // §2.2: not an error -- `key` still matches. A component whose args
727        // encoding is wrong must not get a distinguishable outcome (§8.4).
728        let mut text = Vec::new();
729        ciborium::into_writer(&"not a map", &mut text).unwrap();
730        assert_eq!(args_to_attrs(&text), serde_json::Value::Null);
731        assert_eq!(args_to_attrs(&[]), serde_json::Value::Null);
732        assert_eq!(args_to_attrs(&[0xff, 0xff, 0xff]), serde_json::Value::Null);
733
734        let mut map = Vec::new();
735        ciborium::into_writer(&json!({"table": "events"}), &mut map).unwrap();
736        assert_eq!(args_to_attrs(&map), json!({"table": "events"}));
737    }
738
739    #[tokio::test]
740    async fn a_declared_dimension_outside_key_is_matched_from_args() {
741        // §3.2: `key` resolves from the request, every other dimension from
742        // `args`. Without this the declared ceiling could only ever narrow on
743        // one axis.
744        let gate = ConsentGate::for_test(
745            ceilings_declaring("db:drop", &[json!({"table": "events"})], PolicyMode::Open).await,
746            Arc::new(PanickingPrompter),
747        );
748        assert_eq!(
749            gate.decide("db:drop", "analytics", "s", &json!({"table": "events"}))
750                .await,
751            Decision::Allow
752        );
753        assert_eq!(
754            gate.decide("db:drop", "analytics", "s", &json!({"table": "users"}))
755                .await,
756            Decision::Deny
757        );
758    }
759
760    #[tokio::test]
761    async fn no_channel_degrades_to_deny() {
762        let gate = ConsentGate::for_test(
763            ceilings_declaring("db:drop", &[], PolicyMode::Ask).await,
764            Arc::new(act_policy::consent::DenyPrompter),
765        );
766        assert_eq!(
767            gate.decide("db:drop", "a", "s", &json!({})).await,
768            Decision::Deny
769        );
770    }
771}