Skip to content
Closed
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
452 changes: 448 additions & 4 deletions crates/openshell-server/src/grpc/policy.rs

Large diffs are not rendered by default.

264 changes: 25 additions & 239 deletions crates/openshell-supervisor/src/lib.rs

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion docs/how-it-works/policies/advisor.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,10 @@ including automatically. You can also review proposals in the terminal UI with
## Approve Proposals Automatically

By default, every proposal waits for your review. In automatic mode, OpenShell
approves a proposal without review when both of these are true:
approves a proposal without review when all of these are true:

- The proposal keeps credential bindings and their rule and process scope unchanged.
Binding changes require workspace admin approval.
- The proposal risk check finds none of the risks described in [Proposal Risk
Check](#proposal-risk-check).
- OpenShell has not flagged the proposal's destination. OpenShell flags hosts
Expand Down
10 changes: 4 additions & 6 deletions docs/how-it-works/policies/default-policy.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,10 @@ baseline read-write path that you list as read-only stays read-only. If the
policy has no `filesystem_policy` section, OpenShell creates one with
`include_workdir: true`.

The sandbox saves the enriched filesystem policy as a new revision, so the
added paths appear in `openshell policy get --base`. While a global policy is
active, the sandbox adds the paths without saving a revision, so they do not
appear in the policy views. OpenShell can reject a
replacement policy that removes filesystem paths, so keep the added paths when
you replace the complete policy.
When adopting an image policy, OpenShell saves the initial baseline paths with
that policy. For a policy already saved through the gateway, OpenShell adds
missing baseline paths locally without creating a new policy revision. These
local additions do not appear in `openshell policy get --base`.

The runtime also grants the workload read-only access to the sandbox's TLS CA
certificates under `/run/openshell-supervisor-ca`. This grant is not saved in
Expand Down
9 changes: 6 additions & 3 deletions docs/how-it-works/policies/manage-policies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@ To include a policy in a sandbox image, add it at `/etc/openshell/policy.yaml`:
COPY policy.yaml /etc/openshell/policy.yaml
```

OpenShell uses the image policy only when the sandbox has no saved policy, so a
`--policy` file or `OPENSHELL_SANDBOX_POLICY` takes precedence. If the image
OpenShell adopts the image policy once, only when the sandbox has no saved policy, so a
`--policy` file or `OPENSHELL_SANDBOX_POLICY` takes precedence. An image policy
must not contain `credential_binding`; a workspace admin must submit policies
with credential bindings through the gateway using `openshell policy set`.
Restarts use the saved policy. If the image
policy is invalid, the sandbox does not start until you [replace it with a valid
policy](#a-new-sandbox-stays-in-provisioning). OpenShell
does not fall back to the default policy.
Expand Down Expand Up @@ -366,7 +369,7 @@ check the sandbox log for the specific error:
openshell sandbox get my-sandbox --output json
```

The same repair window applies when the gateway refuses an image-policy upload or a policy update that adds baseline filesystem paths with `FAILED_PRECONDITION` or `INVALID_ARGUMENT`. After the gateway acknowledges the rejection report, startup waits two seconds, reads a fresh configuration, and retries. If your repair reaches the gateway before that report, startup immediately reads the repaired configuration. The sandbox log records the gateway's reason; repeated refusals of the same write, status code, and configuration are logged once. Authentication and authorization failures still stop startup.
The same repair window applies when the gateway refuses an initial image-policy upload with `FAILED_PRECONDITION` or `INVALID_ARGUMENT`. After the gateway acknowledges the rejection report, startup waits two seconds, reads a fresh configuration, and retries. If your repair reaches the gateway before that report, startup immediately reads the repaired configuration. The sandbox log records the gateway's reason; repeated refusals of the same write, status code, and configuration are logged once. Authentication and authorization failures still stop startup.

You have 300 seconds to fix the configuration after the supervisor starts admission. Image preparation has its own deadline and does not consume that repair time. Replace the policy with `openshell policy set`, or fix the provider configuration. Until the workload first starts, you can also change filesystem, Landlock, and process settings. Each effective change to the policy, providers, or settings restarts the 300 seconds; writing an unchanged value does not.

Expand Down
33 changes: 8 additions & 25 deletions e2e/rust/tests/live_policy_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -625,22 +625,8 @@ async fn live_policy_update_from_empty_network_policies() {
guard.cleanup().await;
}

/// Regression for #2159: a sparse initial policy that the supervisor enriches
/// with baseline filesystem paths during startup must have its resulting
/// revision acknowledged as loaded, not left `Pending`.
///
/// Reproduction (matches the maintainer's triage): create a sandbox with the
/// network-only `examples/policy-advisor/sandbox-policy.yaml`. The supervisor
/// adds baseline filesystem paths, syncs the enriched policy back to the
/// gateway (creating revision 2, superseding revision 1), builds the OPA engine
/// and then acknowledges revision 2 as loaded. Before the fix, revision 2
/// stayed `Pending` even though the sandbox was `Ready` and the policy was
/// effective.
///
/// NOTE: This exercises the Docker-backed supervisor built from this branch.
/// The exact `policy list` status wording ("Loaded"/"Superseded") may differ by
/// CLI version; the assertions below key on the effective version reaching 2 and
/// no revision remaining `Pending` once the acknowledgement lands.
/// A sparse gateway policy must be acknowledged as loaded after local runtime
/// filesystem enrichment, without creating a second authored revision.
#[tokio::test]
async fn initial_sparse_policy_is_acknowledged_as_loaded() {
let sparse_policy = write_sparse_policy().expect("write sparse policy fixture");
Expand All @@ -663,9 +649,8 @@ async fn initial_sparse_policy_is_acknowledged_as_loaded() {
.await
.expect("create keep sandbox with sparse policy");

// The enriched revision (2) is synced during startup; the acknowledgement
// (LOADED) is delivered by the supervisor's poll loop shortly after Ready.
// Poll until the effective policy is version 2 and no revision is Pending.
// Startup enriches locally and acknowledges the original gateway revision.
// Poll until that revision is loaded.
let mut acknowledged = false;
let mut last_list = String::new();
for _ in 0..30 {
Expand All @@ -676,7 +661,7 @@ async fn initial_sparse_policy_is_acknowledged_as_loaded() {
last_list = list.output.clone();
let pending = list.output.to_lowercase().contains("pending");

if version == Some(2) && list.success && !pending {
if version == Some(1) && list.success && !pending {
acknowledged = true;
break;
}
Expand All @@ -685,15 +670,13 @@ async fn initial_sparse_policy_is_acknowledged_as_loaded() {

assert!(
acknowledged,
"enriched initial policy should reach revision 2 with no Pending revision.\n\
"initial policy should remain revision 1 with no Pending revision.\n\
last `policy list` output:\n{last_list}"
);

// Both the superseded original (1) and the loaded enriched revision (2)
// must appear in the revision history.
assert!(
list_output_contains_version(&last_list, 2),
"policy list should contain revision 2:\n{last_list}"
!list_output_contains_version(&last_list, 2),
"local enrichment must not create revision 2:\n{last_list}"
);
assert!(
list_output_contains_version(&last_list, 1),
Expand Down
92 changes: 92 additions & 0 deletions e2e/rust/tests/policy_activation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,3 +381,95 @@ binaries:
drop(resources);
drop(image);
}

#[tokio::test]
async fn image_credential_binding_waits_for_admin_repair_without_launching() {
let suffix = format!("{:016x}", rand::random::<u64>());
let resources = Resources {
sandbox: format!("bi-{suffix}"),
standalone_sandbox: format!("bl-{suffix}"),
provider: format!("bp-{suffix}"),
};
let context = tempfile::tempdir().unwrap();
let image_policy = POLICY.replace(
" port: 443",
&format!(
" port: 443\n credential_binding:\n provider: {}",
resources.provider
),
);
std::fs::write(context.path().join("policy.yaml"), image_policy).unwrap();
std::fs::write(context.path().join("Dockerfile"), r#"FROM public.ecr.aws/docker/library/python:3.13-slim
RUN groupadd sandbox && useradd -m -g sandbox sandbox && mkdir -p /sandbox && chown sandbox:sandbox /sandbox
COPY policy.yaml /etc/openshell/policy.yaml
WORKDIR /sandbox
USER sandbox
"#).unwrap();
let image = ImageGuard::build(
"image-binding",
&context.path().join("Dockerfile"),
context.path(),
)
.unwrap();
let mut create = openshell_cmd()
.args([
"sandbox",
"create",
"--name",
&resources.sandbox,
"--detach",
"--from",
image.tag(),
"--",
"sh",
"-c",
WORKLOAD,
])
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.spawn()
.unwrap();
let diagnostic = "Image policy contains credential_binding. A workspace admin must submit the reviewed policy through the gateway.";
tokio::time::timeout(Duration::from_secs(90), async {
loop {
assert!(
create.try_wait().unwrap().is_none(),
"sandbox create exited before configuration rejection"
);
let (output, code) =
run_cli(&["sandbox", "get", &resources.sandbox, "--output", "json"]).await;
if code == 0 && output.contains(diagnostic) {
let details: serde_json::Value =
serde_json::from_str(&strip_ansi(&output)).unwrap();
assert_eq!(details["phase"].as_str(), Some("Provisioning"), "{output}");
assert!(output.contains("ConfigurationReady"), "{output}");
assert!(output.contains("ConfigurationInvalid"), "{output}");
break;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
})
.await
.expect("image binding must produce an actionable startup diagnostic");
let engine = ContainerEngine::from_env().unwrap();
let container = container_id(&engine, &resources.sandbox);
assert_marker(&engine, &container, false);
assert!(create.try_wait().unwrap().is_none());
let repaired = context.path().join("reviewed.yaml");
std::fs::write(&repaired, POLICY).unwrap();
cli_ok(&[
"policy",
"set",
&resources.sandbox,
"--policy",
repaired.to_str().unwrap(),
"--wait",
])
.await;
let status = tokio::time::timeout(Duration::from_secs(120), create.wait())
.await
.unwrap()
.unwrap();
assert!(status.success());
wait_for_marker(&engine, &container).await;
}
4 changes: 4 additions & 0 deletions skills/generate-sandbox-policy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ When middleware is requested, also read the published [supervisor middleware gui

For enforcement concepts and the shipped baseline, read [sandbox policies](https://docs.nvidia.com/openshell/latest/how-it-works/policies/overview) and the [default policy reference](https://docs.nvidia.com/openshell/latest/how-it-works/policies/default-policy). The default policy is built into the OpenShell runtime and applies when no explicit policy is supplied.

Image policies are adopted only during initial sandbox setup and must not
contain `credential_binding`. Submit credential bindings through the gateway
with `openshell policy set` as a workspace admin.

Validate the intended provider combination as well as the authored policy.
An image endpoint can become credentialed after provider composition and block
startup with `ConfigurationInvalid`. Repair the complete policy or provider
Expand Down
6 changes: 6 additions & 0 deletions skills/openshell-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,12 @@ Review the proposed scope, candidate hash, prover findings, and application erro

Build a custom container image and run it as a sandbox.

An embedded policy is adopted only during initial setup when no gateway policy
is saved. Embedded policies must not contain `credential_binding`; a workspace
admin supplies those through `openshell policy set`. A rejected image policy
keeps the sandbox in `Provisioning` until repaired, within its existing repair
window. Check `ConfigurationReady` for the actionable diagnostic.

### Create a sandbox from a pre-built image

```bash
Expand Down
Loading