Skip to content

Commit 74dcaf5

Browse files
committed
feat(policy): lint authored policy files offline
Validate authored files before gateway selection or authentication. Share creation safety checks and report file-only violations together while deferring checks that require live gateway state. Closes #4035 Signed-off-by: Shiju <shiju@nvidia.com>
1 parent 53bf840 commit 74dcaf5

15 files changed

Lines changed: 409 additions & 45 deletions

File tree

‎Cargo.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/openshell-cli/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ openshell-policy = { path = "../openshell-policy" }
2323
openshell-providers = { path = "../openshell-providers" }
2424
openshell-sdk = { path = "../openshell-sdk" }
2525
openshell-tui = { path = "../openshell-tui" }
26+
openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" }
2627
serde = { workspace = true }
2728
serde_json = { workspace = true }
2829
serde_yml = { workspace = true }

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2155,6 +2155,13 @@ enum DraftCommands {
21552155

21562156
#[derive(Subcommand, Debug)]
21572157
enum PolicyCommands {
2158+
/// Validate an authored policy file offline, without a gateway.
2159+
#[command(help_template = LEAF_HELP_TEMPLATE)]
2160+
Lint {
2161+
/// Path to the authored policy YAML file.
2162+
#[arg(value_hint = ValueHint::FilePath)]
2163+
policy: String,
2164+
},
21582165
/// Update policy on a live sandbox.
21592166
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
21602167
Set {
@@ -3062,13 +3069,17 @@ async fn run_async() -> Result<()> {
30623069
// -----------------------------------------------------------
30633070
// Top-level policy (was `sandbox policy`)
30643071
// -----------------------------------------------------------
3072+
Some(Commands::Policy {
3073+
command: Some(PolicyCommands::Lint { policy }),
3074+
}) => run::sandbox_policy_lint(&policy)?,
30653075
Some(Commands::Policy {
30663076
command: Some(policy_cmd),
30673077
}) => {
30683078
let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?;
30693079
let mut tls = tls.with_gateway_name(&ctx.name);
30703080
apply_auth(&mut tls, &ctx.name)?;
30713081
match policy_cmd {
3082+
PolicyCommands::Lint { .. } => unreachable!("offline lint is dispatched above"),
30723083
PolicyCommands::Set {
30733084
name,
30743085
policy,

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5408,6 +5408,35 @@ pub async fn sandbox_upload(
54085408
// Sandbox policy commands
54095409
// ---------------------------------------------------------------------------
54105410

5411+
pub fn sandbox_policy_lint(policy_path: &str) -> Result<()> {
5412+
let policy = openshell_policy::parse_sandbox_policy_file(Path::new(policy_path))?;
5413+
let mut violations: Vec<String> = openshell_policy::find_endpoint_ambiguities(&policy)
5414+
.iter()
5415+
.map(ToString::to_string)
5416+
.collect();
5417+
let mut middlewares: Vec<_> = policy.network_middlewares.iter().collect();
5418+
middlewares.sort_by_key(|(name, _)| *name);
5419+
for (name, middleware) in middlewares {
5420+
if middleware.middleware.starts_with("openshell/")
5421+
&& let Err(error) = openshell_supervisor_middleware_builtins::validate_config(
5422+
&middleware.middleware,
5423+
&middleware.config.clone().unwrap_or_default(),
5424+
)
5425+
{
5426+
violations.push(format!("middleware config '{name}' is invalid: {error}"));
5427+
}
5428+
}
5429+
if let Err(error) = openshell_policy::validate_and_canonicalize_authored_policy(policy) {
5430+
violations.extend(error);
5431+
}
5432+
if !violations.is_empty() {
5433+
miette::bail!("policy is invalid:\n- {}", violations.join("\n- "));
5434+
}
5435+
println!("Policy is valid (offline): {policy_path}");
5436+
println!("Gateway checks for providers, external middleware, images and drivers are not run.");
5437+
Ok(())
5438+
}
5439+
54115440
pub async fn sandbox_policy_set_global(
54125441
server: &str,
54135442
policy_path: &str,
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
mod common;
5+
6+
#[test]
7+
fn policy_lint_runs_offline_and_reports_file_errors() {
8+
let dir = tempfile::tempdir().unwrap();
9+
let file = dir.path().join("policy.yaml");
10+
let path = file.to_str().unwrap();
11+
let cases = [
12+
(
13+
"version: 1\n",
14+
true,
15+
vec!["Policy is valid (offline)", "Gateway checks"],
16+
),
17+
("process: {}\n", false, vec!["version"]),
18+
("version: 1\nunknown: true\n", false, vec!["unknown"]),
19+
(
20+
"version: 1\nprocess:\n run_as_user: '0'\n run_as_group: root\nfilesystem_policy:\n read_write: [relative, /]\n",
21+
false,
22+
vec![
23+
"run_as_user",
24+
"run_as_group",
25+
"path must be absolute",
26+
"overly broad",
27+
],
28+
),
29+
(
30+
"version: 1\nnetwork_policies:\n _provider_private:\n endpoints: []\n",
31+
false,
32+
vec!["reserved '_provider_'", "--base"],
33+
),
34+
(
35+
"version: 1\nnetwork_policies:\n bound:\n endpoints:\n - host: example.com\n port: 443\n credential_binding: {provider: ' '}\n",
36+
false,
37+
vec!["credential_binding.provider"],
38+
),
39+
(
40+
"version: 1\nprocess:\n run_as_user: '0'\nnetwork_middlewares:\n redact:\n middleware: openshell/regex\n order: 1\n endpoints: {include: [example.com]}\n config: {mode: allow}\n",
41+
false,
42+
vec!["mode: redact", "run_as_user"],
43+
),
44+
(
45+
"version: 1\nnetwork_policies:\n conflict:\n endpoints:\n - {host: example.com, port: 443}\n - {host: example.com, port: 443, tls: skip}\n",
46+
false,
47+
vec!["overlap", "conflicting"],
48+
),
49+
];
50+
for (yaml, valid, messages) in cases {
51+
std::fs::write(&file, yaml).unwrap();
52+
for args in [
53+
vec!["policy", "lint", path],
54+
vec![
55+
"--gateway-endpoint",
56+
"http://127.0.0.1:1",
57+
"policy",
58+
"lint",
59+
path,
60+
],
61+
] {
62+
let output = common::run_isolated(&args);
63+
assert_eq!(output.code == 0, valid, "{yaml}\n{}", output.combined);
64+
assert!(
65+
!output.combined.contains("failed to connect"),
66+
"{}",
67+
output.combined
68+
);
69+
for message in &messages {
70+
assert!(
71+
output.combined.contains(message),
72+
"expected {message}: {}",
73+
output.combined
74+
);
75+
}
76+
}
77+
}
78+
std::fs::write(
79+
&file,
80+
format!(
81+
"version: 1\nnetwork_policies:\n large:\n name: {}\n",
82+
"x".repeat(openshell_policy::MAX_AUTHORED_POLICY_SIZE)
83+
),
84+
)
85+
.unwrap();
86+
let oversized = common::run_isolated(&["policy", "lint", path]);
87+
assert_ne!(oversized.code, 0);
88+
assert!(
89+
oversized
90+
.combined
91+
.contains("policy serialized size exceeds maximum"),
92+
"{}",
93+
oversized.combined
94+
);
95+
let missing = dir.path().join("missing.yaml");
96+
assert_ne!(
97+
common::run_isolated(&["policy", "lint", missing.to_str().unwrap()]).code,
98+
0
99+
);
100+
}

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,48 @@ async fn policy_only_preserves_all_process_identity_combinations_through_gateway
138138
}
139139
}
140140
}
141+
142+
#[tokio::test]
143+
async fn authored_policy_lint_matches_gateway_file_validation() {
144+
let server = run_server().await;
145+
let cases = [
146+
(
147+
"identity",
148+
"process:\n run_as_user: '0'\nfilesystem_policy:\n read_write: [relative]\n",
149+
),
150+
("reserved", "network_policies:\n _provider_private: {}\n"),
151+
(
152+
"binding",
153+
"network_policies:\n bound:\n endpoints:\n - host: example.com\n port: 443\n credential_binding: {provider: ' '}\n",
154+
),
155+
];
156+
for (name, body) in cases {
157+
let policy =
158+
openshell_policy::parse_sandbox_policy(&format!("version: 1\n{body}")).unwrap();
159+
let violations =
160+
openshell_policy::validate_and_canonicalize_authored_policy(policy.clone())
161+
.unwrap_err();
162+
let mut client = grpc_client(&server.endpoint, &server.tls).await.unwrap();
163+
let error = client
164+
.create_sandbox(CreateSandboxRequest {
165+
name: name.to_string(),
166+
spec: Some(SandboxSpec {
167+
policy: Some(policy),
168+
..Default::default()
169+
}),
170+
workspace_scope: Some(openshell_core::proto::workspace_selector(
171+
"default".to_string(),
172+
)),
173+
..Default::default()
174+
})
175+
.await
176+
.expect_err("invalid authored file must be rejected by the gateway too");
177+
assert_eq!(error.code(), tonic::Code::InvalidArgument);
178+
for violation in violations {
179+
assert!(
180+
error.message().contains(&violation),
181+
"{name}: {error} did not report {violation}"
182+
);
183+
}
184+
}
185+
}

‎crates/openshell-policy/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ serde = { workspace = true }
1818
serde_json = { workspace = true }
1919
miette = { workspace = true }
2020
prost-types = { workspace = true }
21+
prost = { workspace = true }
2122

2223
[lints]
2324
workspace = true

0 commit comments

Comments
 (0)