Skip to content

Commit 0e25880

Browse files
committed
feat(runtime): add confined agent capabilities
Add generic filesystem, process, and artifact primitives that require a valid Task 0B execution token before every effect. Register them as cap::* host functions without embedding RSS tool names or schemas.
1 parent 6883626 commit 0e25880

13 files changed

Lines changed: 2532 additions & 42 deletions

File tree

‎src/capabilities/artifacts.rs‎

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
//! Generic bounded artifact put/get/reference primitives.
2+
//!
3+
//! Ownership and quotas are bound to the authorizing token's owner, run, and
4+
//! generation. This module does not format agent-facing artifact payloads.
5+
6+
use std::collections::HashMap;
7+
use std::sync::{Arc, Mutex};
8+
9+
use serde_json::Value;
10+
11+
use super::hash::content_hash;
12+
use super::lifecycle::CapabilityLifecycle;
13+
use super::types::{CapabilityError, CapabilityOwner, CapabilityRisk, TokenClaims};
14+
15+
/// Store-wide artifact ceilings.
16+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17+
pub struct ArtifactLimits {
18+
pub max_object_bytes: usize,
19+
pub max_total_bytes: usize,
20+
pub max_objects: usize,
21+
}
22+
23+
impl Default for ArtifactLimits {
24+
fn default() -> Self {
25+
Self {
26+
max_object_bytes: 8 * 1024 * 1024,
27+
max_total_bytes: 64 * 1024 * 1024,
28+
max_objects: 1_024,
29+
}
30+
}
31+
}
32+
33+
/// Opaque artifact identity plus bounded metadata.
34+
#[derive(Clone, Debug, Eq, PartialEq)]
35+
pub struct ArtifactRef {
36+
pub id: String,
37+
pub len: usize,
38+
pub hash: String,
39+
pub metadata: Value,
40+
}
41+
42+
struct ArtifactRecord {
43+
owner_key: String,
44+
generation: u64,
45+
bytes: Vec<u8>,
46+
hash: String,
47+
metadata: Value,
48+
}
49+
50+
struct ArtifactInner {
51+
lifecycle: CapabilityLifecycle,
52+
owner: CapabilityOwner,
53+
limits: ArtifactLimits,
54+
objects: Mutex<HashMap<String, ArtifactRecord>>,
55+
total_bytes: Mutex<usize>,
56+
}
57+
58+
/// In-memory run-scoped artifact store.
59+
#[derive(Clone)]
60+
pub struct ArtifactCapability {
61+
inner: Arc<ArtifactInner>,
62+
}
63+
64+
impl ArtifactCapability {
65+
/// Constructs an empty store with the supplied quotas.
66+
pub fn new(
67+
lifecycle: CapabilityLifecycle,
68+
owner: CapabilityOwner,
69+
limits: ArtifactLimits,
70+
) -> Result<Self, CapabilityError> {
71+
if limits.max_object_bytes == 0 || limits.max_total_bytes == 0 || limits.max_objects == 0 {
72+
return Err(CapabilityError::new(
73+
"invalid_configuration",
74+
"artifact limits must be positive",
75+
));
76+
}
77+
Ok(Self {
78+
inner: Arc::new(ArtifactInner {
79+
lifecycle,
80+
owner,
81+
limits,
82+
objects: Mutex::new(HashMap::new()),
83+
total_bytes: Mutex::new(0),
84+
}),
85+
})
86+
}
87+
88+
/// Stores bytes under a new opaque id.
89+
pub fn put(
90+
&self,
91+
token: &str,
92+
bytes: &[u8],
93+
metadata: &Value,
94+
) -> Result<ArtifactRef, CapabilityError> {
95+
let claims = self.authorize(token, CapabilityRisk::Write)?;
96+
if bytes.len() > self.inner.limits.max_object_bytes {
97+
return Err(CapabilityError::new(
98+
"artifact_too_large",
99+
"artifact exceeds the per-object bound",
100+
));
101+
}
102+
let mut objects = self
103+
.inner
104+
.objects
105+
.lock()
106+
.unwrap_or_else(|poisoned| poisoned.into_inner());
107+
let mut total = self
108+
.inner
109+
.total_bytes
110+
.lock()
111+
.unwrap_or_else(|poisoned| poisoned.into_inner());
112+
if objects.len() >= self.inner.limits.max_objects
113+
|| total.saturating_add(bytes.len()) > self.inner.limits.max_total_bytes
114+
{
115+
return Err(CapabilityError::new(
116+
"artifact_store_exhausted",
117+
"artifact store quota is exhausted",
118+
));
119+
}
120+
let id = uuid::Uuid::new_v4().to_string();
121+
let hash = content_hash(bytes);
122+
objects.insert(
123+
id.clone(),
124+
ArtifactRecord {
125+
owner_key: claims.owner.key(),
126+
generation: claims.generation,
127+
bytes: bytes.to_vec(),
128+
hash: hash.clone(),
129+
metadata: metadata.clone(),
130+
},
131+
);
132+
*total = total.saturating_add(bytes.len());
133+
Ok(ArtifactRef {
134+
id,
135+
len: bytes.len(),
136+
hash,
137+
metadata: metadata.clone(),
138+
})
139+
}
140+
141+
/// Returns stored bytes for an owned artifact.
142+
pub fn get(&self, token: &str, id: &str) -> Result<Vec<u8>, CapabilityError> {
143+
Ok(self.lookup(token, id, CapabilityRisk::Read)?.bytes)
144+
}
145+
146+
/// Returns identity metadata without payload bytes.
147+
pub fn reference(&self, token: &str, id: &str) -> Result<ArtifactRef, CapabilityError> {
148+
let record = self.lookup(token, id, CapabilityRisk::Read)?;
149+
Ok(ArtifactRef {
150+
id: id.to_string(),
151+
len: record.bytes.len(),
152+
hash: record.hash,
153+
metadata: record.metadata,
154+
})
155+
}
156+
157+
fn authorize(&self, token: &str, risk: CapabilityRisk) -> Result<TokenClaims, CapabilityError> {
158+
self.inner
159+
.lifecycle
160+
.authorize(&self.inner.owner, token, risk)
161+
.map_err(CapabilityError::from)
162+
}
163+
164+
fn lookup(
165+
&self,
166+
token: &str,
167+
id: &str,
168+
risk: CapabilityRisk,
169+
) -> Result<ArtifactRecord, CapabilityError> {
170+
let claims = self.authorize(token, risk)?;
171+
let objects = self
172+
.inner
173+
.objects
174+
.lock()
175+
.unwrap_or_else(|poisoned| poisoned.into_inner());
176+
let record = objects
177+
.get(id)
178+
.ok_or_else(|| CapabilityError::new("artifact_not_found", "artifact is unknown"))?;
179+
if record.owner_key != claims.owner.key() || record.generation != claims.generation {
180+
return Err(CapabilityError::new(
181+
"artifact_not_found",
182+
"artifact is unknown",
183+
));
184+
}
185+
Ok(ArtifactRecord {
186+
owner_key: record.owner_key.clone(),
187+
generation: record.generation,
188+
bytes: record.bytes.clone(),
189+
hash: record.hash.clone(),
190+
metadata: record.metadata.clone(),
191+
})
192+
}
193+
}

0 commit comments

Comments
 (0)