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}