diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5115fb5c..4d89b7bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Changed
- **Examples now default to shell-forward demos with explicit Glia snippets.** Example READMEs now guide users through daemon + `ww shell` flows (`load glia/register.glia`, plus `serve`/`consume` snippets where relevant), per-example demo wiring moved from `examples/*/etc/init.d/*.glia` into new `examples/*/glia/*.glia` snippet files, and `doc/images.md` now documents init.d boot as deployment guidance rather than the demo default.
+- **Init export policy switched to a bare capability map with recursive `attenuate` support (shell + init).** `init.glia` must now return a bare export map (legacy `:export/:caps/:methods` is rejected), kernel boot remains fail-closed with strict unknown-cap errors, and recursive attenuation authored via existing Glia `attenuate` syntax is enforced at the membrane/RPC proxy layer for cap-returning paths (including `host.network`, `runtime.load`, `identity.signer`, and `ipfs.read`). Added init-system docs under `doc/init.md` and migrated bundled/example init scripts to the new policy shape.
+- **Dynamic-cap return paths now use typed envelopes (`TypedCap`) with schema nodes, and WW_ROOT path handling is hardened against symlink escapes.** `Process.bootstrap`, `VatClient.dial`, and `VatHandler.serve` now flow through `TypedCap { cap, schema }` (`SchemaBundle` uses `schema.Node` root + deps), recursive attenuation wrappers consume typed schema metadata, and policy/docs/tests were updated to match the hard cutover. `load-file`, kernel `list-dir`, and kernel `path-is-dir` now enforce WW_ROOT containment against both lexical traversal and symlink escape paths.
+- **Vat protocol now performs inline schema attestation before Cap'n Proto starts.** On `/ww/0.1.0/vat/{cid}`, listener writes a versioned schema-attestation preface (canonical `schema.Node` bytes) before RPC bootstrap; dialer verifies magic/version, canonicalizes payload, recomputes CID, and hard-fails on mismatch. `VatClient.dial` now returns typed schema from attested producer bytes (not caller-echoed schema input).
- **AutoNAT v2 probe observability exposed with bounded history (#512).** Added a ring-buffered `nat_probe_events` surface in runtime `NetworkState` (tested address, probing server peer ID, result, timestamp), wired capture from `AutonatV2` events, and exposed operator JSON at admin `GET /host/nat` alongside existing node-level `nat_status`.
- **`system.Ipfs` read API is now stream-only (`read -> ByteStream`).** Removed `Ipfs.readStream` and changed `Ipfs.read` to return `ByteStream`, consolidating capability attenuation to a single read method while retaining chunked daemon-backed transfer semantics for `/ipfs`, `/ipns`, and `/ipld` paths.
- **`ww shell --mcp` now runs MCP process-local in the shell path (#508).** Replaced daemon-spawned `std/mcp` WASM execution with a process-local MCP JSON-RPC loop in the CLI that reuses shell dial/login/graft auth flow and local Glia evaluation, while keeping daemon-backed transport/auth/capability dispatch unchanged.
@@ -599,6 +602,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- 14 unit tests covering host lifecycle, executor pool scheduling, round-robin distribution, panic handling, exit code piping, and bounded channel backpressure
### Changed
+- `Process.bootstrap` now requires `schema: Data` and enforces non-empty schema payloads.
+- Recursive export attenuation now enforces `AnyPointer` return edges at RPC proxy boundaries for `vat-client.dial.cap` and `process.bootstrap.cap`.
- `spawn_rpc_inner` and child cell spawn paths use ambient `LocalSet` instead of nested `LocalSet`, enabling proper M:N cooperative scheduling across cells on the same worker thread
- `SwarmService` and `EpochService` now respect shutdown signal via `tokio::select!`
- `ExecutorPool` stores worker `JoinHandle`s and joins them on drop for clean shutdown
diff --git a/README.md b/README.md
index 22e4ce50..a9ab1a14 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ curl http://localhost:2080/status
}
```
-The second command hit a WebAssembly cell running inside the daemon. The cell can't read your filesystem, reach the network, or see your environment variables. The only thing it can do is what the membrane handed it; in this case, the `host` capability, so it can report your peer ID and connected peers. The wiring that hands the `host` capability (and nothing else) to the HTTP handler cell lives at `~/.ww/etc/init.d/05-status.glia`:
+The second command hit a WebAssembly cell running inside the daemon. The cell can't read your filesystem, reach the network, or see your environment variables. The only thing it can do is what the membrane handed it; in this case, the `host` capability, so it can report your peer ID and connected peers. The wiring that hands the `host` capability (and nothing else) to the HTTP handler cell lives in `~/.ww/etc/init.d/05-status.glia` (orchestrated by `~/.ww/etc/init.glia`):
```clojure
(perform host :listen (cell (load "bin/status.wasm")) "/status")
diff --git a/capnp/system.capnp b/capnp/system.capnp
index 85a50c18..52a925c6 100644
--- a/capnp/system.capnp
+++ b/capnp/system.capnp
@@ -8,6 +8,17 @@
@0xbf5147b78c0e6a2f;
using MembraneSchema = import "membrane.capnp";
+using Schema = import "/capnp/schema.capnp";
+
+struct SchemaBundle {
+ root @0 :Schema.Node;
+ deps @1 :List(Schema.Node);
+}
+
+struct TypedCap {
+ cap @0 :Capability;
+ schema @1 :SchemaBundle;
+}
struct PeerInfo {
peerId @0 :Data; # libp2p peer ID, serialized.
@@ -128,9 +139,9 @@ interface Process {
wait @3 () -> (exitCode :Int32);
# Block until the process exits and return its exit code.
- bootstrap @4 () -> (cap :AnyPointer);
- # Return the capability exported by the guest via system::serve().
- # The cap is type-erased — cast to the expected interface on the guest side.
+ bootstrap @4 (schema :Data) -> (typed :TypedCap);
+ # Return the capability exported by the guest via system::serve() with
+ # producer-attached schema metadata required for recursive attenuation.
# Errors if the guest didn't export a capability.
kill @5 () -> ();
@@ -141,7 +152,7 @@ struct VatHandler {
union {
spawn @0 :Executor;
# Stateless: spawn a fresh cell per connection.
- serve @1 :AnyPointer;
+ serve @1 :TypedCap;
# Stateful: bootstrap all connections with this persistent capability.
}
}
@@ -166,15 +177,14 @@ interface VatListener {
}
interface VatClient {
- dial @0 (peer :Data, schema :Data) -> (cap :AnyPointer);
+ dial @0 (peer :Data, schema :Data) -> (typed :TypedCap);
# Open a Cap'n Proto RPC connection to peer on /ww/0.1.0/vat/{cid}
# where cid = CIDv1(raw, BLAKE3(schema)).
# The schema is the canonical Cap'n Proto encoding of a schema.Node.
# Bootstraps a Cap'n Proto vat over the stream and returns the remote
# cell's bootstrap capability.
#
- # The returned cap is type-erased (AnyPointer) — cast it to the expected
- # interface type on the guest side.
+ # Returns a capability plus schema metadata for recursive attenuation.
}
interface ByteStream {
diff --git a/crates/glia/src/eval.rs b/crates/glia/src/eval.rs
index ec9c56cc..460d1020 100644
--- a/crates/glia/src/eval.rs
+++ b/crates/glia/src/eval.rs
@@ -18,13 +18,15 @@ use core::pin::Pin;
use core::sync::atomic::{AtomicU64, Ordering};
use core::task::Poll;
use std::cell::RefCell;
-use std::collections::{BTreeSet, HashMap};
+use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::rc::Rc;
use crate::effect::{self, HandlerStack};
use crate::error;
use crate::expr::FnBody;
-use crate::{make_cap, oneshot, AttenuatedCapInner, FnArity, GliaCapInner, Val, ValMap};
+use crate::{
+ make_cap, oneshot, AttenuatedCapInner, AttenuationPolicy, FnArity, GliaCapInner, Val, ValMap,
+};
/// Monotonic counter for `gensym`.
static GENSYM_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -46,6 +48,7 @@ static GENSYM_COUNTER: AtomicU64 = AtomicU64::new(0);
pub struct Env {
frames: Vec,
handler_stack: HandlerStack,
+ attenuate_self_scope_depth: usize,
}
impl Default for Env {
@@ -57,12 +60,63 @@ impl Default for Env {
type Frame = std::collections::HashMap;
+fn resolve_guest_fs_path(path: &str) -> Result {
+ if let Ok(root) = std::env::var("WW_ROOT") {
+ let root = root.trim_end_matches('/');
+ let root_path = std::path::Path::new(root);
+ let canonical_root = std::fs::canonicalize(root_path)
+ .map_err(|e| format!("load-file: WW_ROOT '{root}' is not accessible: {e}"))?;
+ let mut rel = std::path::PathBuf::new();
+ for component in std::path::Path::new(path).components() {
+ use std::path::Component;
+ match component {
+ Component::Prefix(_) => {
+ return Err(format!("load-file: invalid path '{path}'"));
+ }
+ Component::RootDir | Component::CurDir => {}
+ Component::ParentDir => {
+ if !rel.pop() {
+ return Err(format!(
+ "load-file: path escapes WW_ROOT via parent traversal: '{path}'"
+ ));
+ }
+ }
+ Component::Normal(seg) => rel.push(seg),
+ }
+ }
+ let resolved = root_path.join(rel);
+
+ // Prevent WW_ROOT escape through symlink traversal by requiring the
+ // nearest existing ancestor of the requested path to stay under WW_ROOT.
+ let mut probe = resolved.as_path();
+ while !probe.exists() {
+ probe = probe
+ .parent()
+ .ok_or_else(|| format!("load-file: failed to resolve parent for path '{path}'"))?;
+ }
+ let canonical_probe = std::fs::canonicalize(probe)
+ .map_err(|e| format!("load-file: failed to canonicalize '{path}': {e}"))?;
+ if !canonical_probe.starts_with(&canonical_root) {
+ return Err(format!(
+ "load-file: path escapes WW_ROOT via symlink traversal: '{path}'"
+ ));
+ }
+
+ return Ok(resolved.to_string_lossy().to_string());
+ }
+ if path.starts_with('/') {
+ return Ok(path.to_string());
+ }
+ Ok(format!("/{path}"))
+}
+
impl Env {
/// Create a new, empty environment with a single root frame.
pub fn new() -> Self {
Self {
frames: vec![Frame::new()],
handler_stack: effect::new_handler_stack(),
+ attenuate_self_scope_depth: 0,
}
}
@@ -151,6 +205,7 @@ impl Env {
// Keep the current stack on snapshots; invocation still routes through
// the caller's handler stack via `Env::for_call`.
handler_stack: self.handler_stack.clone(),
+ attenuate_self_scope_depth: self.attenuate_self_scope_depth,
}
}
@@ -167,6 +222,7 @@ impl Env {
Self {
frames: vec![filtered],
handler_stack: self.handler_stack.clone(),
+ attenuate_self_scope_depth: self.attenuate_self_scope_depth,
}
}
@@ -189,8 +245,23 @@ impl Env {
Self {
frames: vec![root, Frame::new()], // root + param frame
handler_stack: caller_hs.clone(),
+ attenuate_self_scope_depth: 0,
}
}
+
+ fn enter_attenuate_self_scope(&mut self) {
+ self.attenuate_self_scope_depth += 1;
+ }
+
+ fn exit_attenuate_self_scope(&mut self) {
+ if self.attenuate_self_scope_depth > 0 {
+ self.attenuate_self_scope_depth -= 1;
+ }
+ }
+
+ fn allows_attenuate_self(&self) -> bool {
+ self.attenuate_self_scope_depth > 0
+ }
}
// ---------------------------------------------------------------------------
@@ -235,6 +306,35 @@ fn cap_descriptor_bytes(name: &str, schema_cid: &str, methods: &BTreeSet
.into_bytes()
}
+fn parse_policy_name(value: &Val, context: &'static str) -> Result {
+ match value {
+ Val::Keyword(name) | Val::Sym(name) | Val::Str(name) => Ok(name.clone()),
+ other => Err(error::type_mismatch(
+ context,
+ "keyword/symbol/string",
+ other,
+ )),
+ }
+}
+
+fn canonical_member_name(name: &str) -> String {
+ let mut out = String::new();
+ let mut upper_next = false;
+ for ch in name.chars() {
+ if ch == '-' || ch == '_' {
+ upper_next = true;
+ continue;
+ }
+ if upper_next {
+ out.extend(ch.to_uppercase());
+ upper_next = false;
+ } else {
+ out.push(ch);
+ }
+ }
+ out
+}
+
fn parse_allow_methods(value: &Val) -> Result, Val> {
let items = match value {
Val::Vector(v) | Val::List(v) => v,
@@ -249,14 +349,125 @@ fn parse_allow_methods(value: &Val) -> Result, Val> {
let mut allow = BTreeSet::new();
for item in items {
- match item {
- Val::Keyword(k) => {
- allow.insert(k.clone());
+ let parsed = parse_policy_name(item, "attenuate method")?;
+ allow.insert(canonical_member_name(&parsed));
+ }
+ Ok(allow)
+}
+
+fn parse_self_return_policy(value: &Val) -> Result {
+ let Val::Cap { inner, .. } = value else {
+ return Err(error::type_mismatch(
+ "attenuate :returns field policy",
+ "attenuated :self capability",
+ value,
+ ));
+ };
+ let Some(att) = inner.downcast_ref::() else {
+ return Err(error::type_mismatch(
+ "attenuate :returns field policy",
+ "attenuated :self capability",
+ value,
+ ));
+ };
+ match &att.base {
+ Val::Keyword(k) if k == "self" => Ok(att.policy.clone()),
+ other => Err(error::type_mismatch(
+ "attenuate :returns field policy base",
+ ":self",
+ other,
+ )),
+ }
+}
+
+fn parse_returns_policy(
+ value: &Val,
+) -> Result>, Val> {
+ let methods = match value {
+ Val::Map(m) => m,
+ other => return Err(error::type_mismatch("attenuate :returns", "map", other)),
+ };
+
+ let mut out = BTreeMap::new();
+ for (method_key, fields_val) in methods.iter() {
+ let method_name = canonical_member_name(&parse_policy_name(
+ method_key,
+ "attenuate :returns method key",
+ )?);
+ if out.contains_key(&method_name) {
+ return Err(error::internal(
+ "attenuate",
+ format!("duplicate :returns method key after canonicalization: {method_name}"),
+ ));
+ }
+ let fields = match fields_val {
+ Val::Map(m) => m,
+ other => {
+ return Err(error::type_mismatch(
+ "attenuate :returns method value",
+ "map",
+ other,
+ ))
}
- other => return Err(error::type_mismatch("attenuate method", "keyword", other)),
+ };
+ let mut parsed_fields = BTreeMap::new();
+ for (field_key, field_policy_val) in fields.iter() {
+ let field_name = canonical_member_name(&parse_policy_name(
+ field_key,
+ "attenuate :returns field key",
+ )?);
+ if parsed_fields.contains_key(&field_name) {
+ return Err(error::internal(
+ "attenuate",
+ format!(
+ "duplicate :returns field key after canonicalization for method {method_name}: {field_name}"
+ ),
+ ));
+ }
+ let field_policy = parse_self_return_policy(field_policy_val)?;
+ parsed_fields.insert(field_name, field_policy);
}
+ out.insert(method_name, parsed_fields);
+ }
+ Ok(out)
+}
+
+fn intersect_return_policies(
+ existing: &BTreeMap>,
+ incoming: &BTreeMap>,
+) -> BTreeMap> {
+ let mut out = existing.clone();
+ for (method, incoming_fields) in incoming {
+ if let Some(existing_fields) = out.get_mut(method) {
+ for (field, incoming_policy) in incoming_fields {
+ if let Some(existing_policy) = existing_fields.get_mut(field) {
+ *existing_policy =
+ intersect_attenuation_policy(existing_policy, incoming_policy);
+ } else {
+ existing_fields.insert(field.clone(), incoming_policy.clone());
+ }
+ }
+ } else {
+ out.insert(method.clone(), incoming_fields.clone());
+ }
+ }
+ out
+}
+
+fn intersect_attenuation_policy(
+ existing: &AttenuationPolicy,
+ incoming: &AttenuationPolicy,
+) -> AttenuationPolicy {
+ let allow_methods = existing
+ .allow_methods
+ .intersection(&incoming.allow_methods)
+ .cloned()
+ .collect();
+ let returns = intersect_return_policies(&existing.returns, &incoming.returns);
+ AttenuationPolicy {
+ allow_methods,
+ returns,
}
- Ok(allow)
}
fn is_authority_free(value: &Val) -> bool {
@@ -2063,20 +2274,20 @@ pub fn eval_expr<'a, D: Dispatch>(
return Ok(cap);
}
- // Special form: (attenuate cap [:method ...])
+ // Special form:
+ // (attenuate cap [:method ...])
+ // (attenuate cap :allow [:method ...] :returns {...})
if head == "attenuate" {
- if args.len() != 2 {
- return Err(error::arity("attenuate", "2", args.len()));
+ if args.len() < 2 {
+ return Err(error::arity("attenuate", "at least 2", args.len()));
}
let cap_val = eval_expr(&args[0], env, dispatch).await?;
- let allow_val = eval_expr(&args[1], env, dispatch).await?;
- let mut allow_methods = parse_allow_methods(&allow_val)?;
- let (name, schema_cid, base, nested_allow): (
+ let (name, schema_cid, base, existing_policy): (
String,
String,
Val,
- Option>,
+ Option,
) = match &cap_val {
Val::Cap {
name,
@@ -2085,32 +2296,130 @@ pub fn eval_expr<'a, D: Dispatch>(
..
} => {
if let Some(inner_att) = inner.downcast_ref::() {
+ if matches!(&inner_att.base, Val::Keyword(k) if k == "self")
+ && !env.allows_attenuate_self()
+ {
+ return Err(error::internal(
+ "attenuate",
+ ":self is only valid inside attenuate :returns",
+ ));
+ }
(
name.clone(),
schema_cid.clone(),
inner_att.base.clone(),
- Some(inner_att.allow_methods.clone()),
+ Some(inner_att.policy.clone()),
)
} else {
(name.clone(), schema_cid.clone(), cap_val.clone(), None)
}
}
+ Val::Keyword(k) if k == "self" => {
+ if !env.allows_attenuate_self() {
+ return Err(error::internal(
+ "attenuate",
+ ":self is only valid inside attenuate :returns",
+ ));
+ }
+ (
+ "self".into(),
+ "glia:self:v1".into(),
+ Val::Keyword("self".into()),
+ None,
+ )
+ }
other => {
return Err(error::type_mismatch("attenuate first arg", "cap", other))
}
};
- if let Some(existing) = nested_allow {
- allow_methods = allow_methods.intersection(&existing).cloned().collect();
- }
+ let incoming_policy = if args.len() == 2 {
+ let allow_val = eval_expr(&args[1], env, dispatch).await?;
+ AttenuationPolicy {
+ allow_methods: parse_allow_methods(&allow_val)?,
+ returns: BTreeMap::new(),
+ }
+ } else {
+ if (args.len() - 1) % 2 != 0 {
+ return Err(error::internal(
+ "attenuate",
+ "keyword form expects :allow/:returns key-value pairs",
+ ));
+ }
+ let mut allow_methods: Option> = None;
+ let mut saw_returns = false;
+ let mut returns =
+ BTreeMap::>::new();
+ for pair in args[1..].chunks(2) {
+ let key = eval_expr(&pair[0], env, dispatch).await?;
+ let key = match key {
+ Val::Keyword(k) => k,
+ other => {
+ return Err(error::type_mismatch(
+ "attenuate option key",
+ "keyword",
+ &other,
+ ))
+ }
+ };
+ match key.as_str() {
+ "allow" => {
+ if allow_methods.is_some() {
+ return Err(error::internal(
+ "attenuate",
+ "duplicate :allow option",
+ ));
+ }
+ let allow_val = eval_expr(&pair[1], env, dispatch).await?;
+ allow_methods = Some(parse_allow_methods(&allow_val)?);
+ }
+ "returns" => {
+ if saw_returns {
+ return Err(error::internal(
+ "attenuate",
+ "duplicate :returns option",
+ ));
+ }
+ saw_returns = true;
+ env.enter_attenuate_self_scope();
+ let returns_result = eval_expr(&pair[1], env, dispatch).await;
+ env.exit_attenuate_self_scope();
+ let returns_val = returns_result?;
+ returns = parse_returns_policy(&returns_val)?;
+ }
+ other => {
+ return Err(error::internal(
+ "attenuate",
+ format!(
+ "unknown option :{other}; expected :allow and optional :returns"
+ ),
+ ))
+ }
+ }
+ }
+ let allow_methods = allow_methods.ok_or_else(|| {
+ error::internal("attenuate", "keyword form requires :allow option")
+ })?;
+ AttenuationPolicy {
+ allow_methods,
+ returns,
+ }
+ };
+
+ let policy = if let Some(existing) = existing_policy {
+ intersect_attenuation_policy(&existing, &incoming_policy)
+ } else {
+ incoming_policy
+ };
- let descriptor = cap_descriptor_bytes(&name, &schema_cid, &allow_methods);
+ let descriptor =
+ cap_descriptor_bytes(&name, &schema_cid, &policy.allow_methods);
return Ok(make_cap(
name,
schema_cid,
Rc::new(AttenuatedCapInner {
base,
- allow_methods,
+ policy,
descriptor,
}),
));
@@ -2252,6 +2561,60 @@ pub fn eval_expr<'a, D: Dispatch>(
return eval_expr(&body_expr, &mut isolate_env, &restricted).await;
}
+ // Special form: (eval "