Skip to content

Commit b41d1fd

Browse files
committed
feat(sandbox): validate configuration before workload activation
Closes #3145 Signed-off-by: John Myers <[email protected]>
1 parent a316fd7 commit b41d1fd

37 files changed

Lines changed: 2968 additions & 1017 deletions

File tree

‎architecture/sandbox.md‎

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,44 @@ the structured 403 and authors the narrowest rule. Mechanistically mapping L7
597597
would either over-broaden rules or require path-templating logic that rots
598598
quickly.
599599

600+
## Configuration Admission
601+
602+
Gateway-managed supervisors reconcile configuration before launching the main
603+
process or exposing workload services. Admission covers the effective policy,
604+
provider layers, credential bindings, and gateway-derived provenance. Explicit
605+
user and global policy precedence is unchanged; an image without a policy uses
606+
the restrictive baseline. An invalid image policy does not become a launchable
607+
default.
608+
609+
The gateway tracks configuration admission independently of compute health.
610+
A blocked startup remains `Provisioning` with a `ConfigurationInvalid` readiness
611+
condition, even when the container backend reports readiness. Gateway management
612+
operations remain available. Replacing the policy or repairing providers allows
613+
the same supervisor to reconcile and launch; it does not recreate the sandbox.
614+
Static policy fields can be replaced before the first accepted activation.
615+
Admission validates policy composition; image and host setup failures, such as
616+
an unresolved OCI user or unavailable isolation facilities, retain their existing
617+
startup error behavior.
618+
619+
Acceptance identifies the effective policy hash/version, configuration revision,
620+
provider-environment revision, and reporting supervisor instance. Startup captures
621+
the matching provider environment and constructs the runtime before reporting
622+
acceptance. Live reconciliation begins only after the main process has spawned,
623+
so it cannot replace the configuration captured for that launch. Restart resets
624+
admission and requires a fresh accepted configuration.
625+
626+
Policy and provider refreshes are prepared before publication. Publication
627+
invalidates prior policy guards before exposing new provider material and swaps
628+
the policy under the same publication locks. Rejected candidates cannot install
629+
their credentials alongside the previous policy. Existing runtime fail-closed
630+
checks remain necessary for in-flight traffic and invalid live updates.
631+
632+
In sidecar topology, the authenticated process supervisor supplies discovery
633+
from the workload image over the existing control socket. The network supervisor
634+
withholds bootstrap until admission succeeds, then sends the accepted policy and
635+
child environment together. Subsequent configuration messages carry both parts
636+
and an ordered generation; older messages cannot restore stale child credentials.
637+
600638
## Policy Revision Acknowledgement
601639

602640
When the supervisor loads a sandbox-scoped policy from the gateway, it retains
@@ -632,12 +670,11 @@ outages cannot block policy polling, enforcement, settings, or provider
632670
refreshes and cannot permanently lose the initial acknowledgement.
633671

634672
Only sandbox-scoped revisions (`PolicySource::Sandbox`, version greater than
635-
zero) are acknowledged. Global policies and local-file development policies do
636-
not use the sandbox revision API and produce no acknowledgement. When explicit
637-
local Rego and data files are provisioned into the supervisor, it continues
638-
polling the gateway for settings and provider refreshes but never replaces the
639-
local OPA engine with a gateway policy revision. Workload image files and
640-
environment variables do not configure the separately isolated supervisor.
673+
zero) use the policy revision acknowledgement API. Global policies use the
674+
configuration admission contract without a sandbox policy revision acknowledgement.
675+
Local Rego/data overrides remain available for standalone development; combining
676+
them with a gateway-managed sandbox is rejected because the gateway cannot admit
677+
the runtime policy it would enforce.
641678

642679
## Failure Behavior
643680

‎architecture/security-policy.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,14 @@ flag defaults to `false` and is security-flagged in policy approval flows.
159159
Incremental merges only ever add the flag to a matching endpoint; clearing it
160160
requires removing the endpoint or replacing the policy.
161161

162+
Image discovery may persist a desired policy for repair, but does not authorize
163+
workload activation. The gateway applies the credential gate after full provider
164+
composition and provenance derivation. A rejected effective configuration keeps
165+
startup blocked with a bounded diagnostic; the supervisor waits for management
166+
repair instead of launching with connection-time denials or a fallback policy.
167+
Accepted runtime state includes the matching provider-environment revision, so
168+
policy and credential updates cannot activate independently.
169+
162170
The network supervisor independently enforces the same boundary. Credentialed
163171
WebSocket upgrades use the parsed relay, binary frames fail closed, and text
164172
placeholders require rewrite. REST bodies continue streaming when body rewrite is disabled. The relay holds

‎crates/openshell-cli/src/run.rs‎

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1560,11 +1560,23 @@ where
15601560
sandbox.object_id().to_string()
15611561
};
15621562

1563-
let config = client
1563+
let config_result = client
15641564
.get_sandbox_config(GetSandboxConfigRequest { sandbox_id })
1565-
.await
1566-
.into_diagnostic()?
1567-
.into_inner();
1565+
.await;
1566+
let config = match config_result {
1567+
Ok(response) => response.into_inner(),
1568+
Err(_) if !policy_only && configuration_failure_message(&sandbox).is_some() => {
1569+
// An invalid desired policy must not hide the status needed to
1570+
// repair it. Keep payload-only reads strict.
1571+
GetSandboxConfigResponse {
1572+
configuration_error: configuration_failure_message(&sandbox)
1573+
.unwrap_or_default()
1574+
.to_string(),
1575+
..Default::default()
1576+
}
1577+
}
1578+
Err(error) => return Err(error).into_diagnostic(),
1579+
};
15681580

15691581
if policy_only {
15701582
let Some(ref policy) = config.policy else {
@@ -1598,6 +1610,22 @@ where
15981610
println!(" {} {}", "Id:".dimmed(), id);
15991611
println!(" {} {}", "Name:".dimmed(), name);
16001612
println!(" {} {}", "Phase:".dimmed(), phase_name(sandbox.phase()));
1613+
if let Some(status) = sandbox.status.as_ref() {
1614+
for condition in &status.conditions {
1615+
if matches!(
1616+
condition.r#type.as_str(),
1617+
"ConfigurationReady" | "DesiredConfigurationReady"
1618+
) && condition.status.eq_ignore_ascii_case("false")
1619+
{
1620+
println!(
1621+
" {} {}: {}",
1622+
"Configuration:".dimmed(),
1623+
condition.reason,
1624+
condition.message
1625+
);
1626+
}
1627+
}
1628+
}
16011629
if let Some(exit_code) = sandbox.status.as_ref().and_then(|status| status.exit_code) {
16021630
println!(" {} {}", "Exit Code:".dimmed(), exit_code);
16031631
}
@@ -2460,7 +2488,19 @@ pub async fn sandbox_list(
24602488
Ok(())
24612489
}
24622490

2491+
fn configuration_failure_message(sandbox: &Sandbox) -> Option<&str> {
2492+
sandbox
2493+
.status
2494+
.as_ref()?
2495+
.configuration_admission
2496+
.as_ref()
2497+
.map(|admission| admission.error.as_str())
2498+
.filter(|message| !message.is_empty())
2499+
}
2500+
24632501
fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value {
2502+
use openshell_core::proto::ConfigurationAdmissionState;
2503+
24642504
let meta = sandbox.metadata.as_ref();
24652505
let labels = meta.map_or_else(|| serde_json::json!({}), |m| serde_json::json!(m.labels));
24662506
let annotations = meta.map_or_else(
@@ -2500,6 +2540,25 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value {
25002540
})
25012541
.collect::<Vec<_>>()
25022542
});
2543+
let admission = sandbox
2544+
.status
2545+
.as_ref()
2546+
.and_then(|status| status.configuration_admission.as_ref())
2547+
.map(|admission| {
2548+
serde_json::json!({
2549+
"state": match ConfigurationAdmissionState::try_from(admission.state) {
2550+
Ok(ConfigurationAdmissionState::Pending) => "pending",
2551+
Ok(ConfigurationAdmissionState::Accepted) => "accepted",
2552+
Ok(ConfigurationAdmissionState::Rejected) => "rejected",
2553+
_ => "unknown",
2554+
},
2555+
"error": admission.error,
2556+
"policy_version": admission.policy_version,
2557+
"policy_hash": admission.policy_hash,
2558+
"config_revision": admission.config_revision,
2559+
"provider_env_revision": admission.provider_env_revision,
2560+
})
2561+
});
25032562
serde_json::json!({
25042563
"id": sandbox.object_id(),
25052564
"name": sandbox.object_name(),
@@ -2513,6 +2572,7 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value {
25132572
"exit_code": sandbox.status.as_ref().and_then(|status| status.exit_code),
25142573
"conditions": conditions,
25152574
"endpoint_statuses": endpoint_statuses,
2575+
"configuration_admission": admission,
25162576
"created_from_workload_template": created_from_workload_template,
25172577
})
25182578
}
@@ -7348,6 +7408,45 @@ mod tests {
73487408
assert_eq!(json["resources"]["gpu"], 2);
73497409
}
73507410

7411+
#[test]
7412+
fn sandbox_json_exposes_repair_diagnostic_and_accepted_generation() {
7413+
use openshell_core::proto::{ConfigurationAdmissionState, SandboxConfigurationAdmission};
7414+
7415+
let mut sandbox = Sandbox::default();
7416+
sandbox.set_phase(SandboxPhase::Provisioning as i32);
7417+
let status = sandbox.status.as_mut().unwrap();
7418+
status.configuration_admission = Some(SandboxConfigurationAdmission {
7419+
state: ConfigurationAdmissionState::Rejected as i32,
7420+
error: "rule image_api requires L7 inspection".to_string(),
7421+
policy_hash: "candidate-hash".to_string(),
7422+
..Default::default()
7423+
});
7424+
status.conditions.push(SandboxCondition {
7425+
r#type: "ConfigurationReady".to_string(),
7426+
status: "False".to_string(),
7427+
reason: "ConfigurationInvalid".to_string(),
7428+
message: "rule image_api requires L7 inspection".to_string(),
7429+
..Default::default()
7430+
});
7431+
let json = super::sandbox_to_json(&sandbox);
7432+
assert_eq!(json["configuration_admission"]["state"], "rejected");
7433+
assert_eq!(json["conditions"][0]["reason"], "ConfigurationInvalid");
7434+
assert_eq!(
7435+
super::configuration_failure_message(&sandbox),
7436+
Some("rule image_api requires L7 inspection")
7437+
);
7438+
sandbox
7439+
.status
7440+
.as_mut()
7441+
.unwrap()
7442+
.configuration_admission
7443+
.as_mut()
7444+
.unwrap()
7445+
.error
7446+
.clear();
7447+
assert_eq!(super::configuration_failure_message(&sandbox), None);
7448+
}
7449+
73517450
#[test]
73527451
fn sandbox_detail_to_json_includes_policy_fields() {
73537452
let mut sandbox = Sandbox {

‎crates/openshell-cli/tests/ensure_providers_integration.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,13 @@ impl OpenShell for TestOpenShell {
565565
Err(Status::unimplemented("not implemented in test"))
566566
}
567567

568+
async fn report_sandbox_configuration(
569+
&self,
570+
_request: tonic::Request<openshell_core::proto::ReportSandboxConfigurationRequest>,
571+
) -> Result<Response<openshell_core::proto::ReportSandboxConfigurationResponse>, Status> {
572+
Err(Status::unimplemented("not implemented in test"))
573+
}
574+
568575
async fn report_policy_status(
569576
&self,
570577
_request: tonic::Request<openshell_core::proto::ReportPolicyStatusRequest>,

‎crates/openshell-cli/tests/mtls_integration.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,13 @@ impl OpenShell for TestOpenShell {
420420
Err(Status::unimplemented("not implemented in test"))
421421
}
422422

423+
async fn report_sandbox_configuration(
424+
&self,
425+
_request: tonic::Request<openshell_core::proto::ReportSandboxConfigurationRequest>,
426+
) -> Result<Response<openshell_core::proto::ReportSandboxConfigurationResponse>, Status> {
427+
Err(Status::unimplemented("not implemented in test"))
428+
}
429+
423430
async fn report_policy_status(
424431
&self,
425432
_request: tonic::Request<openshell_core::proto::ReportPolicyStatusRequest>,

‎crates/openshell-cli/tests/provider_commands_integration.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,6 +1011,13 @@ impl OpenShell for TestOpenShell {
10111011
Err(Status::unimplemented("not implemented in test"))
10121012
}
10131013

1014+
async fn report_sandbox_configuration(
1015+
&self,
1016+
_request: tonic::Request<openshell_core::proto::ReportSandboxConfigurationRequest>,
1017+
) -> Result<Response<openshell_core::proto::ReportSandboxConfigurationResponse>, Status> {
1018+
Err(Status::unimplemented("not implemented in test"))
1019+
}
1020+
10141021
async fn report_policy_status(
10151022
&self,
10161023
_request: tonic::Request<openshell_core::proto::ReportPolicyStatusRequest>,

‎crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -834,6 +834,13 @@ impl OpenShell for TestOpenShell {
834834
Err(Status::unimplemented("not implemented in test"))
835835
}
836836

837+
async fn report_sandbox_configuration(
838+
&self,
839+
_request: tonic::Request<openshell_core::proto::ReportSandboxConfigurationRequest>,
840+
) -> Result<Response<openshell_core::proto::ReportSandboxConfigurationResponse>, Status> {
841+
Err(Status::unimplemented("not implemented in test"))
842+
}
843+
837844
async fn report_policy_status(
838845
&self,
839846
_request: tonic::Request<openshell_core::proto::ReportPolicyStatusRequest>,

‎crates/openshell-cli/tests/sandbox_name_fallback_integration.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,13 @@ impl OpenShell for TestOpenShell {
510510
Err(Status::unimplemented("not implemented in test"))
511511
}
512512

513+
async fn report_sandbox_configuration(
514+
&self,
515+
_request: tonic::Request<openshell_core::proto::ReportSandboxConfigurationRequest>,
516+
) -> Result<Response<openshell_core::proto::ReportSandboxConfigurationResponse>, Status> {
517+
Err(Status::unimplemented("not implemented in test"))
518+
}
519+
513520
async fn report_policy_status(
514521
&self,
515522
_request: tonic::Request<openshell_core::proto::ReportPolicyStatusRequest>,

‎crates/openshell-core/src/grpc_client.rs‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,40 @@ pub async fn sync_policy_and_fetch_snapshot(
10011001
fetch_settings_snapshot_with_client(&mut client, sandbox_id).await
10021002
}
10031003

1004+
/// Report an exact runtime configuration generation. Pending registration uses
1005+
/// the snapshot's instance fence; retain that snapshot across registration retries.
1006+
pub async fn report_sandbox_configuration(
1007+
endpoint: &str,
1008+
sandbox_id: &str,
1009+
instance_id: &str,
1010+
snapshot: Option<&SettingsPollResult>,
1011+
state: crate::proto::ConfigurationAdmissionState,
1012+
error: &str,
1013+
) -> Result<()> {
1014+
let mut client = connect(endpoint).await?;
1015+
client
1016+
.report_sandbox_configuration(crate::proto::ReportSandboxConfigurationRequest {
1017+
sandbox_id: sandbox_id.to_string(),
1018+
expected_instance_id: snapshot.map_or_else(String::new, |snapshot| {
1019+
snapshot.configuration_instance_id.clone()
1020+
}),
1021+
admission: Some(crate::proto::SandboxConfigurationAdmission {
1022+
instance_id: instance_id.to_string(),
1023+
state: state.into(),
1024+
policy_version: snapshot.map_or(0, |snapshot| snapshot.version),
1025+
policy_hash: snapshot
1026+
.map_or_else(String::new, |snapshot| snapshot.policy_hash.clone()),
1027+
config_revision: snapshot.map_or(0, |snapshot| snapshot.config_revision),
1028+
provider_env_revision: snapshot
1029+
.map_or(0, |snapshot| snapshot.provider_env_revision),
1030+
error: error.to_string(),
1031+
}),
1032+
})
1033+
.await
1034+
.into_diagnostic()?;
1035+
Ok(())
1036+
}
1037+
10041038
/// Fetch provider environment variables for a sandbox from `OpenShell` server via gRPC.
10051039
///
10061040
/// Returns a map of environment variable names to values derived from provider
@@ -1117,6 +1151,9 @@ pub struct CachedOpenShellClient {
11171151
/// Settings poll result returned by [`CachedOpenShellClient::poll_settings`].
11181152
#[derive(Clone, Debug)]
11191153
pub struct SettingsPollResult {
1154+
pub configuration_instance_id: String,
1155+
pub configuration_admitted: bool,
1156+
pub configuration_error: String,
11201157
pub policy: Option<ProtoSandboxPolicy>,
11211158
pub version: u32,
11221159
pub policy_hash: String,
@@ -1138,6 +1175,9 @@ pub struct SettingsPollResult {
11381175

11391176
fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> SettingsPollResult {
11401177
SettingsPollResult {
1178+
configuration_instance_id: inner.configuration_instance_id,
1179+
configuration_admitted: inner.configuration_admitted,
1180+
configuration_error: inner.configuration_error,
11411181
policy: inner.policy,
11421182
version: inner.version,
11431183
policy_hash: inner.policy_hash,

0 commit comments

Comments
 (0)