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
//! Confining the running process itself under a capability policy.
//!
//! Every other entry point in this module confines a child at spawn. This one
//! confines the caller: a long-lived agent server that interprets model output
//! and runs untrusted pipeline code calls it once, before it serves, so a
//! compromise of the VM is held by the kernel to the same workspace boundary a
//! confined command gets, instead of inheriting the user's full authority.
//!
//! The profile is the one the backend renders for a child under the same
//! policy. There is no second renderer to drift: a root granted here is a root
//! a confined command would be granted, and a credential directory denied to
//! one is denied to the other.
//!
//! Confinement cannot be undone or widened, and on macOS it cannot be stacked:
//! once this process is confined, children it spawns run under its profile
//! rather than a narrower one of their own (see `macos/nested.rs`, which
//! refuses a child whose policy the outer profile would silently widen). On
//! Linux, Landlock domains stack, so a child's own ruleset still narrows it
//! further.
use std::path::PathBuf;
use std::sync::OnceLock;
use crate::orchestration::CapabilityPolicy;
use crate::value::VmError;
use super::{ActiveBackend, SandboxBackend, SandboxMechanism};
use crate::stdlib::sandbox;
/// A record of this process's own confinement, set once and never cleared.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct ProcessConfinement {
/// The platform backend that applied it, as `active_backend_name` reports.
pub backend: &'static str,
/// The kernel mechanism now enforcing it.
pub mechanism: SandboxMechanism,
/// The workspace roots the process may write, as the profile rendered them.
pub workspace_roots: Vec<PathBuf>,
/// The process's private temp dir, which `TMPDIR` now names.
pub temp_dir: PathBuf,
}
impl ProcessConfinement {
/// Whether `path` lies inside a root this process may write.
pub fn covers(&self, path: &std::path::Path) -> bool {
let path = sandbox::normalize_for_policy(path);
self.workspace_roots
.iter()
.any(|root| path.starts_with(root))
}
}
static CONFINEMENT: OnceLock<ProcessConfinement> = OnceLock::new();
/// Confine the calling process under `policy`, for the rest of its life.
///
/// Fails closed: an error means the process is NOT confined and the caller
/// must not go on to serve as if it were. Refuses a policy whose sandbox
/// profile confines no process, since there is then nothing to apply, and a
/// second call, since a confinement can be neither replaced nor stacked.
pub fn confine_current_process(
policy: &CapabilityPolicy,
) -> Result<&'static ProcessConfinement, VmError> {
confine_with::<ActiveBackend>(policy)
}
fn confine_with<B: SandboxBackend>(
policy: &CapabilityPolicy,
) -> Result<&'static ProcessConfinement, VmError> {
if CONFINEMENT.get().is_some() {
return Err(sandbox::sandbox_rejection(
"this process is already confined, and a confinement cannot be replaced".to_string(),
));
}
if !policy.sandbox_profile.confines_processes() {
return Err(sandbox::sandbox_rejection(format!(
"the `{}` sandbox profile confines no process, so there is nothing to confine this \
process with",
policy.sandbox_profile.as_str()
)));
}
// A root that contains the home directory makes the profile a formality:
// it hands the process the user's dotfiles, shell startup files, and keys.
let home = crate::user_dirs::home_dir().map(|home| sandbox::normalize_for_policy(&home));
if let Some(root) = sandbox::process_sandbox_roots(policy)
.into_iter()
.find(|root| {
root.parent().is_none() || home.as_ref().is_some_and(|home| home.starts_with(root))
})
{
return Err(sandbox::sandbox_rejection(format!(
"`{}` contains the home directory, so confining to it would confine nothing",
root.display()
)));
}
// The host temp dir is shared with every other process the user runs, so
// it is not granted. The process gets a private one instead: Harn writes
// command artifacts and scratch files through `TMPDIR`.
let temp_dir = private_temp_dir()?;
let mut policy = policy.clone();
policy
.process_sandbox
.write_roots
.push(temp_dir.display().to_string());
let mechanism = match B::confine_current_process(&policy) {
Ok(mechanism) => mechanism,
Err(error) => {
// Nothing was applied, so nothing will use the directory.
let _ = std::fs::remove_dir(&temp_dir);
return Err(error);
}
};
// SAFETY: called once, before the process serves its first message.
// Runtime threads may already exist, but none is reading the environment
// yet: they are parked until the server starts handing them work.
unsafe { std::env::set_var("TMPDIR", &temp_dir) };
let confinement = ProcessConfinement {
backend: B::name(),
mechanism,
workspace_roots: sandbox::process_sandbox_roots(&policy),
temp_dir,
};
let confinement = CONFINEMENT.get_or_init(|| confinement);
report(confinement);
Ok(confinement)
}
/// A fresh directory under the host temp dir that only this process uses.
///
/// Created with a random name and without following an existing entry, so a
/// name another user planted in a shared temp dir (a symlink to somewhere
/// they want written) is an error rather than a grant.
fn private_temp_dir() -> Result<PathBuf, VmError> {
let parent = sandbox::normalize_for_policy(&std::env::temp_dir());
let dir = parent.join(format!("harn-confined-{}", uuid::Uuid::new_v4().simple()));
// Only Unix sets a mode, so elsewhere the builder is never mutated.
#[cfg_attr(not(unix), allow(unused_mut))]
let mut builder = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
builder.create(&dir).map_err(|error| {
sandbox::sandbox_rejection(format!(
"cannot create the private temp dir {}: {error}",
dir.display()
))
})?;
Ok(dir)
}
/// This process's own confinement, if [`confine_current_process`] applied one.
pub fn current_process_confinement() -> Option<&'static ProcessConfinement> {
CONFINEMENT.get()
}
fn report(confinement: &ProcessConfinement) {
let mut metadata = std::collections::BTreeMap::new();
metadata.insert(
"backend".to_string(),
serde_json::json!(confinement.backend),
);
metadata.insert(
"mechanism".to_string(),
serde_json::json!(confinement.mechanism.as_str()),
);
metadata.insert(
"workspace_roots".to_string(),
serde_json::json!(confinement.workspace_roots),
);
metadata.insert(
"temp_dir".to_string(),
serde_json::json!(confinement.temp_dir),
);
crate::events::log_info_meta(
"process_sandbox_self",
"this process is now confined by the kernel to its workspace profile",
metadata,
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::orchestration::SandboxProfile;
fn policy_for(root: &std::path::Path) -> CapabilityPolicy {
CapabilityPolicy {
workspace_roots: vec![root.display().to_string()],
sandbox_profile: SandboxProfile::Worktree,
..Default::default()
}
}
// Each refusal happens before anything is applied, so the test process
// stays unconfined.
#[test]
fn a_root_containing_home_is_refused() {
let home = crate::user_dirs::home_dir().expect("a home directory");
for root in [home.clone(), home.parent().unwrap().to_path_buf()] {
let error = confine_current_process(&policy_for(&root)).unwrap_err();
assert!(
error.to_string().contains("contains the home directory"),
"{error}"
);
}
assert!(current_process_confinement().is_none());
}
/// A backend that cannot confine must leave the process unconfined and
/// say so, not report success: the caller would otherwise serve open.
#[test]
fn a_backend_failure_fails_closed() {
let workspace = tempfile::tempdir().unwrap();
let error = confine_with::<super::super::UnconfinedBackend>(&policy_for(workspace.path()))
.unwrap_err();
assert!(
error
.to_string()
.contains("cannot confine a running process"),
"{error}"
);
assert!(current_process_confinement().is_none());
assert!(std::env::var_os("TMPDIR")
.is_none_or(|tmp| { !tmp.to_string_lossy().contains("harn-confined-") }));
}
#[test]
fn a_profile_that_confines_nothing_is_refused() {
let workspace = tempfile::tempdir().unwrap();
let policy = CapabilityPolicy {
sandbox_profile: SandboxProfile::Unrestricted,
..policy_for(workspace.path())
};
let error = confine_current_process(&policy).unwrap_err();
assert!(error.to_string().contains("confines no process"), "{error}");
assert!(current_process_confinement().is_none());
}
}