Skip to content

Commit 4dfd41e

Browse files
committed
fix(runtime): enforce capability resource ceilings
Drive process cancel-all from run state so spawned children die on cancel, recover, stop, shutdown, and drop. Freeze ConfinedFsRoot at admission, bound listing to admitted page/cursor, serialize write_atomic CAS, clamp process ceilings from host config, and return lossless Value::Bytes for fs/artifact payloads.
1 parent 0e25880 commit 4dfd41e

6 files changed

Lines changed: 617 additions & 92 deletions

File tree

src/capabilities/filesystem.rs

Lines changed: 109 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
//! These operations do not embed model-visible tool names, schemas, or result
44
//! formatting. Every effect requires a valid execution token.
55
6+
use std::collections::HashMap;
7+
use std::sync::{Arc, Mutex};
8+
69
use rustscript_vm::{
710
ConfinedFileType, ConfinedFsError, ConfinedFsErrorKind, ConfinedFsLimits, ConfinedFsRoot,
8-
ConfinedMetadata, EnumerationBudget, MAX_ENUM_ENTRIES,
11+
ConfinedMetadata, EnumerationBudget, MAX_COMPONENT_BYTES, MAX_ENUM_ENTRIES, MAX_READ_BYTES,
12+
MAX_WRITE_BYTES,
913
};
1014

1115
use super::hash::content_hash;
@@ -76,10 +80,14 @@ pub struct FilesystemCapability {
7680
lifecycle: CapabilityLifecycle,
7781
owner: CapabilityOwner,
7882
limits: FilesystemLimits,
83+
root: Arc<ConfinedFsRoot>,
84+
cas_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
7985
}
8086

8187
impl FilesystemCapability {
8288
/// Constructs a filesystem capability. Limits must be positive.
89+
///
90+
/// Opens and validates the confined workspace root once at admission.
8391
pub fn new(
8492
lifecycle: CapabilityLifecycle,
8593
owner: CapabilityOwner,
@@ -92,18 +100,30 @@ impl FilesystemCapability {
92100
"filesystem limits must be positive",
93101
));
94102
}
103+
let root = ConfinedFsRoot::with_limits(
104+
lifecycle.workspace(),
105+
ConfinedFsLimits {
106+
max_read_bytes: MAX_READ_BYTES,
107+
max_write_bytes: limits.max_write_bytes.min(MAX_WRITE_BYTES),
108+
max_entries: MAX_ENUM_ENTRIES,
109+
max_entry_name_bytes: MAX_COMPONENT_BYTES,
110+
max_temp_attempts: 32,
111+
},
112+
)
113+
.map_err(map_fs_error)?;
95114
Ok(Self {
96115
lifecycle,
97116
owner,
98117
limits,
118+
root: Arc::new(root),
119+
cas_locks: Arc::new(Mutex::new(HashMap::new())),
99120
})
100121
}
101122

102123
/// Stats a workspace-relative path without following a leaf symlink.
103124
pub fn metadata(&self, token: &str, path: &str) -> Result<FsMetadata, CapabilityError> {
104-
let claims = self.authorize(token, CapabilityRisk::Read)?;
105-
let root = open_root(&claims)?;
106-
let meta = deny_symlink(root.metadata(path).map_err(map_fs_error)?)?;
125+
let _claims = self.authorize(token, CapabilityRisk::Read)?;
126+
let meta = deny_symlink(self.root.metadata(path).map_err(map_fs_error)?)?;
107127
Ok(FsMetadata {
108128
file_type: file_type_name(meta.file_type()),
109129
len: meta.len(),
@@ -118,38 +138,53 @@ impl FilesystemCapability {
118138
offset: u64,
119139
limit: usize,
120140
) -> Result<FsRead, CapabilityError> {
121-
let claims = self.authorize(token, CapabilityRisk::Read)?;
141+
let _claims = self.authorize(token, CapabilityRisk::Read)?;
122142
if limit > self.limits.max_read_bytes {
123143
return Err(CapabilityError::new(
124144
"budget_exceeded",
125145
"requested read exceeds the configured bound",
126146
));
127147
}
128-
let root = open_root(&claims)?;
129-
let meta = deny_symlink(root.metadata(path).map_err(map_fs_error)?)?;
148+
let meta = deny_symlink(self.root.metadata(path).map_err(map_fs_error)?)?;
130149
if meta.file_type() != ConfinedFileType::File {
131150
return Err(CapabilityError::new(
132151
"wrong_type",
133152
"path is not a regular file",
134153
));
135154
}
136-
let contents = root.read_file(path).map_err(map_fs_error)?;
137-
let hash = Some(content_hash(&contents));
138-
let start = usize::try_from(offset).unwrap_or(usize::MAX);
139-
if start >= contents.len() {
155+
let file_len = meta.len();
156+
let start = offset.min(file_len);
157+
let want = u64::try_from(limit).unwrap_or(u64::MAX);
158+
let end = start.saturating_add(want).min(file_len);
159+
let window_len = usize::try_from(end.saturating_sub(start)).unwrap_or(0);
160+
if window_len == 0 {
140161
return Ok(FsRead {
141162
bytes: Vec::new(),
142163
offset,
143164
truncated: false,
144-
hash,
165+
hash: Some(bounded_identity(offset, 0, file_len)),
166+
});
167+
}
168+
let mut file = self.root.open_read(path).map_err(map_fs_error)?;
169+
let contents = file.read_to_end().map_err(map_fs_error)?;
170+
let read_len = u64::try_from(contents.len()).unwrap_or(u64::MAX);
171+
let start_idx = usize::try_from(start).unwrap_or(usize::MAX);
172+
if start_idx >= contents.len() {
173+
return Ok(FsRead {
174+
bytes: Vec::new(),
175+
offset,
176+
truncated: file_len > read_len,
177+
hash: Some(identity_for(&contents, start, 0, file_len)),
145178
});
146179
}
147-
let end = start.saturating_add(limit).min(contents.len());
180+
let end_idx = start_idx.saturating_add(window_len).min(contents.len());
181+
let bytes = contents[start_idx..end_idx].to_vec();
182+
let truncated = start.saturating_add(bytes.len() as u64) < file_len;
148183
Ok(FsRead {
149-
bytes: contents[start..end].to_vec(),
184+
hash: Some(identity_for(&contents, start, bytes.len(), file_len)),
185+
bytes,
150186
offset,
151-
truncated: end < contents.len(),
152-
hash,
187+
truncated,
153188
})
154189
}
155190

@@ -161,16 +196,15 @@ impl FilesystemCapability {
161196
cursor: u64,
162197
limit: usize,
163198
) -> Result<FsList, CapabilityError> {
164-
let claims = self.authorize(token, CapabilityRisk::Read)?;
199+
let _claims = self.authorize(token, CapabilityRisk::Read)?;
165200
if limit > self.limits.max_list_entries {
166201
return Err(CapabilityError::new(
167202
"budget_exceeded",
168203
"requested listing exceeds the configured bound",
169204
));
170205
}
171-
let root = open_root(&claims)?;
172206
if !path.is_empty() {
173-
let meta = deny_symlink(root.metadata(path).map_err(map_fs_error)?)?;
207+
let meta = deny_symlink(self.root.metadata(path).map_err(map_fs_error)?)?;
174208
if meta.file_type() != ConfinedFileType::Directory {
175209
return Err(CapabilityError::new(
176210
"wrong_type",
@@ -179,10 +213,11 @@ impl FilesystemCapability {
179213
}
180214
}
181215
let budget = EnumerationBudget {
182-
max_entries: MAX_ENUM_ENTRIES,
183-
max_name_bytes: 255,
216+
max_entries: listing_entry_budget(cursor, limit, self.limits.max_list_entries),
217+
max_name_bytes: MAX_COMPONENT_BYTES,
184218
};
185-
let mut entries = root
219+
let mut entries = self
220+
.root
186221
.enumerate_with_budget(path, budget)
187222
.map_err(map_fs_error)?;
188223
entries.retain(|entry| entry.name() != "." && entry.name() != "..");
@@ -218,15 +253,29 @@ impl FilesystemCapability {
218253
expected_hash: &str,
219254
bytes: &[u8],
220255
) -> Result<FsWrite, CapabilityError> {
221-
let claims = self.authorize(token, CapabilityRisk::Write)?;
256+
let _claims = self.authorize(token, CapabilityRisk::Write)?;
222257
if bytes.len() > self.limits.max_write_bytes {
223258
return Err(CapabilityError::new(
224259
"budget_exceeded",
225260
"requested write exceeds the configured bound",
226261
));
227262
}
228-
let root = open_root(&claims)?;
229-
match root.metadata(path) {
263+
let lock = self.lock_for(path);
264+
let _guard = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
265+
self.validate_expected_hash(path, expected_hash)?;
266+
self.root.write_file(path, bytes).map_err(map_fs_error)?;
267+
Ok(FsWrite {
268+
hash: content_hash(bytes),
269+
len: bytes.len(),
270+
})
271+
}
272+
273+
fn validate_expected_hash(
274+
&self,
275+
path: &str,
276+
expected_hash: &str,
277+
) -> Result<(), CapabilityError> {
278+
match self.root.metadata(path) {
230279
Ok(meta) => {
231280
if meta.file_type() == ConfinedFileType::Symlink {
232281
return Err(CapabilityError::new(
@@ -246,7 +295,7 @@ impl FilesystemCapability {
246295
"destination is not a regular file",
247296
));
248297
}
249-
let current = root.read_file(path).map_err(map_fs_error)?;
298+
let current = self.root.read_file(path).map_err(map_fs_error)?;
250299
if content_hash(&current) != expected_hash {
251300
return Err(CapabilityError::new(
252301
"cas_mismatch",
@@ -264,11 +313,18 @@ impl FilesystemCapability {
264313
}
265314
Err(error) => return Err(map_fs_error(error)),
266315
}
267-
root.write_file(path, bytes).map_err(map_fs_error)?;
268-
Ok(FsWrite {
269-
hash: content_hash(bytes),
270-
len: bytes.len(),
271-
})
316+
Ok(())
317+
}
318+
319+
fn lock_for(&self, path: &str) -> Arc<Mutex<()>> {
320+
let mut locks = self
321+
.cas_locks
322+
.lock()
323+
.unwrap_or_else(|poisoned| poisoned.into_inner());
324+
locks
325+
.entry(path.to_string())
326+
.or_insert_with(|| Arc::new(Mutex::new(())))
327+
.clone()
272328
}
273329

274330
fn authorize(&self, token: &str, risk: CapabilityRisk) -> Result<TokenClaims, CapabilityError> {
@@ -278,9 +334,28 @@ impl FilesystemCapability {
278334
}
279335
}
280336

281-
fn open_root(claims: &TokenClaims) -> Result<ConfinedFsRoot, CapabilityError> {
282-
ConfinedFsRoot::with_limits(&claims.workspace, ConfinedFsLimits::default())
283-
.map_err(map_fs_error)
337+
fn listing_entry_budget(cursor: u64, limit: usize, max_list_entries: usize) -> usize {
338+
let page = limit.min(max_list_entries);
339+
let start = usize::try_from(cursor).unwrap_or(usize::MAX);
340+
let observe = start
341+
.saturating_add(page)
342+
.saturating_add(1)
343+
.saturating_add(2);
344+
let cap = max_list_entries.saturating_add(3);
345+
observe.min(cap)
346+
}
347+
348+
fn identity_for(contents: &[u8], offset: u64, window_len: usize, file_len: u64) -> String {
349+
let read_len = u64::try_from(contents.len()).unwrap_or(u64::MAX);
350+
if read_len == file_len {
351+
content_hash(contents)
352+
} else {
353+
bounded_identity(offset, window_len, file_len)
354+
}
355+
}
356+
357+
fn bounded_identity(offset: u64, window_len: usize, file_len: u64) -> String {
358+
format!("range:{offset}:{window_len}:{file_len}")
284359
}
285360

286361
fn deny_symlink(meta: ConfinedMetadata) -> Result<ConfinedMetadata, CapabilityError> {

src/capabilities/lifecycle.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Injectable durable lifecycle, clock, tokens, and approval.
22
33
use std::collections::HashMap;
4-
use std::path::PathBuf;
4+
use std::path::{Path, PathBuf};
55
use std::sync::Arc;
66
use std::sync::atomic::{AtomicU64, Ordering};
77
use std::time::Instant;
@@ -288,6 +288,11 @@ impl CapabilityLifecycle {
288288
CapabilityLifecycleBuilder::default()
289289
}
290290

291+
/// Frozen workspace path captured at admission.
292+
pub fn workspace(&self) -> &Path {
293+
&self.inner.workspace
294+
}
295+
291296
pub fn prepare(
292297
&self,
293298
owner: &CapabilityOwner,

0 commit comments

Comments
 (0)