1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
//! Error types for Bashkit
//!
//! This module provides error types for the interpreter with the following design goals:
//! - Human-readable error messages for users
//! - No leakage of sensitive information (paths, memory addresses, secrets)
//! - Clear categorization for programmatic handling
use crate::limits::LimitExceeded;
use thiserror::Error;
/// Result type alias using Bashkit's Error.
pub type Result<T> = std::result::Result<T, Error>;
/// Bashkit error types.
///
/// All error messages are designed to be safe for display to end users without
/// exposing internal details or sensitive information.
#[derive(Error, Debug)]
pub enum Error {
/// Parse error occurred while parsing the script.
///
/// When `line` and `column` are 0, the error has no source location.
#[error("parse error{}: {message}", if *line > 0 { format!(" at line {}, column {}", line, column) } else { String::new() })]
Parse {
message: String,
line: usize,
column: usize,
},
/// Execution error occurred while running the script.
#[error("execution error: {0}")]
Execution(String),
/// I/O error from filesystem operations.
#[error("io error: {0}")]
Io(#[from] std::io::Error),
/// Resource limit exceeded.
#[error("resource limit exceeded: {0}")]
ResourceLimit(LimitExceeded),
/// Network error.
#[error("network error: {0}")]
Network(String),
/// Regex compilation or matching error.
#[error("regex error: {0}")]
Regex(#[from] regex::Error),
/// Execution was cancelled via the cancellation token.
#[error("execution cancelled")]
Cancelled,
/// A bash-level command failure that should not abort the interpreter.
///
/// Used for errors (e.g. missing input-redirect target) that bash handles
/// by failing the individual command with exit 1 and continuing execution,
/// rather than aborting the whole script. Callers that process redirections
/// before executing a command must catch this variant and convert it into a
/// non-fatal `Ok(ExecResult)` with the enclosed stderr message.
#[error("{0}")]
CommandFailure(String),
/// A snapshot declares a minimum reader version this build cannot satisfy.
///
/// Distinct from [`Error::Internal`] so callers can tell "your bashkit is
/// too old for this snapshot" apart from corruption, and prompt an upgrade
/// instead of discarding stored state. See
/// `knowledge/foundations/snapshot-history.md` for the version policy.
#[error("snapshot requires reader version {required}, this build supports {supported}")]
SnapshotTooNew {
/// Minimum reader version the snapshot declares.
required: u16,
/// Highest reader version this build implements.
supported: u16,
},
/// The environment restoring a snapshot differs from the one that made it.
///
/// Carries a rendered [`CapabilityDelta`](crate::CapabilityDelta) summary.
#[error("snapshot capability mismatch: {0}")]
SnapshotCapabilityMismatch(String),
/// Internal error for unexpected failures.
///
/// THREAT[TM-INT-002]: Unexpected internal failures should not crash the interpreter.
/// This error type provides a human-readable message without exposing:
/// - Stack traces
/// - Memory addresses
/// - Internal file paths
/// - Panic messages that may contain sensitive data
///
/// Use this for:
/// - Recovered panics that need to abort execution
/// - Logic errors that indicate a bug
/// - Security-sensitive failures where details should not be exposed
#[error("internal error: {0}")]
Internal(String),
}
impl From<LimitExceeded> for Error {
fn from(limit: LimitExceeded) -> Self {
match limit {
LimitExceeded::ExecutionBudget(crate::limits::ExecutionBudgetExceeded::Cancelled) => {
Self::Cancelled
}
LimitExceeded::ExecutionBudget(crate::limits::ExecutionBudgetExceeded::Deadline {
limit,
}) => Self::ResourceLimit(LimitExceeded::Timeout(limit)),
limit => Self::ResourceLimit(limit),
}
}
}
impl Error {
/// Create a parse error with source location.
pub fn parse_at(message: impl Into<String>, line: usize, column: usize) -> Self {
Self::Parse {
message: message.into(),
line,
column,
}
}
/// Create a parse error without source location.
pub fn parse(message: impl Into<String>) -> Self {
Self::Parse {
message: message.into(),
line: 0,
column: 0,
}
}
/// THREAT[TM-INF-016]: Create an I/O error with sanitized message.
/// Strips host-internal paths from the error message to prevent information
/// leakage to the sandbox guest.
pub fn io_sanitized(err: std::io::Error) -> Self {
Self::Io(std::io::Error::new(
err.kind(),
sanitize_error_message(&err.to_string()),
))
}
/// THREAT[TM-INF-016]: Create a network error with sanitized message.
/// Strips resolved IPs, TLS details, and DNS info from reqwest errors.
pub fn network_sanitized(context: &str, err: &dyn std::fmt::Display) -> Self {
Self::Network(format!(
"{}: {}",
context,
sanitize_error_message(&err.to_string())
))
}
}
/// THREAT[TM-INF-016]: Sanitize error messages to prevent information leakage.
/// Strips:
/// - Host filesystem paths (anything starting with /)
/// - Resolved IP addresses (IPv4 and IPv6)
/// - TLS/SSL negotiation details
fn sanitize_error_message(msg: &str) -> String {
use std::sync::LazyLock;
static PATH_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(
r#"(/(?:home|usr|var|etc|opt|root|proc|sys|run|snap|nix|mnt|media)[/][^\s:"']+)"#,
)
.expect("path regex")
});
static IPV4_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?\b").expect("ipv4 regex")
});
static IPV6_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"\[?[0-9a-fA-F:]{3,39}\]?(:\d+)?").expect("ipv6 regex")
});
static TLS_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"(?i)(ssl|tls)\s*(handshake|negotiation|error|alert)[^.;]*[.;]?")
.expect("tls regex")
});
let mut result = msg.to_string();
// Strip absolute host paths (preserve VFS paths like /tmp, /dev/null)
result = PATH_RE.replace_all(&result, "<path>").to_string();
// Strip IPv4 addresses
result = IPV4_RE.replace_all(&result, "<address>").to_string();
// Strip IPv6 addresses (only if :: present to avoid false positives)
if result.contains("::") {
result = IPV6_RE.replace_all(&result, "<address>").to_string();
}
// Strip TLS/SSL handshake details
result = TLS_RE.replace_all(&result, "<tls-error>").to_string();
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_strips_host_paths() {
let msg = "No such file: /home/user/.config/bashkit/settings.json";
let sanitized = sanitize_error_message(msg);
assert!(!sanitized.contains("/home/user"));
assert!(sanitized.contains("<path>"));
}
#[test]
fn sanitize_strips_ipv4() {
let msg = "connection refused: 192.168.1.100:8080";
let sanitized = sanitize_error_message(msg);
assert!(!sanitized.contains("192.168"));
assert!(sanitized.contains("<address>"));
}
#[test]
fn sanitize_strips_tls_details() {
let msg = "SSL handshake failed with cipher TLS_AES_256_GCM;";
let sanitized = sanitize_error_message(msg);
assert!(!sanitized.contains("cipher"));
assert!(sanitized.contains("<tls-error>"));
}
#[test]
fn sanitize_preserves_safe_paths() {
let msg = "file not found: /tmp/script.sh";
let sanitized = sanitize_error_message(msg);
assert!(sanitized.contains("/tmp/script.sh"));
}
#[test]
fn sanitize_preserves_generic_messages() {
let msg = "operation timed out";
assert_eq!(sanitize_error_message(msg), msg);
}
}