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
//! The `[capabilities]` block — what a capsule asks for from the OS.
//!
//! Every field is fail-closed by default (empty `Vec` or `false`). The kernel
//! security gates consult these allowlists before granting access.
use serde::{Deserialize, Serialize};
/// A collection of capabilities the capsule requests from the OS.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CapabilitiesDef {
/// Whether the capsule acts as a long-lived uplink/daemon (e.g. the CLI proxy).
/// When true, the WASM execution timeout is disabled.
#[serde(default)]
pub uplink: bool,
/// Network domains the capsule wants to access.
#[serde(default)]
pub net: Vec<String>,
/// Scoped KV store access requests.
/// Note: KV access is inherently scoped per-capsule at runtime,
/// so this field is currently not enforced via a security gate, but
/// is present for future cross-capsule KV request declarations.
#[serde(default)]
pub kv: Vec<String>,
/// VFS read paths.
#[serde(default)]
pub fs_read: Vec<String>,
/// VFS write paths.
#[serde(default)]
pub fs_write: Vec<String>,
/// Legacy host process executions (the "Airlock Override").
#[serde(default)]
pub host_process: Vec<String>,
/// Operator sub-grant of `host_process`: whether the capsule may spawn
/// PERSISTENT (host-owned, instance-outliving) background processes via
/// `astrid:process.spawn-persistent`, not just ephemeral `spawn-background`.
///
/// Fail-closed (default `false`). `host_process` alone grants only
/// ephemeral exec, whose child is reaped when the spawning instance resets.
/// A persistent child survives the instance and — on macOS, which has no
/// `die-with-parent` — a daemon hard-crash can orphan a still-sandboxed
/// child, so persistence is an additional operator-reviewed opt-in on top
/// of `host_process`. Without it, `spawn-persistent` returns
/// `capability-denied` (the ephemeral `spawn` / `spawn-background` stay
/// available under `host_process`).
#[serde(default)]
pub allow_persistent: bool,
/// Unix/TCP socket bind addresses the capsule requires.
#[serde(default)]
pub net_bind: Vec<String>,
/// Outbound TCP destinations the capsule is allowed to connect to.
///
/// Each entry is a `"host:port"` pattern. The `host` portion is a
/// literal DNS name or `*` (universal — see security review note
/// before allowing). The `port` portion is a decimal `u16` or `*`
/// (any port for the named host). Empty list → no outbound TCP
/// (fail-closed). Gated by the `astrid:capsule/net.net-connect-tcp`
/// host fn; the same kernel-side SSRF airlock that gates
/// `http-request` runs on the resolved IP after the capability
/// check passes.
#[serde(default)]
pub net_connect: Vec<String>,
/// Identity operations this capsule is allowed to perform.
///
/// Valid values: `"resolve"` (read-only lookups), `"link"` (create/delete
/// links, list links), `"admin"` (create users). The hierarchy is
/// `admin > link > resolve` - higher levels imply all lower levels.
///
/// An empty list means NO identity access (fail-closed).
#[serde(default)]
pub identity: Vec<String>,
/// Whether the capsule may override or modify the system prompt via the
/// prompt builder's hook pipeline.
///
/// When `false` (default), hook responses from this capsule have their
/// `systemPrompt`, `prependSystemContext`, and `appendSystemContext`
/// fields stripped. Only `prependContext` (user-visible context) passes
/// through.
///
/// This is a critical security boundary: unprivileged capsules cannot
/// inject arbitrary instructions into the LLM's system prompt.
#[serde(default)]
pub allow_prompt_injection: bool,
}
impl CapabilitiesDef {
/// Whether a serialized capability field counts as HELD: a non-empty
/// allowlist (`Vec` → JSON array) or an enabled flag (`bool` → JSON
/// `true`). Any other JSON shape is fail-closed (`false`) — a future
/// capability field whose "held" meaning is neither of those two must opt
/// in here deliberately rather than be silently reported.
fn value_is_held(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::Bool(enabled) => *enabled,
serde_json::Value::Array(allowlist) => !allowlist.is_empty(),
_ => false,
}
}
/// The capability NAMES this capsule declared in its `[capabilities]`
/// manifest block (`host_process`, `net_connect`, `fs_read`, …) — the
/// capability categories, NOT the scoped arguments within them
/// (allowlists, `host:port`, paths).
///
/// DERIVED from the struct itself, not a hand-maintained list: every field
/// IS a capability, so the names are the struct's serialized field names
/// (which are exactly the manifest TOML keys — no `#[serde(rename)]`),
/// filtered to the ones that are held (a non-empty allowlist or an enabled
/// flag). Adding a field to `CapabilitiesDef` therefore flows through
/// `held_names` AND [`has`](Self::has) automatically — there is no parallel
/// list to drift from the struct, which is the very code-vs-manifest drift
/// this introspection surface exists to prevent. Returned sorted, so the
/// order is deterministic and independent of serde's map ordering.
///
/// Backs `astrid:sys/host.enumerate-capabilities`; `n` appears here iff
/// [`has(n)`](Self::has) is true.
pub fn held_names(&self) -> Vec<String> {
let serde_json::Value::Object(fields) =
serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
else {
return Vec::new();
};
let mut names: Vec<String> = fields
.into_iter()
.filter(|(_, value)| Self::value_is_held(value))
.map(|(name, _)| name)
.collect();
names.sort_unstable();
names
}
/// Whether this capsule holds the named capability — the per-name dual of
/// [`held_names`](Self::held_names), derived from the same serialized form
/// so the two cannot disagree. `has(n)` is true exactly when `n` is in
/// `held_names()`. Unknown names are fail-closed (`false`), so this backs
/// `astrid:sys/host.check-capsule-capability` directly.
pub fn has(&self, name: &str) -> bool {
let serde_json::Value::Object(fields) =
serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
else {
return false;
};
fields.get(name).is_some_and(Self::value_is_held)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A fully-populated set: every list non-empty, every bool true. Every
/// name must be reported by `held_names` AND answer true to `has`.
#[test]
fn held_names_and_has_agree_when_all_held() {
let caps = CapabilitiesDef {
uplink: true,
net: vec!["example.com".into()],
kv: vec!["scope".into()],
fs_read: vec!["/r".into()],
fs_write: vec!["/w".into()],
host_process: vec!["bash".into()],
allow_persistent: true,
net_bind: vec!["127.0.0.1:0".into()],
net_connect: vec!["host:443".into()],
identity: vec!["resolve".into()],
allow_prompt_injection: true,
};
let names = caps.held_names();
let expected = [
"allow_persistent",
"allow_prompt_injection",
"fs_read",
"fs_write",
"host_process",
"identity",
"kv",
"net",
"net_bind",
"net_connect",
"uplink",
];
assert_eq!(
names, expected,
"deterministic, sorted order — all 11 fields"
);
for n in expected {
assert!(caps.has(n), "has({n}) must agree with held_names");
}
// Derivation guard: with every field held, `held_names` must report
// EVERY serialized field — not a hand-picked subset. A capability
// added to `CapabilitiesDef` is then surfaced without editing this
// module (and if its JSON shape is not bool/array, `value_is_held`
// fails this on purpose, forcing a deliberate decision).
let serde_json::Value::Object(fields) = serde_json::to_value(&caps).unwrap() else {
panic!("CapabilitiesDef serializes to a JSON object");
};
assert_eq!(
names.len(),
fields.len(),
"held_names must cover every serialized capability field"
);
}
/// The default (fail-closed) set holds nothing.
#[test]
fn default_holds_nothing() {
let caps = CapabilitiesDef::default();
assert!(caps.held_names().is_empty());
for n in [
"uplink",
"net",
"kv",
"fs_read",
"fs_write",
"host_process",
"allow_persistent",
"net_bind",
"net_connect",
"identity",
"allow_prompt_injection",
] {
assert!(!caps.has(n), "empty set must not report {n}");
}
}
/// Unknown capability names are fail-closed.
#[test]
fn unknown_name_is_false() {
let caps = CapabilitiesDef {
host_process: vec!["bash".into()],
..Default::default()
};
assert!(!caps.has("not_a_capability"));
assert!(!caps.has(""));
assert!(caps.has("host_process"));
assert_eq!(caps.held_names(), vec!["host_process".to_string()]);
}
}