Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
55357df
feat(policy): plumb chunk_ids and rejection_reason through proposal p…
zredlined May 11, 2026
652081d
feat(sandbox): add /v1/proposals/{id} and /wait long-poll to policy.l…
zredlined May 11, 2026
d5fe465
docs(sandbox): teach policy_advisor skill the wait + redraft loop
zredlined May 11, 2026
14949e0
test(policy-advisor): add end-to-end smoke for the agent feedback loop
zredlined May 11, 2026
ad11745
fix(policy-advisor): surface real CLI errors from wait-smoke preflight
zredlined May 11, 2026
62d40bc
fix(policy-advisor): pass --json to settings get in wait-smoke preflight
zredlined May 11, 2026
f2dfa8a
fix(policy): dedup draft chunks only in mechanistic mode; return effe…
zredlined May 11, 2026
6e135a0
docs(examples): retarget policy-management demo at the /wait endpoint
zredlined May 11, 2026
97723e8
fix(examples): pass --yes on demo.sh's global setting writes
zredlined May 11, 2026
0ed74b0
feat(sandbox): emit OCSF audit events for policy proposal lifecycle
zredlined May 12, 2026
e44f1a0
feat(policy): /wait awaits local policy reload; demo auto-approves re…
zredlined May 12, 2026
7a9f82a
fix(sandbox): /wait detects policy reload by content, not the schema …
zredlined May 12, 2026
bafd8ac
fix(examples): redact tokens with python literal-string replace, not sed
zredlined May 12, 2026
88afffb
fix(sandbox): scope /wait reload check to the approved rule
zredlined May 12, 2026
c2632dd
fix(server): make GetDraftPolicy dual-auth so /wait works under OIDC
zredlined May 13, 2026
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: 1 addition & 1 deletion crates/openshell-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize};
pub use compose::{ProviderPolicyLayer, compose_effective_policy, provider_rule_name};
pub use merge::{
PolicyMergeError, PolicyMergeOp, PolicyMergeResult, PolicyMergeWarning, generated_rule_name,
merge_policy,
merge_policy, policy_covers_rule,
};

// ---------------------------------------------------------------------------
Expand Down
365 changes: 365 additions & 0 deletions crates/openshell-policy/src/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,78 @@ pub struct PolicyMergeResult {
pub changed: bool,
}

/// Returns true iff `policy` semantically contains the rule an `AddRule`
/// merge of `proposed` would produce.
///
/// "Contains" means: for every endpoint in `proposed`, some rule in
/// `policy.network_policies` has an endpoint with overlapping
/// host/path/port set AND containing every L7 allow (method/path) the
/// proposed endpoint requested, and that rule's binaries cover every
/// binary in `proposed`.
///
/// The sandbox's `policy.local /wait` long-poll uses this to decide when
/// the local supervisor has actually loaded a policy that includes the
/// chunk the agent just had approved. A whole-policy hash compare is wrong
/// in both directions: it can wake the wait on unrelated reloads (false
/// wakeup) and can fail to wake when the supervisor reloaded between two
/// `/wait` calls (false sleep). This check is the property the agent
/// actually cares about — "is my rule in effect right now?".
///
/// L4-vs-L7 split: endpoint overlap reuses `endpoints_overlap` so the
/// L4 surface (host/path/port) lines up with the `add_rule` merge — if
/// the gateway folded the chunk into an existing rule under a different
/// key, this check still returns true. The L7 layer is checked
/// separately because `endpoints_overlap` is intentionally L4-only:
/// without the L7 check, coverage would return true the instant the
/// supervisor reloaded *any* change to an overlapping endpoint, even
/// before the new method/path actually landed — exactly the false-wakeup
/// mode this fix exists to prevent, just one layer down.
pub fn policy_covers_rule(policy: &SandboxPolicy, proposed: &NetworkPolicyRule) -> bool {
if proposed.endpoints.is_empty() {
return false;
}
proposed.endpoints.iter().all(|target_endpoint| {
policy.network_policies.values().any(|rule| {
rule.endpoints.iter().any(|endpoint| {
endpoints_overlap(endpoint, target_endpoint)
&& endpoint_l7_covers(endpoint, target_endpoint)
}) && proposed.binaries.iter().all(|target_binary| {
rule.binaries
.iter()
.any(|binary| binary.path == target_binary.path)
})
})
})
}

/// L7 coverage for a single endpoint match. If the proposed endpoint
/// declared explicit L7 allow rules (method+path), every one of them must
/// be present in the merged endpoint's `rules`. An empty `proposed.rules`
/// is treated as "L4-only" and returns true (the endpoint match alone is
/// sufficient).
///
/// Conservative on access presets: if a merged endpoint uses
/// `access: read-write` instead of explicit rules, this returns false
/// even though the preset would permit the method at runtime. That
/// produces a one-cycle re-issue on the agent's side — preferable to a
/// false-positive coverage signal that lets the agent retry too early.
fn endpoint_l7_covers(merged: &NetworkEndpoint, proposed: &NetworkEndpoint) -> bool {
if proposed.rules.is_empty() {
return true;
}
proposed.rules.iter().all(|proposed_rule| {
let Some(proposed_allow) = proposed_rule.allow.as_ref() else {
return true;
};
merged.rules.iter().any(|existing| {
existing.allow.as_ref().is_some_and(|existing_allow| {
existing_allow.method == proposed_allow.method
&& existing_allow.path == proposed_allow.path
})
})
})
}

pub fn merge_policy(
policy: SandboxPolicy,
operations: &[PolicyMergeOp],
Expand Down Expand Up @@ -782,6 +854,7 @@ mod tests {

use super::{
PolicyMergeError, PolicyMergeOp, PolicyMergeWarning, generated_rule_name, merge_policy,
policy_covers_rule,
};
use crate::restrictive_default_policy;
use openshell_core::proto::{
Expand Down Expand Up @@ -1187,6 +1260,298 @@ mod tests {
assert!(!result.policy.network_policies.contains_key("github"));
}

#[test]
fn policy_covers_rule_returns_true_when_merged_rule_present() {
let proposed = NetworkPolicyRule {
name: "agent_proposed".to_string(),
endpoints: vec![endpoint("api.github.com", 443)],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
..Default::default()
}],
};

let merged = merge_policy(
restrictive_default_policy(),
&[PolicyMergeOp::AddRule {
rule_name: "allow_api_github_com_443".to_string(),
rule: proposed.clone(),
}],
)
.expect("merge should succeed");

assert!(policy_covers_rule(&merged.policy, &proposed));
}

#[test]
fn policy_covers_rule_returns_false_when_unrelated_rule_present() {
let proposed = NetworkPolicyRule {
name: "agent_proposed".to_string(),
endpoints: vec![endpoint("api.github.com", 443)],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
..Default::default()
}],
};

// Merge an *unrelated* rule for a different host. The proposed rule
// for api.github.com is still not present — this is John's
// "false-wakeup" case: an unrelated policy reload must not signal
// that the agent's rule is loaded.
let merged = merge_policy(
restrictive_default_policy(),
&[PolicyMergeOp::AddRule {
rule_name: "allow_api_example_com_443".to_string(),
rule: rule_with_endpoint("unrelated", "api.example.com", 443),
}],
)
.expect("merge should succeed");

assert!(!policy_covers_rule(&merged.policy, &proposed));
}

#[test]
fn policy_covers_rule_handles_merge_into_existing_endpoint() {
// The merge logic folds a new rule into an existing rule when their
// endpoints overlap, even under a different network_policies key.
// Coverage must survive that fold — name-keyed checks would miss it.
let proposed = NetworkPolicyRule {
name: "agent_proposed".to_string(),
endpoints: vec![endpoint("api.github.com", 443)],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
..Default::default()
}],
};

let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"preexisting_github".to_string(),
NetworkPolicyRule {
name: "preexisting_github".to_string(),
endpoints: vec![endpoint("api.github.com", 443)],
binaries: vec![NetworkBinary {
path: "/usr/bin/git".to_string(),
..Default::default()
}],
},
);

let merged = merge_policy(
policy,
&[PolicyMergeOp::AddRule {
rule_name: "allow_api_github_com_443".to_string(),
rule: proposed.clone(),
}],
)
.expect("merge should succeed");

assert!(
!merged
.policy
.network_policies
.contains_key("allow_api_github_com_443"),
"proposed rule should have been folded into the existing key"
);
assert!(policy_covers_rule(&merged.policy, &proposed));
}

#[test]
fn policy_covers_rule_returns_false_when_binary_missing() {
let proposed = NetworkPolicyRule {
name: "agent_proposed".to_string(),
endpoints: vec![endpoint("api.github.com", 443)],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
..Default::default()
}],
};

// Endpoint exists in the policy but with a *different* binary. The
// agent's retry would still be denied; reload coverage should
// reflect that.
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"existing".to_string(),
NetworkPolicyRule {
name: "existing".to_string(),
endpoints: vec![endpoint("api.github.com", 443)],
binaries: vec![NetworkBinary {
path: "/usr/bin/git".to_string(),
..Default::default()
}],
},
);

assert!(!policy_covers_rule(&policy, &proposed));
}

#[test]
fn policy_covers_rule_returns_false_for_empty_proposed_endpoints() {
// Defensive: a rule with no endpoints carries no signal we can match
// on, so coverage is never true.
let proposed = NetworkPolicyRule::default();
let policy = restrictive_default_policy();
assert!(!policy_covers_rule(&policy, &proposed));
}

#[test]
fn policy_covers_rule_returns_false_when_proposed_l7_method_not_loaded() {
// John's false-wakeup mode at L7: the supervisor has an
// overlapping endpoint loaded (e.g. read-only GET), but the
// chunk's proposed PUT method is not in the merged endpoint's
// rules yet. Coverage must NOT return true here, or the agent
// retries the PUT and hits another policy_denied.
let proposed = NetworkPolicyRule {
name: "agent_put".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
ports: vec![443],
protocol: "rest".to_string(),
rules: vec![rest_rule("PUT", "/repos/foo/bar/contents/x.md")],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
..Default::default()
}],
};

let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"existing_readonly".to_string(),
NetworkPolicyRule {
name: "existing_readonly".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
ports: vec![443],
protocol: "rest".to_string(),
rules: vec![rest_rule("GET", "/repos/foo/bar/contents/x.md")],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
..Default::default()
}],
},
);

assert!(
!policy_covers_rule(&policy, &proposed),
"endpoint overlaps but L7 PUT not loaded yet; must not signal coverage"
);
}

#[test]
fn policy_covers_rule_returns_true_after_l7_merge_lands() {
// Same setup as above, but with the proposed L7 rule merged in.
// Coverage must now return true.
let proposed = NetworkPolicyRule {
name: "agent_put".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
ports: vec![443],
protocol: "rest".to_string(),
rules: vec![rest_rule("PUT", "/repos/foo/bar/contents/x.md")],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
..Default::default()
}],
};

let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"existing".to_string(),
NetworkPolicyRule {
name: "existing".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
ports: vec![443],
protocol: "rest".to_string(),
rules: vec![
rest_rule("GET", "/repos/foo/bar/contents/x.md"),
rest_rule("PUT", "/repos/foo/bar/contents/x.md"),
],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
..Default::default()
}],
},
);

assert!(policy_covers_rule(&policy, &proposed));
}

#[test]
fn policy_covers_rule_returns_true_for_l4_only_proposed_when_endpoint_present() {
// A chunk that targets a non-REST surface (no L7 rules) needs
// only the L4 endpoint match to be considered covered. Empty
// proposed.rules must not be treated as "no method matches".
let proposed = NetworkPolicyRule {
name: "ssh_clone".to_string(),
endpoints: vec![NetworkEndpoint {
host: "github.com".to_string(),
port: 22,
ports: vec![22],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/git".to_string(),
..Default::default()
}],
};

let merged = merge_policy(
restrictive_default_policy(),
&[PolicyMergeOp::AddRule {
rule_name: "allow_github_com_22".to_string(),
rule: proposed.clone(),
}],
)
.expect("merge should succeed");

assert!(policy_covers_rule(&merged.policy, &proposed));
}

#[test]
fn policy_covers_rule_treats_empty_proposed_binaries_as_any_binary() {
// A proposed rule with no binaries is the "any binary" shape.
// The merged rule keeps its own binaries; coverage holds iff
// endpoint and (vacuously satisfied) binary set match. Document
// the semantics so a future reader doesn't flip it accidentally.
let proposed = NetworkPolicyRule {
name: "any_binary_rule".to_string(),
endpoints: vec![endpoint("api.github.com", 443)],
binaries: vec![],
};

let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"existing".to_string(),
NetworkPolicyRule {
name: "existing".to_string(),
endpoints: vec![endpoint("api.github.com", 443)],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
..Default::default()
}],
},
);

assert!(
policy_covers_rule(&policy, &proposed),
"empty proposed binaries should match any merged binary set"
);
}

#[test]
fn add_rule_without_existing_match_inserts_requested_key() {
let policy = restrictive_default_policy();
Expand Down
Loading
Loading