mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-02 07:34:45 +08:00
fix(policy): refresh pending proposals when the sandbox policy changes (#3923)
* fix(policy): refresh pending proposals when the sandbox policy changes Approving, removing, or undoing a rule, or updating the sandbox policy, changes the inputs every other pending proposal was evaluated against. Only proposals the new policy covered were reconciled; the rest kept their old prover result and review token. The review surface (GetDraftPolicy) therefore showed a stale evaluation, and the first approval of the next proposal refreshed it and failed with FAILED_PRECONDITION, so approving proposals one after another always failed once. Re-evaluate the remaining pending proposals at each policy change, reusing the cached prover result unless the proposal's inputs changed. Approval still rejects a review token that does not match the stored evaluation, so a reviewer holding a pre-refresh evaluation must still refetch it. When a refresh does happen at approval time (inputs changed between fetch and approve), the CLI now explains that the rule was re-evaluated and how to review it, instead of printing the raw gRPC status. Closes #3884 Signed-off-by: fede-kamel <fkamelhar@gmail.com> * fix(policy): make pending proposal refresh race-safe and bounded Store refreshed evaluations with a compare-and-swap: the store re-reads the proposal, refuses when its rule name, proposed rule, or review token changed since the evaluation read it, copies only the evaluation fields onto the stored record, and updates only if the payload is still the one it read. A refresh can no longer revert a concurrent edit or observation, and the edit path uses the same guard against a concurrent refresh. Bound each refresh to the 32 newest pending proposals; the rest keep the approval-time recheck, which still refuses a stale review token. Operator decisions (approve, approve-all, remove, undo) refresh before responding. UpdateConfig, which holds the gateway-wide sandbox sync guard, and agent-driven auto-approval refresh in a background task instead, one per sandbox with later changes coalesced into a single rerun. Refs #3884 Signed-off-by: fede-kamel <fkamelhar@gmail.com> * docs(policy): describe proposal rechecks after approvals and approve-all Explain that approving, removing, or undoing a rule rechecks the other pending proposals so they can be approved one after another, when the recheck is deferred or bounded, and what rule approve reports when a proposal changed after it was listed. Show rule approve-all in Run Your First Agent with its security-flag behavior. Refs #3884 Signed-off-by: fede-kamel <fkamelhar@gmail.com> * fix(policy): refresh pending proposals after a full policy replacement A full policy UpdateConfig (openshell policy set) re-reads the latest revision after its atomic write, finds the revision it just committed, and returns before reaching the pending-proposal refresh at the end of the handler. Pending proposals kept their stale evaluation, so rule get showed the old candidate and the next approval failed with the refresh precondition. Schedule the background refresh right after the commit. Refs #3884 Signed-off-by: fede-kamel <fkamelhar@gmail.com> --------- Signed-off-by: fede-kamel <fkamelhar@gmail.com>
This commit is contained in:
@@ -6407,7 +6407,7 @@ pub async fn sandbox_draft_approve(
|
||||
review_token,
|
||||
})
|
||||
.await
|
||||
.into_diagnostic()?;
|
||||
.map_err(|status| draft_approval_error(status, name))?;
|
||||
|
||||
let inner = response.into_inner();
|
||||
println!(
|
||||
@@ -6420,6 +6420,23 @@ pub async fn sandbox_draft_approve(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Explain an approval the gateway refused because the proposal's evaluation
|
||||
/// changed after it was fetched. The gateway has already stored the refreshed
|
||||
/// evaluation, so the reviewer needs to look at it before approving again.
|
||||
fn draft_approval_error(status: Status, name: &str) -> miette::Report {
|
||||
if status.code() == Code::FailedPrecondition
|
||||
&& status.message().contains("refetch and review again")
|
||||
{
|
||||
return miette::miette!(
|
||||
help = format!(
|
||||
"review it with `openshell rule get {name} --status pending`, then approve again"
|
||||
),
|
||||
"the sandbox policy or its inputs changed after this rule was fetched, so the gateway re-evaluated it"
|
||||
);
|
||||
}
|
||||
miette::Report::from_err(status)
|
||||
}
|
||||
|
||||
/// Reject a network rule.
|
||||
pub async fn sandbox_draft_reject(
|
||||
server: &str,
|
||||
@@ -6488,7 +6505,7 @@ pub async fn sandbox_draft_approve_all(
|
||||
approvals,
|
||||
})
|
||||
.await
|
||||
.into_diagnostic()?;
|
||||
.map_err(|status| draft_approval_error(status, name))?;
|
||||
|
||||
let inner = response.into_inner();
|
||||
println!(
|
||||
@@ -6648,6 +6665,26 @@ mod tests {
|
||||
service_url_for_gateway, workspace_member_to_json,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn draft_approval_error_explains_refreshed_evaluation() {
|
||||
use super::draft_approval_error;
|
||||
use tonic::Status;
|
||||
|
||||
let refreshed = draft_approval_error(
|
||||
Status::failed_precondition(
|
||||
"proposal inputs changed; evaluation refreshed, refetch and review again",
|
||||
),
|
||||
"my-agent",
|
||||
);
|
||||
assert!(refreshed.to_string().contains("re-evaluated"));
|
||||
let help = refreshed.help().expect("help text").to_string();
|
||||
assert!(help.contains("openshell rule get my-agent --status pending"));
|
||||
|
||||
let other = draft_approval_error(Status::not_found("chunk not found"), "my-agent");
|
||||
assert!(other.to_string().contains("chunk not found"));
|
||||
assert!(other.help().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_exec_timeout_is_omitted() {
|
||||
assert!(proto_execution_timeout(0).unwrap().is_none());
|
||||
|
||||
@@ -1152,6 +1152,148 @@ async fn reconcile_pending_chunks_after_policy_change(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Upper bound on pending proposals re-evaluated after one policy change.
|
||||
/// Pending queues are agent-driven and unbounded; proposals past this bound
|
||||
/// keep their stored evaluation and are refreshed by the approval check, which
|
||||
/// still refuses a stale review token.
|
||||
const MAX_PENDING_REFRESH_PER_POLICY_CHANGE: usize = 32;
|
||||
|
||||
/// Re-evaluate the still-pending chunks (at most
|
||||
/// [`MAX_PENDING_REFRESH_PER_POLICY_CHANGE`], newest first) against the live
|
||||
/// policy inputs and persist the result, so the review surface
|
||||
/// (`GetDraftPolicy`) shows the prover result and review token an approval
|
||||
/// will be checked against.
|
||||
///
|
||||
/// Without this, a policy change leaves other pending chunks carrying the
|
||||
/// evaluation from before the change, and the refresh only happens inside
|
||||
/// the next approval, which then fails with "proposal inputs changed".
|
||||
/// Approval still rejects a token that does not match the stored one, so a
|
||||
/// reviewer holding an evaluation from before the refresh must refetch.
|
||||
async fn refresh_pending_chunk_evaluations(
|
||||
state: &Arc<ServerState>,
|
||||
workspace: &str,
|
||||
sandbox: &Sandbox,
|
||||
) -> Result<u32, Status> {
|
||||
let pending = state
|
||||
.store
|
||||
.list_draft_chunks(sandbox.object_id(), Some("pending"))
|
||||
.await
|
||||
.map_err(|error| Status::internal(format!("list pending chunks failed: {error}")))?;
|
||||
if pending.len() > MAX_PENDING_REFRESH_PER_POLICY_CHANGE {
|
||||
debug!(
|
||||
sandbox_id = %sandbox.object_id(),
|
||||
pending = pending.len(),
|
||||
limit = MAX_PENDING_REFRESH_PER_POLICY_CHANGE,
|
||||
"refreshing only the newest pending proposals after policy change"
|
||||
);
|
||||
}
|
||||
let mut refreshed = 0;
|
||||
for chunk in pending
|
||||
.into_iter()
|
||||
.take(MAX_PENDING_REFRESH_PER_POLICY_CHANGE)
|
||||
{
|
||||
match refresh_pending_chunk_evaluation(state, workspace, sandbox, &chunk).await {
|
||||
Ok(true) => refreshed += 1,
|
||||
Ok(false) => {}
|
||||
Err(error) => warn!(
|
||||
sandbox_id = %sandbox.object_id(),
|
||||
chunk_id = %chunk.id,
|
||||
error = %error.message(),
|
||||
"failed to refresh pending proposal evaluation after policy change"
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(refreshed)
|
||||
}
|
||||
|
||||
/// Refresh pending proposals after an operator decision (approve, remove,
|
||||
/// undo). These paths hold no gateway-wide lock, and refreshing before the
|
||||
/// response lets the operator's next `rule get` and approval see the current
|
||||
/// evaluation. Failures are logged; the next approval still re-checks the
|
||||
/// proposal against live inputs.
|
||||
async fn refresh_pending_chunk_evaluations_best_effort(
|
||||
state: &Arc<ServerState>,
|
||||
workspace: &str,
|
||||
sandbox: &Sandbox,
|
||||
) {
|
||||
if let Err(error) = refresh_pending_chunk_evaluations(state, workspace, sandbox).await {
|
||||
warn!(
|
||||
sandbox_id = %sandbox.object_id(),
|
||||
error = %error,
|
||||
"failed to refresh pending policy proposals after policy change"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sandboxes with a background refresh running, mapped to whether another
|
||||
/// policy change arrived meanwhile and the refresh must run once more.
|
||||
fn background_pending_refreshes() -> &'static std::sync::Mutex<HashMap<String, bool>> {
|
||||
static REFRESHES: std::sync::OnceLock<std::sync::Mutex<HashMap<String, bool>>> =
|
||||
std::sync::OnceLock::new();
|
||||
REFRESHES.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Refresh pending proposals off the request path. Used where the policy
|
||||
/// change runs under the gateway-wide sandbox sync guard (`UpdateConfig`) or
|
||||
/// is driven by the sandbox itself (auto-approval), so the refresh neither
|
||||
/// extends the guard nor adds agent-controlled work to the response. At most
|
||||
/// one refresh runs per sandbox; changes that arrive meanwhile coalesce into
|
||||
/// a single rerun.
|
||||
fn spawn_pending_chunk_refresh(state: &Arc<ServerState>, workspace: &str, sandbox: &Sandbox) {
|
||||
let sandbox_id = sandbox.object_id().to_string();
|
||||
{
|
||||
let mut running = background_pending_refreshes()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(rerun) = running.get_mut(&sandbox_id) {
|
||||
*rerun = true;
|
||||
return;
|
||||
}
|
||||
running.insert(sandbox_id.clone(), false);
|
||||
}
|
||||
let state = state.clone();
|
||||
let workspace = workspace.to_string();
|
||||
let sandbox = sandbox.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
refresh_pending_chunk_evaluations_best_effort(&state, &workspace, &sandbox).await;
|
||||
let mut running = background_pending_refreshes()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
match running.get_mut(&sandbox_id) {
|
||||
Some(rerun) if *rerun => *rerun = false,
|
||||
_ => {
|
||||
running.remove(&sandbox_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn refresh_pending_chunk_evaluation(
|
||||
state: &Arc<ServerState>,
|
||||
workspace: &str,
|
||||
sandbox: &Sandbox,
|
||||
chunk: &DraftChunkRecord,
|
||||
) -> Result<bool, Status> {
|
||||
// Reuse the cached prover result to compute the live token cheaply; run
|
||||
// the prover again only when the inputs behind the token changed.
|
||||
let reuse = (!chunk.review_token.is_empty()).then_some(chunk.validation_result.as_str());
|
||||
let live =
|
||||
evaluate_stored_chunk_against_live_inputs(state, workspace, sandbox, chunk, reuse).await?;
|
||||
let evaluation = if reuse.is_none() || live.review_token != chunk.review_token {
|
||||
evaluate_stored_chunk_against_live_inputs(state, workspace, sandbox, chunk, None).await?
|
||||
} else if live.application_error != chunk.application_error {
|
||||
live
|
||||
} else {
|
||||
return Ok(false);
|
||||
};
|
||||
// A concurrent edit or evaluation of this chunk wins; its own path owns
|
||||
// the evaluation it stored.
|
||||
store_refreshed_evaluation(state, chunk, &evaluation).await
|
||||
}
|
||||
|
||||
/// Auto-reject any pending chunks for the same sandbox that share the
|
||||
/// `(host, port, binary)` of the newly-submitted chunk. Mode-agnostic: the
|
||||
/// rule is "the latest submission for this endpoint wins; older pending
|
||||
@@ -1461,27 +1603,54 @@ async fn evaluate_stored_chunk_against_live_inputs(
|
||||
))
|
||||
}
|
||||
|
||||
async fn persist_refreshed_evaluation(
|
||||
/// Store a re-evaluation of `chunk` only if the stored proposal is still the
|
||||
/// one it was computed from; returns `false` when an edit, another
|
||||
/// evaluation, or a decision changed it after `chunk` was read.
|
||||
async fn store_refreshed_evaluation(
|
||||
state: &Arc<ServerState>,
|
||||
chunk: &DraftChunkRecord,
|
||||
evaluation: &ProposalEvaluation,
|
||||
) -> Result<DraftChunkRecord, Status> {
|
||||
let mut refreshed = chunk.clone();
|
||||
) -> Result<bool, Status> {
|
||||
store_evaluation_if_unchanged(state, chunk, chunk, evaluation).await
|
||||
}
|
||||
|
||||
/// Store `evaluation` of `evaluated` (the stored proposal, or an edit of it)
|
||||
/// only if the stored proposal still matches `expected`, the record the
|
||||
/// caller read before evaluating.
|
||||
async fn store_evaluation_if_unchanged(
|
||||
state: &Arc<ServerState>,
|
||||
expected: &DraftChunkRecord,
|
||||
evaluated: &DraftChunkRecord,
|
||||
evaluation: &ProposalEvaluation,
|
||||
) -> Result<bool, Status> {
|
||||
let mut refreshed = evaluated.clone();
|
||||
apply_evaluation_to_chunk(&mut refreshed, evaluation);
|
||||
let chunk = expected;
|
||||
let updated = state
|
||||
.store
|
||||
.update_draft_chunk_evaluation(&refreshed)
|
||||
.update_draft_chunk_evaluation_if_unchanged(expected, &refreshed)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
Status::internal(format!("persist proposal evaluation failed: {error}"))
|
||||
})?;
|
||||
if !updated {
|
||||
return Err(Status::failed_precondition(
|
||||
"proposal is no longer pending; refetch before deciding",
|
||||
));
|
||||
if updated {
|
||||
state.sandbox_watch_bus.notify(&chunk.sandbox_id);
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
async fn persist_refreshed_evaluation(
|
||||
state: &Arc<ServerState>,
|
||||
chunk: &DraftChunkRecord,
|
||||
evaluation: &ProposalEvaluation,
|
||||
) -> Result<(), Status> {
|
||||
if store_refreshed_evaluation(state, chunk, evaluation).await? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Status::failed_precondition(
|
||||
"proposal changed or is no longer pending; refetch and review again",
|
||||
))
|
||||
}
|
||||
state.sandbox_watch_bus.notify(&chunk.sandbox_id);
|
||||
Ok(refreshed)
|
||||
}
|
||||
|
||||
async fn persist_pending_application_error(
|
||||
@@ -1697,6 +1866,7 @@ async fn auto_approve_chunk(
|
||||
"failed to reconcile pending policy proposals after auto-approval"
|
||||
);
|
||||
}
|
||||
spawn_pending_chunk_refresh(state, context.workspace, context.sandbox);
|
||||
|
||||
let source_label = if context.source.is_empty() {
|
||||
"unspecified"
|
||||
@@ -3958,6 +4128,7 @@ async fn handle_update_config_inner(
|
||||
operation_count = merge_ops.len(),
|
||||
"UpdateConfig: merged incremental policy operations"
|
||||
);
|
||||
spawn_pending_chunk_refresh(state, &workspace, &sandbox);
|
||||
emit_config_update_policy_success(sandbox_caller);
|
||||
|
||||
return Ok(update_config_response(
|
||||
@@ -4131,6 +4302,10 @@ async fn handle_update_config_inner(
|
||||
};
|
||||
response_annotations = committed_annotations;
|
||||
state.sandbox_watch_bus.notify(&sandbox_id);
|
||||
// The committed revision changed what pending proposals were evaluated
|
||||
// against. Schedule the refresh here: the matching-revision check below
|
||||
// returns before the end of this handler for every committed write.
|
||||
spawn_pending_chunk_refresh(state, &workspace, &sandbox);
|
||||
|
||||
if backfill_policy.is_some() {
|
||||
info!(
|
||||
@@ -4189,6 +4364,7 @@ async fn handle_update_config_inner(
|
||||
policy_hash = %hash,
|
||||
"UpdateConfig: new policy version persisted"
|
||||
);
|
||||
spawn_pending_chunk_refresh(state, &workspace, &sandbox);
|
||||
emit_full_policy_update_success(sandbox_caller, next_version);
|
||||
|
||||
Ok(update_config_response(
|
||||
@@ -5156,7 +5332,9 @@ pub(super) async fn handle_submit_policy_analysis(
|
||||
})
|
||||
&& let Some(existing) = existing_mechanistic.as_ref()
|
||||
{
|
||||
persist_refreshed_evaluation(state, existing, &evaluation).await?;
|
||||
// Losing a race here means another path already stored a newer
|
||||
// evaluation for this chunk; the submission itself succeeded.
|
||||
store_refreshed_evaluation(state, existing, &evaluation).await?;
|
||||
}
|
||||
accepted += 1;
|
||||
|
||||
@@ -5428,6 +5606,7 @@ async fn handle_approve_draft_chunk_inner(
|
||||
"failed to reconcile pending policy proposals after approval"
|
||||
);
|
||||
}
|
||||
refresh_pending_chunk_evaluations_best_effort(state, &workspace, &sandbox).await;
|
||||
emit_gateway_policy_audit_log(
|
||||
&sandbox_id,
|
||||
sandbox.object_name(),
|
||||
@@ -5553,6 +5732,9 @@ async fn handle_reject_draft_chunk_inner(
|
||||
.map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?;
|
||||
|
||||
state.sandbox_watch_bus.notify(&sandbox_id);
|
||||
if was_approved {
|
||||
refresh_pending_chunk_evaluations_best_effort(state, &workspace, &sandbox).await;
|
||||
}
|
||||
emit_policy_decision_success(PolicyDecisionOperation::Reject, 1);
|
||||
|
||||
Ok(Response::new(RejectDraftChunkResponse {}))
|
||||
@@ -5836,6 +6018,7 @@ async fn handle_approve_all_draft_chunks_inner(
|
||||
"failed to reconcile pending policy proposals after bulk approval"
|
||||
);
|
||||
}
|
||||
refresh_pending_chunk_evaluations_best_effort(state, &workspace, &sandbox).await;
|
||||
emit_gateway_policy_audit_log(
|
||||
&sandbox_id,
|
||||
sandbox.object_name(),
|
||||
@@ -5919,7 +6102,11 @@ pub(super) async fn handle_edit_draft_chunk(
|
||||
let evaluation =
|
||||
evaluate_stored_chunk_against_live_inputs(state, &workspace, &sandbox, &edited_chunk, None)
|
||||
.await?;
|
||||
persist_refreshed_evaluation(state, &edited_chunk, &evaluation).await?;
|
||||
if !store_evaluation_if_unchanged(state, &chunk, &edited_chunk, &evaluation).await? {
|
||||
return Err(Status::failed_precondition(
|
||||
"proposal changed while it was being edited; refetch and edit again",
|
||||
));
|
||||
}
|
||||
|
||||
info!(
|
||||
chunk_id = %req.chunk_id,
|
||||
@@ -6019,6 +6206,7 @@ async fn handle_undo_draft_chunk_inner(
|
||||
policy_hash = %hash,
|
||||
"UndoDraftChunk: rule removed, chunk reverted to pending"
|
||||
);
|
||||
refresh_pending_chunk_evaluations_best_effort(state, &workspace, &sandbox).await;
|
||||
emit_sandbox_policy_update_success();
|
||||
emit_policy_decision_success(PolicyDecisionOperation::Undo, 1);
|
||||
|
||||
@@ -16559,6 +16747,455 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approval_refreshes_other_pending_proposals_for_sequential_review() {
|
||||
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule};
|
||||
|
||||
let state = test_server_state().await;
|
||||
let sandbox_id = "sb-sequential-approve";
|
||||
let sandbox_name = "sequential-approve";
|
||||
state
|
||||
.store
|
||||
.put_message(&test_sandbox(
|
||||
sandbox_id,
|
||||
sandbox_name,
|
||||
ProtoSandboxPolicy::default(),
|
||||
vec![],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let submit = handle_submit_policy_analysis(
|
||||
&state,
|
||||
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
||||
name: sandbox_name.to_string(),
|
||||
analysis_mode: "agent_authored".to_string(),
|
||||
proposed_chunks: [("alpha", "alpha.example.com"), ("beta", "beta.example.com")]
|
||||
.into_iter()
|
||||
.map(|(name, host)| PolicyChunk {
|
||||
rule_name: name.to_string(),
|
||||
proposed_rule: Some(NetworkPolicyRule {
|
||||
name: name.to_string(),
|
||||
endpoints: vec![NetworkEndpoint {
|
||||
host: host.to_string(),
|
||||
port: 443,
|
||||
..Default::default()
|
||||
}],
|
||||
binaries: vec![NetworkBinary {
|
||||
path: "/usr/bin/curl".to_string(),
|
||||
}],
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
assert_eq!(submit.accepted_chunk_ids.len(), 2);
|
||||
let (alpha_id, beta_id) = (&submit.accepted_chunk_ids[0], &submit.accepted_chunk_ids[1]);
|
||||
|
||||
let draft_chunk = |chunk_id: String| {
|
||||
let state = state.clone();
|
||||
async move {
|
||||
handle_get_draft_policy(
|
||||
&state,
|
||||
with_user(Request::new(GetDraftPolicyRequest {
|
||||
sandbox: sandbox_name.to_string(),
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
||||
"default".to_string(),
|
||||
)),
|
||||
status_filter: "pending".to_string(),
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.chunks
|
||||
.into_iter()
|
||||
.find(|chunk| chunk.id == chunk_id)
|
||||
.expect("pending chunk")
|
||||
}
|
||||
};
|
||||
let approve = |chunk_id: String, review_token: String| {
|
||||
let state = state.clone();
|
||||
async move {
|
||||
handle_approve_draft_chunk(
|
||||
&state,
|
||||
with_user(Request::new(ApproveDraftChunkRequest {
|
||||
request_id: String::new(),
|
||||
sandbox: sandbox_name.to_string(),
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
||||
"default".to_string(),
|
||||
)),
|
||||
chunk_id,
|
||||
review_token,
|
||||
})),
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
let alpha = draft_chunk(alpha_id.clone()).await;
|
||||
let beta_before = draft_chunk(beta_id.clone()).await;
|
||||
approve(alpha_id.clone(), alpha.review_token)
|
||||
.await
|
||||
.expect("first approval");
|
||||
|
||||
// Approving alpha changed the policy beta was evaluated against. The
|
||||
// review surface must now show beta's refreshed evaluation.
|
||||
let beta_after = draft_chunk(beta_id.clone()).await;
|
||||
assert_ne!(beta_after.review_token, beta_before.review_token);
|
||||
|
||||
// A reviewer still holding the pre-approval evaluation must refetch.
|
||||
let outdated = approve(beta_id.clone(), beta_before.review_token)
|
||||
.await
|
||||
.expect_err("pre-refresh token must not approve");
|
||||
assert_eq!(outdated.code(), Code::FailedPrecondition);
|
||||
assert!(outdated.message().contains("refetch and review again"));
|
||||
|
||||
// Approving the evaluation the review surface shows succeeds first time.
|
||||
approve(beta_id.clone(), beta_after.review_token)
|
||||
.await
|
||||
.expect("approval of the refreshed evaluation");
|
||||
assert_eq!(
|
||||
state
|
||||
.store
|
||||
.get_draft_chunk(beta_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.status,
|
||||
"approved"
|
||||
);
|
||||
}
|
||||
|
||||
/// Submit one agent-authored proposal per host and return their ids.
|
||||
async fn submit_host_proposals(
|
||||
state: &Arc<ServerState>,
|
||||
sandbox_name: &str,
|
||||
hosts: &[String],
|
||||
) -> Vec<String> {
|
||||
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule};
|
||||
|
||||
handle_submit_policy_analysis(
|
||||
state,
|
||||
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
||||
name: sandbox_name.to_string(),
|
||||
analysis_mode: "agent_authored".to_string(),
|
||||
proposed_chunks: hosts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, host)| PolicyChunk {
|
||||
rule_name: format!("rule{index}"),
|
||||
proposed_rule: Some(NetworkPolicyRule {
|
||||
name: format!("rule{index}"),
|
||||
endpoints: vec![NetworkEndpoint {
|
||||
host: host.clone(),
|
||||
port: 443,
|
||||
..Default::default()
|
||||
}],
|
||||
binaries: vec![NetworkBinary {
|
||||
path: "/usr/bin/curl".to_string(),
|
||||
}],
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.accepted_chunk_ids
|
||||
}
|
||||
|
||||
/// Store a policy revision that changes the inputs every pending proposal
|
||||
/// was evaluated against.
|
||||
async fn change_sandbox_policy(state: &Arc<ServerState>, sandbox_id: &str, version: i64) {
|
||||
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule};
|
||||
|
||||
let mut policy = ProtoSandboxPolicy::default();
|
||||
policy.network_policies.insert(
|
||||
format!("unrelated{version}"),
|
||||
NetworkPolicyRule {
|
||||
name: format!("unrelated{version}"),
|
||||
endpoints: vec![NetworkEndpoint {
|
||||
host: format!("unrelated{version}.example"),
|
||||
port: 443,
|
||||
..Default::default()
|
||||
}],
|
||||
binaries: vec![NetworkBinary {
|
||||
path: "/usr/bin/wget".to_string(),
|
||||
}],
|
||||
},
|
||||
);
|
||||
let hash = deterministic_policy_hash(&policy);
|
||||
state
|
||||
.store
|
||||
.put_policy_revision(
|
||||
&format!("{sandbox_id}-revision-{version}"),
|
||||
sandbox_id,
|
||||
"default",
|
||||
version,
|
||||
&policy.encode_to_vec(),
|
||||
&hash,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_refresh_does_not_revert_concurrent_proposal_edit() {
|
||||
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule};
|
||||
|
||||
let state = test_server_state().await;
|
||||
let sandbox_id = "sb-refresh-edit-race";
|
||||
let sandbox_name = "refresh-edit-race";
|
||||
let sandbox = test_sandbox(
|
||||
sandbox_id,
|
||||
sandbox_name,
|
||||
ProtoSandboxPolicy::default(),
|
||||
vec![],
|
||||
);
|
||||
state.store.put_message(&sandbox).await.unwrap();
|
||||
let chunk_id =
|
||||
submit_host_proposals(&state, sandbox_name, &["original.example.com".into()])
|
||||
.await
|
||||
.remove(0);
|
||||
|
||||
// A refresh reads the proposal and evaluates it against a new policy.
|
||||
let read_by_refresh = state
|
||||
.store
|
||||
.get_draft_chunk(&chunk_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
change_sandbox_policy(&state, sandbox_id, 1).await;
|
||||
let evaluation = evaluate_stored_chunk_against_live_inputs(
|
||||
&state,
|
||||
"default",
|
||||
&sandbox,
|
||||
&read_by_refresh,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Before the refresh stores that result, an operator edits the rule.
|
||||
handle_edit_draft_chunk(
|
||||
&state,
|
||||
with_user(Request::new(EditDraftChunkRequest {
|
||||
request_id: String::new(),
|
||||
sandbox: sandbox_name.to_string(),
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
||||
"default".to_string(),
|
||||
)),
|
||||
chunk_id: chunk_id.clone(),
|
||||
proposed_rule: Some(NetworkPolicyRule {
|
||||
name: "rule0".to_string(),
|
||||
endpoints: vec![NetworkEndpoint {
|
||||
host: "edited.example.com".to_string(),
|
||||
port: 443,
|
||||
..Default::default()
|
||||
}],
|
||||
binaries: vec![NetworkBinary {
|
||||
path: "/usr/bin/curl".to_string(),
|
||||
}],
|
||||
}),
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
!store_refreshed_evaluation(&state, &read_by_refresh, &evaluation)
|
||||
.await
|
||||
.unwrap(),
|
||||
"a refresh computed before the edit must not be stored"
|
||||
);
|
||||
let stored = state
|
||||
.store
|
||||
.get_draft_chunk(&chunk_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let stored_rule = NetworkPolicyRule::decode(stored.proposed_rule.as_slice()).unwrap();
|
||||
assert_eq!(stored_rule.endpoints[0].host, "edited.example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn policy_change_refreshes_a_bounded_number_of_pending_proposals() {
|
||||
let state = test_server_state().await;
|
||||
let sandbox_id = "sb-bounded-refresh";
|
||||
let sandbox_name = "bounded-refresh";
|
||||
let sandbox = test_sandbox(
|
||||
sandbox_id,
|
||||
sandbox_name,
|
||||
ProtoSandboxPolicy::default(),
|
||||
vec![],
|
||||
);
|
||||
state.store.put_message(&sandbox).await.unwrap();
|
||||
let hosts = (0..MAX_PENDING_REFRESH_PER_POLICY_CHANGE + 5)
|
||||
.map(|index| format!("host{index}.example.com"))
|
||||
.collect::<Vec<_>>();
|
||||
submit_host_proposals(&state, sandbox_name, &hosts).await;
|
||||
let tokens_before = state
|
||||
.store
|
||||
.list_draft_chunks(sandbox_id, Some("pending"))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|chunk| (chunk.id, chunk.review_token))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
change_sandbox_policy(&state, sandbox_id, 1).await;
|
||||
let refreshed = refresh_pending_chunk_evaluations(&state, "default", &sandbox)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(refreshed as usize, MAX_PENDING_REFRESH_PER_POLICY_CHANGE);
|
||||
let changed = state
|
||||
.store
|
||||
.list_draft_chunks(sandbox_id, Some("pending"))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter(|chunk| tokens_before[&chunk.id] != chunk.review_token)
|
||||
.count();
|
||||
assert_eq!(changed, MAX_PENDING_REFRESH_PER_POLICY_CHANGE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_refresh_updates_pending_proposals_and_coalesces() {
|
||||
let state = test_server_state().await;
|
||||
let sandbox_id = "sb-background-refresh";
|
||||
let sandbox_name = "background-refresh";
|
||||
let sandbox = test_sandbox(
|
||||
sandbox_id,
|
||||
sandbox_name,
|
||||
ProtoSandboxPolicy::default(),
|
||||
vec![],
|
||||
);
|
||||
state.store.put_message(&sandbox).await.unwrap();
|
||||
let chunk_id = submit_host_proposals(&state, sandbox_name, &["bg.example.com".into()])
|
||||
.await
|
||||
.remove(0);
|
||||
let before = state
|
||||
.store
|
||||
.get_draft_chunk(&chunk_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.review_token;
|
||||
|
||||
change_sandbox_policy(&state, sandbox_id, 1).await;
|
||||
spawn_pending_chunk_refresh(&state, "default", &sandbox);
|
||||
spawn_pending_chunk_refresh(&state, "default", &sandbox);
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
let idle = !background_pending_refreshes()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains_key(sandbox_id);
|
||||
let token = state
|
||||
.store
|
||||
.get_draft_chunk(&chunk_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.review_token;
|
||||
if idle && token != before {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"background refresh did not finish"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_policy_update_refreshes_pending_proposals() {
|
||||
let state = test_server_state().await;
|
||||
let sandbox_id = "sb-policy-set-refresh";
|
||||
let sandbox_name = "policy-set-refresh";
|
||||
let baseline = test_policy_with_rule("baseline", "baseline.example.com");
|
||||
state
|
||||
.store
|
||||
.put_message(&test_sandbox(
|
||||
sandbox_id,
|
||||
sandbox_name,
|
||||
baseline.clone(),
|
||||
Vec::new(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.store
|
||||
.put_policy_revision(
|
||||
"policy-set-refresh-v1",
|
||||
sandbox_id,
|
||||
"default",
|
||||
1,
|
||||
&baseline.encode_to_vec(),
|
||||
&deterministic_policy_hash(&baseline),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let chunk_id = submit_host_proposals(&state, sandbox_name, &["pending.example.com".into()])
|
||||
.await
|
||||
.remove(0);
|
||||
let before = state
|
||||
.store
|
||||
.get_draft_chunk(&chunk_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.review_token;
|
||||
|
||||
// `openshell policy set`: a full policy replacement through UpdateConfig.
|
||||
handle_update_config(
|
||||
&state,
|
||||
with_user(Request::new(UpdateConfigRequest {
|
||||
sandbox: sandbox_name.to_string(),
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
||||
"default".to_string(),
|
||||
)),
|
||||
policy: Some(test_policy_with_rule("replaced", "replaced.example.com")),
|
||||
..Default::default()
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
let token = state
|
||||
.store
|
||||
.get_draft_chunk(&chunk_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.review_token;
|
||||
if token != before {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"full policy update did not refresh the pending proposal"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn policy_change_reconciles_pending_chunk_already_covered_by_live_policy() {
|
||||
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule};
|
||||
|
||||
@@ -7,9 +7,9 @@ use super::{
|
||||
map_migrate_error,
|
||||
};
|
||||
use crate::policy_store::{
|
||||
AtomicPolicyRevisionWrite, draft_chunk_payload_from_record, draft_chunk_record_from_parts,
|
||||
policy_payload_from_record, policy_record_for_atomic_write, policy_record_from_parts,
|
||||
project_policy_revision_onto_sandbox,
|
||||
AtomicPolicyRevisionWrite, apply_draft_chunk_evaluation, draft_chunk_evaluation_inputs_match,
|
||||
draft_chunk_payload_from_record, draft_chunk_record_from_parts, policy_payload_from_record,
|
||||
policy_record_for_atomic_write, policy_record_from_parts, project_policy_revision_onto_sandbox,
|
||||
};
|
||||
use openshell_core::SetResourceVersion;
|
||||
use openshell_core::proto::Sandbox;
|
||||
@@ -1547,6 +1547,54 @@ WHERE "object_type" = $1 AND "id" = $2 AND "status" IN ('pending', 'rejected')
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn update_draft_chunk_evaluation_if_unchanged(
|
||||
&self,
|
||||
expected: &DraftChunkRecord,
|
||||
evaluated: &DraftChunkRecord,
|
||||
) -> PersistenceResult<bool> {
|
||||
let Some(row) = sqlx::query(
|
||||
r#"
|
||||
SELECT "id", "scope", "status", "hit_count", "payload", "created_at_ms", "updated_at_ms"
|
||||
FROM "objects"
|
||||
WHERE "object_type" = $1 AND "id" = $2 AND "status" IN ('pending', 'rejected')
|
||||
"#,
|
||||
)
|
||||
.bind(DRAFT_CHUNK_OBJECT_TYPE)
|
||||
.bind(&expected.id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| map_db_error(&e))?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let stored_payload: Vec<u8> = row.get("payload");
|
||||
let mut current = row_to_draft_chunk_record(row)?;
|
||||
if !draft_chunk_evaluation_inputs_match(¤t, expected) {
|
||||
return Ok(false);
|
||||
}
|
||||
apply_draft_chunk_evaluation(&mut current, evaluated);
|
||||
let payload = draft_chunk_payload_from_record(¤t)?;
|
||||
// Compare-and-swap on the payload read above: a concurrent edit or
|
||||
// evaluation between the read and this write leaves nothing updated.
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE "objects"
|
||||
SET "payload" = $3, "updated_at_ms" = $4
|
||||
WHERE "object_type" = $1 AND "id" = $2 AND "status" IN ('pending', 'rejected')
|
||||
AND "payload" = $5
|
||||
"#,
|
||||
)
|
||||
.bind(DRAFT_CHUNK_OBJECT_TYPE)
|
||||
.bind(&expected.id)
|
||||
.bind(payload)
|
||||
.bind(current.last_seen_ms)
|
||||
.bind(stored_payload)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| map_db_error(&e))?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn delete_draft_chunks(
|
||||
&self,
|
||||
sandbox_id: &str,
|
||||
|
||||
@@ -7,9 +7,9 @@ use super::{
|
||||
map_migrate_error,
|
||||
};
|
||||
use crate::policy_store::{
|
||||
AtomicPolicyRevisionWrite, draft_chunk_payload_from_record, draft_chunk_record_from_parts,
|
||||
policy_payload_from_record, policy_record_for_atomic_write, policy_record_from_parts,
|
||||
project_policy_revision_onto_sandbox,
|
||||
AtomicPolicyRevisionWrite, apply_draft_chunk_evaluation, draft_chunk_evaluation_inputs_match,
|
||||
draft_chunk_payload_from_record, draft_chunk_record_from_parts, policy_payload_from_record,
|
||||
policy_record_for_atomic_write, policy_record_from_parts, project_policy_revision_onto_sandbox,
|
||||
};
|
||||
use openshell_core::SetResourceVersion;
|
||||
use openshell_core::paths::set_file_owner_only;
|
||||
@@ -1781,6 +1781,54 @@ WHERE "object_type" = ?1 AND "id" = ?2 AND "status" IN ('pending', 'rejected')
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn update_draft_chunk_evaluation_if_unchanged(
|
||||
&self,
|
||||
expected: &DraftChunkRecord,
|
||||
evaluated: &DraftChunkRecord,
|
||||
) -> PersistenceResult<bool> {
|
||||
let Some(row) = sqlx::query(
|
||||
r#"
|
||||
SELECT "id", "scope", "status", "hit_count", "payload", "created_at_ms", "updated_at_ms"
|
||||
FROM "objects"
|
||||
WHERE "object_type" = ?1 AND "id" = ?2 AND "status" IN ('pending', 'rejected')
|
||||
"#,
|
||||
)
|
||||
.bind(DRAFT_CHUNK_OBJECT_TYPE)
|
||||
.bind(&expected.id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| map_db_error(&e))?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let stored_payload: Vec<u8> = row.get("payload");
|
||||
let mut current = row_to_draft_chunk_record(row)?;
|
||||
if !draft_chunk_evaluation_inputs_match(¤t, expected) {
|
||||
return Ok(false);
|
||||
}
|
||||
apply_draft_chunk_evaluation(&mut current, evaluated);
|
||||
let payload = draft_chunk_payload_from_record(¤t)?;
|
||||
// Compare-and-swap on the payload read above: a concurrent edit or
|
||||
// evaluation between the read and this write leaves nothing updated.
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE "objects"
|
||||
SET "payload" = ?3, "updated_at_ms" = ?4
|
||||
WHERE "object_type" = ?1 AND "id" = ?2 AND "status" IN ('pending', 'rejected')
|
||||
AND "payload" = ?5
|
||||
"#,
|
||||
)
|
||||
.bind(DRAFT_CHUNK_OBJECT_TYPE)
|
||||
.bind(&expected.id)
|
||||
.bind(payload)
|
||||
.bind(current.last_seen_ms)
|
||||
.bind(stored_payload)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| map_db_error(&e))?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn delete_draft_chunks(
|
||||
&self,
|
||||
sandbox_id: &str,
|
||||
|
||||
@@ -253,6 +253,17 @@ pub trait PolicyStoreExt {
|
||||
chunk: &DraftChunkRecord,
|
||||
) -> PersistenceResult<bool>;
|
||||
|
||||
/// Store `evaluated`'s evaluation fields only if the proposal is still the
|
||||
/// one `expected` recorded: same rule name, proposed rule, and review
|
||||
/// token. Every other field keeps its current stored value, so a
|
||||
/// concurrent edit or observation is never reverted. Returns `false` when
|
||||
/// the proposal changed or is no longer pending or rejected.
|
||||
async fn update_draft_chunk_evaluation_if_unchanged(
|
||||
&self,
|
||||
expected: &DraftChunkRecord,
|
||||
evaluated: &DraftChunkRecord,
|
||||
) -> PersistenceResult<bool>;
|
||||
|
||||
async fn delete_draft_chunks(&self, sandbox_id: &str, status: &str) -> PersistenceResult<u64>;
|
||||
|
||||
async fn get_draft_version(&self, sandbox_id: &str) -> PersistenceResult<i64>;
|
||||
@@ -474,6 +485,25 @@ impl PolicyStoreExt for Store {
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_draft_chunk_evaluation_if_unchanged(
|
||||
&self,
|
||||
expected: &DraftChunkRecord,
|
||||
evaluated: &DraftChunkRecord,
|
||||
) -> PersistenceResult<bool> {
|
||||
match self {
|
||||
Self::Postgres(store) => {
|
||||
store
|
||||
.update_draft_chunk_evaluation_if_unchanged(expected, evaluated)
|
||||
.await
|
||||
}
|
||||
Self::Sqlite(store) => {
|
||||
store
|
||||
.update_draft_chunk_evaluation_if_unchanged(expected, evaluated)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_draft_chunks(&self, sandbox_id: &str, status: &str) -> PersistenceResult<u64> {
|
||||
match self {
|
||||
Self::Postgres(store) => store.delete_draft_chunks(sandbox_id, status).await,
|
||||
@@ -581,6 +611,43 @@ pub fn draft_chunk_payload_from_record(chunk: &DraftChunkRecord) -> PersistenceR
|
||||
.encode_to_vec())
|
||||
}
|
||||
|
||||
/// Whether `current` is still the proposal an evaluation was computed from.
|
||||
pub fn draft_chunk_evaluation_inputs_match(
|
||||
current: &DraftChunkRecord,
|
||||
expected: &DraftChunkRecord,
|
||||
) -> bool {
|
||||
current.rule_name == expected.rule_name
|
||||
&& current.proposed_rule == expected.proposed_rule
|
||||
&& current.review_token == expected.review_token
|
||||
}
|
||||
|
||||
/// Copy the policy-dependent evaluation fields from `evaluated` onto the
|
||||
/// currently stored record, leaving edits and observation counters as stored.
|
||||
pub fn apply_draft_chunk_evaluation(current: &mut DraftChunkRecord, evaluated: &DraftChunkRecord) {
|
||||
current.rule_name.clone_from(&evaluated.rule_name);
|
||||
current.proposed_rule.clone_from(&evaluated.proposed_rule);
|
||||
current
|
||||
.validation_result
|
||||
.clone_from(&evaluated.validation_result);
|
||||
current
|
||||
.application_error
|
||||
.clone_from(&evaluated.application_error);
|
||||
current.review_token.clone_from(&evaluated.review_token);
|
||||
current
|
||||
.current_effective_policy_hash
|
||||
.clone_from(&evaluated.current_effective_policy_hash);
|
||||
current
|
||||
.candidate_effective_policy_hash
|
||||
.clone_from(&evaluated.candidate_effective_policy_hash);
|
||||
current
|
||||
.current_effective_policy
|
||||
.clone_from(&evaluated.current_effective_policy);
|
||||
current
|
||||
.candidate_effective_policy
|
||||
.clone_from(&evaluated.candidate_effective_policy);
|
||||
current.last_seen_ms = evaluated.last_seen_ms;
|
||||
}
|
||||
|
||||
pub fn draft_chunk_record_from_parts(
|
||||
id: String,
|
||||
sandbox_id: String,
|
||||
|
||||
@@ -94,6 +94,17 @@ openshell rule reject my-agent \
|
||||
--reason "Not needed for this task."
|
||||
```
|
||||
|
||||
Approving a proposal rechecks the other pending proposals against the updated
|
||||
policy, so you can list them again and approve them one after another. To
|
||||
approve every pending proposal in one step, run:
|
||||
|
||||
```shell
|
||||
openshell rule approve-all my-agent
|
||||
```
|
||||
|
||||
`rule approve-all` skips proposals with a security flag unless you add
|
||||
`--include-security-flagged`, and it reports how many it skipped.
|
||||
|
||||
Approved rules hot-reload into the running sandbox without a restart, so the
|
||||
agent can retry the request. To let the agent propose its own narrower rules,
|
||||
or to approve proposals automatically when the prover finds no new risk, refer
|
||||
|
||||
@@ -137,9 +137,16 @@ openshell rule reject <sandbox-name> \
|
||||
--reason "Scope this to docs/ paths only."
|
||||
```
|
||||
|
||||
If the sandbox's policy or providers change after a proposal is submitted,
|
||||
OpenShell rechecks the proposal and asks you to review it again before you can
|
||||
approve it. While a global policy is active, OpenShell cannot approve proposals,
|
||||
When you approve, remove, or undo a rule, OpenShell rechecks the other pending
|
||||
proposals against the updated policy before it responds, so `rule get` shows
|
||||
the risk check results your next approval uses and you can approve proposals one
|
||||
after another. Other policy updates recheck pending proposals in the
|
||||
background. Each change rechecks up to 32 of the newest pending proposals.
|
||||
|
||||
If a proposal's inputs changed after you listed it, for example because the
|
||||
sandbox's providers changed, `rule approve` rechecks it and stops. Review the
|
||||
updated proposal with `openshell rule get <sandbox-name> --status pending`,
|
||||
then approve it again. While a global policy is active, OpenShell cannot approve proposals,
|
||||
including automatically. You can also review proposals in the terminal UI with
|
||||
`openshell term`, but it rejects proposals without a reason.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user