Skip to content

Commit c5e9c56

Browse files
committed
feat(diagnostics): expose structured diagnostics for Python supervisors
Add PyO3 bindings for nono #1171 structured diagnostics: error diagnostic_code and remediation attrs, proxy startup diagnostics, exec session reports with stderr translation, and merge helpers. Signed-off-by: Aleksy Siek <aleksy@alwaysfurther.ai>
1 parent 188d3f2 commit c5e9c56

11 files changed

Lines changed: 1089 additions & 38 deletions

File tree

Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ name = "_nono_py"
1313
crate-type = ["cdylib"]
1414

1515
[dependencies]
16-
nono = "0.57.0"
17-
nono-proxy = "0.57.0"
18-
pyo3 = { version = "0.28", features = ["extension-module"] }
16+
nono = { git = "https://github.com/always-further/nono", branch = "exposeDiags" }
17+
nono-proxy = { git = "https://github.com/always-further/nono", branch = "exposeDiags" }
18+
pyo3 = { version = "0.29", features = ["extension-module"] }
1919
tokio = { version = "1", features = ["rt-multi-thread"] }
2020
libc = "0.2"
2121
serde = { version = "1", features = ["derive"] }

python/nono_py/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,12 @@
5252
SupportInfo,
5353
apply,
5454
apply_unlink_overrides,
55+
build_session_diagnostic_report,
5556
embedded_policy_json,
5657
is_supported,
5758
load_embedded_policy,
5859
load_policy,
60+
merge_diagnostic_report_json,
5961
sandboxed_exec,
6062
start_proxy,
6163
support_info,
@@ -88,10 +90,12 @@
8890
"SupportInfo",
8991
"apply",
9092
"apply_unlink_overrides",
93+
"build_session_diagnostic_report",
9194
"embedded_policy_json",
9295
"is_supported",
9396
"load_embedded_policy",
9497
"load_policy",
98+
"merge_diagnostic_report_json",
9599
"sandboxed_exec",
96100
"start_proxy",
97101
"support_info",

python/nono_py/_nono_py.pyi

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,8 +321,27 @@ class ExecResult:
321321
"""Process exit code (0 = success, -N = killed by signal N)."""
322322
...
323323

324+
def session_diagnostics(self) -> dict[str, object]:
325+
"""Structured session diagnostic report for this execution."""
326+
...
327+
328+
def session_diagnostics_json(self) -> str:
329+
"""JSON session diagnostic report."""
330+
...
331+
324332
def __repr__(self) -> str: ...
325333

334+
def build_session_diagnostic_report(exit_code: int) -> dict[str, object]:
335+
"""Build a minimal session diagnostic report."""
336+
...
337+
338+
def merge_diagnostic_report_json(
339+
session_report_json: str,
340+
proxy_diagnostics_json: str | None = None,
341+
) -> dict[str, object]:
342+
"""Merge session and proxy diagnostic JSON."""
343+
...
344+
326345
def sandboxed_exec(
327346
caps: CapabilitySet,
328347
command: list[str],
@@ -561,6 +580,14 @@ class ProxyHandle:
561580
"""Drain and return collected network audit events."""
562581
...
563582

583+
def diagnostics(self) -> list[dict[str, object]]:
584+
"""Startup diagnostics from credential loading."""
585+
...
586+
587+
def diagnostics_json(self) -> str:
588+
"""Serialize startup diagnostics to JSON."""
589+
...
590+
564591
def shutdown(self) -> None:
565592
"""Signal the proxy to shut down gracefully."""
566593
...

src/diagnostic.rs

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
//! Structured diagnostics for Python clients (PyO3 → nono / nono-proxy).
2+
3+
use crate::stderr_observation::{ErrorObservation, ObservedPathHint, analyze_error_output};
4+
use nono::diagnostic::{
5+
NonoDiagnostic, NonoDiagnosticCode, NonoRemediation, SessionDiagnosticReport,
6+
diagnostic_application_failure, diagnostic_likely_sandbox_path, diagnostic_missing_path,
7+
diagnostic_network_blocked, diagnostic_protected_file_write, follow_up_diagnostics,
8+
};
9+
use nono::{AccessMode, CapabilitySet, NonoError, try_canonicalize};
10+
use nono_proxy::ProxyDiagnostic;
11+
use pyo3::prelude::*;
12+
use std::path::Path;
13+
14+
pub(crate) fn diagnostic_code_label(code: NonoDiagnosticCode) -> &'static str {
15+
match code {
16+
NonoDiagnosticCode::SandboxDeniedPath => "sandbox_denied_path",
17+
NonoDiagnosticCode::SandboxDeniedNetwork => "sandbox_denied_network",
18+
NonoDiagnosticCode::SandboxDeniedUnixSocket => "sandbox_denied_unix_socket",
19+
NonoDiagnosticCode::CommandNotFound => "command_not_found",
20+
NonoDiagnosticCode::CommandFailedLikelySandbox => "command_failed_likely_sandbox",
21+
NonoDiagnosticCode::CommandFailedApplication => "command_failed_application",
22+
NonoDiagnosticCode::CredentialNotFound => "credential_not_found",
23+
NonoDiagnosticCode::CredentialUnavailable => "credential_unavailable",
24+
NonoDiagnosticCode::UnsupportedPlatformFeature => "unsupported_platform_feature",
25+
NonoDiagnosticCode::RollbackBudgetExceeded => "rollback_budget_exceeded",
26+
NonoDiagnosticCode::CwdAccessRequired => "cwd_access_required",
27+
NonoDiagnosticCode::ConfigurationError => "configuration_error",
28+
NonoDiagnosticCode::TrustVerificationFailed => "trust_verification_failed",
29+
NonoDiagnosticCode::IoError => "io_error",
30+
NonoDiagnosticCode::Cancelled => "cancelled",
31+
NonoDiagnosticCode::Other => "other",
32+
_ => "other",
33+
}
34+
}
35+
36+
pub(crate) fn attach_nono_error_diagnostics(py: Python<'_>, err: &PyErr, error: &NonoError) {
37+
let value = err.value(py);
38+
let _ = value.setattr(
39+
"diagnostic_code",
40+
diagnostic_code_label(error.diagnostic_code()),
41+
);
42+
if let Some(remediation) = error.remediation()
43+
&& let Ok(rem) = remediation_to_py(py, &remediation)
44+
{
45+
let _ = value.setattr("remediation", rem);
46+
}
47+
}
48+
49+
pub(crate) fn remediation_to_py(
50+
py: Python<'_>,
51+
remediation: &NonoRemediation,
52+
) -> PyResult<Py<PyAny>> {
53+
let json = serde_json::to_string(remediation).map_err(|e| {
54+
pyo3::exceptions::PyRuntimeError::new_err(format!("serialize remediation JSON: {e}"))
55+
})?;
56+
json_to_py(py, &json)
57+
}
58+
59+
pub(crate) fn proxy_diagnostics_to_py(
60+
py: Python<'_>,
61+
diagnostics: &[ProxyDiagnostic],
62+
) -> PyResult<Py<PyAny>> {
63+
let json = serde_json::to_string(diagnostics).map_err(|e| {
64+
pyo3::exceptions::PyRuntimeError::new_err(format!("serialize proxy diagnostics JSON: {e}"))
65+
})?;
66+
json_to_py(py, &json)
67+
}
68+
69+
pub(crate) fn session_report_to_py(
70+
py: Python<'_>,
71+
report: &SessionDiagnosticReport,
72+
) -> PyResult<Py<PyAny>> {
73+
let json = report.to_json().map_err(|e| {
74+
pyo3::exceptions::PyRuntimeError::new_err(format!(
75+
"serialize session diagnostic report JSON: {e}"
76+
))
77+
})?;
78+
json_to_py(py, &json)
79+
}
80+
81+
pub(crate) fn json_to_py(py: Python<'_>, json: &str) -> PyResult<Py<PyAny>> {
82+
let json_mod = py.import("json")?;
83+
Ok(json_mod.call_method1("loads", (json,))?.unbind())
84+
}
85+
86+
/// Build a session diagnostic report from exec output and capability context.
87+
#[must_use]
88+
pub(crate) fn build_session_report_from_exec(
89+
exit_code: i32,
90+
stderr: &[u8],
91+
cwd: Option<&Path>,
92+
caps: &CapabilitySet,
93+
) -> SessionDiagnosticReport {
94+
let stderr_text = String::from_utf8_lossy(stderr);
95+
let observation = analyze_error_output(&stderr_text, &[], cwd);
96+
let mut report =
97+
SessionDiagnosticReport::from_merged_session(exit_code, Vec::new(), Vec::new(), Vec::new());
98+
append_stderr_observations(caps, cwd, &observation, &mut report.diagnostics);
99+
report.diagnostics.extend(follow_up_diagnostics());
100+
report
101+
}
102+
103+
fn append_stderr_observations(
104+
caps: &CapabilitySet,
105+
cwd: Option<&Path>,
106+
observation: &ErrorObservation,
107+
diagnostics: &mut Vec<NonoDiagnostic>,
108+
) {
109+
if let Some(ref file) = observation.blocked_protected_file {
110+
push_unique_diagnostic(diagnostics, diagnostic_protected_file_write(file.clone()));
111+
}
112+
for path in &observation.missing_paths {
113+
push_unique_diagnostic(diagnostics, diagnostic_missing_path(path.clone()));
114+
}
115+
if let Some(ref message) = observation.non_sandbox_failure {
116+
push_unique_diagnostic(diagnostics, diagnostic_application_failure(message.clone()));
117+
}
118+
if observation.network_blocked_hint && caps.is_network_blocked() {
119+
push_unique_diagnostic(diagnostics, diagnostic_network_blocked());
120+
}
121+
for hint in actionable_observed_path_hints(caps, observation) {
122+
if observation_path_already_logged(diagnostics, &hint.path) {
123+
continue;
124+
}
125+
let remediation = remediation_for_observed_hint(caps, cwd, &hint);
126+
push_unique_diagnostic(
127+
diagnostics,
128+
diagnostic_likely_sandbox_path(hint.path, hint.access, remediation),
129+
);
130+
}
131+
}
132+
133+
fn actionable_observed_path_hints(
134+
caps: &CapabilitySet,
135+
observation: &ErrorObservation,
136+
) -> Vec<ObservedPathHint> {
137+
observation
138+
.path_hints
139+
.iter()
140+
.filter_map(|hint| {
141+
actionable_observed_access(caps, &hint.path, hint.access).map(|access| {
142+
ObservedPathHint {
143+
path: hint.path.clone(),
144+
access,
145+
}
146+
})
147+
})
148+
.collect()
149+
}
150+
151+
fn actionable_observed_access(
152+
caps: &CapabilitySet,
153+
path: &Path,
154+
inferred: AccessMode,
155+
) -> Option<AccessMode> {
156+
let Some(cap) = closest_covering_capability(caps, path) else {
157+
return Some(inferred);
158+
};
159+
160+
if cap.access.contains(inferred) {
161+
return None;
162+
}
163+
164+
Some(match (cap.access, inferred) {
165+
(AccessMode::Read, AccessMode::ReadWrite) => AccessMode::Write,
166+
(AccessMode::Write, AccessMode::ReadWrite) => AccessMode::Read,
167+
_ => inferred,
168+
})
169+
}
170+
171+
fn closest_covering_capability<'a>(
172+
caps: &'a CapabilitySet,
173+
path: &Path,
174+
) -> Option<&'a nono::FsCapability> {
175+
let canonical = try_canonicalize(path);
176+
let mut best_covering: Option<&nono::FsCapability> = None;
177+
let mut best_covering_score = 0usize;
178+
179+
for cap in caps.fs_capabilities() {
180+
let covers = if cap.is_file {
181+
cap.resolved == canonical
182+
} else {
183+
canonical.starts_with(&cap.resolved)
184+
};
185+
186+
if !covers {
187+
continue;
188+
}
189+
190+
let score = cap.resolved.as_os_str().len();
191+
if score >= best_covering_score {
192+
best_covering = Some(cap);
193+
best_covering_score = score;
194+
}
195+
}
196+
197+
best_covering
198+
}
199+
200+
fn remediation_for_observed_hint(
201+
caps: &CapabilitySet,
202+
cwd: Option<&Path>,
203+
hint: &ObservedPathHint,
204+
) -> NonoRemediation {
205+
if observed_hint_points_to_ungranted_cwd(caps, cwd, &hint.path) {
206+
return NonoRemediation::AllowCwd;
207+
}
208+
if let Some(cap) = closest_covering_capability(caps, &hint.path) {
209+
let access = match (cap.access, hint.access) {
210+
(AccessMode::Read, AccessMode::ReadWrite) => AccessMode::Write,
211+
(AccessMode::Write, AccessMode::ReadWrite) => AccessMode::Read,
212+
_ => hint.access,
213+
};
214+
return NonoRemediation::GrantPath {
215+
is_file: cap.is_file,
216+
path: cap.resolved.clone(),
217+
access,
218+
};
219+
}
220+
NonoRemediation::GrantPath {
221+
is_file: hint.path.is_file() || hint.path.file_name().is_some_and(|_| !hint.path.is_dir()),
222+
path: hint.path.clone(),
223+
access: hint.access,
224+
}
225+
}
226+
227+
fn observed_hint_points_to_ungranted_cwd(
228+
caps: &CapabilitySet,
229+
cwd: Option<&Path>,
230+
path: &Path,
231+
) -> bool {
232+
let Some(current_dir) = cwd else {
233+
return false;
234+
};
235+
236+
if !path.starts_with(current_dir) {
237+
return false;
238+
}
239+
240+
closest_covering_capability(caps, current_dir).is_none()
241+
}
242+
243+
fn push_unique_diagnostic(diagnostics: &mut Vec<NonoDiagnostic>, diagnostic: NonoDiagnostic) {
244+
if diagnostics.iter().any(|existing| existing == &diagnostic) {
245+
return;
246+
}
247+
diagnostics.push(diagnostic);
248+
}
249+
250+
fn observation_path_already_logged(diagnostics: &[NonoDiagnostic], path: &Path) -> bool {
251+
diagnostics
252+
.iter()
253+
.any(|diagnostic| diagnostic.path.as_deref() == Some(path))
254+
}
255+
256+
/// Build a minimal session diagnostic report for a sandboxed child exit.
257+
///
258+
/// Uses empty denial/violation lists and appends standard follow-up hints.
259+
#[pyfunction]
260+
#[pyo3(signature = (exit_code))]
261+
pub fn build_session_diagnostic_report(exit_code: i32) -> PyResult<Py<PyAny>> {
262+
Python::attach(|py| {
263+
let mut report = SessionDiagnosticReport::from_merged_session(
264+
exit_code,
265+
Vec::new(),
266+
Vec::new(),
267+
Vec::new(),
268+
);
269+
report.diagnostics.extend(follow_up_diagnostics());
270+
session_report_to_py(py, &report)
271+
})
272+
}
273+
274+
/// Merge session diagnostic JSON with optional proxy diagnostics JSON.
275+
///
276+
/// ``proxy_diagnostics_json`` must be a JSON array when provided (the shape
277+
/// returned by ``ProxyHandle.diagnostics_json()``).
278+
#[pyfunction]
279+
#[pyo3(signature = (session_report_json, proxy_diagnostics_json=None))]
280+
pub fn merge_diagnostic_report_json(
281+
session_report_json: &str,
282+
proxy_diagnostics_json: Option<&str>,
283+
) -> PyResult<Py<PyAny>> {
284+
Python::attach(|py| {
285+
let merged = SessionDiagnosticReport::merge_with_proxy_json(
286+
session_report_json,
287+
proxy_diagnostics_json,
288+
)
289+
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
290+
json_to_py(py, &merged)
291+
})
292+
}

0 commit comments

Comments
 (0)