Skip to content

Commit 679fe4c

Browse files
authored
fix(policy): validate the applicable advisor candidate (#2850)
* fix(policy): bind reviews to applicable candidates Build and validate the exact effective-policy candidate before approval, bind review to live policy/provider/credential inputs, and preserve inspected endpoint contracts during mechanistic expansion. Closes #2821 Signed-off-by: John Myers <johntmyers@users.noreply.github.com> * fix(policy): canonicalize advisor review inputs Serialize nested protobuf maps in stable key order for proposal review tokens and effective-policy hashes. Narrow reused multi-port endpoint contracts to the denied port so advisor proposals cannot widen binary access. Add regressions for both cases. Signed-off-by: John Myers <johntmyers@users.noreply.github.com> * test(e2e): keep advisor sandbox running Create the issue 2821 regression sandbox detached with a durable canonical main process so policy denial, approval, and hot-reload checks run before lifecycle exit. Signed-off-by: John Myers <johntmyers@users.noreply.github.com> * fix(policy): apply reviewed draft batches atomically Signed-off-by: John Myers <johntmyers@users.noreply.github.com> --------- Signed-off-by: John Myers <johntmyers@users.noreply.github.com> Co-authored-by: John Myers <johntmyers@users.noreply.github.com>
1 parent 7adc05a commit 679fe4c

27 files changed

Lines changed: 3654 additions & 654 deletions

File tree

.agents/skills/openshell-cli/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ Avoid `--yes` during interactive work. A global policy locks policy control for
493493

494494
### Review agent-authored rule proposals
495495

496-
Sandboxes created with `--approval-mode manual` place every proposal in the review inbox. `auto` approves only proposals with an empty prover delta; findings still require review.
496+
Sandboxes created with `--approval-mode manual` place every proposal in the review inbox. `auto` approves only valid effective-policy candidates with an empty prover delta; findings still require review. The CLI binds approval to the candidate's current review token. If live policy, provider, or credential inputs change, approval leaves the chunk pending with a refreshed candidate and requires a fresh review.
497497

498498
```bash
499499
openshell rule get dev --status pending
@@ -502,7 +502,7 @@ openshell rule reject dev --chunk-id <chunk-id> --reason "too broad"
502502
openshell rule history dev
503503
```
504504

505-
Review the proposed scope and prover findings before approval. Treat `rule approve-all --include-security-flagged` as a high-risk bulk action.
505+
Review the proposed scope, candidate hash, prover findings, and application errors before approval. Treat `rule approve-all --include-security-flagged` as a high-risk bulk action.
506506

507507
---
508508

.agents/skills/openshell-cli/cli-reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,7 @@ Review agent-authored network rule proposals. This command group is intentionall
471471
- `openshell rule clear [name]`
472472
- `openshell rule history [name]`
473473

474-
Sandbox names default to the last-used sandbox. Bulk approval of security-flagged proposals requires explicit `--include-security-flagged`.
474+
Sandbox names default to the last-used sandbox. The CLI fetches and submits each proposal's current review token; a changed live candidate remains pending until it is reviewed again. Bulk approval of security-flagged proposals requires explicit `--include-security-flagged`.
475475

476476
---
477477

architecture/security-policy.md

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -216,24 +216,29 @@ through the proposal loop instead of treating the denial as terminal.
216216

217217
1. **Submit.** Both proposers POST through the same `SubmitPolicyAnalysis`
218218
path. Each chunk is persisted with its `analysis_mode` for audit provenance.
219-
2. **Validate.** The gateway runs the prover (`openshell-prover`) on every
220-
chunk regardless of mode. The prover builds a Z3 model from the merged
221-
policy plus the sandbox's attached-provider credential set, then computes
222-
the delta of findings between the current baseline and the merged policy.
219+
2. **Build and validate the candidate.** The gateway first canonicalizes a
220+
mechanistic proposal against the live effective policy. If an endpoint is
221+
already governed by an inspected or provider-owned contract, the candidate
222+
preserves that contract and adds only the proposed sandbox binary. Provider
223+
rules are immutable inputs; the sandbox contribution is stored as an
224+
overlay. The gateway then performs the same merge, policy validation,
225+
provider composition, credential preflight, and prover evaluation that the
226+
candidate would encounter when applied. Each chunk stores the resulting
227+
effective candidate, its hashes, any application error, and a review token
228+
derived from the candidate and its non-secret live inputs.
223229
3. **Auto-approval gate (proposer-agnostic, opt-in).** Auto-approval fires
224230
only when *all three* conditions hold: (a) `proposal_approval_mode`
225231
resolves to `"auto"` — gateway scope wins, sandbox scope is the
226232
per-sandbox override, default is `"manual"`; (b) the prover delta is empty
227233
(`prover: no new findings`); and (c) the security notes recomputed from
228234
the chunk's current proposed rule are empty (see
229235
[Security-notes gate](#security-notes-gate)). Before merging, the gateway
230-
reloads the stored chunk and reruns both checks on its current rule. This is
231-
important after edits and mechanistic deduplication: the stored rule, not a
232-
duplicate incoming payload or stale persisted analysis, controls the
233-
decision. The recalculated prover verdict is decision-local rather than
234-
persisted, so `validation_result` reads can still show the submit-time
235-
verdict after an edit or deduplication. Decode, prover, or merge failures
236-
leave the chunk pending. The audit event uses `CONFIG:APPROVED` and carries
236+
reloads the stored chunk and recomputes its candidate from live policy,
237+
provider, and credential inputs. If the review token is unchanged, the
238+
gateway reuses the persisted prover result. If it changed, the gateway
239+
persists the refreshed candidate and requires a fresh review instead of
240+
applying it. Decode, prover, merge, provider-composition, or credential
241+
failures leave the chunk pending with an application error. The audit event uses `CONFIG:APPROVED` and carries
237242
`auto=true`, `source=<mode>`, `prover_delta=empty`, and
238243
`resolved_from=<gateway|sandbox>` as unmapped fields, with message text
239244
`"auto-approved: no new prover findings"` — never `safe`. The opt-in gate
@@ -259,6 +264,10 @@ through the proposal loop instead of treating the denial as terminal.
259264
policy.
260265
6. **Escalation.** Anything else lands in `pending` for human review.
261266

267+
After any successful policy write, pending chunks already covered by the new
268+
live effective policy are rejected as redundant. This keeps the review inbox
269+
aligned with what the sandbox currently enforces.
270+
262271
### Security-notes gate
263272

264273
Separately from the prover, each chunk carries advisory `security_notes`.

crates/openshell-cli/src/run.rs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6878,10 +6878,25 @@ pub async fn sandbox_draft_get(
68786878
if !chunk.validation_result.is_empty() {
68796879
println!(
68806880
" {} {}",
6881-
"Validation:".dimmed(),
6881+
"Prover:".dimmed(),
68826882
chunk.validation_result.cyan()
68836883
);
68846884
}
6885+
if !chunk.application_error.is_empty() {
6886+
println!(
6887+
" {} {}",
6888+
"Application:".dimmed(),
6889+
chunk.application_error.red()
6890+
);
6891+
}
6892+
if !chunk.candidate_effective_policy_hash.is_empty() {
6893+
println!(
6894+
" {} {}",
6895+
"Candidate:".dimmed(),
6896+
&chunk.candidate_effective_policy_hash
6897+
[..12.min(chunk.candidate_effective_policy_hash.len())]
6898+
);
6899+
}
68856900

68866901
if let Some(ref rule) = chunk.proposed_rule {
68876902
println!(" {} {}", "Endpoints:".dimmed(), format_endpoints(rule));
@@ -6915,12 +6930,27 @@ pub async fn sandbox_draft_approve(
69156930
tls: &TlsOptions,
69166931
) -> Result<()> {
69176932
let mut client = grpc_client(server, tls).await?;
6933+
let review_token = client
6934+
.get_draft_policy(GetDraftPolicyRequest {
6935+
name: name.to_string(),
6936+
status_filter: String::new(),
6937+
workspace: workspace.to_string(),
6938+
})
6939+
.await
6940+
.into_diagnostic()?
6941+
.into_inner()
6942+
.chunks
6943+
.into_iter()
6944+
.find(|chunk| chunk.id == chunk_id)
6945+
.ok_or_else(|| miette::miette!("draft chunk '{chunk_id}' not found"))?
6946+
.review_token;
69186947

69196948
let response = client
69206949
.approve_draft_chunk(ApproveDraftChunkRequest {
69216950
name: name.to_string(),
69226951
chunk_id: chunk_id.to_string(),
69236952
workspace: workspace.to_string(),
6953+
review_token,
69246954
})
69256955
.await
69266956
.into_diagnostic()?;
@@ -6971,12 +7001,29 @@ pub async fn sandbox_draft_approve_all(
69717001
tls: &TlsOptions,
69727002
) -> Result<()> {
69737003
let mut client = grpc_client(server, tls).await?;
7004+
let approvals = client
7005+
.get_draft_policy(GetDraftPolicyRequest {
7006+
name: name.to_string(),
7007+
status_filter: "pending".to_string(),
7008+
workspace: workspace.to_string(),
7009+
})
7010+
.await
7011+
.into_diagnostic()?
7012+
.into_inner()
7013+
.chunks
7014+
.into_iter()
7015+
.map(|chunk| openshell_core::proto::DraftChunkApproval {
7016+
chunk_id: chunk.id,
7017+
review_token: chunk.review_token,
7018+
})
7019+
.collect();
69747020

69757021
let response = client
69767022
.approve_all_draft_chunks(ApproveAllDraftChunksRequest {
69777023
name: name.to_string(),
69787024
include_security_flagged,
69797025
workspace: workspace.to_string(),
7026+
approvals,
69807027
})
69817028
.await
69827029
.into_diagnostic()?;

crates/openshell-policy/src/lib.rs

Lines changed: 138 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ pub use l7_validate::{
4141
validate_l7_endpoint_semantics,
4242
};
4343
pub use merge::{
44-
PolicyMergeError, PolicyMergeOp, PolicyMergeResult, PolicyMergeWarning, generated_rule_name,
45-
merge_policy, policy_covers_rule,
44+
PolicyMergeError, PolicyMergeOp, PolicyMergeResult, PolicyMergeWarning,
45+
canonicalize_advisor_add_rule, generated_rule_name, merge_policy, policy_covers_rule,
4646
};
4747
pub use middleware::middleware_host_matches;
4848
pub use middleware::validate_json as validate_network_middleware_json;
@@ -1206,6 +1206,12 @@ pub enum PolicyViolation {
12061206
},
12071207
/// `credential_signing` and `request_body_credential_rewrite` are both set.
12081208
CredentialSigningWithBodyRewrite { policy_name: String, host: String },
1209+
/// An endpoint contains a deterministic L7 semantic error.
1210+
InvalidL7Endpoint {
1211+
policy_name: String,
1212+
endpoint_index: usize,
1213+
reason: String,
1214+
},
12091215
/// A middleware configuration is structurally invalid.
12101216
InvalidMiddlewareConfig { name: String, reason: String },
12111217
/// Too many middleware configurations are attached to one policy.
@@ -1340,6 +1346,14 @@ impl fmt::Display for PolicyViolation {
13401346
and request_body_credential_rewrite set; these options are mutually exclusive"
13411347
)
13421348
}
1349+
Self::InvalidL7Endpoint {
1350+
policy_name,
1351+
endpoint_index,
1352+
reason,
1353+
} => write!(
1354+
f,
1355+
"network policy '{policy_name}': endpoint {endpoint_index} has invalid L7 configuration: {reason}"
1356+
),
13431357
Self::InvalidMiddlewareConfig { name, reason } => {
13441358
write!(f, "middleware config '{name}' is invalid: {reason}")
13451359
}
@@ -1475,7 +1489,7 @@ pub fn validate_sandbox_policy(
14751489
} else {
14761490
rule.name.clone()
14771491
};
1478-
for ep in &rule.endpoints {
1492+
for (endpoint_index, ep) in rule.endpoints.iter().enumerate() {
14791493
let explicit_tcp = l7_validate::is_explicit_tcp_protocol(&ep.protocol);
14801494
if ep.host.trim().is_empty() && explicit_tcp {
14811495
violations.push(PolicyViolation::MissingTcpEndpointHost {
@@ -1558,6 +1572,127 @@ pub fn validate_sandbox_policy(
15581572
host: ep.host.clone(),
15591573
});
15601574
}
1575+
1576+
let rules_would_deny_all = !ep.rules.is_empty()
1577+
&& ep.rules.iter().all(|rule| {
1578+
rule.allow.as_ref().is_none_or(|allow| {
1579+
allow.method.is_empty()
1580+
&& allow.path.is_empty()
1581+
&& allow.command.is_empty()
1582+
&& allow.operation_type.is_empty()
1583+
&& allow.operation_name.is_empty()
1584+
&& allow.fields.is_empty()
1585+
&& allow.params.is_empty()
1586+
})
1587+
});
1588+
let fields = L7EndpointFields {
1589+
protocol: &ep.protocol,
1590+
access: &ep.access,
1591+
has_rules: !ep.rules.is_empty(),
1592+
has_deny_rules: !ep.deny_rules.is_empty(),
1593+
rules_would_deny_all,
1594+
allow_all_known_mcp_methods: ep
1595+
.mcp
1596+
.as_ref()
1597+
.and_then(|mcp| mcp.allow_all_known_mcp_methods)
1598+
.unwrap_or(false),
1599+
};
1600+
let mut l7_errors = validate_l7_endpoint_semantics(&fields);
1601+
let mut explicit_tcp_fields = Vec::new();
1602+
if !ep.enforcement.is_empty() {
1603+
explicit_tcp_fields.push("enforcement");
1604+
}
1605+
if !ep.path.is_empty() {
1606+
explicit_tcp_fields.push("path");
1607+
}
1608+
if ep.allow_encoded_slash {
1609+
explicit_tcp_fields.push("allow_encoded_slash");
1610+
}
1611+
if ep.websocket_credential_rewrite {
1612+
explicit_tcp_fields.push("websocket_credential_rewrite");
1613+
}
1614+
if ep.request_body_credential_rewrite {
1615+
explicit_tcp_fields.push("request_body_credential_rewrite");
1616+
}
1617+
if !ep.persisted_queries.is_empty() {
1618+
explicit_tcp_fields.push("persisted_queries");
1619+
}
1620+
if !ep.graphql_persisted_queries.is_empty() {
1621+
explicit_tcp_fields.push("graphql_persisted_queries");
1622+
}
1623+
if ep.graphql_max_body_bytes > 0 {
1624+
explicit_tcp_fields.push("graphql_max_body_bytes");
1625+
}
1626+
if ep.json_rpc_max_body_bytes > 0 {
1627+
explicit_tcp_fields.push("json_rpc_max_body_bytes");
1628+
}
1629+
if ep.mcp.is_some() {
1630+
explicit_tcp_fields.push("mcp");
1631+
}
1632+
l7_errors.extend(validate_explicit_tcp_additional_fields(
1633+
&ep.protocol,
1634+
&explicit_tcp_fields,
1635+
));
1636+
if !ep.path.is_empty() && !ep.path.starts_with('/') && ep.path != "**" {
1637+
l7_errors.push("path must start with '/' or be '**'".to_string());
1638+
}
1639+
if !ep.persisted_queries.is_empty()
1640+
&& !matches!(ep.persisted_queries.as_str(), "deny" | "allow_registered")
1641+
{
1642+
l7_errors.push(format!(
1643+
"persisted_queries must be 'deny' or 'allow_registered', got '{}'",
1644+
ep.persisted_queries
1645+
));
1646+
}
1647+
if ep.protocol == "sql" && ep.enforcement == "enforce" {
1648+
l7_errors.push(
1649+
"SQL enforcement requires full SQL parsing; use enforcement: audit".to_string(),
1650+
);
1651+
}
1652+
if ep.mcp.is_some() && ep.protocol != "mcp" {
1653+
l7_errors.push("mcp options are only valid for protocol mcp".to_string());
1654+
}
1655+
if ep.protocol == "graphql" {
1656+
for (rule_index, rule) in ep.rules.iter().enumerate() {
1657+
let operation_type = rule
1658+
.allow
1659+
.as_ref()
1660+
.map(|allow| allow.operation_type.as_str())
1661+
.unwrap_or_default();
1662+
if !matches!(operation_type, "query" | "mutation" | "subscription") {
1663+
l7_errors.push(format!(
1664+
"rules[{rule_index}].allow.operation_type must be query, mutation, or subscription"
1665+
));
1666+
}
1667+
}
1668+
for (rule_index, rule) in ep.deny_rules.iter().enumerate() {
1669+
if !matches!(
1670+
rule.operation_type.as_str(),
1671+
"query" | "mutation" | "subscription"
1672+
) {
1673+
l7_errors.push(format!(
1674+
"deny_rules[{rule_index}].operation_type must be query, mutation, or subscription"
1675+
));
1676+
}
1677+
}
1678+
for (key, operation) in &ep.graphql_persisted_queries {
1679+
if !matches!(
1680+
operation.operation_type.as_str(),
1681+
"query" | "mutation" | "subscription"
1682+
) {
1683+
l7_errors.push(format!(
1684+
"graphql_persisted_queries[{key}].operation_type must be query, mutation, or subscription"
1685+
));
1686+
}
1687+
}
1688+
}
1689+
violations.extend(l7_errors.into_iter().map(|reason| {
1690+
PolicyViolation::InvalidL7Endpoint {
1691+
policy_name: name.clone(),
1692+
endpoint_index,
1693+
reason,
1694+
}
1695+
}));
15611696
}
15621697
}
15631698

0 commit comments

Comments
 (0)