Skip to main content

execsurface_observe/
lib.rs

1//! Linux metadata-only observation backend.
2//!
3//! On Linux x86_64 the reference implementation uses ptrace and reads only
4//! selected metadata pointers. It never dereferences argv or envp.
5//!
6//! M8 introduces an internal backend boundary before adding alternative
7//! collectors. The public observation semantics remain unchanged: ptrace is
8//! still the default backend and the correctness reference.
9
10use std::collections::BTreeSet;
11use std::ffi::{CString, OsStr, OsString};
12use std::fmt;
13use std::fs;
14use std::io;
15use std::os::unix::ffi::OsStrExt;
16use std::sync::Mutex;
17
18use execsurface_model::{Observation, ObserverWarning, RawEventKind, SpawnMechanism};
19
20pub const DEFAULT_EVENT_LIMIT: usize = 1_000_000;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct ObserveOptions {
24    pub event_limit: usize,
25}
26
27impl Default for ObserveOptions {
28    fn default() -> Self {
29        Self {
30            event_limit: DEFAULT_EVENT_LIMIT,
31        }
32    }
33}
34
35#[derive(Clone)]
36pub struct CommandSpec {
37    program: OsString,
38    args: Vec<OsString>,
39}
40
41impl CommandSpec {
42    pub fn new(program: impl Into<OsString>) -> Self {
43        Self {
44            program: program.into(),
45            args: Vec::new(),
46        }
47    }
48
49    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
50        self.args.push(arg.into());
51        self
52    }
53
54    pub fn args<I, S>(mut self, args: I) -> Self
55    where
56        I: IntoIterator<Item = S>,
57        S: Into<OsString>,
58    {
59        self.args.extend(args.into_iter().map(Into::into));
60        self
61    }
62
63    fn c_argv(&self) -> Result<(CString, Vec<CString>), ObserveError> {
64        let program = cstring_from_os(&self.program)?;
65        let mut argv = Vec::with_capacity(self.args.len() + 1);
66        argv.push(cstring_from_os(&self.program)?);
67        for arg in &self.args {
68            argv.push(cstring_from_os(arg)?);
69        }
70        Ok((program, argv))
71    }
72}
73
74fn cstring_from_os(value: &OsStr) -> Result<CString, ObserveError> {
75    CString::new(value.as_bytes()).map_err(|_| {
76        ObserveError::InvalidCommand("command contains an interior NUL byte".to_owned())
77    })
78}
79
80#[derive(Debug)]
81pub enum ObserveError {
82    UnsupportedPlatform(&'static str),
83    InvalidCommand(String),
84    Os(io::Error),
85    Protocol(String),
86}
87
88impl fmt::Display for ObserveError {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        match self {
91            Self::UnsupportedPlatform(message) => write!(f, "unsupported platform: {message}"),
92            Self::InvalidCommand(message) => write!(f, "invalid command: {message}"),
93            Self::Os(error) => write!(f, "observer OS error: {error}"),
94            Self::Protocol(message) => write!(f, "observer protocol error: {message}"),
95        }
96    }
97}
98
99impl std::error::Error for ObserveError {}
100
101impl From<io::Error> for ObserveError {
102    fn from(value: io::Error) -> Self {
103        Self::Os(value)
104    }
105}
106
107/// Typed observation capability vocabulary introduced by M8.3.
108///
109/// Occurrence and path-identity capabilities are deliberately separate. A
110/// backend that can prove that an exec/open happened must not thereby claim it
111/// established the path required by the existing ExecSurface raw model.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
113pub enum ObservationCapability {
114    ProcessSpawnLineage,
115    ProcessExecOccurrence,
116    ProcessExecPathIdentity,
117    ProcessExit,
118    PathAccessIntent,
119    SuccessfulOpenFdIdentity,
120    OpenPathIdentity,
121    FdReadWriteEffect,
122    FdDupCloseLifecycle,
123    ForkFdInheritance,
124    CloseOnExec,
125    RenameDeleteEffects,
126    NetworkConnectDestination,
127    TraceTimeRelativePath,
128    CausalExecutableChain,
129    LossTruncationVisibility,
130}
131
132pub const ALL_OBSERVATION_CAPABILITIES: [ObservationCapability; 16] = [
133    ObservationCapability::ProcessSpawnLineage,
134    ObservationCapability::ProcessExecOccurrence,
135    ObservationCapability::ProcessExecPathIdentity,
136    ObservationCapability::ProcessExit,
137    ObservationCapability::PathAccessIntent,
138    ObservationCapability::SuccessfulOpenFdIdentity,
139    ObservationCapability::OpenPathIdentity,
140    ObservationCapability::FdReadWriteEffect,
141    ObservationCapability::FdDupCloseLifecycle,
142    ObservationCapability::ForkFdInheritance,
143    ObservationCapability::CloseOnExec,
144    ObservationCapability::RenameDeleteEffects,
145    ObservationCapability::NetworkConnectDestination,
146    ObservationCapability::TraceTimeRelativePath,
147    ObservationCapability::CausalExecutableChain,
148    ObservationCapability::LossTruncationVisibility,
149];
150
151/// Machine-readable identity and capability declaration for one backend.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct BackendDescriptor {
154    pub id: String,
155    pub implementation_version: String,
156    pub platform: String,
157    pub architecture: String,
158    pub kernel_release: Option<String>,
159    pub privacy_profile: String,
160    pub capabilities: Vec<ObservationCapability>,
161    pub unsupported_capabilities: Vec<ObservationCapability>,
162}
163
164impl BackendDescriptor {
165    /// Prove that supported and unsupported sets form an exact, disjoint
166    /// partition of the declared capability universe.
167    pub fn validate_capability_partition(&self) -> Result<(), String> {
168        let supported: BTreeSet<_> = self.capabilities.iter().copied().collect();
169        let unsupported: BTreeSet<_> = self.unsupported_capabilities.iter().copied().collect();
170        let universe: BTreeSet<_> = ALL_OBSERVATION_CAPABILITIES.iter().copied().collect();
171
172        if supported.len() != self.capabilities.len() {
173            return Err("backend descriptor repeats a supported capability".to_owned());
174        }
175        if unsupported.len() != self.unsupported_capabilities.len() {
176            return Err("backend descriptor repeats an unsupported capability".to_owned());
177        }
178        if !supported.is_disjoint(&unsupported) {
179            return Err("backend capability is both supported and unsupported".to_owned());
180        }
181
182        let declared: BTreeSet<_> = supported.union(&unsupported).copied().collect();
183        if declared != universe {
184            return Err(
185                "backend descriptor does not classify the full capability universe".to_owned(),
186            );
187        }
188
189        Ok(())
190    }
191}
192
193/// Explicit health state at the backend-to-core handoff.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum CollectionCompleteness {
196    Complete,
197    IncompleteLoss,
198    IncompleteLimit,
199    IncompleteCapability,
200    IncompleteAmbiguity,
201    Error,
202}
203
204impl CollectionCompleteness {
205    pub fn pass_eligible(self) -> bool {
206        matches!(self, Self::Complete)
207    }
208}
209
210/// Internal collection result. The legacy public observer functions continue
211/// returning `Observation`; M8.3 uses this envelope at the backend boundary so
212/// collection health cannot be confused with absence of drift.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct BackendObservation {
215    pub descriptor: BackendDescriptor,
216    pub completeness: CollectionCompleteness,
217    pub observation: Observation,
218}
219
220fn kernel_release() -> Option<String> {
221    fs::read_to_string("/proc/sys/kernel/osrelease")
222        .ok()
223        .map(|value| value.trim().to_owned())
224        .filter(|value| !value.is_empty())
225}
226
227fn ptrace_backend_descriptor() -> BackendDescriptor {
228    BackendDescriptor {
229        id: "linux-ptrace-metadata-v2".to_owned(),
230        implementation_version: env!("CARGO_PKG_VERSION").to_owned(),
231        platform: std::env::consts::OS.to_owned(),
232        architecture: std::env::consts::ARCH.to_owned(),
233        kernel_release: kernel_release(),
234        privacy_profile: "metadata-only-v1".to_owned(),
235        capabilities: vec![
236            ObservationCapability::ProcessSpawnLineage,
237            ObservationCapability::ProcessExecOccurrence,
238            ObservationCapability::ProcessExecPathIdentity,
239            ObservationCapability::PathAccessIntent,
240            ObservationCapability::SuccessfulOpenFdIdentity,
241            ObservationCapability::OpenPathIdentity,
242            ObservationCapability::FdReadWriteEffect,
243            ObservationCapability::FdDupCloseLifecycle,
244            ObservationCapability::ForkFdInheritance,
245            ObservationCapability::CloseOnExec,
246            ObservationCapability::RenameDeleteEffects,
247            ObservationCapability::NetworkConnectDestination,
248            ObservationCapability::TraceTimeRelativePath,
249            ObservationCapability::LossTruncationVisibility,
250        ],
251        unsupported_capabilities: vec![
252            ObservationCapability::ProcessExit,
253            ObservationCapability::CausalExecutableChain,
254        ],
255    }
256}
257
258/// Descriptor for the selected M8.3 libbpf-rs path before product integration.
259///
260/// The supported subset is intentionally limited to semantics actually proved
261/// by M8.2. In particular, M8.2 proved exec occurrence and successful-open fd
262/// metadata, not the path identities required by the current raw model.
263pub fn experimental_ebpf_backend_descriptor() -> BackendDescriptor {
264    BackendDescriptor {
265        id: "linux-libbpf-metadata-experimental-v1".to_owned(),
266        implementation_version: "m8.3-experimental-v1".to_owned(),
267        platform: "linux".to_owned(),
268        architecture: "x86_64".to_owned(),
269        kernel_release: kernel_release(),
270        privacy_profile: "metadata-only-v1".to_owned(),
271        capabilities: vec![
272            ObservationCapability::ProcessSpawnLineage,
273            ObservationCapability::ProcessExecOccurrence,
274            ObservationCapability::SuccessfulOpenFdIdentity,
275            ObservationCapability::LossTruncationVisibility,
276        ],
277        unsupported_capabilities: vec![
278            ObservationCapability::ProcessExecPathIdentity,
279            ObservationCapability::ProcessExit,
280            ObservationCapability::PathAccessIntent,
281            ObservationCapability::OpenPathIdentity,
282            ObservationCapability::FdReadWriteEffect,
283            ObservationCapability::FdDupCloseLifecycle,
284            ObservationCapability::ForkFdInheritance,
285            ObservationCapability::CloseOnExec,
286            ObservationCapability::RenameDeleteEffects,
287            ObservationCapability::NetworkConnectDestination,
288            ObservationCapability::TraceTimeRelativePath,
289            ObservationCapability::CausalExecutableChain,
290        ],
291    }
292}
293
294pub fn reference_backend_descriptor() -> BackendDescriptor {
295    ptrace_backend_descriptor()
296}
297
298fn apply_shared_fd_ambiguity_guard(mut observation: Observation) -> Observation {
299    let clone_seen = observation.events.iter().any(|event| {
300        matches!(
301            &event.kind,
302            RawEventKind::ProcessSpawn {
303                mechanism: SpawnMechanism::Clone,
304                ..
305            }
306        )
307    });
308    let already_reported = observation
309        .warnings
310        .iter()
311        .any(|warning| warning.code == "shared_fd_table_ambiguity");
312
313    if clone_seen && !already_reported {
314        observation.complete = false;
315        observation.warnings.push(ObserverWarning {
316            code: "shared_fd_table_ambiguity".to_owned(),
317            tid: None,
318            message: "clone-based concurrency observed; raw v2 does not retain CLONE_FILES flags, so shared-fd lifecycle attribution cannot be certified complete for this session"
319                .to_owned(),
320        });
321    }
322
323    observation
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq)]
327enum PtraceSharedFdGuardPolicy {
328    LegacyConservative,
329    #[cfg(test)]
330    CertificateAwareResearch,
331}
332
333fn finalize_ptrace_observation(
334    observation: Observation,
335    _clone_fd_semantics_certified: bool,
336    policy: PtraceSharedFdGuardPolicy,
337) -> Observation {
338    match policy {
339        PtraceSharedFdGuardPolicy::LegacyConservative => {
340            apply_shared_fd_ambiguity_guard(observation)
341        }
342        #[cfg(test)]
343        PtraceSharedFdGuardPolicy::CertificateAwareResearch => {
344            if _clone_fd_semantics_certified {
345                observation
346            } else {
347                apply_shared_fd_ambiguity_guard(observation)
348            }
349        }
350    }
351}
352
353fn classify_observation_completeness(observation: &Observation) -> CollectionCompleteness {
354    if observation.complete {
355        return CollectionCompleteness::Complete;
356    }
357
358    if observation
359        .warnings
360        .iter()
361        .any(|warning| warning.code == "event_limit_exceeded")
362    {
363        CollectionCompleteness::IncompleteLimit
364    } else if observation
365        .warnings
366        .iter()
367        .any(|warning| warning.code == "shared_fd_table_ambiguity")
368    {
369        CollectionCompleteness::IncompleteAmbiguity
370    } else {
371        // Existing ptrace warnings represent a known semantic/capability gap.
372        // M8.4 will further refine transport/lifecycle loss classification for
373        // the eBPF implementation.
374        CollectionCompleteness::IncompleteCapability
375    }
376}
377
378#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
379mod linux_ptrace;
380
381/// Internal collection boundary introduced by M8.
382///
383/// Collection mechanism is deliberately kept behind this contract so that an
384/// eBPF backend can be evaluated without changing canonicalization, baseline,
385/// diff, policy, or verdict semantics. Backends are not assumed to be
386/// evidence-equivalent; comparability remains an explicit higher-level
387/// decision.
388trait ObservationBackend {
389    fn descriptor(&self) -> BackendDescriptor;
390
391    fn observe(
392        &self,
393        spec: &CommandSpec,
394        options: ObserveOptions,
395    ) -> Result<BackendObservation, ObserveError>;
396}
397
398/// Native Linux ptrace remains the default backend and the correctness
399/// reference under the accepted M6.5 decision.
400struct PtraceBackend;
401
402impl ObservationBackend for PtraceBackend {
403    fn descriptor(&self) -> BackendDescriptor {
404        ptrace_backend_descriptor()
405    }
406
407    fn observe(
408        &self,
409        spec: &CommandSpec,
410        options: ObserveOptions,
411    ) -> Result<BackendObservation, ObserveError> {
412        #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
413        {
414            let ptrace = linux_ptrace::observe(spec, options)?;
415            // Public/default behavior remains the accepted alpha.4/v2 contract.
416            // The certificate-aware mode is compiled only for research tests and
417            // cannot be selected by observe_command or the default backend.
418            let clone_fd_semantics_certified = ptrace.clone_fd_certification.fully_certified();
419            let observation = finalize_ptrace_observation(
420                ptrace.observation,
421                clone_fd_semantics_certified,
422                PtraceSharedFdGuardPolicy::LegacyConservative,
423            );
424            let descriptor = self.descriptor();
425            descriptor
426                .validate_capability_partition()
427                .map_err(ObserveError::Protocol)?;
428
429            if observation.backend.name != descriptor.id {
430                return Err(ObserveError::Protocol(format!(
431                    "ptrace observation backend identity mismatch: model={} descriptor={}",
432                    observation.backend.name, descriptor.id
433                )));
434            }
435
436            let completeness = classify_observation_completeness(&observation);
437            Ok(BackendObservation {
438                descriptor,
439                completeness,
440                observation,
441            })
442        }
443
444        #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))]
445        {
446            let _ = (spec, options);
447            Err(ObserveError::UnsupportedPlatform(
448                "current observer supports Linux x86_64 only",
449            ))
450        }
451    }
452}
453
454static PTRACE_BACKEND: PtraceBackend = PtraceBackend;
455static OBSERVE_LOCK: Mutex<()> = Mutex::new(());
456
457pub fn observe_command(spec: &CommandSpec) -> Result<Observation, ObserveError> {
458    observe_command_with_options(spec, ObserveOptions::default())
459}
460
461pub fn observe_command_with_options(
462    spec: &CommandSpec,
463    options: ObserveOptions,
464) -> Result<Observation, ObserveError> {
465    Ok(observe_command_with_backend_options(spec, options)?.observation)
466}
467
468/// Experimental workspace-internal access to the typed backend handoff.
469///
470/// This does not change the legacy `observe_command` return contract. It exists
471/// so a separately gated consumer can project evidence already known by the
472/// selected observer without reconstructing collection health from serialized
473/// raw-v2 JSON.
474pub fn observe_command_with_backend(
475    spec: &CommandSpec,
476) -> Result<BackendObservation, ObserveError> {
477    observe_command_with_backend_options(spec, ObserveOptions::default())
478}
479
480pub fn observe_command_with_backend_options(
481    spec: &CommandSpec,
482    options: ObserveOptions,
483) -> Result<BackendObservation, ObserveError> {
484    let _session_guard = OBSERVE_LOCK.lock().map_err(|_| {
485        ObserveError::Protocol("observer session serialization lock was poisoned".to_owned())
486    })?;
487
488    PTRACE_BACKEND.observe(spec, options)
489}
490
491#[cfg(test)]
492mod api_tests {
493    use super::*;
494    use std::os::unix::ffi::OsStringExt;
495
496    #[test]
497    fn invalid_command_metadata_returns_explicit_error() {
498        let invalid = OsString::from_vec(b"bad\0program".to_vec());
499        let result = observe_command(&CommandSpec::new(invalid));
500        assert!(matches!(result, Err(ObserveError::InvalidCommand(_))));
501    }
502
503    #[test]
504    fn default_event_budget_is_fail_closed_and_finite() {
505        let options = ObserveOptions::default();
506        assert_eq!(options.event_limit, DEFAULT_EVENT_LIMIT);
507        assert!(options.event_limit >= 100_000);
508        assert!(options.event_limit < usize::MAX);
509    }
510
511    #[test]
512    fn ptrace_descriptor_partitions_every_capability() {
513        let descriptor = reference_backend_descriptor();
514        assert_eq!(descriptor.id, "linux-ptrace-metadata-v2");
515        descriptor
516            .validate_capability_partition()
517            .expect("ptrace capability partition must be total and disjoint");
518        assert!(descriptor
519            .capabilities
520            .contains(&ObservationCapability::ProcessExecPathIdentity));
521        assert!(descriptor
522            .capabilities
523            .contains(&ObservationCapability::OpenPathIdentity));
524    }
525
526    #[test]
527    fn experimental_libbpf_descriptor_is_explicitly_partial() {
528        let descriptor = experimental_ebpf_backend_descriptor();
529        descriptor
530            .validate_capability_partition()
531            .expect("libbpf capability partition must be total and disjoint");
532
533        assert!(descriptor
534            .capabilities
535            .contains(&ObservationCapability::ProcessSpawnLineage));
536        assert!(descriptor
537            .capabilities
538            .contains(&ObservationCapability::ProcessExecOccurrence));
539        assert!(descriptor
540            .capabilities
541            .contains(&ObservationCapability::SuccessfulOpenFdIdentity));
542        assert!(descriptor
543            .capabilities
544            .contains(&ObservationCapability::LossTruncationVisibility));
545        assert!(descriptor
546            .unsupported_capabilities
547            .contains(&ObservationCapability::ProcessExecPathIdentity));
548        assert!(descriptor
549            .unsupported_capabilities
550            .contains(&ObservationCapability::OpenPathIdentity));
551        assert!(descriptor
552            .unsupported_capabilities
553            .contains(&ObservationCapability::NetworkConnectDestination));
554        assert!(descriptor
555            .unsupported_capabilities
556            .contains(&ObservationCapability::FdReadWriteEffect));
557    }
558
559    #[test]
560    fn incomplete_observation_is_never_pass_eligible() {
561        let mut observation = Observation::empty(execsurface_model::BackendMetadata {
562            name: "linux-ptrace-metadata-v2".to_owned(),
563            platform: "linux".to_owned(),
564            architecture: "x86_64".to_owned(),
565            capabilities: Vec::new(),
566            limitations: Vec::new(),
567        });
568        observation.complete = false;
569        observation
570            .warnings
571            .push(execsurface_model::ObserverWarning {
572                code: "event_limit_exceeded".to_owned(),
573                tid: None,
574                message: "controlled test".to_owned(),
575            });
576
577        let completeness = classify_observation_completeness(&observation);
578        assert_eq!(completeness, CollectionCompleteness::IncompleteLimit);
579        assert!(!completeness.pass_eligible());
580        assert!(CollectionCompleteness::Complete.pass_eligible());
581    }
582
583    fn c6r_clone_observation() -> Observation {
584        let mut observation = Observation::empty(execsurface_model::BackendMetadata {
585            name: "linux-ptrace-metadata-v2".to_owned(),
586            platform: "linux".to_owned(),
587            architecture: "x86_64".to_owned(),
588            capabilities: Vec::new(),
589            limitations: Vec::new(),
590        });
591        observation.events.push(execsurface_model::RawEvent {
592            sequence: 1,
593            tid: 7,
594            kind: execsurface_model::RawEventKind::ProcessSpawn {
595                child_tid: 8,
596                mechanism: execsurface_model::SpawnMechanism::Clone,
597            },
598        });
599        observation
600    }
601
602    #[test]
603    fn c6r_default_policy_remains_legacy_even_with_positive_certificate() {
604        let finalized = finalize_ptrace_observation(
605            c6r_clone_observation(),
606            true,
607            PtraceSharedFdGuardPolicy::LegacyConservative,
608        );
609        assert!(!finalized.complete);
610        assert!(finalized
611            .warnings
612            .iter()
613            .any(|warning| warning.code == "shared_fd_table_ambiguity"));
614    }
615
616    #[test]
617    fn c6r_uncertified_research_mode_remains_fail_closed() {
618        let finalized = finalize_ptrace_observation(
619            c6r_clone_observation(),
620            false,
621            PtraceSharedFdGuardPolicy::CertificateAwareResearch,
622        );
623        assert!(!finalized.complete);
624        assert!(finalized
625            .warnings
626            .iter()
627            .any(|warning| warning.code == "shared_fd_table_ambiguity"));
628    }
629
630    #[test]
631    fn c6r_certified_research_mode_skips_only_synthetic_clone_guard() {
632        let finalized = finalize_ptrace_observation(
633            c6r_clone_observation(),
634            true,
635            PtraceSharedFdGuardPolicy::CertificateAwareResearch,
636        );
637        assert!(finalized.complete);
638        assert!(finalized.warnings.is_empty());
639    }
640
641    #[test]
642    fn c6r_certificate_never_clears_independent_incompleteness() {
643        let mut observation = c6r_clone_observation();
644        observation.complete = false;
645        observation.warnings.push(ObserverWarning {
646            code: "event_limit_exceeded".to_owned(),
647            tid: None,
648            message: "controlled independent blocker".to_owned(),
649        });
650        let finalized = finalize_ptrace_observation(
651            observation,
652            true,
653            PtraceSharedFdGuardPolicy::CertificateAwareResearch,
654        );
655        assert!(!finalized.complete);
656        assert_eq!(finalized.warnings.len(), 1);
657        assert_eq!(finalized.warnings[0].code, "event_limit_exceeded");
658    }
659
660    #[test]
661    fn shared_fd_ambiguity_is_never_pass_eligible() {
662        let mut observation = Observation::empty(execsurface_model::BackendMetadata {
663            name: "linux-ptrace-metadata-v2".to_owned(),
664            platform: "linux".to_owned(),
665            architecture: "x86_64".to_owned(),
666            capabilities: Vec::new(),
667            limitations: Vec::new(),
668        });
669        observation.complete = false;
670        observation.warnings.push(ObserverWarning {
671            code: "shared_fd_table_ambiguity".to_owned(),
672            tid: None,
673            message: "controlled test".to_owned(),
674        });
675
676        let completeness = classify_observation_completeness(&observation);
677        assert_eq!(completeness, CollectionCompleteness::IncompleteAmbiguity);
678        assert!(!completeness.pass_eligible());
679    }
680}
681
682#[cfg(test)]
683mod p8_a3_typed_report;