Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,15 +311,15 @@ The storage schema is intentionally narrow:
### Protobuf API and storage boundaries

Public RPC contracts and durable protobuf formats have separate ownership. The
`openshell.v1.OpenShell` service currently has 74 RPCs. Their request and
`openshell.v1.OpenShell` service currently has 75 RPCs. Their request and
response roots, streaming flags, and transitive message closure come from the
public descriptor set generated by `openshell-core`; a fingerprint test in
`openshell-server` requires this inventory to be reviewed whenever it changes.
Compute-driver, credential-driver, gateway-interceptor, and
supervisor-middleware services are compiled contracts for internal extension
boundaries, not public gateway RPCs. The current public inventory has 74
methods, 276 messages, and 12 enums
(`042034fe4d0000279ee4ed27e587ab8e530934b8d5c3aa36dc9769f81dfa6e51`).
boundaries, not public gateway RPCs. The current public inventory has 75
methods, 279 messages, and 13 enums
(`25b9b3d6f2cebdcd3148b0049838dcce51ee745f52a4051428af6701e610afd0`).

Storage-only messages live in the private, versioned
`openshell.storage.v1` package under `crates/openshell-server/proto`. The server
Expand All @@ -334,13 +334,20 @@ Go, Python, and TypeScript client generation inputs do not advertise them.
| Public messages used directly as encoded storage roots | `Sandbox`, `SandboxWorkloadTemplate`, `Provider`, `Workspace`, `WorkspaceMember`, `SshSession`, `ServiceEndpoint` | The generated public type is also the persisted payload. `SshSession` is not in the current public RPC message closure. |
| Embedded encoded root | `SandboxPolicy` | Stored in policy rows and inside the JSON settings envelope. |

The 12 encoded durable roots above have a closure of 81 messages and eight
enums (`920a5243dfb37ce709f0f562a47d17791a5ede90fd7f662ed01542abd60a0dfb`).
Its intersection with the public RPC closure contains 71 messages and eight
enums (`05add438ba041defc98d791038ae593d3f09352677cae43f2276d494205ce415`).
The 12 encoded durable roots above have a closure of 82 messages and nine
enums (`a6191e11d46430e5e32881f53b13f6c717717fbc6bff6e0a1d270fe8f9710d51`).
Its intersection with the public RPC closure contains 72 messages and nine
enums (`68127e24cdb88b67433f68c4a1443c22a322f37ed1225b7547614a96f768cfac`).
The descriptor-derived test owns these full inventories; the tables here record
the reviewed roots and classifications.

Configuration admission adds optional `SandboxStatus.configuration_admission`
at field 10, extending both the public and durable closures. Existing stored
sandboxes decode with no admission record; no database rewrite is required.
New supervisors register and validate before activation, and explicit restart
resets admission. A pre-admission byte fixture verifies that legacy phase and
policy-version fields survive decoding without fabricating acceptance.

| Dual-purpose encoded root | Current decision |
|---|---|
| `Sandbox` | Defer a storage twin; govern its complete dependency closure as durable. |
Expand Down
48 changes: 43 additions & 5 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,44 @@ the structured 403 and authors the narrowest rule. Mechanistically mapping L7
would either over-broaden rules or require path-templating logic that rots
quickly.

## Configuration Admission

Gateway-managed supervisors reconcile configuration before launching the main
process or exposing workload services. Admission covers the effective policy,
provider layers, credential bindings, and gateway-derived provenance. Explicit
user and global policy precedence is unchanged; an image without a policy uses
the restrictive baseline. An invalid image policy does not become a launchable
default.

The gateway tracks configuration admission independently of compute health.
A blocked startup remains `Provisioning` with a `ConfigurationInvalid` readiness
condition, even when the container backend reports readiness. Gateway management
operations remain available. Replacing the policy or repairing providers allows
the same supervisor to reconcile and launch; it does not recreate the sandbox.
Static policy fields can be replaced before the first accepted activation.
Admission validates policy composition; image and host setup failures, such as
an unresolved OCI user or unavailable isolation facilities, retain their existing
startup error behavior.

Acceptance identifies the effective policy hash/version, configuration revision,
provider-environment revision, and reporting supervisor instance. Startup captures
the matching provider environment and constructs the runtime before reporting
acceptance. Live reconciliation begins only after the main process has spawned,
so it cannot replace the configuration captured for that launch. Restart resets
admission and requires a fresh accepted configuration.

Policy and provider refreshes are prepared before publication. Publication
invalidates prior policy guards before exposing new provider material and swaps
the policy under the same publication locks. Rejected candidates cannot install
their credentials alongside the previous policy. Existing runtime fail-closed
checks remain necessary for in-flight traffic and invalid live updates.

In sidecar topology, the authenticated process supervisor supplies discovery
from the workload image over the existing control socket. The network supervisor
withholds bootstrap until admission succeeds, then sends the accepted policy and
child environment together. Subsequent configuration messages carry both parts
and an ordered generation; older messages cannot restore stale child credentials.

## Policy Revision Acknowledgement

When the supervisor loads a sandbox-scoped policy from the gateway, it retains
Expand Down Expand Up @@ -508,11 +546,11 @@ outages cannot block policy polling, enforcement, settings, or provider
refreshes and cannot permanently lose the initial acknowledgement.

Only sandbox-scoped revisions (`PolicySource::Sandbox`, version greater than
zero) are acknowledged. Global policies and local-file development policies do
not use the sandbox revision API and produce no acknowledgement. When explicit
local Rego and data files are configured, the supervisor continues polling the
gateway for settings and provider refreshes but never replaces the local OPA
engine with a gateway policy revision.
zero) use the policy revision acknowledgement API. Global policies use the
configuration admission contract without a sandbox policy revision acknowledgement.
Local Rego/data overrides remain available for standalone development; combining
them with a gateway-managed sandbox is rejected because the gateway cannot admit
the runtime policy it would enforce.

## Failure Behavior

Expand Down
8 changes: 8 additions & 0 deletions architecture/security-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@ flag defaults to `false` and is security-flagged in policy approval flows.
Incremental merges only ever add the flag to a matching endpoint; clearing it
requires removing the endpoint or replacing the policy.

Image discovery may persist a desired policy for repair, but does not authorize
workload activation. The gateway applies the credential gate after full provider
composition and provenance derivation. A rejected effective configuration keeps
startup blocked with a bounded diagnostic; the supervisor waits for management
repair instead of launching with connection-time denials or a fallback policy.
Accepted runtime state includes the matching provider-environment revision, so
policy and credential updates cannot activate independently.

The network supervisor independently enforces the same boundary. Credentialed
WebSocket upgrades use the parsed relay, binary frames fail closed, and text
placeholders require rewrite. REST bodies can continue streaming when body
Expand Down
122 changes: 118 additions & 4 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1510,11 +1510,23 @@ pub async fn sandbox_get(
sandbox.object_id().to_string()
};

let config = client
let config_result = client
.get_sandbox_config(GetSandboxConfigRequest { sandbox_id })
.await
.into_diagnostic()?
.into_inner();
.await;
let config = match config_result {
Ok(response) => response.into_inner(),
Err(_) if !policy_only && configuration_failure_message(&sandbox).is_some() => {
// An invalid desired policy must not hide the status needed to
// repair it. Keep payload-only reads strict.
GetSandboxConfigResponse {
configuration_error: configuration_failure_message(&sandbox)
.unwrap_or_default()
.to_string(),
..Default::default()
}
}
Err(error) => return Err(error).into_diagnostic(),
};

if policy_only {
let Some(ref policy) = config.policy else {
Expand Down Expand Up @@ -1548,6 +1560,22 @@ pub async fn sandbox_get(
println!(" {} {}", "Id:".dimmed(), id);
println!(" {} {}", "Name:".dimmed(), name);
println!(" {} {}", "Phase:".dimmed(), phase_name(sandbox.phase()));
if let Some(status) = sandbox.status.as_ref() {
for condition in &status.conditions {
if matches!(
condition.r#type.as_str(),
"ConfigurationReady" | "DesiredConfigurationReady"
) && condition.status.eq_ignore_ascii_case("false")
{
println!(
" {} {}: {}",
"Configuration:".dimmed(),
condition.reason,
condition.message
);
}
}
}
if let Some(exit_code) = sandbox.status.as_ref().and_then(|status| status.exit_code) {
println!(" {} {}", "Exit Code:".dimmed(), exit_code);
}
Expand Down Expand Up @@ -2367,7 +2395,19 @@ pub async fn sandbox_list(
Ok(())
}

fn configuration_failure_message(sandbox: &Sandbox) -> Option<&str> {
sandbox
.status
.as_ref()?
.configuration_admission
.as_ref()
.map(|admission| admission.error.as_str())
.filter(|message| !message.is_empty())
}

fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value {
use openshell_core::proto::ConfigurationAdmissionState;

let meta = sandbox.metadata.as_ref();
let labels = meta.map_or_else(|| serde_json::json!({}), |m| serde_json::json!(m.labels));
let annotations = meta.map_or_else(
Expand All @@ -2384,6 +2424,39 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value {
"resource_version": provenance.resource_version,
})
});
let conditions: Vec<_> = sandbox
.status
.as_ref()
.into_iter()
.flat_map(|status| &status.conditions)
.map(|condition| {
serde_json::json!({
"type": condition.r#type,
"status": condition.status,
"reason": condition.reason,
"message": condition.message,
})
})
.collect();
let admission = sandbox
.status
.as_ref()
.and_then(|status| status.configuration_admission.as_ref())
.map(|admission| {
serde_json::json!({
"state": match ConfigurationAdmissionState::try_from(admission.state) {
Ok(ConfigurationAdmissionState::Pending) => "pending",
Ok(ConfigurationAdmissionState::Accepted) => "accepted",
Ok(ConfigurationAdmissionState::Rejected) => "rejected",
_ => "unknown",
},
"error": admission.error,
"policy_version": admission.policy_version,
"policy_hash": admission.policy_hash,
"config_revision": admission.config_revision,
"provider_env_revision": admission.provider_env_revision,
})
});
serde_json::json!({
"id": sandbox.object_id(),
"name": sandbox.object_name(),
Expand All @@ -2395,6 +2468,8 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value {
"phase": phase_name(sandbox.phase()),
"current_policy_version": sandbox.current_policy_version(),
"exit_code": sandbox.status.as_ref().and_then(|status| status.exit_code),
"conditions": conditions,
"configuration_admission": admission,
"created_from_workload_template": created_from_workload_template,
})
}
Expand Down Expand Up @@ -7016,6 +7091,45 @@ mod tests {
assert_eq!(json["resources"]["gpu"], 2);
}

#[test]
fn sandbox_json_exposes_repair_diagnostic_and_accepted_generation() {
use openshell_core::proto::{ConfigurationAdmissionState, SandboxConfigurationAdmission};

let mut sandbox = Sandbox::default();
sandbox.set_phase(SandboxPhase::Provisioning as i32);
let status = sandbox.status.as_mut().unwrap();
status.configuration_admission = Some(SandboxConfigurationAdmission {
state: ConfigurationAdmissionState::Rejected as i32,
error: "rule image_api requires L7 inspection".to_string(),
policy_hash: "candidate-hash".to_string(),
..Default::default()
});
status.conditions.push(SandboxCondition {
r#type: "ConfigurationReady".to_string(),
status: "False".to_string(),
reason: "ConfigurationInvalid".to_string(),
message: "rule image_api requires L7 inspection".to_string(),
..Default::default()
});
let json = super::sandbox_to_json(&sandbox);
assert_eq!(json["configuration_admission"]["state"], "rejected");
assert_eq!(json["conditions"][0]["reason"], "ConfigurationInvalid");
assert_eq!(
super::configuration_failure_message(&sandbox),
Some("rule image_api requires L7 inspection")
);
sandbox
.status
.as_mut()
.unwrap()
.configuration_admission
.as_mut()
.unwrap()
.error
.clear();
assert_eq!(super::configuration_failure_message(&sandbox), None);
}

#[test]
fn sandbox_detail_to_json_includes_policy_fields() {
let mut sandbox = Sandbox {
Expand Down
7 changes: 7 additions & 0 deletions crates/openshell-cli/tests/ensure_providers_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,13 @@ impl OpenShell for TestOpenShell {
Err(Status::unimplemented("not implemented in test"))
}

async fn report_sandbox_configuration(
&self,
_request: tonic::Request<openshell_core::proto::ReportSandboxConfigurationRequest>,
) -> Result<Response<openshell_core::proto::ReportSandboxConfigurationResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn report_policy_status(
&self,
_request: tonic::Request<openshell_core::proto::ReportPolicyStatusRequest>,
Expand Down
7 changes: 7 additions & 0 deletions crates/openshell-cli/tests/mtls_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,13 @@ impl OpenShell for TestOpenShell {
Err(Status::unimplemented("not implemented in test"))
}

async fn report_sandbox_configuration(
&self,
_request: tonic::Request<openshell_core::proto::ReportSandboxConfigurationRequest>,
) -> Result<Response<openshell_core::proto::ReportSandboxConfigurationResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn report_policy_status(
&self,
_request: tonic::Request<openshell_core::proto::ReportPolicyStatusRequest>,
Expand Down
7 changes: 7 additions & 0 deletions crates/openshell-cli/tests/provider_commands_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,13 @@ impl OpenShell for TestOpenShell {
Err(Status::unimplemented("not implemented in test"))
}

async fn report_sandbox_configuration(
&self,
_request: tonic::Request<openshell_core::proto::ReportSandboxConfigurationRequest>,
) -> Result<Response<openshell_core::proto::ReportSandboxConfigurationResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn report_policy_status(
&self,
_request: tonic::Request<openshell_core::proto::ReportPolicyStatusRequest>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,13 @@ impl OpenShell for TestOpenShell {
Err(Status::unimplemented("not implemented in test"))
}

async fn report_sandbox_configuration(
&self,
_request: tonic::Request<openshell_core::proto::ReportSandboxConfigurationRequest>,
) -> Result<Response<openshell_core::proto::ReportSandboxConfigurationResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn report_policy_status(
&self,
_request: tonic::Request<openshell_core::proto::ReportPolicyStatusRequest>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,13 @@ impl OpenShell for TestOpenShell {
Err(Status::unimplemented("not implemented in test"))
}

async fn report_sandbox_configuration(
&self,
_request: tonic::Request<openshell_core::proto::ReportSandboxConfigurationRequest>,
) -> Result<Response<openshell_core::proto::ReportSandboxConfigurationResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn report_policy_status(
&self,
_request: tonic::Request<openshell_core::proto::ReportPolicyStatusRequest>,
Expand Down
Loading
Loading