Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bindings/c/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ pub(crate) fn map_error(e: &nono::NonoError) -> types::NonoErrorCode {
nono::NonoError::Io(_) | nono::NonoError::CommandExecution(_) => NonoErrorCode::ErrIo,
nono::NonoError::ObjectStore(_)
| nono::NonoError::Snapshot(_)
| nono::NonoError::AuditLedgerCorrupt { .. }
| nono::NonoError::HashMismatch { .. }
| nono::NonoError::SessionNotFound(_) => NonoErrorCode::ErrIo,
nono::NonoError::TrustVerification { .. }
Expand Down
40 changes: 40 additions & 0 deletions crates/nono-cli/src/audit_ledger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ use std::path::{Path, PathBuf};
const AUDIT_LEDGER_FILENAME: &str = "ledger.ndjson";
const AUDIT_LEDGER_LOCK_FILENAME: &str = "ledger.lock";

/// Path of the global append-only ledger.
pub(crate) fn ledger_path() -> Result<PathBuf> {
Ok(audit_root()?.join(AUDIT_LEDGER_FILENAME))
}

pub(crate) fn append_session(metadata: &SessionMetadata) -> Result<LedgerRecord> {
validate_ledger_session_id(&metadata.session_id)?;

Expand Down Expand Up @@ -165,6 +170,41 @@ mod tests {
assert_eq!(verified.entry_count, 1);
}

#[test]
fn append_fails_closed_on_an_unparseable_ledger() {
let _env_lock = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let state = tmp.path().join("state");
std::fs::create_dir_all(&state).unwrap();
let home = tmp.path().to_string_lossy().to_string();
let state_str = state.to_string_lossy().to_string();
let _env = EnvVarGuard::set_all(&[("HOME", &home), ("XDG_STATE_HOME", &state_str)]);

append_session(&sample_metadata("20260421-200000-11111")).unwrap();

// Two records on one line with no separator i.e. a corrupt ledger.
let path = ledger_path().unwrap();
let record = std::fs::read_to_string(&path).unwrap();
let record = record.trim_end();
let corrupt = format!("{record}{record}\n");
std::fs::write(&path, &corrupt).unwrap();

let err = match append_session(&sample_metadata("20260421-200001-22222")) {
Ok(_) => panic!("append onto an unparseable ledger should fail"),
Err(err) => err,
};

assert!(
matches!(err, NonoError::AuditLedgerCorrupt { .. }),
"unexpected error: {err}"
);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
corrupt,
"a failed append must leave the ledger byte-identical"
);
}

#[test]
fn ledger_rejects_malformed_session_id() {
let _env_lock = ENV_LOCK.lock().unwrap();
Expand Down
105 changes: 105 additions & 0 deletions crates/nono-cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,111 @@ pub fn print_applying_sandbox(silent: bool) {
eprintln!();
}

/// Report that a completed session was not committed to the global audit ledger.
///
/// An append also fails on a lock timeout, a full disk, or an unwritable audit
/// root, none of which outlive the run that hit them. Only a ledger that no
/// longer parses is permanent, so only that error earns the "stopped growing"
/// and "no repair command" story.
pub fn print_audit_ledger_append_failure(error: &NonoError, ledger: Option<&Path>) {
let t = theme::current();
eprintln!();
eprintln!(
" {} {}",
fg("warning:", t.red).bold(),
fg("this session was not recorded in the audit ledger", t.text),
);
eprintln!(" {}", fg(&error.to_string(), t.text));
if let Some(ledger) = ledger {
eprintln!(
" {}",
fg(&format!("ledger: {}", ledger.display()), t.subtext),
);
}
if !matches!(error, NonoError::AuditLedgerCorrupt { .. }) {
return;
}
eprintln!(
" {}",
fg(
"The tamper-evident chain has stopped growing: no run is recorded \
until the ledger parses again.",
t.subtext
),
);
eprintln!(
" {}",
fg(
"There is no repair command. Moving the file aside starts a fresh \
chain, after which `nono audit verify` no longer finds the sessions \
the old one held.",
t.subtext
),
);
}

/// Report the status nono exits with after a session was left out of the ledger.
///
/// The failure itself is already on stderr from
/// [`print_audit_ledger_append_failure`]; this only accounts for the status,
/// which differs from the child's own only when a clean run is downgraded so
/// that a gap in the chain cannot be read as a recorded success.
pub fn print_audit_ledger_exit_status(child_exit_code: i32, reported_exit_code: i32) {
let t = theme::current();
let status = if child_exit_code == reported_exit_code {
format!("the command exited {child_exit_code}; that status is preserved")
} else {
format!(
"the command exited {child_exit_code}, but nono exits \
{reported_exit_code} because the run is missing from the ledger"
)
};
eprintln!(" {}", fg(&status, t.subtext));
}

/// Report a post-run finalization failure.
///
/// Some work runs after the sandboxed command has already exited: the
/// audit-ledger append, session metadata, attestation and the rollback review.
/// A failure there is nono's, not the child's, so `child_exit_code` is what the
/// command itself returned and `reported_exit_code` is what nono will exit with.
/// They differ only when a clean command is downgraded to signal that its
/// session never finished.
pub fn print_session_finalization_failure(
error: &NonoError,
child_exit_code: i32,
reported_exit_code: i32,
) {
let t = theme::current();
eprintln!();
eprintln!(
" {} {}",
fg("warning:", t.red).bold(),
fg(
"session finalization failed after the command exited",
t.text
),
);
eprintln!(" {}", fg(&error.to_string(), t.text));
eprintln!(
" {}",
fg(
"The rollback review, session metadata and attestation for this run \
may be missing or incomplete.",
t.subtext
),
);
let status = if child_exit_code == reported_exit_code {
format!("the command exited {child_exit_code}; that status is preserved")
} else {
format!(
"the command exited {child_exit_code}, but nono exits \
{reported_exit_code} because the session could not be finalized"
)
};
eprintln!(" {}", fg(&status, t.subtext));
}

/// Print a styled warning message to stderr
pub fn print_warning(message: &str) {
let t = theme::current();
Expand Down
78 changes: 74 additions & 4 deletions crates/nono-cli/src/rollback_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ pub(crate) struct RollbackExitContext<'a> {
pub(crate) rollback_prompt_disabled: bool,
}

/// What post-exit bookkeeping managed to complete.
///
/// The ledger append is reported rather than propagated so the audit shipping,
/// rollback review and temp-file cleanup that follow it still run. The caller
/// turns a missing entry into the exit status once all of that is done.
#[must_use = "dropping the outcome loses the ledger gap the caller owes the exit status"]
pub(crate) struct FinalizeOutcome {
/// False only when an append was attempted and failed. A run with auditing
/// off never attempts one, which is not a gap in the chain.
pub(crate) ledger_recorded: bool,
}

fn rollback_vcs_exclusions() -> Vec<String> {
[".git", ".hg", ".svn"]
.iter()
Expand Down Expand Up @@ -474,7 +486,23 @@ pub(crate) fn initialize_rollback_state(
}))
}

pub(crate) fn finalize_supervised_exit(ctx: RollbackExitContext<'_>) -> Result<()> {
/// Commit a completed session to the global audit ledger, reporting whether the
/// entry landed.
///
/// The error is printed here instead of returned because the caller must keep
/// going: an unappendable ledger may not cost the run its audit delivery or its
/// rollback review. The gap still reaches the exit status via
/// [`FinalizeOutcome`].
#[must_use = "on `false` the session is absent from the chain and the caller MUST NOT report success"]
fn record_session_in_ledger(metadata: &nono::undo::SessionMetadata) -> bool {
let Err(error) = audit_ledger::append_session(metadata) else {
return true;
};
output::print_audit_ledger_append_failure(&error, audit_ledger::ledger_path().ok().as_deref());
false
}

pub(crate) fn finalize_supervised_exit(ctx: RollbackExitContext<'_>) -> Result<FinalizeOutcome> {
let RollbackExitContext {
audit_state,
rollback_state,
Expand Down Expand Up @@ -526,6 +554,7 @@ pub(crate) fn finalize_supervised_exit(ctx: RollbackExitContext<'_>) -> Result<(

let scrubbed_command = nono::scrub_argv_with_policy(command, redaction_policy);
let mut audit_saved = false;
let mut ledger_recorded = true;

if let Some(RollbackRuntimeState {
session_dir,
Expand Down Expand Up @@ -565,7 +594,7 @@ pub(crate) fn finalize_supervised_exit(ctx: RollbackExitContext<'_>) -> Result<(
manager.save_session_metadata(&meta)?;
if let Some(audit_state) = audit_state {
nono::undo::SnapshotManager::write_session_metadata(&audit_state.session_dir, &meta)?;
audit_ledger::append_session(&meta)?;
ledger_recorded = record_session_in_ledger(&meta);
if let Err(error) = audit_client::maybe_ship_session(&audit_state.session_dir, &meta) {
warn!("Audit delivery remains queued: {error}");
}
Expand Down Expand Up @@ -615,13 +644,13 @@ pub(crate) fn finalize_supervised_exit(ctx: RollbackExitContext<'_>) -> Result<(
)?);
}
nono::undo::SnapshotManager::write_session_metadata(&audit_state.session_dir, &meta)?;
audit_ledger::append_session(&meta)?;
ledger_recorded = record_session_in_ledger(&meta);
if let Err(error) = audit_client::maybe_ship_session(&audit_state.session_dir, &meta) {
warn!("Audit delivery remains queued: {error}");
}
}

Ok(())
Ok(FinalizeOutcome { ledger_recorded })
}

#[cfg(test)]
Expand Down Expand Up @@ -941,4 +970,45 @@ mod tests {
assert!(verification.signature_verified);
assert!(verification.verification_error.is_none());
}

/// A failed append has to leave a trace in the return value: reporting it on
/// stderr alone would let `finalize_supervised_exit` return a clean outcome
/// for a run that never entered the chain, and the caller's exit-status
/// downgrade is what keeps that from reading as success.
#[test]
fn a_failed_ledger_append_is_reported_as_unrecorded() {
let _env_lock = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let state = tmp.path().join("state");
fs::create_dir_all(&state).unwrap();
let home = tmp.path().to_string_lossy().to_string();
let state_str = state.to_string_lossy().to_string();
let _env = EnvVarGuard::set_all(&[("HOME", &home), ("XDG_STATE_HOME", &state_str)]);

let mut metadata = SessionMetadata {
session_id: "20260421-200000-11111".to_string(),
started: "2026-04-21T20:00:00Z".to_string(),
ended: Some("2026-04-21T20:00:01Z".to_string()),
command: vec!["/bin/pwd".to_string()],
executable_identity: None,
tracked_paths: vec![PathBuf::from("/tmp/project")],
snapshot_count: 0,
exit_code: Some(0),
merkle_roots: Vec::new(),
network_events: Vec::new(),
audit_event_count: 2,
audit_integrity: None,
audit_attestation: None,
};
assert!(record_session_in_ledger(&metadata));

// Two records on one line with no separator i.e. a corrupt ledger.
let path = audit_ledger::ledger_path().unwrap();
let record = fs::read_to_string(&path).unwrap();
let record = record.trim_end().to_string();
fs::write(&path, format!("{record}{record}\n")).unwrap();

metadata.session_id = "20260421-200001-22222".to_string();
assert!(!record_session_in_ledger(&metadata));
}
}
65 changes: 60 additions & 5 deletions crates/nono-cli/src/supervised_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::launch_runtime::{
ProxyLaunchOptions, RollbackLaunchOptions, SessionLaunchOptions, TrustLaunchOptions,
};
use crate::rollback_runtime::{
AuditState, RollbackExitContext, create_audit_state, finalize_supervised_exit,
AuditState, FinalizeOutcome, RollbackExitContext, create_audit_state, finalize_supervised_exit,
initialize_audit_snapshots, initialize_rollback_state, warn_if_rollback_flags_ignored,
};
use crate::{
Expand Down Expand Up @@ -157,6 +157,24 @@ fn resource_limits_unsupported_platform() -> nono::NonoError {
)
}

/// Exit status for a run whose command succeeded but whose post-exit
/// bookkeeping did not.
const FINALIZATION_FAILURE_EXIT_CODE: i32 = 1;

/// The status to report when post-exit bookkeeping failed.
///
/// A non-zero child status is the child's own report and must survive untouched.
/// A zero one would otherwise hand the caller a clean success for a run whose
/// rollback review, session metadata or attestation never completed.
#[must_use]
const fn exit_code_after_finalization_failure(child_exit_code: i32) -> i32 {
if child_exit_code == 0 {
FINALIZATION_FAILURE_EXIT_CODE
} else {
child_exit_code
}
}

/// True only for the exit code a whole-sandbox OOM kill produces (128 + SIGKILL =
/// 137). Gating the memory-cap diagnostic on this keeps a clean exit, an ordinary
/// crash, or a different signal from borrowing the "out of memory" story.
Expand Down Expand Up @@ -417,7 +435,9 @@ pub(crate) fn execute_supervised_runtime(ctx: SupervisedRuntimeContext<'_>) -> R
}

let ended = chrono::Local::now().to_rfc3339();
finalize_supervised_exit(RollbackExitContext {

// Bookkeeping after the child has exited.
let finalize_result = finalize_supervised_exit(RollbackExitContext {
audit_state: audit_state.as_ref(),
rollback_state,
audit_snapshot_state,
Expand All @@ -435,15 +455,50 @@ pub(crate) fn execute_supervised_runtime(ctx: SupervisedRuntimeContext<'_>) -> R
exit_code,
silent,
rollback_prompt_disabled: rollback.prompt_disabled,
})?;

Ok(exit_code)
});
// Both arms return a status rather than an `Err` so the caller still runs the
// after-hook and drops the proxy handle; propagating would skip both and
// leave the TLS-intercept trust bundle behind on `process::exit`.
match finalize_result {
Err(error) => {
let reported = exit_code_after_finalization_failure(exit_code);
output::print_session_finalization_failure(&error, exit_code, reported);
Ok(reported)
}
// A skipped ledger append is the same class of failure as any other
// unfinished bookkeeping and gets the same downgrade: exiting 0 would
// report a recorded run to a caller that only reads the status, and the
// gap is permanent when the ledger no longer parses. The warning is
// already on stderr from the append itself.
Ok(FinalizeOutcome {
ledger_recorded: false,
}) => {
let reported = exit_code_after_finalization_failure(exit_code);
output::print_audit_ledger_exit_status(exit_code, reported);
Ok(reported)
}
Ok(FinalizeOutcome {
ledger_recorded: true,
}) => Ok(exit_code),
}
}

#[cfg(test)]
mod tests {
use super::should_open_supervised_pty;

/// A finalize failure is nono's own, so it may not overwrite what the child
/// reported: only a clean run is downgraded, which is the one case where the
/// substituted status cannot be mistaken for the command's own.
#[test]
fn finalization_failure_downgrades_only_a_clean_exit() {
use super::exit_code_after_finalization_failure;
assert_eq!(exit_code_after_finalization_failure(0), 1);
assert_eq!(exit_code_after_finalization_failure(1), 1);
assert_eq!(exit_code_after_finalization_failure(7), 7);
assert_eq!(exit_code_after_finalization_failure(137), 137);
}

/// Off-Linux, a requested memory limit is refused with UnsupportedPlatform (not
/// SandboxInit), so it maps to the right diagnostic/exit and reads naturally.
/// Pinned on every host so the variant can't silently regress, even though the
Expand Down
Loading