Skip to content

Commit e1e6da9

Browse files
author
hack-cli-tests
committed
feat(native): bind inactive persistent enrollment to root witnesses
1 parent e494143 commit e1e6da9

22 files changed

Lines changed: 2088 additions & 77 deletions

File tree

‎packages/relay-guest/src/main.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ pub use error::CandidateError;
77
mod private_input;
88
#[cfg(target_os = "linux")]
99
#[path = "../../runtime-core/src/provider/storage_root_witness.rs"]
10+
// The host also uses request encoding/reply decoding from this shared source.
11+
#[allow(dead_code)]
1012
mod storage_root_witness;
1113
// This shared module also supplies host-only authority/effect APIs. Keep them
1214
// compiled and checked by runtime-core without requiring guest-side callers.

‎packages/runtime-core/README.md‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1526,9 +1526,33 @@ created only during the original enrollment with `XATTR_CREATE` on a retained
15261526
nofollow root descriptor, synchronized, and independently reread with exact
15271527
descriptor/path/root checks. Retained startup only reads; missing/changed witness
15281528
refuses without repair. Whole-root/xattr copying remains outside the claimed
1529-
guarantee. This native helper/transport is not implemented or qualified here.
1529+
guarantee. The metadata-only checkpoint does not implement or qualify that helper
1530+
boundary; the inactive witnessed continuation below adds its source.
15301531

15311532
These runtime/SQL gates and replacement safety are not yet qualified by this source implementation. Stock
15321533
PostgreSQL parity remains a separate NC05 gate requiring authored ownership/user
15331534
or a specifically qualified capability policy. This slice guesses no UID/GID,
15341535
changes no volume permissions, and adds no capability to make a stock image work.
1536+
# Witnessed enrollment continuation (inactive)
1537+
1538+
The separate persistent owner version2 stores its expected root xattr in the private
1539+
pending intent before the single original volume create. It captures the original
1540+
volume and root UID/GID/device/inode, seeds with `XATTR_CREATE`, independently verifies
1541+
the root, and only then publishes enrollment under the original private lock. A missing
1542+
or changed witness refuses retained reads without repair. Version1 metadata records
1543+
are not upgraded or treated as continuity proof. Ambiguous create/seed retains the
1544+
intent; publication after rename can be uncertain even when an enrolled file is visible.
1545+
1546+
Native receipt4 may describe a separate pinned guest tool installation, including the
1547+
original directory and helper identities. This control path shares the existing relay
1548+
artifact/upload boundary and uses private descriptor input under the original operation
1549+
deadline. Dependency-free legacy graphs continue to install no guest artifact. Tool
1550+
receipt parsing grants neither installation nor storage authority, and this continuation
1551+
does not enable ordinary persistent startup. The Linux private-filesystem controls,
1552+
installed helper/guest transport, and real Bun/SQLite retention still require qualification.
1553+
The shared artifact reader now requires a current-UID regular file with one link,
1554+
no group/world write permission, and matching held-FD/path identity and bytes. Legacy
1555+
relay graphs with dependencies use that stricter reader and its fixed
1556+
`guest_tool_artifact` refusal; dependency-free graphs do not read an artifact.
1557+
Whole-root copies retaining the exact xattr remain outside this witness guarantee.
1558+
Stock PostgreSQL remains a separate ownership/capability acceptance gate.

‎packages/runtime-core/src/provider/agent.rs‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,19 @@ pub(super) fn exec_until(
136136
script: &str,
137137
arguments: &[&str],
138138
deadline: Instant,
139+
) -> Result<String, CandidateError> {
140+
exec_input_until(path, script, arguments, None, deadline)
141+
}
142+
143+
/// Private input follows the same absolute deadline as connect, dispatch and reply.
144+
/// A timed-out exec is uncertain; no late successful reply restores authority.
145+
#[cfg(feature = "native-config-plan")]
146+
pub(super) fn exec_input_until(
147+
path: &Path,
148+
script: &str,
149+
arguments: &[&str],
150+
input: Option<&str>,
151+
deadline: Instant,
139152
) -> Result<String, CandidateError> {
140153
let mut stream = connect_until(path, deadline)?;
141154
let remaining = deadline
@@ -147,7 +160,7 @@ pub(super) fn exec_until(
147160
}
148161
let mut command = vec!["/bin/sh", "-c", script, "hack-local"];
149162
command.extend_from_slice(arguments);
150-
let body = json!({"method":"vm_exec","command":command,"env":[["PATH","/opt/hack-engine:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]],"workdir":"/","timeout_ms":milliseconds,"interactive":false,"tty":false,"background":false,"stdin_data":null});
163+
let body = json!({"method":"vm_exec","command":command,"env":[["PATH","/opt/hack-engine:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]],"workdir":"/","timeout_ms":milliseconds,"interactive":false,"tty":false,"background":false,"stdin_data":input});
151164
let bytes = encode(&body)?;
152165
let timeout = deadline
153166
.checked_duration_since(Instant::now())

‎packages/runtime-core/src/provider/graph/native/journal.rs‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ pub struct Receipt {
6969
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
7070
pub(super) data_mounts: BTreeMap<String, Vec<crate::project::native::StorageMount>>,
7171
#[serde(default, skip_serializing_if = "Option::is_none")]
72+
pub(super) data_tool: Option<persistent_data::tool::Reference>,
73+
#[serde(default, skip_serializing_if = "Option::is_none")]
7274
failure: Option<Failure>,
7375
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
7476
pub(super) terminal: BTreeMap<String, super::super::shutdown::Terminal>,
@@ -91,6 +93,8 @@ struct ReceiptWire {
9193
data: Option<BTreeMap<String, persistent_data::engine::Reference>>,
9294
#[serde(default, deserialize_with = "present_map")]
9395
data_mounts: Option<BTreeMap<String, Vec<crate::project::native::StorageMount>>>,
96+
#[serde(default, deserialize_with = "present_map")]
97+
data_tool: Option<persistent_data::tool::Reference>,
9498
#[serde(default)]
9599
failure: Option<Failure>,
96100
#[serde(default)]
@@ -104,7 +108,8 @@ fn present_map<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(
104108
impl TryFrom<ReceiptWire> for Receipt {
105109
type Error = &'static str;
106110
fn try_from(wire: ReceiptWire) -> Result<Self, Self::Error> {
107-
if (wire.version == 2 && (wire.data.is_some() || wire.data_mounts.is_some()))
111+
if (wire.version == 2
112+
&& (wire.data.is_some() || wire.data_mounts.is_some() || wire.data_tool.is_some()))
108113
|| (wire.version == 4 && (wire.data.is_none() || wire.data_mounts.is_none()))
109114
{
110115
return Err("Native receipt storage fields do not match its wire version.");
@@ -120,6 +125,7 @@ impl TryFrom<ReceiptWire> for Receipt {
120125
resources: wire.resources,
121126
data: wire.data.unwrap_or_default(),
122127
data_mounts: wire.data_mounts.unwrap_or_default(),
128+
data_tool: wire.data_tool,
123129
failure: wire.failure,
124130
terminal: wire.terminal,
125131
})
@@ -134,6 +140,7 @@ impl Receipt {
134140
|| self.readiness != expected.readiness
135141
|| self.data != expected.data
136142
|| self.data_mounts != expected.data_mounts
143+
|| self.data_tool != expected.data_tool
137144
|| self.resources.keys().ne(expected.resources.keys())
138145
|| self.resources.iter().any(|(key, resource)| {
139146
let prior = &expected.resources[key];
@@ -150,6 +157,9 @@ impl Receipt {
150157
}
151158
Ok(())
152159
}
160+
pub(in crate::provider::graph::native) fn persistent(&self) -> bool {
161+
self.version == 4
162+
}
153163
pub fn phase(&self) -> &Phase {
154164
&self.phase
155165
}
@@ -202,6 +212,7 @@ impl Receipt {
202212
resources: config.resources.clone(),
203213
data: config.data.clone(),
204214
data_mounts: config.data_mounts.clone(),
215+
data_tool: None,
205216
failure: None,
206217
terminal: BTreeMap::new(),
207218
};
@@ -348,7 +359,10 @@ impl Receipt {
348359

349360
fn validate_data(&self) -> Result<(), CandidateError> {
350361
if self.version == 2 {
351-
return if self.data.is_empty() && self.data_mounts.is_empty() {
362+
return if self.data.is_empty()
363+
&& self.data_mounts.is_empty()
364+
&& self.data_tool.is_none()
365+
{
352366
Ok(())
353367
} else {
354368
Err(refused())
@@ -357,6 +371,18 @@ impl Receipt {
357371
if self.data.is_empty() || self.data_mounts.is_empty() {
358372
return Err(refused());
359373
}
374+
if let Some(tool) = &self.data_tool {
375+
tool.validate()?;
376+
if (self.phase == Phase::ReadyObserved
377+
|| self
378+
.resources
379+
.values()
380+
.any(|resource| resource.kind == Kind::Container && resource.id.is_some()))
381+
&& tool.helper.is_none()
382+
{
383+
return Err(refused());
384+
}
385+
}
360386
let mut used = BTreeSet::new();
361387
for (service, mounts) in &self.data_mounts {
362388
if !self.readiness.contains_key(service) || mounts.is_empty() {

‎packages/runtime-core/src/provider/graph/native/persistent_data.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,8 @@ mod tests;
261261

262262
pub(super) mod engine;
263263
pub mod enrollment;
264+
pub mod witnessed;
265+
266+
// Inactive APIs are deliberately not selected by ordinary startup until qualification.
267+
#[allow(dead_code)]
268+
pub(super) mod tool;

‎packages/runtime-core/src/provider/graph/native/persistent_data/engine.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ use reqwest::Method;
99
use serde_json::{Value, json};
1010
use std::{collections::BTreeMap, io::Read, sync::atomic::AtomicBool, time::Instant};
1111

12+
// This installed transport is intentionally not called by the gated runtime yet.
13+
#[allow(dead_code)]
14+
mod witnessed;
15+
1216
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
1317
#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)]
1418
enum State {
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
//! Installed tool + original Engine lease transport. No ordinary runtime caller is
2+
//! enabled. A helper observation never grants durable promotion by itself.
3+
use super::*;
4+
use crate::provider::graph::native::persistent_data::{
5+
tool::Installed,
6+
witnessed::{ExpectedWitness, RootIdentity},
7+
};
8+
use crate::provider::storage_root_witness::{Observation as Reply, Request};
9+
use enrollment::Transport as _;
10+
11+
pub(in crate::provider::graph::native) struct WitnessAdapter<'a, 'guest> {
12+
base: Adapter<'a, 'guest>,
13+
tool: &'a Installed,
14+
}
15+
impl<'a, 'guest> WitnessAdapter<'a, 'guest> {
16+
pub(in crate::provider::graph::native) fn new(
17+
engine: &'a Engine<'guest>,
18+
tool: &'a Installed,
19+
fresh: &'a dyn Fn() -> Result<(), CandidateError>,
20+
) -> Self {
21+
Self {
22+
base: Adapter::new(engine, fresh),
23+
tool,
24+
}
25+
}
26+
fn exact(&mut self, captured: &Observation, deadline: Instant) -> Result<(), CandidateError> {
27+
self.base.verify(&captured.binding, deadline)?;
28+
if self.base.inspect(&captured.volume.name, deadline)?.as_ref() != Some(captured) {
29+
return Err(refused());
30+
}
31+
self.base.check(deadline)
32+
}
33+
fn invoke(&self, request: Request, deadline: Instant) -> Result<Reply, CandidateError> {
34+
self.tool
35+
.invoke(self.base.engine, request, deadline, self.base.fresh)
36+
}
37+
}
38+
impl enrollment::sealed::Transport for WitnessAdapter<'_, '_> {}
39+
impl enrollment::Transport for WitnessAdapter<'_, '_> {
40+
fn verify(&mut self, expected: &Binding, deadline: Instant) -> Result<(), CandidateError> {
41+
self.base.verify(expected, deadline)
42+
}
43+
fn inspect(
44+
&mut self,
45+
name: &str,
46+
deadline: Instant,
47+
) -> Result<Option<Observation>, CandidateError> {
48+
self.base.inspect(name, deadline)
49+
}
50+
fn create_new(
51+
&mut self,
52+
request: &enrollment::CreateRequest,
53+
deadline: Instant,
54+
) -> Result<Observation, CandidateError> {
55+
self.base.create_new(request, deadline)
56+
}
57+
}
58+
impl enrollment::witnessed::Transport for WitnessAdapter<'_, '_> {
59+
fn root(
60+
&mut self,
61+
volume: &Observation,
62+
deadline: Instant,
63+
) -> Result<RootIdentity, CandidateError> {
64+
self.exact(volume, deadline)?;
65+
let Reply::Root(root) = self.invoke(Request::root(&volume.volume.name)?, deadline)? else {
66+
return Err(refused());
67+
};
68+
if (root.device, root.inode)
69+
!= (
70+
volume.volume.directory.device,
71+
volume.volume.directory.inode,
72+
)
73+
{
74+
return Err(refused());
75+
}
76+
self.exact(volume, deadline)?;
77+
Ok(root.into())
78+
}
79+
fn seed(
80+
&mut self,
81+
request: &enrollment::witnessed::SeedRequest,
82+
deadline: Instant,
83+
) -> Result<(), CandidateError> {
84+
let observed = request.captured();
85+
self.exact(observed, deadline)?;
86+
let witness = request.witness();
87+
let reply = self.invoke(
88+
Request::bound(
89+
&observed.volume.name,
90+
true,
91+
request.root().into(),
92+
&witness.name,
93+
&witness.value,
94+
)?,
95+
deadline,
96+
)?;
97+
if !matches!(reply, Reply::Seeded) {
98+
return Err(refused());
99+
}
100+
self.exact(observed, deadline)
101+
}
102+
fn verify_witness(
103+
&mut self,
104+
volume: &Observation,
105+
root: RootIdentity,
106+
witness: &ExpectedWitness,
107+
deadline: Instant,
108+
) -> Result<(), CandidateError> {
109+
self.exact(volume, deadline)?;
110+
let reply = self.invoke(
111+
Request::bound(
112+
&volume.volume.name,
113+
false,
114+
root.into(),
115+
&witness.name,
116+
&witness.value,
117+
)?,
118+
deadline,
119+
)?;
120+
if !matches!(reply, Reply::Verified) {
121+
return Err(refused());
122+
}
123+
self.exact(volume, deadline)
124+
}
125+
}

‎packages/runtime-core/src/provider/graph/native/persistent_data/enrollment.rs‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ fn snapshot(binding: &Binding) -> Result<Binding, CandidateError> {
141141
}
142142
Ok(binding.clone())
143143
}
144-
fn refused() -> CandidateError {
144+
pub(super) fn refused() -> CandidateError {
145145
CandidateError::new(
146146
"native_persistent_data_enrollment",
147147
"Persistent data enrollment is incomplete, ambiguous or changed; retained data was not adopted or deleted.",
@@ -505,6 +505,17 @@ impl RecordPin {
505505
) -> Result<Self, CandidateError> {
506506
let bytes = serde_json::to_vec(owner).map_err(|_| refused())?;
507507
super::decode(&bytes)?;
508+
Self::create_bytes(path, bytes, sync, step)
509+
}
510+
fn create_bytes<S: Sync>(
511+
path: &Path,
512+
bytes: Vec<u8>,
513+
sync: &mut S,
514+
step: Step,
515+
) -> Result<Self, CandidateError> {
516+
if bytes.is_empty() || bytes.len() > super::LIMIT {
517+
return Err(refused());
518+
}
508519
let mut file = OpenOptions::new()
509520
.read(true)
510521
.write(true)
@@ -524,13 +535,19 @@ impl RecordPin {
524535
Ok(pin)
525536
}
526537
fn read(path: &Path) -> Result<Self, CandidateError> {
538+
Self::read_validated(path, |bytes| super::decode(bytes).map(|_| ()))
539+
}
540+
fn read_validated(
541+
path: &Path,
542+
validate: impl FnOnce(&[u8]) -> Result<(), CandidateError>,
543+
) -> Result<Self, CandidateError> {
527544
let file = OpenOptions::new()
528545
.read(true)
529546
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
530547
.open(path)
531548
.map_err(|_| refused())?;
532549
let bytes = read_bytes(&file)?;
533-
super::decode(&bytes)?;
550+
validate(&bytes)?;
534551
let pin = Self {
535552
path: path.into(),
536553
file,
@@ -588,3 +605,5 @@ impl Sync for SystemSync {
588605

589606
#[cfg(test)]
590607
mod tests;
608+
609+
pub mod witnessed;

0 commit comments

Comments
 (0)