Skip to content
Merged
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
2 changes: 2 additions & 0 deletions architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,8 @@ Public RPC contracts and durable protobuf formats have separate ownership. The `

`ReportEndpointStatus` is a sandbox-authenticated public gateway RPC. Its request, response, and `EndpointObservation` messages belong only to the public closure. `EndpointStatus` and `EndpointResult` also belong to the durable closure because `Sandbox.status.endpoint_statuses` persists them. The repeated status field uses a new wire tag; stored sandboxes without it decode with an empty endpoint list and retain their lifecycle fields. A fixed payload encoded with the earlier sandbox schema verifies that no database rewrite is required.

Allow and deny append requests carry `L7RuleTarget` to declare the rule, endpoint, and complete affected scope. The removed `host` and `port` fields remain reserved by number and name, and requests without a target are rejected. These mutation requests are not persisted formats.

`GetSandboxProviderStatus` and `ReportProviderReadiness` are unary public gateway RPCs. The first lets authorized users inspect a provider change; the second accepts installation reports only from the sandbox's current authenticated supervisor session.

The removed `NetworkBinary.harness` field remains reserved by number and name,
Expand Down
2 changes: 2 additions & 0 deletions architecture/security-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ ambiguity atomically, without creating an invalid revision or partially
activating an update. Supervisor validation remains the defense-in-depth
boundary for startup, concurrent changes, and sources outside those mutations.

L7 allow and deny append operations carry an explicit rule target and the complete affected binary and port scope. The merge engine resolves one non-provider endpoint within that rule, optionally by exact endpoint path, and compares both scope sets before mutation. A partial declaration, ambiguous target, or changed scope rejects the batch before revision persistence. The declaration records operator intent; it does not grant policy-writing authority or change the stored binary and port sets.

The `[openshell.gateway] policy_validation_failure_mode` configuration controls
candidates rejected by supervisor runtime validation. Gateway preflight
rejections never become generations and leave the active policy unchanged. The
Expand Down
119 changes: 114 additions & 5 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ const POLICY_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m
$ openshell policy set my-sandbox --policy policy.yaml
$ openshell policy update my-sandbox --add-endpoint api.github.com:443:read-only:rest:enforce
$ openshell policy update my-sandbox --add-endpoint realtime.example.com:443:read-write:websocket:enforce:websocket-credential-rewrite,allowed-ip=10.0.0.0/8
$ openshell policy update my-sandbox --add-allow 'api.github.com:443:GET:/repos/**'
$ openshell policy update my-sandbox --rule-name github --binary /usr/bin/gh --add-allow 'api.github.com:443:GET:/repos/**'
$ openshell policy set --global --policy policy.yaml
$ openshell policy delete --global
$ openshell policy list my-sandbox
Expand Down Expand Up @@ -1944,26 +1944,39 @@ enum PolicyCommands {
#[arg(long = "remove-endpoint")]
remove_endpoints: Vec<String>,

/// Add a REST or WebSocket method/path allow rule: `host:port:METHOD:path_glob`.
/// Append an allow rule: `host:port[,port...]:METHOD:path_glob`.
/// List every port on the target endpoint.
/// Requires --rule-name and the complete --binary list or --any-binary.
#[arg(long = "add-allow")]
add_allow: Vec<String>,

/// Add a REST or WebSocket method/path deny rule: `host:port:METHOD:path_glob`.
/// Append a deny rule: `host:port[,port...]:METHOD:path_glob`.
/// List every port on the target endpoint.
/// Requires --rule-name and the complete --binary list or --any-binary.
#[arg(long = "add-deny")]
add_deny: Vec<String>,

/// Remove a network rule by name.
#[arg(long = "remove-rule")]
remove_rules: Vec<String>,

/// Add binaries to each --add-endpoint rule.
/// Add a binary to --add-endpoint, or declare every binary of an L7 target rule.
#[arg(long = "binary", value_hint = ValueHint::FilePath)]
binaries: Vec<String>,

/// Override the generated rule name when exactly one --add-endpoint is provided.
/// Name the target rule for L7 appends, or override one --add-endpoint rule name.
#[arg(long = "rule-name")]
rule_name: Option<String>,

/// Explicitly declare that the target rule for L7 appends allows any binary.
#[arg(long, conflicts_with = "binaries")]
any_binary: bool,

/// Select an exact endpoint path for L7 appends; an empty value selects no path.
/// This is distinct from the appended method/path matcher.
#[arg(long)]
endpoint_path: Option<String>,

/// Preview the merged policy without sending it to the gateway.
#[arg(long)]
dry_run: bool,
Expand Down Expand Up @@ -2839,6 +2852,8 @@ async fn run_async() -> Result<()> {
remove_rules,
binaries,
rule_name,
any_binary,
endpoint_path,
dry_run,
wait,
timeout,
Expand All @@ -2854,6 +2869,8 @@ async fn run_async() -> Result<()> {
&remove_rules,
&binaries,
rule_name.as_deref(),
any_binary,
endpoint_path.as_deref(),
dry_run,
wait,
timeout,
Expand Down Expand Up @@ -4014,6 +4031,98 @@ mod tests {
use std::ffi::OsString;
use std::fs;

#[test]
fn policy_update_parses_explicit_l7_scope_and_endpoint_path() {
let cli = Cli::try_parse_from([
"openshell",
"policy",
"update",
"sandbox-1",
"--rule-name",
"api",
"--binary",
"/usr/bin/curl",
"--binary",
"/usr/bin/python3",
"--endpoint-path",
"",
"--add-allow",
"api.example.com:443,8443:POST:/v1/a:b",
])
.expect("explicit scope flags should parse");
let Some(Commands::Policy {
command:
Some(PolicyCommands::Update {
rule_name,
binaries,
any_binary,
endpoint_path,
add_allow,
..
}),
..
}) = cli.command
else {
panic!("expected policy update");
};
assert_eq!(rule_name.as_deref(), Some("api"));
assert_eq!(binaries, vec!["/usr/bin/curl", "/usr/bin/python3"]);
assert!(!any_binary);
assert_eq!(endpoint_path.as_deref(), Some(""));
assert_eq!(add_allow, vec!["api.example.com:443,8443:POST:/v1/a:b"]);
}

#[test]
fn policy_update_any_binary_is_explicit_and_conflicts_with_binary() {
let args = [
"openshell",
"policy",
"update",
"--rule-name",
"api",
"--any-binary",
"--add-deny",
"api.example.com:443:DELETE:/v1/**",
];
let cli = Cli::try_parse_from(args).expect("explicit wildcard should parse");
assert!(matches!(
cli.command,
Some(Commands::Policy {
command: Some(PolicyCommands::Update {
any_binary: true,
..
}),
..
})
));
let conflicting = args.into_iter().chain(["--binary", "/usr/bin/curl"]);
assert!(Cli::try_parse_from(conflicting).is_err());
}

#[test]
fn policy_update_add_endpoint_keeps_scope_defaults() {
let cli = Cli::try_parse_from([
"openshell",
"policy",
"update",
"--add-endpoint",
"api.example.com:443",
])
.expect("endpoint creation retains its existing flags");
assert!(matches!(
cli.command,
Some(Commands::Policy {
command: Some(PolicyCommands::Update {
any_binary: false,
endpoint_path: None,
rule_name: None,
..
}),
..
})
));
}

// Tests below mutate the process-global XDG_CONFIG_HOME env var.
// A static mutex serialises them so concurrent threads don't clobber
// each other's environment.
Expand Down
Loading
Loading