Skip to content

Commit 4ad8ba9

Browse files
authored
refactor(diagnostic): move diagnostic UX out of core nono crate (#1155)
Relocate DiagnosticFormatter and all user-facing diagnostic rendering from the core 'nono' crate to 'nono-cli', restoring the library/CLI boundary documented in AGENTS.md: the library stays a pure sandbox primitive and the CLI owns policy, footer text, flag suggestions, and stderr heuristics. Core 'nono::diagnostic' now exposes only structured, policy-free facts: DenialReason, DenialRecord, IpcDenialRecord, SandboxViolation, and the seatbelt_operation_to_access mapping. Everything else (DiagnosticFormatter, PolicyExplanation, ErrorObservation, analyze_error_output, and the footer rendering with 'nono why'/'nono learn' guidance and CLI flag suggestions) moves to crates/nono-cli/src/diagnostic/. The bulk of this change is a near-verbatim move; the only behavioral edits: - Remove dead code surfaced by the move (previously masked by pub visibility in the library): PolicyExplanation.details/policy_source fields (written but never read), and the unused DiagnosticFormatter methods with_blocked_protected_file, detect_protected_file_in_error, and format_summary. - Deduplicate suggested_flag_parts: the formatter now uses the single query_ext implementation, which guards against suggesting access to the root filesystem. This fixes an edge case where a nonexistent path directly under '/' could yield a '--allow /' suggestion. No change to diagnostic footer output in normal cases; the moved unit tests are the rendering contract and pass unchanged. Signed-off-by: Aleksy Siek <aleksy@alwaysfurther.ai>
1 parent e8293b3 commit 4ad8ba9

8 files changed

Lines changed: 3910 additions & 3944 deletions

File tree

crates/nono-cli/src/diagnostic/formatter.rs

Lines changed: 3826 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//! CLI-owned diagnostic rendering and stderr heuristics.
2+
//!
3+
//! The core `nono::diagnostic` module owns the structured denial records; this
4+
//! module owns all user-facing diagnostic UX: the `nono diagnostic` footer,
5+
//! CLI flag suggestions, policy explanations, and best-effort parsing of a
6+
//! command's own error output.
7+
8+
mod formatter;
9+
10+
pub use formatter::{
11+
CommandContext, DiagnosticFormatter, DiagnosticMode, ErrorObservation, PolicyExplanation,
12+
analyze_error_output,
13+
};

crates/nono-cli/src/exec_strategy.rs

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ mod env_sanitization;
1414
#[cfg(target_os = "linux")]
1515
mod supervisor_linux;
1616

17+
use crate::diagnostic::{DiagnosticFormatter, DiagnosticMode};
1718
use crate::startup_prompt::{notify_startup_termination_for_child, print_terminal_safe_stderr};
1819
use crate::{DETACHED_CWD_PROMPT_RESPONSE_ENV, DETACHED_LAUNCH_ENV, DETACHED_SESSION_ID_ENV};
1920
use nix::libc;
@@ -22,9 +23,8 @@ use nix::sys::wait::{WaitPidFlag, WaitStatus, waitpid};
2223
use nix::unistd::{ForkResult, Pid, fork};
2324
use nono::supervisor::{ApprovalDecision, AuditEntry, SupervisorMessage, SupervisorResponse};
2425
use nono::{
25-
ApprovalBackend, CapabilitySet, DenialReason, DenialRecord, DiagnosticFormatter,
26-
DiagnosticMode, NonoError, Result, Sandbox, SupervisorListener, SupervisorSocket,
27-
UnixSocketCapability, UnixSocketMode,
26+
ApprovalBackend, CapabilitySet, DenialReason, DenialRecord, NonoError, Result, Sandbox,
27+
SupervisorListener, SupervisorSocket, UnixSocketCapability, UnixSocketMode,
2828
};
2929
use std::collections::HashSet;
3030
use std::ffi::{CString, OsStr};
@@ -74,8 +74,8 @@ const MAX_TRACKED_REQUEST_IDS: usize = 4096;
7474
use crate::timeouts;
7575

7676
struct ProfileSaveOffer<'a> {
77-
policy_explanations: &'a [nono::diagnostic::PolicyExplanation],
78-
error_observation: &'a nono::diagnostic::ErrorObservation,
77+
policy_explanations: &'a [crate::diagnostic::PolicyExplanation],
78+
error_observation: &'a crate::diagnostic::ErrorObservation,
7979
caps: &'a CapabilitySet,
8080
command: &'a [String],
8181
compared_profile: Option<&'a str>,
@@ -1281,7 +1281,7 @@ pub fn execute_supervised(
12811281
let error_observation = pty_proxy
12821282
.as_ref()
12831283
.map(|p| {
1284-
nono::diagnostic::analyze_error_output(
1284+
crate::diagnostic::analyze_error_output(
12851285
&p.screen_plaintext(),
12861286
config.protected_paths,
12871287
Some(config.current_dir),
@@ -1371,7 +1371,7 @@ pub fn execute_supervised(
13711371
)
13721372
.with_canonical_denial_paths(canonical_denial_paths);
13731373
if let Some(program) = config.command.first() {
1374-
formatter = formatter.with_command(nono::diagnostic::CommandContext {
1374+
formatter = formatter.with_command(crate::diagnostic::CommandContext {
13751375
program: program.clone(),
13761376
resolved_path: config.resolved_program.to_path_buf(),
13771377
args: nono::scrub_argv_with_policy(config.command, redaction_policy),
@@ -1421,9 +1421,9 @@ fn build_policy_explanations(
14211421
denials: &[nono::diagnostic::DenialRecord],
14221422
sandbox_violations: &[nono::SandboxViolation],
14231423
caps: &nono::CapabilitySet,
1424-
) -> Vec<nono::diagnostic::PolicyExplanation> {
1424+
) -> Vec<crate::diagnostic::PolicyExplanation> {
1425+
use crate::diagnostic::PolicyExplanation;
14251426
use nono::AccessMode;
1426-
use nono::diagnostic::PolicyExplanation;
14271427
use std::collections::BTreeMap;
14281428

14291429
// Merge access modes per path so a path denied for both Read and Write
@@ -1475,17 +1475,13 @@ fn build_policy_explanations(
14751475
match crate::query_ext::query_path(&path, access, caps, &[]) {
14761476
Ok(crate::query_ext::QueryResult::Denied {
14771477
reason,
1478-
details,
1479-
policy_source,
14801478
suggested_flag,
14811479
..
14821480
}) => {
14831481
explanations.push(PolicyExplanation {
14841482
path,
14851483
access,
14861484
reason,
1487-
details,
1488-
policy_source,
14891485
suggested_flag,
14901486
});
14911487
}
@@ -1535,7 +1531,7 @@ fn should_print_diagnostic_footer(
15351531
denials: &[nono::diagnostic::DenialRecord],
15361532
ipc_denials: &[nono::diagnostic::IpcDenialRecord],
15371533
sandbox_violations: &[nono::SandboxViolation],
1538-
error_observation: &nono::diagnostic::ErrorObservation,
1534+
error_observation: &crate::diagnostic::ErrorObservation,
15391535
) -> bool {
15401536
!no_diagnostics
15411537
&& (exit_code != 0
@@ -1566,8 +1562,8 @@ fn filter_suppressed_system_service_violations(
15661562
fn should_offer_profile_save(
15671563
no_diagnostics: bool,
15681564
exit_code: i32,
1569-
policy_explanations: &[nono::diagnostic::PolicyExplanation],
1570-
error_observation: &nono::diagnostic::ErrorObservation,
1565+
policy_explanations: &[crate::diagnostic::PolicyExplanation],
1566+
error_observation: &crate::diagnostic::ErrorObservation,
15711567
sandbox_violations: &[nono::SandboxViolation],
15721568
) -> bool {
15731569
!no_diagnostics
@@ -3723,7 +3719,7 @@ mod tests {
37233719
target: Some("/tmp/secret.txt".to_string()),
37243720
}];
37253721
let denials = Vec::new();
3726-
let observation = nono::diagnostic::ErrorObservation::default();
3722+
let observation = crate::diagnostic::ErrorObservation::default();
37273723

37283724
assert!(should_print_diagnostic_footer(
37293725
false,
@@ -3745,15 +3741,13 @@ mod tests {
37453741

37463742
#[test]
37473743
fn test_profile_save_prompt_triggers_on_policy_explanation_with_zero_exit() {
3748-
let explanations = vec![nono::diagnostic::PolicyExplanation {
3744+
let explanations = vec![crate::diagnostic::PolicyExplanation {
37493745
path: PathBuf::from("/tmp/secret.txt"),
37503746
access: nono::AccessMode::Read,
37513747
reason: "path_not_granted".to_string(),
3752-
details: None,
3753-
policy_source: None,
37543748
suggested_flag: Some("--read-file /tmp/secret.txt".to_string()),
37553749
}];
3756-
let observation = nono::diagnostic::ErrorObservation::default();
3750+
let observation = crate::diagnostic::ErrorObservation::default();
37573751

37583752
assert!(should_offer_profile_save(
37593753
false,
@@ -3767,7 +3761,7 @@ mod tests {
37673761
#[test]
37683762
fn test_profile_save_prompt_triggers_on_user_preferences_violation_with_zero_exit() {
37693763
let explanations = Vec::new();
3770-
let observation = nono::diagnostic::ErrorObservation::default();
3764+
let observation = crate::diagnostic::ErrorObservation::default();
37713765
let violations = vec![nono::SandboxViolation {
37723766
operation: "user-preference-read".to_string(),
37733767
target: Some("kcfpreferencesanyapplication".to_string()),
@@ -3785,7 +3779,7 @@ mod tests {
37853779
#[test]
37863780
fn test_suppressed_system_service_violations_do_not_offer_profile_save() {
37873781
let explanations = Vec::new();
3788-
let observation = nono::diagnostic::ErrorObservation::default();
3782+
let observation = crate::diagnostic::ErrorObservation::default();
37893783
let violations = vec![nono::SandboxViolation {
37903784
operation: "forbidden-exec-sugid".to_string(),
37913785
target: None,
@@ -3833,7 +3827,7 @@ mod tests {
38333827
#[test]
38343828
fn test_profile_save_prompt_preserves_nonzero_exit_behavior() {
38353829
let explanations = Vec::new();
3836-
let observation = nono::diagnostic::ErrorObservation::default();
3830+
let observation = crate::diagnostic::ErrorObservation::default();
38373831

38383832
assert!(should_offer_profile_save(
38393833
false,

crates/nono-cli/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ mod credential_runtime;
2020
mod deprecated_policy;
2121
mod deprecated_schema;
2222
mod deprecation_warnings;
23+
mod diagnostic;
2324
mod exec_strategy;
2425
mod execution_runtime;
2526
#[cfg(unix)]

crates/nono-cli/src/profile_save_runtime.rs

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
use crate::command_display::format_command_line;
2+
use crate::diagnostic::{ErrorObservation, PolicyExplanation};
23
use crate::theme;
34
use crate::{profile, query_ext};
45
use colored::Colorize;
56
use nono::SandboxViolation;
6-
use nono::diagnostic::{ErrorObservation, PolicyExplanation};
77
use nono::{AccessMode, CapabilitySet, NonoError, Result};
88
use std::collections::{BTreeMap, BTreeSet};
99
use std::io::{BufRead, IsTerminal, Write};
@@ -1675,8 +1675,6 @@ mod tests {
16751675
path: target,
16761676
access: AccessMode::Read,
16771677
reason: "sensitive_path".to_string(),
1678-
details: None,
1679-
policy_source: None,
16801678
suggested_flag: None,
16811679
};
16821680

@@ -1710,16 +1708,12 @@ mod tests {
17101708
path: target.clone(),
17111709
access: AccessMode::Read,
17121710
reason: "path_not_granted".to_string(),
1713-
details: None,
1714-
policy_source: None,
17151711
suggested_flag: Some(format!("--read-file {}", target.display())),
17161712
};
17171713
let write = PolicyExplanation {
17181714
path: target,
17191715
access: AccessMode::Write,
17201716
reason: "insufficient_access".to_string(),
1721-
details: None,
1722-
policy_source: None,
17231717
suggested_flag: None,
17241718
};
17251719

@@ -1754,16 +1748,12 @@ mod tests {
17541748
path: ignored.clone(),
17551749
access: AccessMode::Read,
17561750
reason: "path_not_granted".to_string(),
1757-
details: None,
1758-
policy_source: None,
17591751
suggested_flag: None,
17601752
};
17611753
let saved_explanation = PolicyExplanation {
17621754
path: saved,
17631755
access: AccessMode::Read,
17641756
reason: "path_not_granted".to_string(),
1765-
details: None,
1766-
policy_source: None,
17671757
suggested_flag: None,
17681758
};
17691759

@@ -1794,8 +1784,6 @@ mod tests {
17941784
path: target.clone(),
17951785
access: AccessMode::Read,
17961786
reason: "path_not_granted".to_string(),
1797-
details: None,
1798-
policy_source: None,
17991787
suggested_flag: None,
18001788
};
18011789

crates/nono-cli/src/query_ext.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,7 @@ pub fn print_result(result: &QueryResult) {
560560
}
561561
}
562562

563-
fn suggested_flag_for_path(path: &Path, requested: AccessMode) -> String {
563+
pub(crate) fn suggested_flag_for_path(path: &Path, requested: AccessMode) -> String {
564564
let (flag, target) = suggested_flag_parts(path, requested);
565565
format!("{flag} {}", target.display())
566566
}

0 commit comments

Comments
 (0)