diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index d760d590a..de7821874 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -122,6 +122,9 @@ jobs: - name: Test run: mise run test:rust + - name: Verify telemetry can be compiled out + run: mise run rust:verify:telemetry-off + - name: sccache stats if: always() run: | diff --git a/README.md b/README.md index 4fe242d93..d36032c3c 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,9 @@ OpenShell is built agent-first — your agent is your first collaborator. Before OpenShell collects anonymous telemetry to help improve the project for developers. This data is not used to track individual user behavior. It helps us understand aggregate usage of sandbox, provider, and policy workflows so we can prioritize product improvements and share usage trends with the community. -Disable telemetry by setting `OPENSHELL_TELEMETRY_ENABLED=false` on the gateway deployment. OpenShell propagates this deployment setting into sandbox supervisor environments so sandbox-side telemetry collection is disabled as well. +Disable telemetry at runtime by setting `OPENSHELL_TELEMETRY_ENABLED=false` on the gateway deployment. OpenShell propagates this deployment setting into sandbox supervisor environments so sandbox-side telemetry collection is disabled as well. + +You can also compile telemetry out entirely. Telemetry support is a default-on `telemetry` Cargo feature; building with `--no-default-features` produces binaries that contain no telemetry endpoint, no telemetry HTTP client, and no emission code. Build telemetry-free artifacts with, for example, `cargo build --release -p openshell-server --no-default-features` (gateway) and the equivalent for `openshell-sandbox` and `openshell-driver-vm`. With telemetry compiled out, the gateway emits nothing and reports telemetry disabled to the sandboxes it launches. Telemetry events are limited to anonymous operational categories and counts, such as sandbox lifecycle outcomes, provider profile buckets, policy decision counts, and aggregate network activity denial categories. OpenShell telemetry does not collect sandbox names or IDs, hostnames, file paths, binary paths, prompts, credentials, provider names, model names, or user content. diff --git a/architecture/build.md b/architecture/build.md index 200be8b1e..012a72d3f 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -20,6 +20,25 @@ OpenShell builds these main artifacts: Sandbox community images are built outside this repository. +## Build Features + +Anonymous telemetry emission is gated behind a default-on `telemetry` Cargo +feature. It is defined in `openshell-core` (where the emission code, HTTP +client, and endpoint live) and forwarded by the binary crates that emit or +collect telemetry: `openshell-server` (gateway), `openshell-sandbox` +(supervisor), and `openshell-driver-vm`. Every crate depends on +`openshell-core` with `default-features = false`, so the binary crate's feature +is the single switch that enables `openshell-core/telemetry` for its build +graph. In-process drivers (`docker`, `kubernetes`, `podman`) inherit the +gateway's setting through feature unification and carry no passthrough. + +Building a binary with `--no-default-features` compiles out telemetry entirely: +no endpoint, no telemetry HTTP client, and no emission code. With telemetry +compiled out, `telemetry::enabled()` is always `false` and the `emit_*` helpers +are no-ops, so the data-model types stay available and dependent crates compile +unchanged. The runtime `OPENSHELL_TELEMETRY_ENABLED` switch remains the way to +disable telemetry in a default (telemetry-enabled) build. + ## Linux Runtime Environments OpenShell uses different Linux libc environments for different host artifacts. diff --git a/crates/openshell-bootstrap/Cargo.toml b/crates/openshell-bootstrap/Cargo.toml index 578d59e65..c860cb138 100644 --- a/crates/openshell-bootstrap/Cargo.toml +++ b/crates/openshell-bootstrap/Cargo.toml @@ -10,7 +10,7 @@ repository.workspace = true rust-version.workspace = true [dependencies] -openshell-core = { path = "../openshell-core" } +openshell-core = { path = "../openshell-core", default-features = false } bollard = "0.20" bytes = { workspace = true } futures = { workspace = true } diff --git a/crates/openshell-cli/Cargo.toml b/crates/openshell-cli/Cargo.toml index b69a9629b..4d7241de3 100644 --- a/crates/openshell-cli/Cargo.toml +++ b/crates/openshell-cli/Cargo.toml @@ -16,7 +16,7 @@ path = "src/main.rs" [dependencies] openshell-bootstrap = { path = "../openshell-bootstrap" } -openshell-core = { path = "../openshell-core" } +openshell-core = { path = "../openshell-core", default-features = false } openshell-policy = { path = "../openshell-policy" } openshell-providers = { path = "../openshell-providers" } openshell-prover = { path = "../openshell-prover" } diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index 78c87d54c..469a0f4d9 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -20,10 +20,15 @@ serde = { workspace = true } serde_json = { workspace = true } url = { workspace = true } ipnet = "2" -chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } -reqwest = { workspace = true, features = ["blocking", "rustls-tls-webpki-roots"] } +chrono = { version = "0.4", default-features = false, features = ["clock", "std"], optional = true } +reqwest = { workspace = true, features = ["blocking", "rustls-tls-webpki-roots"], optional = true } [features] +default = ["telemetry"] +## Compile in anonymous telemetry emission support. On by default; disable with +## `--no-default-features` (plus any other features you need) for a build that +## contains no telemetry endpoint, no HTTP client, and no emission code at all. +telemetry = ["dep:reqwest", "dep:chrono"] ## Include test-only settings (dummy_bool, dummy_int) in the registry. ## Off by default so production builds have an empty registry. ## Enabled by e2e tests and during development. diff --git a/crates/openshell-core/src/telemetry.rs b/crates/openshell-core/src/telemetry.rs index e4dc0c37e..96f68d35c 100644 --- a/crates/openshell-core/src/telemetry.rs +++ b/crates/openshell-core/src/telemetry.rs @@ -3,25 +3,40 @@ //! Best-effort anonymous telemetry emission helpers. +#[cfg(feature = "telemetry")] use chrono::{SecondsFormat, Utc}; +#[cfg(feature = "telemetry")] use reqwest::blocking::Client; use serde_json::{Value, json}; use std::collections::BTreeMap; +#[cfg(feature = "telemetry")] use std::sync::{OnceLock, mpsc}; +#[cfg(feature = "telemetry")] use std::thread; +#[cfg(feature = "telemetry")] use std::time::Duration; -const TELEMETRY_EVENT_QUEUE_CAPACITY: usize = 1024; const MAX_TELEMETRY_INTEGER: u64 = 9_223_372_036_854_775_807; +const SOURCE: TelemetrySource = TelemetrySource::OpenShell; + +#[cfg(feature = "telemetry")] +const TELEMETRY_EVENT_QUEUE_CAPACITY: usize = 1024; +#[cfg(feature = "telemetry")] const CLIENT_ID: &str = "415437562476676"; +#[cfg(feature = "telemetry")] const DEFAULT_ENDPOINT: &str = "https://events.telemetry.data.nvidia.com/v1.1/events/json"; +#[cfg(feature = "telemetry")] const EVENT_SCHEMA_VERSION: &str = "4.0"; +#[cfg(feature = "telemetry")] const EVENT_PROTOCOL_VERSION: &str = "1.6"; +#[cfg(feature = "telemetry")] const EVENT_SYSTEM_VERSION: &str = "openshell-telemetry/1.0"; +#[cfg(feature = "telemetry")] const HTTP_TIMEOUT: Duration = Duration::from_secs(5); -const SOURCE: TelemetrySource = TelemetrySource::OpenShell; +#[cfg(feature = "telemetry")] static TELEMETRY_SENDER: OnceLock>> = OnceLock::new(); +#[cfg(feature = "telemetry")] #[derive(Debug)] struct TelemetryEvent { endpoint: String, @@ -277,14 +292,30 @@ impl DenyGroup { } } +#[cfg(feature = "telemetry")] pub fn enabled() -> bool { telemetry_enabled_from(std::env::var("OPENSHELL_TELEMETRY_ENABLED").ok().as_deref()) } +/// Telemetry support is compiled out: always disabled. +#[cfg(not(feature = "telemetry"))] +pub fn enabled() -> bool { + false +} + +#[cfg(feature = "telemetry")] pub fn enabled_env_value() -> &'static str { enabled_env_value_from(std::env::var("OPENSHELL_TELEMETRY_ENABLED").ok().as_deref()) } +/// Telemetry support is compiled out: report disabled so sandbox supervisors +/// inherit the disabled state and skip activity collection. +#[cfg(not(feature = "telemetry"))] +pub fn enabled_env_value() -> &'static str { + "false" +} + +#[cfg(feature = "telemetry")] fn enabled_env_value_from(value: Option<&str>) -> &'static str { if telemetry_enabled_from(value) { "true" @@ -293,6 +324,7 @@ fn enabled_env_value_from(value: Option<&str>) -> &'static str { } } +#[cfg(feature = "telemetry")] fn telemetry_enabled_from(value: Option<&str>) -> bool { let value = value.unwrap_or("true"); !matches!( @@ -301,6 +333,7 @@ fn telemetry_enabled_from(value: Option<&str>) -> bool { ) } +#[cfg(feature = "telemetry")] fn telemetry_endpoint() -> Option { telemetry_endpoint_from( std::env::var("OPENSHELL_TELEMETRY_ENDPOINT") @@ -309,6 +342,7 @@ fn telemetry_endpoint() -> Option { ) } +#[cfg(feature = "telemetry")] fn telemetry_endpoint_from(endpoint: Option<&str>) -> Option { let endpoint = endpoint.unwrap_or(DEFAULT_ENDPOINT); let endpoint = endpoint.trim(); @@ -319,14 +353,17 @@ fn telemetry_endpoint_from(endpoint: Option<&str>) -> Option { } } +#[cfg(feature = "telemetry")] fn timestamp() -> String { Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) } +#[cfg(feature = "telemetry")] fn client_version() -> &'static str { crate::VERSION } +#[cfg(feature = "telemetry")] fn build_payload(name: &str, event: Value, event_ts: &str, sent_ts: &str) -> Value { json!({ "browserType": "undefined", @@ -368,6 +405,7 @@ fn build_payload(name: &str, event: Value, event_ts: &str, sent_ts: &str) -> Val }) } +#[cfg(feature = "telemetry")] fn telemetry_sender() -> Option<&'static mpsc::SyncSender> { TELEMETRY_SENDER .get_or_init(|| { @@ -381,6 +419,7 @@ fn telemetry_sender() -> Option<&'static mpsc::SyncSender> { .as_ref() } +#[cfg(feature = "telemetry")] fn telemetry_worker(rx: mpsc::Receiver) { for event in rx { let payload = build_payload(event.name, event.event, &event.event_ts, ×tamp()); @@ -388,6 +427,7 @@ fn telemetry_worker(rx: mpsc::Receiver) { } } +#[cfg(feature = "telemetry")] fn publish_payload(endpoint: &str, payload: Value) -> Result<(), reqwest::Error> { Client::builder() .use_rustls_tls() @@ -401,10 +441,12 @@ fn publish_payload(endpoint: &str, payload: Value) -> Result<(), reqwest::Error> Ok(()) } +#[cfg(feature = "telemetry")] fn try_enqueue_event(sender: &mpsc::SyncSender, event: TelemetryEvent) -> bool { sender.try_send(event).is_ok() } +#[cfg(feature = "telemetry")] fn emit_event(name: &'static str, event: Value) { if !enabled() { return; @@ -427,6 +469,10 @@ fn emit_event(name: &'static str, event: Value) { ); } +/// Telemetry support is compiled out: emission is a no-op. +#[cfg(not(feature = "telemetry"))] +fn emit_event(_name: &'static str, _event: Value) {} + pub fn emit_lifecycle( resource: LifecycleResource, operation: LifecycleOperation, @@ -563,7 +609,7 @@ where Some(sanitized) } -#[cfg(test)] +#[cfg(all(test, feature = "telemetry"))] mod tests { use super::*; @@ -709,3 +755,44 @@ mod tests { assert_eq!(rows.get(&DenyGroup::Unknown), Some(&3)); } } + +#[cfg(all(test, not(feature = "telemetry")))] +mod disabled_tests { + use super::*; + + #[test] + fn telemetry_reports_disabled_when_compiled_out() { + assert!(!enabled()); + assert_eq!(enabled_env_value(), "false"); + } + + #[test] + fn emit_functions_are_no_ops_when_compiled_out() { + // These must remain callable so dependent crates compile unchanged; + // with telemetry compiled out they do nothing and never panic. + emit_lifecycle( + LifecycleResource::Sandbox, + LifecycleOperation::Create, + TelemetryOutcome::Success, + ); + emit_provider_lifecycle( + LifecycleOperation::Create, + TelemetryOutcome::Success, + ProviderProfile::Custom, + ); + emit_sandbox_create( + TelemetryOutcome::Success, + false, + 1, + false, + SandboxTemplateSource::Default, + TelemetryComputeDriver::Docker, + ); + emit_policy_decision( + PolicyDecisionOperation::Approve, + TelemetryOutcome::Success, + 1, + ); + emit_sandbox_activity_summary(0, 0, 0.0, [(DenyGroup::ConnectPolicy, 0)]); + } +} diff --git a/crates/openshell-driver-docker/Cargo.toml b/crates/openshell-driver-docker/Cargo.toml index fb2a643ea..4ddb1a913 100644 --- a/crates/openshell-driver-docker/Cargo.toml +++ b/crates/openshell-driver-docker/Cargo.toml @@ -11,7 +11,7 @@ license.workspace = true repository.workspace = true [dependencies] -openshell-core = { path = "../openshell-core" } +openshell-core = { path = "../openshell-core", default-features = false } tokio = { workspace = true } tonic = { workspace = true } diff --git a/crates/openshell-driver-kubernetes/Cargo.toml b/crates/openshell-driver-kubernetes/Cargo.toml index 885c64944..07fa91015 100644 --- a/crates/openshell-driver-kubernetes/Cargo.toml +++ b/crates/openshell-driver-kubernetes/Cargo.toml @@ -15,7 +15,7 @@ name = "openshell-driver-kubernetes" path = "src/main.rs" [dependencies] -openshell-core = { path = "../openshell-core" } +openshell-core = { path = "../openshell-core", default-features = false } tokio = { workspace = true } tonic = { workspace = true, features = ["transport"] } diff --git a/crates/openshell-driver-podman/Cargo.toml b/crates/openshell-driver-podman/Cargo.toml index 6f2963d92..4a1c8de83 100644 --- a/crates/openshell-driver-podman/Cargo.toml +++ b/crates/openshell-driver-podman/Cargo.toml @@ -15,7 +15,7 @@ name = "openshell-driver-podman" path = "src/main.rs" [dependencies] -openshell-core = { path = "../openshell-core" } +openshell-core = { path = "../openshell-core", default-features = false } tokio = { workspace = true } tonic = { workspace = true, features = ["transport"] } diff --git a/crates/openshell-driver-vm/Cargo.toml b/crates/openshell-driver-vm/Cargo.toml index a601e982b..8436be194 100644 --- a/crates/openshell-driver-vm/Cargo.toml +++ b/crates/openshell-driver-vm/Cargo.toml @@ -19,7 +19,7 @@ name = "openshell-driver-vm" path = "src/main.rs" [dependencies] -openshell-core = { path = "../openshell-core" } +openshell-core = { path = "../openshell-core", default-features = false } openshell-vfio = { path = "../openshell-vfio" } bollard = { version = "0.20", features = ["ssh"] } @@ -46,6 +46,13 @@ flate2 = "1" sha2 = "0.10" zstd = "0.13" +[features] +default = ["telemetry"] +## Compile in telemetry support (forwards to openshell-core/telemetry). On by +## default; build with `--no-default-features` for a telemetry-free VM driver +## that reports telemetry disabled to the sandboxes it launches. +telemetry = ["openshell-core/telemetry"] + [dev-dependencies] temp-env = "0.3" diff --git a/crates/openshell-policy/Cargo.toml b/crates/openshell-policy/Cargo.toml index 8936b85be..16719de13 100644 --- a/crates/openshell-policy/Cargo.toml +++ b/crates/openshell-policy/Cargo.toml @@ -11,7 +11,7 @@ license.workspace = true repository.workspace = true [dependencies] -openshell-core = { path = "../openshell-core" } +openshell-core = { path = "../openshell-core", default-features = false } serde = { workspace = true } serde_json = { workspace = true } serde_yml = { workspace = true } diff --git a/crates/openshell-providers/Cargo.toml b/crates/openshell-providers/Cargo.toml index e82574d73..2c9c48b63 100644 --- a/crates/openshell-providers/Cargo.toml +++ b/crates/openshell-providers/Cargo.toml @@ -11,7 +11,7 @@ license.workspace = true repository.workspace = true [dependencies] -openshell-core = { path = "../openshell-core" } +openshell-core = { path = "../openshell-core", default-features = false } serde = { workspace = true } serde_json = { workspace = true } serde_yml = { workspace = true } diff --git a/crates/openshell-router/Cargo.toml b/crates/openshell-router/Cargo.toml index e4c3d5ea7..97bbf4dc7 100644 --- a/crates/openshell-router/Cargo.toml +++ b/crates/openshell-router/Cargo.toml @@ -11,7 +11,7 @@ license.workspace = true repository.workspace = true [dependencies] -openshell-core = { path = "../openshell-core" } +openshell-core = { path = "../openshell-core", default-features = false } bytes = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 6d527bc53..cf98193a2 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -15,7 +15,7 @@ name = "openshell-sandbox" path = "src/main.rs" [dependencies] -openshell-core = { path = "../openshell-core" } +openshell-core = { path = "../openshell-core", default-features = false } openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } openshell-router = { path = "../openshell-router" } @@ -89,6 +89,13 @@ seccompiler = "0.5" tempfile = "3" uuid = { version = "1", features = ["v4"] } +[features] +default = ["telemetry"] +## Compile in telemetry activity collection (forwards to openshell-core/telemetry). +## On by default; build with `--no-default-features` for a telemetry-free sandbox +## supervisor that never collects or forwards activity summaries. +telemetry = ["openshell-core/telemetry"] + [dev-dependencies] tempfile = "3" temp-env = "0.3" diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 231b588ea..fa4654243 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -3353,6 +3353,7 @@ filesystem_policy: }); } + #[cfg(feature = "telemetry")] #[test] fn telemetry_enabled_creates_activity_collection_and_flush_channel() { let _guard = ENV_LOCK.lock().unwrap(); diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 0b7e3a97e..3fc746c6d 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -16,7 +16,7 @@ path = "src/main.rs" [dependencies] openshell-bootstrap = { path = "../openshell-bootstrap" } -openshell-core = { path = "../openshell-core" } +openshell-core = { path = "../openshell-core", default-features = false } openshell-driver-docker = { path = "../openshell-driver-docker" } openshell-driver-kubernetes = { path = "../openshell-driver-kubernetes" } openshell-driver-podman = { path = "../openshell-driver-podman" } @@ -96,6 +96,11 @@ rustix = { workspace = true } x509-parser = "0.16" [features] +default = ["telemetry"] +## Compile in anonymous telemetry emission (forwards to openshell-core/telemetry). +## On by default; build with `--no-default-features` for a telemetry-free gateway +## that contains no telemetry endpoint, HTTP client, or emission code. +telemetry = ["openshell-core/telemetry"] bundled-z3 = ["openshell-prover/bundled-z3"] dev-settings = ["openshell-core/dev-settings"] test-support = [] diff --git a/crates/openshell-tui/Cargo.toml b/crates/openshell-tui/Cargo.toml index b0ac0c7ca..71e3935f4 100644 --- a/crates/openshell-tui/Cargo.toml +++ b/crates/openshell-tui/Cargo.toml @@ -11,7 +11,7 @@ license.workspace = true repository.workspace = true [dependencies] -openshell-core = { path = "../openshell-core" } +openshell-core = { path = "../openshell-core", default-features = false } openshell-bootstrap = { path = "../openshell-bootstrap" } openshell-policy = { path = "../openshell-policy" } openshell-providers = { path = "../openshell-providers" } diff --git a/tasks/rust.toml b/tasks/rust.toml index 2972957fc..a8856377f 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -25,3 +25,17 @@ hide = true description = "Check Rust formatting" run = "cargo fmt --all -- --check" hide = true + +["rust:verify:telemetry-off"] +description = "Verify telemetry emission code is compiled out with --no-default-features" +run = [ + # Positive control: the default (telemetry-on) gateway must contain the + # markers, so the absent checks below can never become silently vacuous. + "cargo build -p openshell-server --bin openshell-gateway", + "tasks/scripts/verify-telemetry-compiled-out.sh present target/debug/openshell-gateway", + # Guard: telemetry-free builds must contain no telemetry markers. + "cargo build -p openshell-server --bin openshell-gateway --no-default-features", + "tasks/scripts/verify-telemetry-compiled-out.sh absent target/debug/openshell-gateway", + "cargo build -p openshell-sandbox --bin openshell-sandbox --no-default-features", + "tasks/scripts/verify-telemetry-compiled-out.sh absent target/debug/openshell-sandbox", +] diff --git a/tasks/scripts/verify-telemetry-compiled-out.sh b/tasks/scripts/verify-telemetry-compiled-out.sh new file mode 100755 index 000000000..22a238ea1 --- /dev/null +++ b/tasks/scripts/verify-telemetry-compiled-out.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Verify whether telemetry emission code is present in a compiled binary. +# +# The `telemetry` Cargo feature (default-on, defined in openshell-core) gates the +# telemetry endpoint, HTTP client, and emission code. Building with +# --no-default-features must produce a binary that contains none of it. This +# guard inspects a built binary for telemetry markers that only exist when the +# emission code is compiled in. + +set -euo pipefail + +# Markers that appear only in compiled-in telemetry emission code. Sourced from +# crates/openshell-core/src/telemetry.rs (DEFAULT_ENDPOINT host and CLIENT_ID). +# Keep in sync with that file; the `present` positive control fails loudly if a +# marker goes stale, so the `absent` checks can never become silently vacuous. +MARKERS=( + "events.telemetry.data.nvidia.com" + "415437562476676" +) + +usage() { + echo "Usage: verify-telemetry-compiled-out.sh [binary ...]" >&2 + echo " present assert telemetry markers ARE present (positive control for a telemetry-enabled build)" >&2 + echo " absent assert telemetry markers are NOT present (telemetry compiled out)" >&2 +} + +if [[ $# -lt 2 ]]; then + usage + exit 2 +fi + +mode=$1 +shift +case "$mode" in + present | absent) ;; + *) + usage + exit 2 + ;; +esac + +if ! command -v strings >/dev/null 2>&1; then + echo "error: 'strings' (binutils) is required to inspect the binary" >&2 + exit 2 +fi + +failed=0 +for binary in "$@"; do + if [[ ! -f $binary ]]; then + echo "error: binary not found: $binary" >&2 + failed=1 + continue + fi + + dump=$(strings -a "$binary") + for marker in "${MARKERS[@]}"; do + count=$(grep -c -F "$marker" <<<"$dump" || true) + if [[ $mode == absent && $count -ne 0 ]]; then + echo "FAIL: telemetry marker '$marker' found in $binary ($count occurrence(s)); telemetry was not compiled out" >&2 + failed=1 + elif [[ $mode == present && $count -eq 0 ]]; then + echo "FAIL: telemetry marker '$marker' missing from $binary; positive control failed (marker stale or build misconfigured)" >&2 + failed=1 + else + echo "OK: marker '$marker' $mode in $(basename "$binary") ($count occurrence(s))" + fi + done +done + +exit "$failed"