Skip to main content

newt_core/
ocap.rs

1//! OCAP enforcement scaffold — the *runtime* side of the deviation ratchet.
2//!
3//! `docs/security/ocap-deviations.md` defines the rule:
4//!
5//! > effective authority = meet( the human's grant , what the currently-verified
6//! > invariants can actually enforce ).
7//!
8//! A dangerous capability is available **iff** all its required OCAP invariants
9//! *verify*; otherwise it is **fail-closed OFF**, with honest evidence. A
10//! *deviation* is an invariant currently **absent** (unbuilt). This module is the
11//! runtime checker plus the fail-closed capability gates the register names
12//! (`verify_b1`, `seed_live_credential`, …). CI's `just ocap-check`
13//! (`scripts/ocap_check.py`) statically asserts that every `OCAP-DANGER:<id>`
14//! site carries its `OCAP-GATE:<id>` while the deviation is open — so these gates
15//! cannot be removed without turning the build red.
16//!
17//! Everything here is **fail-closed**: the verifiers return [`Verification::Absent`]
18//! until the real OS-isolation / disclosure-filter / broker code lands, so the
19//! dangerous paths are structurally unreachable — bounded *by construction*, not by
20//! discipline. See `docs/design/ocap-enforcement.md` for the architecture.
21
22use std::fmt;
23
24/// The result of checking one OCAP invariant at runtime.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Verification {
27    /// The invariant is enforced; `evidence` records how it was confirmed.
28    Verified { evidence: String },
29    /// The invariant is not yet enforced (an open deviation). Dependent
30    /// capabilities stay fail-closed; `reason` is the honest "why".
31    Absent {
32        deviation: &'static str,
33        reason: String,
34    },
35}
36
37impl Verification {
38    /// True only when the invariant is actually enforced.
39    #[must_use]
40    pub fn is_verified(&self) -> bool {
41        matches!(self, Self::Verified { .. })
42    }
43
44    /// The deviation id when absent (for honest banners / the ledger).
45    #[must_use]
46    pub fn deviation(&self) -> Option<&'static str> {
47        match self {
48            Self::Absent { deviation, .. } => Some(deviation),
49            Self::Verified { .. } => None,
50        }
51    }
52}
53
54/// Refusal of a dangerous capability because a required invariant is absent.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct FailClosed {
57    pub deviation: &'static str,
58    pub reason: String,
59}
60
61impl fmt::Display for FailClosed {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        write!(
64            f,
65            "refused (fail-closed): OCAP invariant '{}' is not enforced — {}",
66            self.deviation, self.reason
67        )
68    }
69}
70
71impl std::error::Error for FailClosed {}
72
73/// Require an invariant before proceeding; the fail-closed gate primitive.
74fn require(v: Verification) -> Result<(), FailClosed> {
75    match v {
76        Verification::Verified { .. } => Ok(()),
77        Verification::Absent { deviation, reason } => Err(FailClosed { deviation, reason }),
78    }
79}
80
81/// Verify **b1-os-isolation**: uid-namespace + Landlock fs + seccomp +
82/// default-deny netns + an egress proxy that is the *only* egress.
83///
84/// UNBUILT — always [`Verification::Absent`] (`sandbox_kind = none`; the
85/// in-process monitor is the only barrier). When the per-OS stack lands (Linux
86/// Landlock-net 6.7 / seccomp / netns, macOS Seatbelt, Windows AppContainer —
87/// `docs/design/captured-shell-cross-platform.md`), this returns `Verified` with
88/// the confirmed floor, re-run *per session* (no COW-cloned-pod skip).
89#[must_use]
90pub fn verify_b1() -> Verification {
91    Verification::Absent {
92        deviation: "b1-os-isolation",
93        reason: "no OS sandbox or egress proxy; the in-process monitor is the only barrier".into(),
94    }
95}
96
97/// Verify **disclosure-gate-live-path**: every tool result passes a single
98/// disclosure filter before it is pushed into `messages` (one chokepoint).
99///
100/// UNBUILT — always [`Verification::Absent`] (today redaction runs only on the
101/// next-turn observation and is shape-only). When the single chokepoint lands and
102/// a canary seeded at session start never appears in the model-facing stream,
103/// this returns `Verified`.
104#[must_use]
105pub fn verify_disclosure_gate() -> Verification {
106    Verification::Absent {
107        deviation: "disclosure-gate-live-path",
108        reason: "no single disclosure chokepoint on the live tool-result path".into(),
109    }
110}
111
112/// A live, scoped credential to seed (the `pa login` use case): a short-lived
113/// token a broker would present to outbound requests. The token VALUE is
114/// deliberately not modelled here — the design keeps it *out of the box* (the
115/// worker/model never sees it); only a non-secret `label` is carried for the
116/// ledger.
117#[derive(Debug, Clone)]
118pub struct ScopedCredential {
119    pub label: String,
120}
121
122/// Seed a live scoped credential into the agent's environment (`pa login`).
123///
124/// DANGEROUS: a live token with no OS sandbox is a direct token→internet
125/// exfiltration path the instant the in-process monitor is bypassed, and the
126/// token could surface to the model on the un-gated disclosure path. Per the
127/// register it is **disabled while `b1-os-isolation` / `disclosure-gate-live-path`
128/// are open**. Fail-closed: refuses unless both verify.
129pub fn seed_live_credential(cred: &ScopedCredential) -> Result<(), FailClosed> {
130    // OCAP-DANGER: b1-os-isolation — a live token with no OS sandbox is exfil-ready.
131    // OCAP-GATE: b1-os-isolation
132    require(verify_b1())?;
133    // OCAP-DANGER: disclosure-gate-live-path — the token could reach the model raw.
134    // OCAP-GATE: disclosure-gate-live-path
135    require(verify_disclosure_gate())?;
136    // (unreachable today) Both invariants verified: a broker now holds `cred` out
137    // of the box and presents it to outbound requests; the value never enters the
138    // model-facing environment.
139    let _ = cred;
140    Ok(())
141}
142
143/// Admit a genuinely-untrusted / foreign remote voice that may hold anything
144/// sensitive (a future remote swarm peer).
145///
146/// DANGEROUS without the OS sandbox: a hostile voice with no containment can
147/// escalate. **Disabled while `b1-os-isolation` is open.** Fail-closed.
148pub fn admit_untrusted_remote(voice_fingerprint: &str) -> Result<(), FailClosed> {
149    // OCAP-DANGER: b1-os-isolation — an untrusted voice needs OS containment.
150    // OCAP-GATE: b1-os-isolation
151    require(verify_b1())?;
152    let _ = voice_fingerprint;
153    Ok(())
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn verifiers_are_absent_until_built() {
162        assert!(!verify_b1().is_verified());
163        assert_eq!(verify_b1().deviation(), Some("b1-os-isolation"));
164        assert!(!verify_disclosure_gate().is_verified());
165        assert_eq!(
166            verify_disclosure_gate().deviation(),
167            Some("disclosure-gate-live-path")
168        );
169    }
170
171    #[test]
172    fn verified_reports_no_deviation() {
173        let v = Verification::Verified {
174            evidence: "synthetic".into(),
175        };
176        assert!(v.is_verified());
177        assert_eq!(v.deviation(), None);
178    }
179
180    #[test]
181    fn seed_live_credential_fails_closed_on_b1() {
182        let cred = ScopedCredential {
183            label: "pa-token".into(),
184        };
185        let err = seed_live_credential(&cred).unwrap_err();
186        assert_eq!(err.deviation, "b1-os-isolation");
187        assert!(err.to_string().contains("fail-closed"));
188    }
189
190    #[test]
191    fn admit_untrusted_remote_fails_closed() {
192        let err = admit_untrusted_remote("SHA256:deadbeef").unwrap_err();
193        assert_eq!(err.deviation, "b1-os-isolation");
194    }
195
196    #[test]
197    fn require_passes_only_when_verified() {
198        assert!(require(Verification::Verified {
199            evidence: "ok".into()
200        })
201        .is_ok());
202        assert!(require(verify_b1()).is_err());
203    }
204}
205
206// ===========================================================================
207// Disclosure filter — the by-VALUE redaction primitive for
208// `disclosure-gate-live-path` (docs/design/ocap-enforcement.md §3).
209//
210// Threat-model finding: filter by KNOWN VALUE, not by shape. A shape filter
211// ("looks like a token") both over-blocks and is **defeated by re-encoding**; a
212// value filter catches the registered secret's actual bytes in any common
213// encoding. This is the mechanism `verify_disclosure_gate` will assert on the
214// live tool-result path — the canary: a value seeded at session start must never
215// reach a model-facing message, in ANY encoding. (Wiring it into the live
216// `messages` chokepoint lives in the agentic loop — a follow-up, kept out of
217// this module to avoid colliding with concurrent work there.)
218// ===========================================================================
219
220use base64::Engine as _;
221
222/// Redacts known secret VALUES — and their common re-encodings — from text
223/// before it reaches the model. Register the live token / session canary;
224/// [`leaks`](Self::leaks) detects and [`redact`](Self::redact) removes it.
225#[derive(Debug, Default, Clone)]
226pub struct DisclosureFilter {
227    secrets: Vec<String>,
228}
229
230impl DisclosureFilter {
231    #[must_use]
232    pub fn new() -> Self {
233        Self::default()
234    }
235
236    /// Register a secret value to catch (raw + re-encoded). Empty values are
237    /// ignored. Secrets should be high-entropy (tokens/canaries) — a value
238    /// filter trusts the caller not to register common substrings.
239    pub fn register(&mut self, secret: impl Into<String>) {
240        let s = secret.into();
241        if !s.is_empty() {
242            self.secrets.push(s);
243        }
244    }
245
246    /// The forms of `secret` we match: raw, standard base64, and lowercase hex.
247    fn encodings(secret: &str) -> [String; 3] {
248        let bytes = secret.as_bytes();
249        let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
250        let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
251        [secret.to_string(), b64, hex]
252    }
253
254    /// Does `text` disclose any registered secret, raw or re-encoded?
255    #[must_use]
256    pub fn leaks(&self, text: &str) -> bool {
257        self.secrets
258            .iter()
259            .any(|s| Self::encodings(s).iter().any(|e| text.contains(e.as_str())))
260    }
261
262    /// Replace every occurrence of every registered secret (raw or re-encoded)
263    /// with `[REDACTED]`.
264    #[must_use]
265    pub fn redact(&self, text: &str) -> String {
266        let mut out = text.to_string();
267        for s in &self.secrets {
268            for enc in Self::encodings(s) {
269                out = out.replace(enc.as_str(), "[REDACTED]");
270            }
271        }
272        out
273    }
274}
275
276#[cfg(test)]
277mod disclosure_tests {
278    use super::*;
279
280    fn b64(s: &str) -> String {
281        base64::engine::general_purpose::STANDARD.encode(s.as_bytes())
282    }
283    fn hexs(s: &str) -> String {
284        s.as_bytes().iter().map(|b| format!("{b:02x}")).collect()
285    }
286
287    #[test]
288    fn catches_raw_value() {
289        let mut f = DisclosureFilter::new();
290        f.register("CANARY-7f3a9c2b");
291        assert!(f.leaks("the token is CANARY-7f3a9c2b right here"));
292        assert!(!f.leaks("nothing secret in this text"));
293    }
294
295    #[test]
296    fn catches_base64_reencoding() {
297        // The key property: re-encoding defeats a SHAPE filter, not a VALUE filter.
298        let mut f = DisclosureFilter::new();
299        f.register("CANARY-7f3a9c2b");
300        let leaked = format!("here is {} encoded", b64("CANARY-7f3a9c2b"));
301        assert!(f.leaks(&leaked), "base64 re-encoding must still be caught");
302    }
303
304    #[test]
305    fn catches_hex_reencoding() {
306        let mut f = DisclosureFilter::new();
307        f.register("CANARY-7f3a9c2b");
308        assert!(f.leaks(&format!("payload={}", hexs("CANARY-7f3a9c2b"))));
309    }
310
311    #[test]
312    fn redacts_all_forms() {
313        let mut f = DisclosureFilter::new();
314        f.register("SECRETVAL-abc123");
315        let text = format!(
316            "raw=SECRETVAL-abc123 b64={} hex={}",
317            b64("SECRETVAL-abc123"),
318            hexs("SECRETVAL-abc123")
319        );
320        let red = f.redact(&text);
321        assert!(!f.leaks(&red), "redacted text must not leak");
322        assert!(red.contains("[REDACTED]"));
323    }
324
325    #[test]
326    fn value_filter_not_shape_filter() {
327        // An UNREGISTERED token-shaped string is NOT flagged — we filter by known
328        // value, not "looks like a secret". (The deliberate threat-model choice.)
329        let f = DisclosureFilter::new();
330        assert!(!f.leaks("AKIAIOSFODNN7EXAMPLE looks like a key but isn't registered"));
331    }
332
333    #[test]
334    fn empty_registration_is_ignored() {
335        let mut f = DisclosureFilter::new();
336        f.register("");
337        assert!(!f.leaks("anything at all"));
338    }
339}
340
341// ===========================================================================
342// Separation of duties — `sod-proposer-not-worker`
343// (docs/security/ocap-deviations.md §sod, docs/design/ocap-enforcement.md §5).
344//
345// The policy-proposing surface must be a cryptographically DISTINCT, more-trusted
346// identity than the confined worker — otherwise observe-then-propose lets the
347// worker author its own ceiling (privilege escalation by self-proposal). The
348// distinctness half is checkable now (`proposer_distinct`); the taint-aware
349// observe-then-propose half is UNBUILT, so `verify_sod` stays Absent
350// (fail-closed) and `auto_apply_policy` refuses regardless.
351// ===========================================================================
352
353/// The distinctness primitive: a non-empty proposer fingerprint different from
354/// the worker's. **Necessary, not sufficient**, for separation of duties.
355#[must_use]
356pub fn proposer_distinct(proposer_fp: &str, worker_fp: &str) -> bool {
357    !proposer_fp.is_empty() && proposer_fp != worker_fp
358}
359
360/// Verify **sod-proposer-not-worker**: a distinct, more-trusted proposer key
361/// (`proposer_fp != worker_fp`) AND taint-aware observe-then-propose. The
362/// distinctness half is checked here; the taint-aware half is unbuilt, so this
363/// stays [`Verification::Absent`] — but the `reason` reports the distinctness
364/// state so the ledger is honest. Flips to `Verified` when taint-awareness lands.
365#[must_use]
366pub fn verify_sod(proposer_fp: &str, worker_fp: &str) -> Verification {
367    if !proposer_distinct(proposer_fp, worker_fp) {
368        return Verification::Absent {
369            deviation: "sod-proposer-not-worker",
370            reason: "proposer key is not distinct from the worker (self-proposal)".into(),
371        };
372    }
373    Verification::Absent {
374        deviation: "sod-proposer-not-worker",
375        reason: "distinct proposer key confirmed, but taint-aware observe-then-propose is unbuilt"
376            .into(),
377    }
378}
379
380/// Auto-apply a proposed policy (lower/raise a worker's `Caveats` without a human).
381///
382/// DANGEROUS: with no separation of duties this is privilege escalation by
383/// self-proposal. **Disabled while `sod-proposer-not-worker` is open** — every
384/// promotion needs a human approval bound to the lowered-`Caveats` hash. Fail-closed.
385pub fn auto_apply_policy(proposer_fp: &str, worker_fp: &str) -> Result<(), FailClosed> {
386    // OCAP-DANGER: sod-proposer-not-worker — auto-apply enables self-proposal escalation.
387    // OCAP-GATE: sod-proposer-not-worker
388    require(verify_sod(proposer_fp, worker_fp))?;
389    Ok(())
390}
391
392#[cfg(test)]
393mod sod_tests {
394    use super::*;
395
396    #[test]
397    fn distinctness_primitive() {
398        assert!(proposer_distinct("SHA256:proposer", "SHA256:worker"));
399        assert!(!proposer_distinct("SHA256:same", "SHA256:same"));
400        assert!(
401            !proposer_distinct("", "SHA256:worker"),
402            "empty proposer is not distinct"
403        );
404    }
405
406    #[test]
407    fn verify_sod_is_absent_until_taint_aware() {
408        // Even with distinct keys, sod stays open (taint-aware half unbuilt).
409        let v = verify_sod("SHA256:proposer", "SHA256:worker");
410        assert!(!v.is_verified());
411        assert_eq!(v.deviation(), Some("sod-proposer-not-worker"));
412        // Self-proposal reports the distinctness failure specifically.
413        let self_prop = verify_sod("SHA256:same", "SHA256:same");
414        assert!(
415            matches!(self_prop, Verification::Absent { reason, .. } if reason.contains("self-proposal"))
416        );
417    }
418
419    #[test]
420    fn auto_apply_fails_closed_both_ways() {
421        assert!(matches!(
422            auto_apply_policy("SHA256:p", "SHA256:w"),
423            Err(FailClosed {
424                deviation: "sod-proposer-not-worker",
425                ..
426            })
427        ));
428        assert!(auto_apply_policy("SHA256:same", "SHA256:same").is_err());
429    }
430}