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
27 changes: 27 additions & 0 deletions .agents/skills/generate-sandbox-policy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,33 @@ network_policies:
- { path: <binary_path> }
```

### Deny Rules

Use `deny_rules` to block specific dangerous operations while allowing broad access. Deny rules are evaluated after allow rules and take precedence. This is the inverse of the `rules` approach — instead of enumerating every allowed operation, you grant broad access and block a small set of dangerous ones.

```yaml
# Example: Allow full access to GitHub but block admin operations
github_api:
name: github_api
endpoints:
- host: api.github.com
port: 443
protocol: rest
enforcement: enforce
access: read-write
deny_rules:
- method: POST
path: "/repos/*/pulls/*/reviews"
- method: PUT
path: "/repos/*/branches/*/protection"
- method: "*"
path: "/repos/*/rulesets"
binaries:
- { path: /usr/bin/curl }
```

Deny rules support the same matching capabilities as allow rules: `method`, `path`, `command` (SQL), and `query` parameter matchers. When generating policies, prefer deny rules when the user needs broad access with a small set of blocked operations — it produces a shorter, more maintainable policy than enumerating 60+ allow rules.

### Private IP Destinations

When the endpoint resolves to a private IP (RFC 1918), the proxy's SSRF protection blocks the connection by default. Use `allowed_ips` to selectively allow specific private IP ranges:
Expand Down
172 changes: 171 additions & 1 deletion crates/openshell-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use std::path::Path;

use miette::{IntoDiagnostic, Result, WrapErr};
use openshell_core::proto::{
FilesystemPolicy, L7Allow, L7QueryMatcher, L7Rule, LandlockPolicy, NetworkBinary,
FilesystemPolicy, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, LandlockPolicy, NetworkBinary,
NetworkEndpoint, NetworkPolicyRule, ProcessPolicy, SandboxPolicy,
};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -100,6 +100,8 @@ struct NetworkEndpointDef {
rules: Vec<L7RuleDef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
allowed_ips: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
deny_rules: Vec<L7DenyRuleDef>,
}

fn is_zero(v: &u16) -> bool {
Expand Down Expand Up @@ -139,6 +141,19 @@ struct QueryAnyDef {
any: Vec<String>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct L7DenyRuleDef {
#[serde(default, skip_serializing_if = "String::is_empty")]
method: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
path: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
command: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
query: BTreeMap<String, QueryMatcherDef>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct NetworkBinaryDef {
Expand Down Expand Up @@ -214,6 +229,31 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy {
})
.collect(),
allowed_ips: e.allowed_ips,
deny_rules: e
.deny_rules
.into_iter()
.map(|d| L7DenyRule {
method: d.method,
path: d.path,
command: d.command,
query: d
.query
.into_iter()
.map(|(key, matcher)| {
let proto = match matcher {
QueryMatcherDef::Glob(glob) => {
L7QueryMatcher { glob, any: vec![] }
}
QueryMatcherDef::Any(any) => L7QueryMatcher {
glob: String::new(),
any: any.any,
},
};
(key, proto)
})
.collect(),
})
.collect(),
}
})
.collect(),
Expand Down Expand Up @@ -330,6 +370,29 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile {
})
.collect(),
allowed_ips: e.allowed_ips.clone(),
deny_rules: e
.deny_rules
.iter()
.map(|d| L7DenyRuleDef {
method: d.method.clone(),
path: d.path.clone(),
command: d.command.clone(),
query: d
.query
.iter()
.map(|(key, matcher)| {
let yaml_matcher = if !matcher.any.is_empty() {
QueryMatcherDef::Any(QueryAnyDef {
any: matcher.any.clone(),
})
} else {
QueryMatcherDef::Glob(matcher.glob.clone())
};
(key.clone(), yaml_matcher)
})
.collect(),
})
.collect(),
}
})
.collect(),
Expand Down Expand Up @@ -1323,6 +1386,113 @@ network_policies:
);
}

#[test]
fn parse_deny_rules_from_yaml() {
let yaml = r#"
version: 1
network_policies:
github:
name: github
endpoints:
- host: api.github.com
port: 443
protocol: rest
access: read-write
deny_rules:
- method: POST
path: "/repos/*/pulls/*/reviews"
- method: PUT
path: "/repos/*/branches/*/protection"
binaries:
- path: /usr/bin/curl
"#;
let proto = parse_sandbox_policy(yaml).expect("parse failed");
let ep = &proto.network_policies["github"].endpoints[0];
assert_eq!(ep.deny_rules.len(), 2);
assert_eq!(ep.deny_rules[0].method, "POST");
assert_eq!(ep.deny_rules[0].path, "/repos/*/pulls/*/reviews");
assert_eq!(ep.deny_rules[1].method, "PUT");
assert_eq!(ep.deny_rules[1].path, "/repos/*/branches/*/protection");
}

#[test]
fn round_trip_preserves_deny_rules() {
let yaml = r#"
version: 1
network_policies:
github:
name: github
endpoints:
- host: api.github.com
port: 443
protocol: rest
access: full
deny_rules:
- method: POST
path: "/repos/*/pulls/*/reviews"
- method: DELETE
path: "/repos/*/branches/*/protection"
query:
force: "true"
binaries:
- path: /usr/bin/curl
"#;
let proto1 = parse_sandbox_policy(yaml).expect("parse failed");
let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed");
let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed");

let ep1 = &proto1.network_policies["github"].endpoints[0];
let ep2 = &proto2.network_policies["github"].endpoints[0];
assert_eq!(ep1.deny_rules.len(), ep2.deny_rules.len());
assert_eq!(ep2.deny_rules[0].method, "POST");
assert_eq!(ep2.deny_rules[0].path, "/repos/*/pulls/*/reviews");
assert_eq!(ep2.deny_rules[1].method, "DELETE");
assert_eq!(ep2.deny_rules[1].query["force"].glob, "true");
}

#[test]
fn parse_deny_rules_with_query_any() {
let yaml = r#"
version: 1
network_policies:
test:
name: test
endpoints:
- host: api.example.com
port: 443
protocol: rest
access: full
deny_rules:
- method: POST
path: /action
query:
type:
any: ["admin-*", "root-*"]
binaries:
- path: /usr/bin/curl
"#;
let proto = parse_sandbox_policy(yaml).expect("parse failed");
let deny = &proto.network_policies["test"].endpoints[0].deny_rules[0];
assert_eq!(deny.query["type"].any, vec!["admin-*", "root-*"]);
}

#[test]
fn parse_rejects_unknown_fields_in_deny_rule() {
let yaml = r#"
version: 1
network_policies:
test:
endpoints:
- host: example.com
port: 443
deny_rules:
- method: POST
path: /foo
bogus: true
"#;
assert!(parse_sandbox_policy(yaml).is_err());
}

#[test]
fn rejects_port_above_65535() {
let yaml = r#"
Expand Down
101 changes: 100 additions & 1 deletion crates/openshell-sandbox/data/sandbox-policy.rego
Original file line number Diff line number Diff line change
Expand Up @@ -183,19 +183,118 @@ _policy_allows_l7(policy) if {
request_allowed_for_endpoint(input.request, ep)
}

# L7 request allowed if any matching L4 policy also allows the L7 request.
# L7 request allowed if any matching L4 policy also allows the L7 request
# AND no deny rule blocks it. Deny rules take precedence over allow rules.
allow_request if {
some name
policy := data.network_policies[name]
endpoint_allowed(policy, input.network)
binary_allowed(policy, input.exec)
_policy_allows_l7(policy)
not deny_request
}

# --- L7 deny rules ---
#
# Deny rules are evaluated after allow rules and take precedence.
# If a request matches any deny rule on any matching endpoint, it is blocked
# even if it would otherwise be allowed.

default deny_request = false

# Per-policy helper: true when this policy has at least one endpoint matching
# the L4 request whose deny_rules also match the specific L7 request.
_policy_denies_l7(policy) if {
some ep
ep := policy.endpoints[_]
endpoint_matches_request(ep, input.network)
request_denied_for_endpoint(input.request, ep)
}

deny_request if {
some name
policy := data.network_policies[name]
endpoint_allowed(policy, input.network)
binary_allowed(policy, input.exec)
_policy_denies_l7(policy)
}

# --- L7 deny rule matching: REST method + path + query ---

request_denied_for_endpoint(request, endpoint) if {
some deny_rule
deny_rule := endpoint.deny_rules[_]
deny_rule.method
method_matches(request.method, deny_rule.method)
path_matches(request.path, deny_rule.path)
deny_query_params_match(request, deny_rule)
}

# --- L7 deny rule matching: SQL command ---

request_denied_for_endpoint(request, endpoint) if {
some deny_rule
deny_rule := endpoint.deny_rules[_]
deny_rule.command
command_matches(request.command, deny_rule.command)
}

# Deny query matching: fail-closed semantics.
# If no query rules on the deny rule, match unconditionally (any query params).
# If query rules present, trigger the deny if ANY value for a configured key
# matches the matcher. This is the inverse of allow-side semantics where ALL
# values must match. For deny logic, a single matching value is enough to block.
deny_query_params_match(request, deny_rule) if {
deny_query_rules := object.get(deny_rule, "query", {})
count(deny_query_rules) == 0
}

deny_query_params_match(request, deny_rule) if {
deny_query_rules := object.get(deny_rule, "query", {})
count(deny_query_rules) > 0
not deny_query_key_missing(request, deny_query_rules)
not deny_query_value_mismatch_all(request, deny_query_rules)
}

# A configured deny query key is missing from the request entirely.
# Missing key means the deny rule doesn't apply (fail-open on absence).
deny_query_key_missing(request, query_rules) if {
some key
query_rules[key]
request_query := object.get(request, "query_params", {})
values := object.get(request_query, key, null)
values == null
}

# ALL values for a configured key fail to match the matcher.
# If even one value matches, deny fires. This rule checks the opposite:
# true when NO value matches (i.e., every value is a mismatch).
deny_query_value_mismatch_all(request, query_rules) if {
some key
matcher := query_rules[key]
request_query := object.get(request, "query_params", {})
values := object.get(request_query, key, [])
count(values) > 0
not deny_any_value_matches(values, matcher)
}

# True if at least one value in the list matches the matcher.
deny_any_value_matches(values, matcher) if {
some i
query_value_matches(values[i], matcher)
}

# --- L7 deny reason ---

request_deny_reason := reason if {
input.request
deny_request
reason := sprintf("%s %s blocked by deny rule", [input.request.method, input.request.path])
}

request_deny_reason := reason if {
input.request
not deny_request
not allow_request
reason := sprintf("%s %s not permitted by policy", [input.request.method, input.request.path])
}
Expand Down
Loading
Loading