Files
Drew Newberry 252882f37f feat(providers): serve sandbox config files on demand (#3832)
* feat(providers): serve sandbox config files on demand

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* docs(providers): defer managed file documentation

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* docs(providers): mark managed file api experimental

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* style(go): format provider profile fields

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(providers): preserve legacy environment with managed files

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

---------

Signed-off-by: Drew Newberry <anewberry@nvidia.com>
2026-09-30 01:41:32 +00:00

22856 lines
836 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Policy updates, status, draft chunks, config/settings layer, and sandbox logs.
#![allow(clippy::result_large_err)] // gRPC handlers return Result<Response<_>, Status>
#![allow(clippy::cast_possible_truncation)] // Intentional u128->i64 etc. for timestamp math
#![allow(clippy::cast_sign_loss)] // Intentional i32->u32 conversions from proto types
#![allow(clippy::cast_possible_wrap)] // Intentional u32->i32 conversions for proto compat
#![allow(clippy::cast_precision_loss)] // f64->f32 for confidence scores
#![allow(clippy::items_after_statements)] // DB_PORTS const inside function
mod endpoint_status;
mod provisioning_clock;
pub use provisioning_clock::configuration_change;
pub(super) use endpoint_status::{
handle_peer_report_endpoint_status, handle_report_endpoint_status,
};
pub use endpoint_status::{
invalidate_endpoint_status_on_startup, reset_endpoint_status_for_supervisor_session,
retry_endpoint_status_after_supervisor_disconnect,
};
use crate::ServerState;
use crate::auth::principal::Principal;
use crate::auth::workspace_authz::{MinWorkspaceRole, require_platform_admin};
use crate::pagination::Pagination;
use crate::persistence::{
DraftChunkRecord, ObjectId, ObjectListQuery, ObjectName, ObjectType, ObjectWorkspace,
PolicyRecord, Store,
};
use crate::policy_store::{AtomicPolicyRevisionWrite, PolicyStoreExt};
use crate::provider_profile_sources::EffectiveProviderProfileCatalog;
#[cfg(test)]
use crate::provider_profile_sources::ProviderProfileSources;
use crate::storage_proto::StoredProviderCredentialRefreshStateV2 as StoredProviderCredentialRefreshState;
#[cfg(test)]
use crate::storage_proto::StoredProviderProfile;
use openshell_core::net::{is_always_blocked_ip, is_internal_ip};
use openshell_core::policy_identity::{canonical_rule_bytes, deterministic_policy_hash};
use openshell_core::proto::policy_merge_operation;
use openshell_core::proto::setting_value;
use openshell_core::proto::{
AddAllowRules as ProtoAddAllowRules, AddDenyRules as ProtoAddDenyRules,
ApproveAllDraftChunksRequest, ApproveAllDraftChunksResponse, ApproveDraftChunkRequest,
ApproveDraftChunkResponse, ClearDraftChunksRequest, ClearDraftChunksResponse,
DraftHistoryEntry, EditDraftChunkRequest, EditDraftChunkResponse, EffectiveSetting,
GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse,
GetGatewayConfigRequest, GetGatewayConfigResponse, GetSandboxConfigRequest,
GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxLogsResponse,
GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse,
GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse,
L7RuleTarget as ProtoL7RuleTarget, ListSandboxPoliciesRequest, ListSandboxPoliciesResponse,
PolicyChunk, PolicyMergeOperation, PolicySource, PolicyStatus, ProviderReadinessReason,
PushSandboxLogsRequest, PushSandboxLogsResponse, RejectDraftChunkRequest,
RejectDraftChunkResponse, ReportPolicyStatusRequest, ReportPolicyStatusResponse,
SandboxLogLine, SandboxPolicyRevision, SettingScope, SettingValue, SubmitPolicyAnalysisRequest,
SubmitPolicyAnalysisResponse, UndoDraftChunkRequest, UndoDraftChunkResponse,
UpdateConfigRequest, UpdateConfigResponse,
};
use openshell_core::proto::{
L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, Provider, Sandbox,
SandboxPolicy as ProtoSandboxPolicy, StaticCredentialEndpointBinding,
};
use openshell_core::telemetry::{
LifecycleOperation, LifecycleResource, PolicyDecisionOperation, TelemetryOutcome,
};
use openshell_core::{
GetResourceVersion,
endpoint_path::EndpointPathPattern,
host_pattern::{host_matches, host_patterns_overlap},
settings::{self, SettingValueKind},
};
use openshell_ocsf::{ConfigStateChangeBuilder, OcsfEvent, SeverityId, StateId, StatusId};
use openshell_policy::{
L7BinaryScope, L7RuleTarget, PolicyMergeOp, ProviderPolicyLayer, canonicalize_advisor_add_rule,
compose_effective_policy, merge_policy, policy_covers_rule, serialize_sandbox_policy,
strip_provider_rule_names,
};
use openshell_prover::{
credentials::{Credential, CredentialSet},
finding::{Finding, FindingPath},
model::build_model,
policy::parse_policy_str,
queries::run_all_queries,
registry::load_embedded_binary_registry,
report::finding_shorthand,
};
use prost::Message;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::net::IpAddr;
use std::sync::Arc;
use tonic::{Request, Response, Status};
use tracing::{debug, info, warn};
use super::validation::{
level_matches, source_matches, validate_and_canonicalize_policy, validate_annotations,
validate_no_reserved_provider_policy_keys, validate_policy_safety,
validate_static_fields_unchanged,
};
use super::{StoredSettingValue, StoredSettings};
use crate::persistence::current_time_ms;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Internal object type for durable gateway-global settings.
const GLOBAL_SETTINGS_OBJECT_TYPE: &str = "gateway_settings";
const GLOBAL_SETTINGS_NAME: &str = "global";
/// Internal object type for durable sandbox-scoped settings.
pub const SANDBOX_SETTINGS_OBJECT_TYPE: &str = "sandbox_settings";
const PROVIDER_COMPOSITION_VALIDATION_PAGE_SIZE: u32 = 1000;
/// Reserved settings key used to store global policy payload.
const POLICY_SETTING_KEY: &str = "policy";
/// Sentinel `sandbox_id` used to store global policy revisions.
const GLOBAL_POLICY_SANDBOX_ID: &str = "__global__";
/// Stable labels used when stored policy state fails validation.
const STORED_POLICY_SOURCE_HISTORY: &str = "sandbox policy history";
const STORED_POLICY_SOURCE_SPEC: &str = "sandbox spec policy";
const STORED_POLICY_SOURCE_GLOBAL: &str = "global policy setting";
/// Maximum number of optimistic retry attempts for policy version conflicts.
const MERGE_RETRY_LIMIT: usize = 5;
// Private wire-only compatibility types for policy history written before
// 0.1.0. Public generated bindings intentionally reserve NetworkBinary tag 2,
// but stored policies still need its former advisor-provenance value migrated
// before the unknown field is discarded.
#[derive(Clone, PartialEq, Message)]
struct LegacyStoredNetworkBinary {
#[prost(string, tag = "1")]
path: String,
#[prost(bool, tag = "2")]
advisor_proposed: bool,
}
#[derive(Clone, PartialEq, Message)]
struct LegacyStoredNetworkPolicyRule {
#[prost(string, tag = "1")]
name: String,
#[prost(message, repeated, tag = "2")]
endpoints: Vec<NetworkEndpoint>,
#[prost(message, repeated, tag = "3")]
binaries: Vec<LegacyStoredNetworkBinary>,
}
#[derive(Clone, PartialEq, Message)]
struct LegacyStoredSandboxPolicy {
#[prost(map = "string, message", tag = "5")]
network_policies: HashMap<String, LegacyStoredNetworkPolicyRule>,
}
fn decode_stored_policy(payload: &[u8]) -> Result<ProtoSandboxPolicy, prost::DecodeError> {
let mut policy = ProtoSandboxPolicy::decode(payload)?;
let legacy = LegacyStoredSandboxPolicy::decode(payload)?;
for (rule_key, legacy_rule) in legacy.network_policies {
let advisor_paths = legacy_rule
.binaries
.into_iter()
.filter(|binary| binary.advisor_proposed)
.map(|binary| binary.path)
.collect::<HashSet<_>>();
if advisor_paths.is_empty() {
continue;
}
let Some(mut explicit_rule) = policy.network_policies.remove(&rule_key) else {
continue;
};
let mut advisor_rule = explicit_rule.clone();
advisor_rule
.binaries
.retain(|binary| advisor_paths.contains(&binary.path));
explicit_rule
.binaries
.retain(|binary| !advisor_paths.contains(&binary.path));
if advisor_rule.binaries.is_empty() {
policy.network_policies.insert(rule_key, explicit_rule);
continue;
}
for endpoint in &mut advisor_rule.endpoints {
endpoint.advisor_proposed = true;
}
if explicit_rule.binaries.is_empty() {
policy.network_policies.insert(rule_key, advisor_rule);
continue;
}
policy
.network_policies
.insert(rule_key.clone(), explicit_rule);
let key_base = format!("{rule_key}__legacy_advisor");
let mut advisor_key = key_base.clone();
let mut suffix = 2_u32;
while policy.network_policies.contains_key(&advisor_key) {
advisor_key = format!("{key_base}_{suffix}");
suffix += 1;
}
advisor_rule.name.clone_from(&advisor_key);
policy.network_policies.insert(advisor_key, advisor_rule);
}
Ok(policy)
}
fn emit_sandbox_policy_update_success() {
openshell_core::telemetry::emit_lifecycle(
LifecycleResource::SandboxPolicy,
LifecycleOperation::Update,
TelemetryOutcome::Success,
);
}
fn emit_sandbox_policy_update_failure() {
openshell_core::telemetry::emit_lifecycle(
LifecycleResource::SandboxPolicy,
LifecycleOperation::Update,
TelemetryOutcome::Failure,
);
}
fn should_emit_config_update_policy_telemetry(sandbox_caller: bool) -> bool {
!sandbox_caller
}
fn emit_config_update_policy_success(sandbox_caller: bool) {
if should_emit_config_update_policy_telemetry(sandbox_caller) {
emit_sandbox_policy_update_success();
}
}
fn should_emit_full_policy_update_telemetry(sandbox_caller: bool, next_version: i64) -> bool {
!sandbox_caller && next_version > 1
}
fn emit_full_policy_update_success(sandbox_caller: bool, next_version: i64) {
if should_emit_full_policy_update_telemetry(sandbox_caller, next_version) {
emit_sandbox_policy_update_success();
}
}
fn emit_policy_decision_success(operation: PolicyDecisionOperation, rule_count: u64) {
openshell_core::telemetry::emit_policy_decision(
operation,
TelemetryOutcome::Success,
rule_count,
);
}
fn emit_policy_decision_failure(operation: PolicyDecisionOperation, rule_count: u64) {
openshell_core::telemetry::emit_policy_decision(
operation,
TelemetryOutcome::Failure,
rule_count,
);
}
fn emit_gateway_policy_audit_log(
sandbox_id: &str,
sandbox_name: &str,
state_label: &str,
detail: impl Into<String>,
version: i64,
policy_hash: &str,
) {
openshell_ocsf::ocsf_emit!(build_gateway_policy_audit_event(
sandbox_id,
sandbox_name,
state_label,
detail,
version,
policy_hash,
&[],
));
}
/// Emit a `CONFIG:APPROVED` audit event for an auto-approval — same event
/// class as a human approval, with extra unmapped fields carrying the
/// safety reasoning so the audit is reconstructable. `source` records the
/// proposer (`mechanistic` or `agent_authored`) for provenance.
/// `resolved_from` records the scope that supplied the `auto` mode setting
/// (`gateway`, `sandbox`, or `default`) so operators can see why a given
/// approval was auto vs manual.
fn emit_gateway_policy_auto_approve_audit_log(
sandbox_id: &str,
sandbox_name: &str,
detail: impl Into<String>,
version: i64,
policy_hash: &str,
source: &str,
resolved_from: &str,
) {
let extra = [
("auto", "true".to_string()),
("source", source.to_string()),
("prover_delta", "empty".to_string()),
("resolved_from", resolved_from.to_string()),
];
openshell_ocsf::ocsf_emit!(build_gateway_policy_audit_event(
sandbox_id,
sandbox_name,
"approved",
detail,
version,
policy_hash,
&extra,
));
}
fn build_gateway_policy_audit_event(
sandbox_id: &str,
sandbox_name: &str,
state_label: &str,
detail: impl Into<String>,
version: i64,
policy_hash: &str,
extra_fields: &[(&str, String)],
) -> OcsfEvent {
let ctx = crate::gateway_ocsf::context(sandbox_id, sandbox_name);
let mut builder = ConfigStateChangeBuilder::new(&ctx)
.state(StateId::Other, state_label)
.severity(SeverityId::Informational)
.status(StatusId::Success)
.message(detail.into());
if version > 0 {
builder = builder.unmapped("policy_version", format!("v{version}"));
}
if !policy_hash.is_empty() {
builder = builder.unmapped("policy_hash", policy_hash.to_string());
}
for (key, value) in extra_fields {
builder = builder.unmapped(key, value.clone());
}
builder.build()
}
fn summarize_cli_policy_merge_op(operation: &PolicyMergeOp) -> String {
match operation {
PolicyMergeOp::AddRule { rule_name, rule } => summarize_add_endpoint(rule_name, rule),
PolicyMergeOp::RemoveEndpoint {
rule_name,
host,
port,
} => rule_name.as_ref().map_or_else(
|| format!("remove-endpoint {host}:{port}"),
|rule_name| format!("remove-endpoint {host}:{port} from rule {rule_name}"),
),
PolicyMergeOp::RemoveRule { rule_name } => format!("remove-rule {rule_name}"),
PolicyMergeOp::AddDenyRules { target, deny_rules } => format!(
"add-deny {} [{}]",
summarize_l7_target(target),
deny_rules
.iter()
.map(summarize_l7_deny_rule)
.collect::<Vec<_>>()
.join(", ")
),
PolicyMergeOp::AddAllowRules { target, rules } => format!(
"add-allow {} [{}]",
summarize_l7_target(target),
rules
.iter()
.map(summarize_l7_rule)
.collect::<Vec<_>>()
.join(", ")
),
PolicyMergeOp::RemoveBinary {
rule_name,
binary_path,
} => format!("remove-binary {rule_name} {binary_path}"),
}
}
fn summarize_l7_target(target: &L7RuleTarget) -> String {
let ports = target
.ports
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
let binaries = match &target.binaries {
L7BinaryScope::Any => "any".to_string(),
L7BinaryScope::Restricted(binaries) => binaries
.iter()
.map(|binary| binary.path.as_str())
.collect::<Vec<_>>()
.join(", "),
};
// Preserve optional path presence in the audit trail: None means unique
// endpoint selection, while Some("") selects an endpoint with no path scope.
format!(
"{}:{ports} rule={} endpoint-path={:?} binaries=[{binaries}]",
target.host, target.rule_name, target.path,
)
}
fn ensure_chunk_belongs_to_sandbox(
chunk: &DraftChunkRecord,
sandbox_id: &str,
) -> Result<(), Status> {
if chunk.sandbox_id != sandbox_id {
return Err(Status::not_found("chunk not found"));
}
Ok(())
}
fn summarize_add_endpoint(rule_name: &str, rule: &NetworkPolicyRule) -> String {
let endpoints = rule
.endpoints
.iter()
.map(summarize_endpoint)
.collect::<Vec<_>>()
.join(", ");
let binaries = summarize_binaries(&rule.binaries);
format!("add-endpoint {rule_name} endpoints=[{endpoints}] binaries=[{binaries}]")
}
fn summarize_add_rule(rule_name: &str, rule: &NetworkPolicyRule) -> String {
let endpoints = rule
.endpoints
.iter()
.map(summarize_endpoint)
.collect::<Vec<_>>()
.join(", ");
let binaries = summarize_binaries(&rule.binaries);
format!("add-rule {rule_name} endpoints=[{endpoints}] binaries=[{binaries}]")
}
fn summarize_endpoint(endpoint: &NetworkEndpoint) -> String {
let mut parts = vec![format!("{}:{}", endpoint.host, endpoint.port)];
if !endpoint.protocol.is_empty() {
parts.push(format!("protocol={}", endpoint.protocol));
}
if endpoint.access != 0 {
parts.push(format!(
"access={}",
openshell_policy::network_access_preset_to_str(endpoint.access).unwrap_or("unknown")
));
}
if endpoint.enforcement != 0 {
parts.push(format!(
"enforcement={}",
openshell_policy::network_enforcement_mode_to_str(endpoint.enforcement)
.unwrap_or("unknown")
));
}
if endpoint.tls != 0 {
parts.push(format!(
"tls={}",
openshell_policy::network_tls_mode_to_str(endpoint.tls).unwrap_or("unknown")
));
}
if endpoint.websocket_credential_rewrite {
parts.push("websocket_credential_rewrite=true".to_string());
}
if endpoint.request_body_credential_rewrite {
parts.push("request_body_credential_rewrite=true".to_string());
}
if endpoint.allow_uninspected_credentials {
parts.push("allow_uninspected_credentials=true".to_string());
}
if !endpoint.allowed_ips.is_empty() {
parts.push(format!("allowed_ips={}", endpoint.allowed_ips.len()));
}
if !endpoint.ports.is_empty() {
parts.push(format!("ports={}", endpoint.ports.len()));
}
if !endpoint.rules.is_empty() {
parts.push(format!(
"allow=[{}]",
endpoint
.rules
.iter()
.map(summarize_l7_rule)
.collect::<Vec<_>>()
.join(", ")
));
}
if !endpoint.deny_rules.is_empty() {
parts.push(format!(
"deny=[{}]",
endpoint
.deny_rules
.iter()
.map(summarize_l7_deny_rule)
.collect::<Vec<_>>()
.join(", ")
));
}
parts.join(" ")
}
fn summarize_l7_rule(rule: &L7Rule) -> String {
let Some(allow) = rule.allow.as_ref() else {
return "allow".to_string();
};
summarize_l7_match(
&allow.method,
&allow.path,
&allow.command,
allow.query.len(),
)
}
fn summarize_l7_deny_rule(rule: &L7DenyRule) -> String {
summarize_l7_match(&rule.method, &rule.path, &rule.command, rule.query.len())
}
fn summarize_l7_match(method: &str, path: &str, command: &str, query_count: usize) -> String {
let mut parts = Vec::new();
if !method.is_empty() {
parts.push(method.to_string());
}
if !path.is_empty() {
parts.push(path.to_string());
}
if !command.is_empty() {
parts.push(format!("command={}", truncate_for_log(command, 48)));
}
if query_count > 0 {
parts.push(format!("query_keys={query_count}"));
}
if parts.is_empty() {
"rule".to_string()
} else {
parts.join(" ")
}
}
fn summarize_binaries(binaries: &[NetworkBinary]) -> String {
binaries
.iter()
.map(|binary| binary.path.as_str())
.collect::<Vec<_>>()
.join(", ")
}
fn summarize_draft_chunk_rule(chunk: &DraftChunkRecord) -> Result<String, Status> {
let rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice())
.map_err(|e| Status::internal(format!("decode proposed_rule failed: {e}")))?;
Ok(summarize_add_rule(&chunk.rule_name, &rule))
}
/// Run prover queries against the merged policy and render a short
/// human-readable verdict for the reviewer. The verdict reports only the
/// **delta** — findings the proposal introduces on top of the current policy.
/// Baseline gaps (pre-existing findings) are intentionally not surfaced here;
/// they belong on a posture surface, not on the per-proposal approval moment.
///
/// The string is the entire output — no taxonomy, no greppable prefixes; the
/// reviewer reads it like an OCSF shorthand line. One of:
///
/// - `prover: no new findings`
/// - `prover: N new finding(s)` followed by one ` <category>: <detail>`
/// line per finding path (categorical shorthand from `openshell-prover`)
/// - `merge failed: <one-line error>` — proposal won't merge into the current
/// policy
/// - `policy invalid: <one-line error>` — merged policy fails the cheap
/// structural safety check
/// - `validation unavailable` — gateway-side infrastructure failure (registry
/// load, YAML serialize/parse). Internal error detail is logged via
/// `warn!`, never exposed to the reviewer.
#[cfg(test)]
fn validation_result_for_agent_proposal(
current_policy: ProtoSandboxPolicy,
rule_name: &str,
proposed_rule: &NetworkPolicyRule,
credentials: &CredentialSet,
) -> String {
let merge_op = PolicyMergeOp::AddRule {
rule_name: rule_name.to_string(),
rule: proposed_rule.clone(),
};
let merged = match merge_policy(current_policy.clone(), &[merge_op]) {
Ok(result) => result.policy,
Err(error) => return format!("merge failed: {}", one_line(&error.to_string())),
};
if let Err(error) = validate_policy_safety(&merged) {
return format!("policy invalid: {}", one_line(&error.to_string()));
}
let merged_findings = match run_prover_findings(&merged, credentials) {
Ok(findings) => findings,
Err(error) => {
warn!(error = %error, "prover validation unavailable for merged policy");
return "validation unavailable".to_string();
}
};
// If the baseline prover run fails (e.g. the current policy uses a shape
// the prover hasn't caught up to yet), fall back to an empty baseline so
// every merged finding surfaces as new. Safer to over-warn than miss a
// real regression introduced by the proposal.
let base_findings = match run_prover_findings(&current_policy, credentials) {
Ok(findings) => findings,
Err(error) => {
warn!(error = %error, "prover baseline run failed; treating baseline as empty");
Vec::new()
}
};
let new_findings = finding_delta(&base_findings, &merged_findings);
if new_findings.is_empty() {
return "prover: no new findings".to_string();
}
let count = new_findings.len();
let mut out = format!(
"prover: {} new finding{}",
count,
if count == 1 { "" } else { "s" }
);
for finding in &new_findings {
out.push_str("\n ");
out.push_str(&finding_shorthand(finding));
}
out
}
#[derive(Debug)]
struct ProposalEvaluation {
rule_name: String,
rule: NetworkPolicyRule,
current_effective_policy: ProtoSandboxPolicy,
candidate_effective_policy: Option<ProtoSandboxPolicy>,
validation_result: String,
application_error: String,
review_token: String,
}
impl ProposalEvaluation {
fn current_hash(&self) -> String {
deterministic_policy_hash(&self.current_effective_policy)
}
fn candidate_hash(&self) -> String {
self.candidate_effective_policy
.as_ref()
.map(deterministic_policy_hash)
.unwrap_or_default()
}
}
fn proposal_prover_result(
current_effective_policy: &ProtoSandboxPolicy,
candidate_effective_policy: &ProtoSandboxPolicy,
credentials: &CredentialSet,
) -> String {
let candidate_findings = match run_prover_findings(candidate_effective_policy, credentials) {
Ok(findings) => findings,
Err(error) => {
warn!(error = %error, "prover validation unavailable for candidate policy");
return "validation unavailable".to_string();
}
};
let base_findings = match run_prover_findings(current_effective_policy, credentials) {
Ok(findings) => findings,
Err(error) => {
warn!(error = %error, "prover baseline run failed; treating baseline as empty");
Vec::new()
}
};
let new_findings = finding_delta(&base_findings, &candidate_findings);
if new_findings.is_empty() {
return "prover: no new findings".to_string();
}
let mut out = format!(
"prover: {} new finding{}",
new_findings.len(),
if new_findings.len() == 1 { "" } else { "s" }
);
for finding in &new_findings {
out.push_str("\n ");
out.push_str(&finding_shorthand(finding));
}
out
}
fn compute_proposal_review_token(
rule_name: &str,
rule: &NetworkPolicyRule,
current_effective_policy: &ProtoSandboxPolicy,
candidate_effective_policy: &ProtoSandboxPolicy,
credentials: &CredentialSet,
) -> String {
let mut hasher = Sha256::new();
hasher.update(b"openshell-proposal-evaluator-v2\0");
hasher.update(rule_name.as_bytes());
hasher.update(canonical_rule_bytes(rule));
hasher.update(deterministic_policy_hash(current_effective_policy).as_bytes());
hasher.update(deterministic_policy_hash(candidate_effective_policy).as_bytes());
let mut credential_fingerprints = credentials
.credentials
.iter()
.map(|credential| {
let mut scopes = credential.scopes.clone();
scopes.sort();
let mut hosts = credential.target_hosts.clone();
hosts.sort();
format!(
"{}\0{}\0{}\0{}\0{}",
credential.name,
credential.cred_type,
credential.injected_via,
scopes.join("\0"),
hosts.join("\0")
)
})
.collect::<Vec<_>>();
credential_fingerprints.sort();
for fingerprint in credential_fingerprints {
hasher.update(fingerprint.as_bytes());
}
hex::encode(hasher.finalize())
}
fn compute_failed_proposal_evaluation_hash(
rule_name: &str,
rule: &NetworkPolicyRule,
current_effective_policy: &ProtoSandboxPolicy,
application_error: &str,
) -> String {
let mut hasher = Sha256::new();
hasher.update(b"openshell-proposal-evaluator-v2-failed\0");
hasher.update(rule_name.as_bytes());
hasher.update(canonical_rule_bytes(rule));
hasher.update(deterministic_policy_hash(current_effective_policy).as_bytes());
hasher.update(application_error.as_bytes());
hex::encode(hasher.finalize())
}
fn available_proposal_rule_name(
base_policy: &ProtoSandboxPolicy,
current_effective_policy: &ProtoSandboxPolicy,
requested_rule_name: &str,
) -> String {
let mut candidate = requested_rule_name.to_string();
let mut suffix = 2_u32;
while base_policy.network_policies.contains_key(&candidate)
|| current_effective_policy
.network_policies
.contains_key(&candidate)
{
candidate = format!("{requested_rule_name}_{suffix}");
suffix += 1;
}
candidate
}
#[allow(clippy::too_many_arguments)]
fn evaluate_proposal_candidate(
base_policy: &ProtoSandboxPolicy,
current_effective_policy: &ProtoSandboxPolicy,
requested_rule_name: &str,
proposed_rule: &NetworkPolicyRule,
analysis_mode: &str,
credentials: &CredentialSet,
validation_context: PolicyMergeValidationContext<'_>,
reuse_validation_result: Option<&str>,
) -> ProposalEvaluation {
// The gateway owns advisor provenance. Do not rely on a supervisor or an
// agent-authored proposal to set this internal marker correctly: every
// proposed endpoint must remain ineligible for exact-host private-address
// trust until an explicit declaration or allowed_ips grants that trust.
let mut proposed_rule = proposed_rule.clone();
for endpoint in &mut proposed_rule.endpoints {
endpoint.advisor_proposed = true;
}
let canonical = if analysis_mode == "mechanistic" {
canonicalize_advisor_add_rule(
base_policy,
current_effective_policy,
requested_rule_name,
&proposed_rule,
)
} else {
let rule_name = available_proposal_rule_name(
base_policy,
current_effective_policy,
requested_rule_name,
);
proposed_rule.name.clone_from(&rule_name);
Ok((rule_name, proposed_rule.clone()))
};
let (rule_name, rule) = match canonical {
Ok(value) => value,
Err(error) => {
let application_error = format!("candidate canonicalization failed: {error}");
return ProposalEvaluation {
rule_name: requested_rule_name.to_string(),
rule: proposed_rule.clone(),
current_effective_policy: current_effective_policy.clone(),
candidate_effective_policy: None,
validation_result: String::new(),
review_token: compute_failed_proposal_evaluation_hash(
requested_rule_name,
&proposed_rule,
current_effective_policy,
&application_error,
),
application_error,
};
}
};
let operations = [PolicyMergeOp::AddRule {
rule_name: rule_name.clone(),
rule: rule.clone(),
}];
let candidate_base = match merge_policy(base_policy.clone(), &operations) {
Ok(result) => result.policy,
Err(error) => {
let application_error = format!("merge failed: {}", one_line(&error.to_string()));
let review_token = compute_failed_proposal_evaluation_hash(
&rule_name,
&rule,
current_effective_policy,
&application_error,
);
return ProposalEvaluation {
rule_name,
rule,
current_effective_policy: current_effective_policy.clone(),
candidate_effective_policy: None,
validation_result: String::new(),
review_token,
application_error,
};
}
};
let validation = (|| -> Result<ProtoSandboxPolicy, Status> {
validate_policy_safety(&candidate_base)?;
validate_candidate_effective_policy(&candidate_base, validation_context.provider_layers)?;
let mut effective = if validation_context.provider_layers.is_empty() {
candidate_base.clone()
} else {
compose_effective_policy(&candidate_base, validation_context.provider_layers)
};
let bindings = policy_static_credential_endpoint_bindings(Some(&effective))?;
if let Some(context) = validation_context.credential_binding {
validate_operator_merged_credential_policy(&mut effective, &bindings, context)?;
}
Ok(effective)
})();
let candidate_effective_policy = match validation {
Ok(policy) => policy,
Err(error) => {
let application_error = format!("candidate invalid: {}", one_line(error.message()));
let review_token = compute_failed_proposal_evaluation_hash(
&rule_name,
&rule,
current_effective_policy,
&application_error,
);
return ProposalEvaluation {
rule_name,
rule,
current_effective_policy: current_effective_policy.clone(),
candidate_effective_policy: None,
validation_result: String::new(),
review_token,
application_error,
};
}
};
let validation_result = reuse_validation_result.map_or_else(
|| {
proposal_prover_result(
current_effective_policy,
&candidate_effective_policy,
credentials,
)
},
ToString::to_string,
);
let application_error = if validation_result == "validation unavailable" {
"prover validation unavailable; proposal cannot be reviewed".to_string()
} else {
String::new()
};
let review_token = if application_error.is_empty() {
compute_proposal_review_token(
&rule_name,
&rule,
current_effective_policy,
&candidate_effective_policy,
credentials,
)
} else {
compute_failed_proposal_evaluation_hash(
&rule_name,
&rule,
current_effective_policy,
&application_error,
)
};
ProposalEvaluation {
rule_name,
rule,
current_effective_policy: current_effective_policy.clone(),
candidate_effective_policy: Some(candidate_effective_policy),
validation_result,
application_error,
review_token,
}
}
/// Run the prover end-to-end against a single policy with the given
/// credential set. Returns the raw finding list, or a short error string
/// identifying which infrastructure step failed.
///
/// The credential set is passed in because it's stable across all chunks in
/// one `SubmitPolicyAnalysis` batch — the caller builds it once and shares.
fn run_prover_findings(
policy: &ProtoSandboxPolicy,
credentials: &CredentialSet,
) -> Result<Vec<Finding>, String> {
let yaml =
serialize_sandbox_policy(policy).map_err(|e| format!("serialize policy failed: {e}"))?;
let prover_policy = parse_policy_str(&yaml).map_err(|e| format!("parse policy failed: {e}"))?;
let registry =
load_embedded_binary_registry().map_err(|e| format!("load registry failed: {e}"))?;
let model = build_model(prover_policy, credentials.clone(), registry);
Ok(run_all_queries(&model))
}
/// Build a `CredentialSet` for the sandbox by walking its attached providers.
///
/// v1 models "credential is present in scope for these hosts" — no scope
/// modeling. Each attached provider produces one [`Credential`] entry whose
/// `target_hosts` lists the hosts from the provider's profile endpoints.
/// Missing providers or providers whose type has no profile are skipped with
/// a `warn!` — the merged policy already excludes them at compose time, so
/// silently treating them as absent here keeps the credential set consistent
/// with the merged policy the prover validates against.
async fn build_credential_set_for_sandbox_with_catalog(
store: &Store,
catalog: &EffectiveProviderProfileCatalog,
workspace: &str,
provider_names: &[String],
) -> Result<CredentialSet, Status> {
let mut credentials = Vec::new();
for name in provider_names {
let Some(provider) = store
.get_message_by_name::<Provider>(workspace, name)
.await
.map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))?
else {
warn!(provider_name = %name, "provider not found while building credential set; skipping");
continue;
};
let provider_type = provider.r#type.trim();
let Some(profile) = super::provider::get_provider_type_profile_for_scope(
catalog,
provider_type,
&provider.profile_workspace,
) else {
warn!(
provider_name = %name,
provider_type,
"provider type has no profile; skipping credential entry"
);
continue;
};
let target_hosts: Vec<String> = profile
.endpoints
.iter()
.map(|ep| ep.host.to_lowercase())
.filter(|h| !h.is_empty())
.collect();
if target_hosts.is_empty() {
continue;
}
credentials.push(Credential {
name: name.clone(),
cred_type: provider_type.to_string(),
scopes: Vec::new(),
injected_via: String::new(),
target_hosts,
});
}
Ok(CredentialSet {
credentials,
api_registries: HashMap::new(),
})
}
/// Stable identity key for a finding path. Deliberately excludes
/// `policy_name`: two paths with identical (binary, endpoint, mechanism) are
/// the same security gap whether they live in rule `foo` or rule `bar`. This
/// keeps the delta from spuriously surfacing baseline gaps just because the
/// proposal added a new rule name that produces the same gap shape.
fn finding_path_key(path: &FindingPath) -> String {
let FindingPath::Exfil(p) = path;
// Include the category and (for capability_expansion) the method so
// adding a new method on an already-reached host surfaces as a new
// path; reuse of an existing method does not.
format!(
"exfil|{}|{}:{}|{}|{}",
p.binary, p.endpoint_host, p.endpoint_port, p.category, p.method
)
}
/// Return the merged-policy findings that aren't already present in the
/// baseline. Comparison is per-(query, path) so that a single finding whose
/// evidence grew (e.g. a new method allowed on an already-reached host)
/// surfaces only the new evidence paths.
///
/// **Category suppression:** `capability_expansion` paths whose (binary,
/// host, port) tuple appears in the `credential_reach_expansion` delta
/// are suppressed. A brand-new credentialed reach is described by the
/// reach-expansion finding alone; we don't double-report by also
/// flagging every method as a separate `capability_expansion`.
fn finding_delta(base: &[Finding], merged: &[Finding]) -> Vec<Finding> {
use openshell_prover::finding::category;
let base_keys: HashSet<(String, String)> = base
.iter()
.flat_map(|f| {
let query = f.query.clone();
f.paths
.iter()
.map(move |p| (query.clone(), finding_path_key(p)))
})
.collect();
let mut delta: Vec<Finding> = Vec::new();
for finding in merged {
let new_paths: Vec<FindingPath> = finding
.paths
.iter()
.filter(|p| !base_keys.contains(&(finding.query.clone(), finding_path_key(p))))
.cloned()
.collect();
if new_paths.is_empty() {
continue;
}
delta.push(Finding {
paths: new_paths,
..finding.clone()
});
}
// Suppress capability_expansion paths whose (binary, host, port)
// appears in the credential_reach_expansion delta — a new reach is
// described once, by the reach-expansion category, not also by per-
// method capability findings.
let reach_tuples: HashSet<(String, String, u16)> = delta
.iter()
.filter(|f| f.query == category::CREDENTIAL_REACH_EXPANSION)
.flat_map(|f| {
f.paths.iter().map(|p| {
let FindingPath::Exfil(e) = p;
(e.binary.clone(), e.endpoint_host.clone(), e.endpoint_port)
})
})
.collect();
delta.retain_mut(|f| {
if f.query != category::CAPABILITY_EXPANSION {
return true;
}
f.paths.retain(|p| {
let FindingPath::Exfil(e) = p;
!reach_tuples.contains(&(e.binary.clone(), e.endpoint_host.clone(), e.endpoint_port))
});
!f.paths.is_empty()
});
delta
}
/// Collapse multi-line / multi-message error text to a single line so the
/// `validation_result` stays a clean, scannable string.
fn one_line(s: &str) -> String {
s.split('\n')
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("; ")
}
async fn reconcile_pending_chunks_covered_by_policy(
state: &Arc<ServerState>,
sandbox_id: &str,
effective_policy: &ProtoSandboxPolicy,
policy_version: i64,
) -> Result<u32, Status> {
let pending = state
.store
.list_draft_chunks(sandbox_id, Some("pending"))
.await
.map_err(|error| Status::internal(format!("list pending chunks failed: {error}")))?;
let mut reconciled = 0;
for chunk in pending {
let Some(rule) = decode_draft_chunk_rule(&chunk)? else {
continue;
};
if !policy_covers_rule(effective_policy, &rule) {
continue;
}
let reason = format!("covered by active policy revision {policy_version}");
if state
.store
.conditionally_reject_draft_chunk(&chunk.id, current_time_ms(), &reason)
.await
.map_err(|error| Status::internal(format!("reconcile covered chunk failed: {error}")))?
{
reconciled += 1;
}
}
if reconciled > 0 {
state.sandbox_watch_bus.notify(sandbox_id);
}
Ok(reconciled)
}
async fn reconcile_pending_chunks_after_policy_change(
state: &Arc<ServerState>,
workspace: &str,
sandbox: &Sandbox,
) -> Result<u32, Status> {
let catalog = state
.provider_profile_sources
.snapshot_catalog(state.store.as_ref(), workspace)
.await?;
let effective = current_effective_policy_for_sandbox(
state,
&catalog,
workspace,
sandbox,
sandbox.object_id(),
)
.await?;
let version = state
.store
.get_latest_policy(sandbox.object_id())
.await
.map_err(|error| Status::internal(format!("fetch latest policy failed: {error}")))?
.map_or(0, |record| record.version);
reconcile_pending_chunks_covered_by_policy(state, sandbox.object_id(), &effective, version)
.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
/// proposals are stale."
///
/// In practice this implements the supersede behavior for the
/// `mechanistic`→`agent_authored` refinement loop: when the agent submits a
/// narrow L7 proposal in response to a denial, any pending mechanistic L4
/// draft for the same key gets auto-rejected here, without the agent or the
/// proto needing an explicit `supersedes_chunk_id` field.
///
/// Failures (DB error, scan error) are logged via `warn!` and the function
/// returns silently. The new chunk's persistence has already succeeded;
/// failing this cleanup pass should not abort the submission flow.
async fn supersede_other_pending_chunks_for_endpoint(
state: &Arc<ServerState>,
sandbox_id: &str,
new_chunk_id: &str,
host: &str,
port: i32,
binary: &str,
) {
// Empty host/port/binary should not supersede anything — the matcher would
// accidentally cover unrelated chunks. Defensive skip.
if host.is_empty() || port == 0 || binary.is_empty() {
return;
}
let pending = match state
.store
.list_draft_chunks(sandbox_id, Some("pending"))
.await
{
Ok(records) => records,
Err(err) => {
warn!(
sandbox_id = %sandbox_id,
error = %err,
"supersede scan failed; older pending chunks (if any) remain pending"
);
return;
}
};
let now_ms = current_time_ms();
for other in pending {
if other.id == new_chunk_id
|| other.host != host
|| other.port != port
|| other.binary != binary
{
continue;
}
let reason = format!("superseded by chunk {new_chunk_id}");
match state
.store
.update_draft_chunk_status(&other.id, "rejected", Some(now_ms), Some(&reason))
.await
{
Ok(_) => {
info!(
sandbox_id = %sandbox_id,
superseded_chunk = %other.id,
by_chunk = %new_chunk_id,
host = %host,
port = port,
binary = %binary,
"Auto-rejected pending chunk: superseded by newer submission for same (host, port, binary)"
);
}
Err(err) => {
warn!(
chunk_id = %other.id,
error = %err,
"supersede auto-reject failed; chunk remains pending"
);
}
}
}
}
/// If the just-submitted mechanistic chunk targets a `(host, port, binary)`
/// already covered by an approved `agent_authored` chunk, auto-reject the
/// mechanistic chunk on arrival. The agent has already handled this access
/// decision; the mechanistic draft would only add approval-queue noise.
///
/// `agent_authored` submissions are NEVER self-rejected — that path remains
/// open for refinement. Only the mechanistic side is asymmetric.
async fn self_reject_mechanistic_if_already_covered(
state: &Arc<ServerState>,
sandbox_id: &str,
new_chunk_id: &str,
host: &str,
port: i32,
binary: &str,
) {
if host.is_empty() || port == 0 || binary.is_empty() {
return;
}
// `put_draft_chunk` dedups mechanistic submissions on `(host, port,
// binary)` and returns the id of whatever row now owns that key. On a
// dedup hit that id belongs to a pre-existing row, which may already be
// `approved`. Self-reject must only fire on a genuinely fresh, still
// `pending` submission: flipping a non-pending row here would move an
// already-decided chunk to `rejected` with no human action, and because
// this path never un-merges the rule (unlike the human reject handler),
// the live policy would keep enforcing an access the ledger now reports as
// revoked.
match state.store.get_draft_chunk(new_chunk_id).await {
Ok(Some(chunk)) if chunk.status == "pending" => {}
Ok(_) => return,
Err(err) => {
warn!(
sandbox_id = %sandbox_id,
chunk_id = %new_chunk_id,
error = %err,
"self-reject status check failed; mechanistic chunk remains pending"
);
return;
}
}
let approved = match state
.store
.list_draft_chunks(sandbox_id, Some("approved"))
.await
{
Ok(records) => records,
Err(err) => {
warn!(
sandbox_id = %sandbox_id,
error = %err,
"approved-chunk scan for self-reject failed; mechanistic chunk remains pending"
);
return;
}
};
// If any *other* approved chunk for this sandbox already targets the same
// (host, port, binary), the mechanistic submission is redundant. Excluding
// `new_chunk_id` is load-bearing: the dedup upsert can alias the incoming
// id onto an approved row for this endpoint, and without the exclusion that
// row would be found as its own "covering" chunk and reject itself.
let covered_by = approved
.iter()
.find(|c| c.id != new_chunk_id && c.host == host && c.port == port && c.binary == binary);
let Some(covering) = covered_by else {
return;
};
let reason = format!(
"already covered by approved chunk {} (agent_authored or prior auto-approval)",
covering.id
);
match state
.store
.conditionally_reject_draft_chunk(new_chunk_id, current_time_ms(), &reason)
.await
{
Ok(true) => {
info!(
sandbox_id = %sandbox_id,
chunk_id = %new_chunk_id,
covering_chunk = %covering.id,
host = %host,
port = port,
binary = %binary,
"Auto-rejected incoming mechanistic chunk: endpoint already covered by an approved chunk"
);
}
Ok(false) => {
info!(
sandbox_id = %sandbox_id,
chunk_id = %new_chunk_id,
"mechanistic self-reject skipped: chunk no longer pending (decided concurrently)"
);
}
Err(err) => {
warn!(
chunk_id = %new_chunk_id,
error = %err,
"mechanistic self-reject failed; chunk remains pending"
);
}
}
}
/// Internally approve a chunk on the auto-approval path: merge into the
/// active policy, flip status to "approved", notify watchers, and emit a
/// `CONFIG:APPROVED` audit event carrying `auto=true`, `source=<mode>`,
/// `prover_delta=empty` so the audit trail records why no human approved
/// this chunk.
///
/// `source` is the `analysis_mode` of the originating submission
/// (`mechanistic` or `agent_authored`). The audit copy says "auto-approved:
/// no new prover findings" — never "safe" — because the claim is about the
/// prover's reasoning, not the world.
/// Resolve the effective proposal-approval mode for a sandbox.
///
/// Precedence (matches the rest of the settings model): gateway scope wins
/// over sandbox scope. A reviewer can pin manual mode fleet-wide by setting
/// it globally; per-sandbox overrides only apply when no global is set.
///
/// Returns `(auto_approve_enabled, resolved_from)` where `resolved_from`
/// is `"gateway"`, `"sandbox"`, or `"default"`. Only an exact `"auto"`
/// value enables auto-approval; any other string (including future-
/// reserved modes like `"auto_on_low_risk"`) is conservatively treated as
/// manual.
async fn resolve_proposal_approval_mode(
store: &Store,
workspace: &str,
sandbox_name: &str,
) -> Result<(bool, &'static str), Status> {
let global = load_global_settings(store).await?;
if let Some(StoredSettingValue::String(value)) =
global.settings.get(settings::PROPOSAL_APPROVAL_MODE_KEY)
{
return Ok((value == "auto", "gateway"));
}
let sandbox = load_sandbox_settings(store, workspace, sandbox_name).await?;
if let Some(StoredSettingValue::String(value)) =
sandbox.settings.get(settings::PROPOSAL_APPROVAL_MODE_KEY)
{
return Ok((value == "auto", "sandbox"));
}
Ok((false, "default"))
}
fn apply_evaluation_to_chunk(chunk: &mut DraftChunkRecord, evaluation: &ProposalEvaluation) {
chunk.rule_name.clone_from(&evaluation.rule_name);
chunk.proposed_rule = evaluation.rule.encode_to_vec();
chunk
.validation_result
.clone_from(&evaluation.validation_result);
chunk
.application_error
.clone_from(&evaluation.application_error);
chunk.review_token.clone_from(&evaluation.review_token);
chunk.current_effective_policy_hash = evaluation.current_hash();
chunk.candidate_effective_policy_hash = evaluation.candidate_hash();
chunk.current_effective_policy = Some(evaluation.current_effective_policy.clone());
chunk
.candidate_effective_policy
.clone_from(&evaluation.candidate_effective_policy);
chunk.last_seen_ms = current_time_ms();
}
async fn evaluate_stored_chunk_against_live_inputs(
state: &Arc<ServerState>,
workspace: &str,
sandbox: &Sandbox,
chunk: &DraftChunkRecord,
reuse_validation_result: Option<&str>,
) -> Result<ProposalEvaluation, Status> {
let rule = decode_draft_chunk_rule(chunk)?
.ok_or_else(|| Status::failed_precondition("draft chunk has no proposed rule"))?;
let provider_names = sandbox
.spec
.as_ref()
.map(|spec| spec.providers.clone())
.unwrap_or_default();
let catalog = state
.provider_profile_sources
.snapshot_catalog(state.store.as_ref(), workspace)
.await?;
let current_effective = current_effective_policy_for_sandbox(
state,
&catalog,
workspace,
sandbox,
sandbox.object_id(),
)
.await?;
let current_base = current_base_policy_for_sandbox(state.store.as_ref(), sandbox).await?;
let credentials = build_credential_set_for_sandbox_with_catalog(
state.store.as_ref(),
&catalog,
workspace,
&provider_names,
)
.await?;
let merge_validation = sandbox_policy_merge_validation_data_with_catalog(
state,
workspace,
sandbox,
&provider_names,
&catalog,
)
.await?;
let credential_binding_context = merge_validation.credential_binding_context();
Ok(evaluate_proposal_candidate(
&current_base,
&current_effective,
&chunk.rule_name,
&rule,
"stored",
&credentials,
PolicyMergeValidationContext {
provider_layers: &merge_validation.provider_layers,
credential_binding: Some(&credential_binding_context),
},
reuse_validation_result,
))
}
async fn persist_refreshed_evaluation(
state: &Arc<ServerState>,
chunk: &DraftChunkRecord,
evaluation: &ProposalEvaluation,
) -> Result<DraftChunkRecord, Status> {
let mut refreshed = chunk.clone();
apply_evaluation_to_chunk(&mut refreshed, evaluation);
let updated = state
.store
.update_draft_chunk_evaluation(&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",
));
}
state.sandbox_watch_bus.notify(&chunk.sandbox_id);
Ok(refreshed)
}
async fn persist_pending_application_error(
state: &Arc<ServerState>,
chunk_id: &str,
error: &Status,
) {
let Ok(Some(mut chunk)) = state.store.get_draft_chunk(chunk_id).await else {
return;
};
if chunk.status != "pending" {
return;
}
chunk.application_error = one_line(error.message());
chunk.last_seen_ms = current_time_ms();
if let Err(persist_error) = state.store.update_draft_chunk_evaluation(&chunk).await {
warn!(
chunk_id,
error = %persist_error,
"failed to persist proposal application error"
);
return;
}
state.sandbox_watch_bus.notify(&chunk.sandbox_id);
}
async fn clear_pending_application_error(state: &Arc<ServerState>, chunk_id: &str) {
let Ok(Some(mut chunk)) = state.store.get_draft_chunk(chunk_id).await else {
return;
};
if chunk.status != "pending" || chunk.application_error.is_empty() {
return;
}
chunk.application_error.clear();
chunk.last_seen_ms = current_time_ms();
if let Err(error) = state.store.update_draft_chunk_evaluation(&chunk).await {
warn!(chunk_id, error = %error, "failed to clear proposal application error");
}
}
async fn require_current_proposal_evaluation(
state: &Arc<ServerState>,
workspace: &str,
sandbox: &Sandbox,
chunk: &DraftChunkRecord,
supplied_review_token: Option<&str>,
) -> Result<ProposalEvaluation, Status> {
if !chunk.review_token.is_empty()
&& supplied_review_token.is_some_and(|token| token != chunk.review_token)
{
return Err(Status::failed_precondition(
"review token does not match the fetched proposal; refetch and review again",
));
}
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?;
if !live.application_error.is_empty() {
persist_refreshed_evaluation(state, chunk, &live).await?;
return Err(Status::failed_precondition(format!(
"proposal is not applicable: {}",
live.application_error
)));
}
if chunk.review_token.is_empty() {
// Compatibility path for proposals stored before review tokens were
// introduced. Evaluate once, persist the token, and allow the legacy
// approval request to proceed against that exact live candidate.
let evaluated =
evaluate_stored_chunk_against_live_inputs(state, workspace, sandbox, chunk, None)
.await?;
persist_refreshed_evaluation(state, chunk, &evaluated).await?;
return Ok(evaluated);
}
if live.review_token != chunk.review_token {
let refreshed =
evaluate_stored_chunk_against_live_inputs(state, workspace, sandbox, chunk, None)
.await?;
persist_refreshed_evaluation(state, chunk, &refreshed).await?;
return Err(Status::failed_precondition(
"proposal inputs changed; evaluation refreshed, refetch and review again",
));
}
Ok(live)
}
struct AutoApproveChunkContext<'a> {
sandbox: &'a Sandbox,
workspace: &'a str,
source: &'a str,
resolved_from: &'a str,
}
async fn auto_approve_chunk(
state: &Arc<ServerState>,
chunk_id: &str,
context: AutoApproveChunkContext<'_>,
) -> Result<(), Status> {
let sandbox_id = context.sandbox.object_id();
let sandbox_name = context.sandbox.object_name();
// Same gate the human-driven approve paths apply: if a global policy is
// active, sandbox-scoped chunk approvals are meaningless because
// `GetSandboxConfig` prefers the global policy. Auto-approving here
// would persist a sandbox revision that the runtime silently ignores
// and leave a misleading "approved" chunk in the table. Bail before
// touching state; the calling site logs this as `warn!` and leaves the
// chunk pending.
require_no_global_policy(state).await?;
let chunk = state
.store
.get_draft_chunk(chunk_id)
.await
.map_err(|e| Status::internal(format!("fetch chunk failed: {e}")))?
.ok_or_else(|| Status::not_found("chunk not found"))?;
// The chunk may have been superseded or rejected by something else
// between persist and auto-approve. Only approve from a pending state.
if chunk.status != "pending" {
return Ok(());
}
let live_evaluation = require_current_proposal_evaluation(
state,
context.workspace,
context.sandbox,
&chunk,
None,
)
.await?;
let validation_result = live_evaluation.validation_result;
if validation_result != "prover: no new findings" {
info!(
sandbox_id = %sandbox_id,
chunk_id = %chunk_id,
rule_name = %chunk.rule_name,
validation_result = %validation_result,
source = %context.source,
resolved_from = %context.resolved_from,
"Auto-approval skipped: current stored rule has prover findings"
);
return Ok(());
}
let security_notes = current_draft_chunk_security_notes(&chunk)?;
if !security_notes.is_empty() {
info!(
sandbox_id = %sandbox_id,
chunk_id = %chunk_id,
rule_name = %chunk.rule_name,
security_notes = %security_notes,
source = %context.source,
resolved_from = %context.resolved_from,
"Auto-approval skipped: current rule is security-flagged"
);
return Ok(());
}
let provider_names = context
.sandbox
.spec
.as_ref()
.map(|spec| spec.providers.as_slice())
.unwrap_or_default();
let merge_validation = sandbox_policy_merge_validation_data(
state,
context.workspace,
context.sandbox,
provider_names,
)
.await?;
let credential_binding_context = merge_validation.credential_binding_context();
let merge_result = merge_chunk_into_policy_with_validation(
state.store.as_ref(),
sandbox_id,
context.workspace,
&chunk,
PolicyMergeValidationContext {
provider_layers: &merge_validation.provider_layers,
credential_binding: Some(&credential_binding_context),
},
)
.await;
let (version, hash) = match merge_result {
Ok(result) => result,
Err(status) => {
persist_pending_application_error(state, chunk_id, &status).await;
return Err(status);
}
};
let chunk_summary = summarize_draft_chunk_rule(&chunk)?;
let now_ms = current_time_ms();
clear_pending_application_error(state, chunk_id).await;
state
.store
.update_draft_chunk_status(chunk_id, "approved", Some(now_ms), None)
.await
.map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?;
state.sandbox_watch_bus.notify(sandbox_id);
if let Err(error) =
reconcile_pending_chunks_after_policy_change(state, context.workspace, context.sandbox)
.await
{
warn!(
sandbox_id,
error = %error,
"failed to reconcile pending policy proposals after auto-approval"
);
}
let source_label = if context.source.is_empty() {
"unspecified"
} else {
context.source
};
emit_gateway_policy_auto_approve_audit_log(
sandbox_id,
sandbox_name,
format!(
"auto-approved: no new prover findings (source={source_label}) — chunk {chunk_id}: {chunk_summary}"
),
version,
&hash,
source_label,
context.resolved_from,
);
info!(
sandbox_id = %sandbox_id,
chunk_id = %chunk_id,
rule_name = %chunk.rule_name,
version = version,
policy_hash = %hash,
source = %source_label,
resolved_from = %context.resolved_from,
"Auto-approved chunk: no new prover findings"
);
Ok(())
}
// TODO: share effective-policy lookup with `load_sandbox_policy` /
// `GetSandboxConfig`. They re-implement very similar global-settings and
// profile-composition logic; consolidating them is out of scope for the
// agent-authored proposal validation slice.
async fn current_effective_policy_for_sandbox(
state: &ServerState,
catalog: &EffectiveProviderProfileCatalog,
workspace: &str,
sandbox: &Sandbox,
sandbox_id: &str,
) -> Result<ProtoSandboxPolicy, Status> {
let provider_names = sandbox
.spec
.as_ref()
.map(|spec| spec.providers.clone())
.unwrap_or_default();
let records = super::provider::load_provider_environment_records(
state.store.as_ref(),
workspace,
&provider_names,
)
.await?;
current_effective_policy_from_records(state, catalog, sandbox, sandbox_id, &records).await
}
async fn current_effective_policy_from_records(
state: &ServerState,
catalog: &EffectiveProviderProfileCatalog,
sandbox: &Sandbox,
sandbox_id: &str,
records: &[super::provider::ProviderEnvironmentRecord],
) -> Result<ProtoSandboxPolicy, Status> {
let global_settings = load_global_settings(state.store.as_ref()).await?;
if let Some(global_policy) = decode_policy_from_global_settings(&global_settings)? {
// A global policy is the complete effective policy. Dormant sandbox
// history and specs may predate the current schema, but they must not
// prevent the valid global policy from being served.
return apply_captured_policy_context(
provider_policy_context_from_records(catalog, records),
global_policy,
PolicySource::Global,
);
}
let policy = if let Some(record) = state
.store
.get_latest_policy(sandbox_id)
.await
.map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?
{
canonical_policy_record_identity(&record)?.0
} else {
match sandbox.spec.as_ref().and_then(|spec| spec.policy.clone()) {
Some(policy) => {
validate_and_canonicalize_stored_policy(policy, STORED_POLICY_SOURCE_SPEC)?
}
None => ProtoSandboxPolicy::default(),
}
};
apply_captured_policy_context(
provider_policy_context_from_records(catalog, records),
policy,
PolicySource::Sandbox,
)
}
async fn effective_policy_for_source(
state: &ServerState,
catalog: &EffectiveProviderProfileCatalog,
workspace: &str,
provider_names: &[String],
mut policy: ProtoSandboxPolicy,
) -> Result<ProtoSandboxPolicy, Status> {
let global_settings = load_global_settings(state.store.as_ref()).await?;
let policy_source = decode_policy_from_global_settings(&global_settings)?.map_or(
PolicySource::Sandbox,
|global_policy| {
policy = global_policy;
PolicySource::Global
},
);
apply_effective_policy_context(
state,
catalog,
workspace,
provider_names,
policy,
policy_source,
)
.await
}
async fn apply_effective_policy_context(
state: &ServerState,
catalog: &EffectiveProviderProfileCatalog,
workspace: &str,
provider_names: &[String],
policy: ProtoSandboxPolicy,
policy_source: PolicySource,
) -> Result<ProtoSandboxPolicy, Status> {
let provider_context = provider_policy_context_with_catalog(
state.store.as_ref(),
catalog,
workspace,
provider_names,
)
.await?;
apply_captured_policy_context(provider_context, policy, policy_source)
}
fn apply_captured_policy_context(
mut provider_context: ProviderPolicyContext,
mut policy: ProtoSandboxPolicy,
policy_source: PolicySource,
) -> Result<ProtoSandboxPolicy, Status> {
clear_provider_credentialed_markers(&mut policy);
if !matches!(policy_source, PolicySource::Global) && !provider_context.layers.is_empty() {
policy = compose_effective_policy(&policy, &provider_context.layers);
}
let policy_credential_bindings = policy_static_credential_endpoint_bindings(Some(&policy))?;
extend_credentialed_scopes_from_policy_bindings(
&mut provider_context.credentialed_scopes,
&policy_credential_bindings,
&provider_context.endpointless_provider_names,
);
stamp_provider_credentialed_endpoints(&mut policy, &provider_context.credentialed_scopes);
Ok(policy)
}
fn validate_endpoint_ambiguities(policy: &ProtoSandboxPolicy) -> Result<(), Status> {
let ambiguities = openshell_policy::find_endpoint_ambiguities(policy);
if ambiguities.is_empty() {
return Ok(());
}
Err(Status::failed_precondition(format!(
"network endpoint ambiguity validation failed:\n{}",
ambiguities
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n")
)))
}
pub(super) fn validate_candidate_effective_policy(
base_policy: &ProtoSandboxPolicy,
provider_layers: &[ProviderPolicyLayer],
) -> Result<(), Status> {
let effective_policy = if provider_layers.is_empty() {
base_policy.clone()
} else {
compose_effective_policy(base_policy, provider_layers)
};
validate_endpoint_ambiguities(&effective_policy)
}
fn policy_static_credential_endpoint_bindings(
policy: Option<&ProtoSandboxPolicy>,
) -> Result<HashMap<String, Vec<StaticCredentialEndpointBinding>>, Status> {
let mut bindings = HashMap::<String, Vec<StaticCredentialEndpointBinding>>::new();
let Some(policy) = policy else {
return Ok(bindings);
};
for rule in policy.network_policies.values() {
for endpoint in &rule.endpoints {
let Some(binding) = endpoint.credential_binding.as_ref() else {
continue;
};
let provider = binding.provider.trim();
if provider.is_empty() {
return Err(Status::invalid_argument(format!(
"credential_binding.provider is required for endpoint '{}'",
endpoint.host
)));
}
if provider != binding.provider {
return Err(Status::invalid_argument(format!(
"credential_binding.provider '{}' must not contain leading or trailing whitespace",
binding.provider
)));
}
if endpoint.host.trim().is_empty() {
return Err(Status::invalid_argument(format!(
"credential-bound endpoint for provider '{provider}' must define a host"
)));
}
let ports = if endpoint.ports.is_empty() {
vec![endpoint.port]
} else {
endpoint.ports.clone()
};
if ports
.iter()
.any(|port| *port == 0 || *port > u32::from(u16::MAX))
{
return Err(Status::invalid_argument(format!(
"credential-bound endpoint '{}' for provider '{provider}' must define ports in range 1..=65535",
endpoint.host
)));
}
let provider_bindings = bindings.entry(provider.to_string()).or_default();
for port in ports {
let candidate = StaticCredentialEndpointBinding {
host: endpoint.host.clone(),
port,
path: endpoint.path.clone(),
};
if !provider_bindings.contains(&candidate) {
provider_bindings.push(candidate);
}
}
}
}
for endpoints in bindings.values_mut() {
endpoints.sort_by(|left, right| {
(&left.host, left.port, &left.path).cmp(&(&right.host, right.port, &right.path))
});
}
Ok(bindings)
}
pub(super) fn policy_has_credential_binding_for_provider(
policy: &ProtoSandboxPolicy,
provider_name: &str,
) -> bool {
policy.network_policies.values().any(|rule| {
rule.endpoints.iter().any(|endpoint| {
endpoint
.credential_binding
.as_ref()
.is_some_and(|binding| binding.provider == provider_name)
})
})
}
fn validate_policy_credential_binding_context(
catalog: &EffectiveProviderProfileCatalog,
records: &[super::provider::ProviderEnvironmentRecord],
policy: &ProtoSandboxPolicy,
bindings: &HashMap<String, Vec<StaticCredentialEndpointBinding>>,
) -> Result<(), Status> {
for provider_name in bindings.keys() {
let record = records
.iter()
.find(|record| record.name == *provider_name)
.ok_or_else(|| {
Status::failed_precondition(format!(
"credential_binding references provider '{provider_name}', but that provider is not attached to the sandbox"
))
})?;
let profile = super::provider::get_provider_type_profile_for_scope(
catalog,
&record.provider.r#type,
&record.provider.profile_workspace,
)
.ok_or_else(|| {
Status::failed_precondition(format!(
"credential_binding provider '{provider_name}' has no provider profile"
))
})?;
if !profile.to_proto().endpoints.is_empty() {
return Err(Status::failed_precondition(format!(
"credential_binding provider '{provider_name}' profile already defines endpoints; \
profile endpoints remain the credential boundary"
)));
}
}
validate_policy_signing_credential_sources(catalog, records, policy)?;
Ok(())
}
const SIGV4_REQUIRED_CREDENTIAL_KEYS: [&str; 2] = ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"];
fn validate_policy_signing_credential_sources(
catalog: &EffectiveProviderProfileCatalog,
records: &[super::provider::ProviderEnvironmentRecord],
policy: &ProtoSandboxPolicy,
) -> Result<(), Status> {
for rule in policy.network_policies.values() {
for endpoint in &rule.endpoints {
if endpoint.credential_signing.is_empty() {
continue;
}
let source = endpoint.credential_binding.as_ref().map_or_else(
|| {
records.iter().find_map(|record| {
signing_profile_for_record(catalog, record).filter(|profile| {
profile_declares_sigv4_credentials(profile)
&& !profile.endpoints.is_empty()
&& signed_endpoint_is_covered(
endpoint,
&profile.to_proto().endpoints,
)
})
})
},
|binding| {
records
.iter()
.find(|record| record.name == binding.provider)
.and_then(|record| signing_profile_for_record(catalog, record))
.filter(|profile| {
profile_declares_sigv4_credentials(profile)
&& profile.endpoints.is_empty()
})
},
);
if source.is_none() {
let selector = format_endpoint_selector(endpoint);
return Err(Status::failed_precondition(format!(
"credential_signing endpoint '{selector}' has no resolvable AWS credential source; attach an endpoint-bearing provider profile that declares AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY and covers this endpoint, or set credential_binding.provider to an attached endpointless profile that declares those credentials"
)));
}
}
}
Ok(())
}
fn signing_profile_for_record(
catalog: &EffectiveProviderProfileCatalog,
record: &super::provider::ProviderEnvironmentRecord,
) -> Option<openshell_providers::ProviderTypeProfile> {
super::provider::get_provider_type_profile_for_scope(
catalog,
&record.provider.r#type,
&record.provider.profile_workspace,
)
}
fn profile_declares_sigv4_credentials(profile: &openshell_providers::ProviderTypeProfile) -> bool {
let env_vars = profile.credential_env_vars();
SIGV4_REQUIRED_CREDENTIAL_KEYS
.iter()
.all(|required| env_vars.contains(required))
}
fn signed_endpoint_is_covered(
signed: &NetworkEndpoint,
profile_endpoints: &[NetworkEndpoint],
) -> bool {
endpoint_ports_for_validation(signed)
.into_iter()
.all(|port| {
profile_endpoints.iter().any(|profile| {
endpoint_ports_for_validation(profile).contains(&port)
&& host_pattern_covers(&profile.host, &signed.host)
&& path_pattern_covers(&profile.path, &signed.path)
})
})
}
fn endpoint_ports_for_validation(endpoint: &NetworkEndpoint) -> Vec<u32> {
if endpoint.ports.is_empty() {
vec![endpoint.port]
} else {
endpoint.ports.clone()
}
}
fn host_pattern_covers(binding_pattern: &str, policy_pattern: &str) -> bool {
if binding_pattern.eq_ignore_ascii_case(policy_pattern) {
return true;
}
if contains_glob_syntax(policy_pattern) {
return false;
}
host_matches(binding_pattern, policy_pattern).unwrap_or(false)
}
fn path_pattern_covers(binding_pattern: &str, policy_pattern: &str) -> bool {
if binding_pattern == policy_pattern || matches!(binding_pattern, "" | "**" | "/**") {
return true;
}
if contains_glob_syntax(policy_pattern) {
return false;
}
EndpointPathPattern::new(binding_pattern).matches(policy_pattern)
}
fn contains_glob_syntax(value: &str) -> bool {
value
.chars()
.any(|character| matches!(character, '*' | '?' | '['))
}
fn format_endpoint_selector(endpoint: &NetworkEndpoint) -> String {
let ports = endpoint_ports_for_validation(endpoint)
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
format!("{}:{ports}{}", endpoint.host, endpoint.path)
}
async fn validate_policy_credential_bindings_for_sandbox(
state: &ServerState,
catalog: &EffectiveProviderProfileCatalog,
workspace: &str,
provider_names: &[String],
policy: &ProtoSandboxPolicy,
) -> Result<HashMap<String, Vec<StaticCredentialEndpointBinding>>, Status> {
let bindings = policy_static_credential_endpoint_bindings(Some(policy))?;
let has_signing = policy.network_policies.values().any(|rule| {
rule.endpoints
.iter()
.any(|endpoint| !endpoint.credential_signing.is_empty())
});
if bindings.is_empty() && !has_signing {
return Ok(bindings);
}
let records = super::provider::load_provider_environment_records(
state.store.as_ref(),
workspace,
provider_names,
)
.await?;
validate_policy_credential_binding_context(catalog, &records, policy, &bindings)?;
Ok(bindings)
}
async fn provider_policy_layers_for_sandbox(
state: &ServerState,
workspace: &str,
sandbox: &Sandbox,
provider_names: &[String],
) -> Result<Vec<ProviderPolicyLayer>, Status> {
let global_settings = load_global_settings(state.store.as_ref()).await?;
if decode_policy_from_global_settings(&global_settings)?.is_some() {
return Ok(Vec::new());
}
let catalog = state
.provider_profile_sources
.snapshot_catalog(state.store.as_ref(), workspace)
.await?;
let layers = provider_policy_context_with_catalog(
state.store.as_ref(),
&catalog,
workspace,
provider_names,
)
.await?
.layers;
debug!(
sandbox_id = %sandbox.object_id(),
provider_layer_count = layers.len(),
"Composed candidate provider policy layers for ambiguity validation"
);
Ok(layers)
}
pub(super) async fn current_base_policy_for_sandbox(
store: &Store,
sandbox: &Sandbox,
) -> Result<ProtoSandboxPolicy, Status> {
if let Some(record) = store
.get_latest_policy(sandbox.object_id())
.await
.map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?
{
let (policy, _) = canonical_policy_record_identity(&record)?;
return Ok(policy);
}
sandbox
.spec
.as_ref()
.and_then(|spec| spec.policy.clone())
.map_or_else(
|| Ok(ProtoSandboxPolicy::default()),
|policy| validate_and_canonicalize_stored_policy(policy, STORED_POLICY_SOURCE_SPEC),
)
}
pub(super) async fn validate_candidate_provider_attachments(
state: &ServerState,
workspace: &str,
sandbox: &Sandbox,
provider_names: &[String],
) -> Result<(), Status> {
let base_policy = current_base_policy_for_sandbox(state.store.as_ref(), sandbox).await?;
let provider_layers =
provider_policy_layers_for_sandbox(state, workspace, sandbox, provider_names).await?;
validate_candidate_effective_policy(&base_policy, &provider_layers)?;
let effective_policy = if provider_layers.is_empty() {
base_policy
} else {
compose_effective_policy(&base_policy, &provider_layers)
};
let catalog = state
.provider_profile_sources
.snapshot_catalog(state.store.as_ref(), workspace)
.await?;
validate_policy_credential_bindings_for_sandbox(
state,
&catalog,
workspace,
provider_names,
&effective_policy,
)
.await?;
Ok(())
}
pub(super) async fn provider_policy_composition_enabled(store: &Store) -> Result<bool, Status> {
let global_settings = load_global_settings(store).await?;
provider_policy_composition_enabled_in(&global_settings)
}
fn provider_policy_composition_enabled_in(settings: &StoredSettings) -> Result<bool, Status> {
Ok(decode_policy_from_global_settings(settings)?.is_none())
}
async fn validate_provider_composition_for_existing_sandboxes(
state: &ServerState,
) -> Result<(), Status> {
let mut catalogs = HashMap::<String, EffectiveProviderProfileCatalog>::new();
let mut cursor = None;
loop {
let page = state
.store
.list_message_page::<Sandbox>(
ObjectListQuery::AllWorkspaces,
cursor.as_ref(),
PROVIDER_COMPOSITION_VALIDATION_PAGE_SIZE,
)
.await
.map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))?;
for sandbox in page.messages {
let provider_names = sandbox
.spec
.as_ref()
.map(|spec| spec.providers.as_slice())
.unwrap_or_default();
if provider_names.is_empty() {
continue;
}
let workspace = sandbox.object_workspace().to_string();
if !catalogs.contains_key(&workspace) {
let catalog = state
.provider_profile_sources
.snapshot_catalog(state.store.as_ref(), &workspace)
.await?;
catalogs.insert(workspace.clone(), catalog);
}
let catalog = catalogs
.get(&workspace)
.expect("catalog was inserted for sandbox workspace");
let base_policy =
current_base_policy_for_sandbox(state.store.as_ref(), &sandbox).await?;
let provider_layers = provider_policy_context_with_catalog(
state.store.as_ref(),
catalog,
&workspace,
provider_names,
)
.await?
.layers;
validate_candidate_effective_policy(&base_policy, &provider_layers).map_err(|error| {
Status::failed_precondition(format!(
"cannot activate provider policy composition: sandbox '{}/{}' has an invalid effective policy: {}",
workspace,
sandbox.object_name(),
error.message()
))
})?;
}
let Some(next_cursor) = page.next_cursor else {
return Ok(());
};
cursor = Some(next_cursor);
}
}
pub async fn validate_provider_composition_startup_preflight(
state: &ServerState,
) -> Result<(), Status> {
if provider_policy_composition_enabled(state.store.as_ref()).await? {
validate_provider_composition_for_existing_sandboxes(state).await?;
}
Ok(())
}
pub(super) async fn validate_candidate_sandbox_credential_policy(
state: &ServerState,
workspace: &str,
provider_names: &[String],
policy: Option<&ProtoSandboxPolicy>,
) -> Result<(), Status> {
let catalog = state
.provider_profile_sources
.snapshot_catalog(state.store.as_ref(), workspace)
.await?;
let effective = effective_policy_for_source(
state,
&catalog,
workspace,
provider_names,
policy.cloned().unwrap_or_default(),
)
.await?;
validate_uninspected_credentialed_endpoints(&effective)
}
fn truncate_for_log(input: &str, max_chars: usize) -> String {
let mut chars = input.chars();
let truncated: String = chars.by_ref().take(max_chars).collect();
if chars.next().is_some() {
format!("{truncated}...")
} else {
truncated
}
}
#[cfg(test)]
fn is_sandbox_caller<T>(request: &Request<T>) -> bool {
matches!(
request.extensions().get::<Principal>(),
Some(Principal::Sandbox(_))
)
}
/// Sandbox-class callers may only perform sandbox-scoped policy sync. They
/// must not mutate global config or sandbox settings.
fn validate_sandbox_caller_update(req: &UpdateConfigRequest) -> Result<(), Status> {
if req.global {
return Err(Status::permission_denied(
"sandbox callers cannot mutate global config",
));
}
if req.delete_setting {
return Err(Status::permission_denied(
"sandbox callers cannot delete settings",
));
}
if req.sandbox.is_empty() {
return Err(Status::permission_denied(
"sandbox callers may only perform sandbox policy sync",
));
}
if req.policy.is_none() || !req.setting_key.trim().is_empty() {
return Err(Status::permission_denied(
"sandbox callers may only perform sandbox policy sync",
));
}
Ok(())
}
fn sandbox_metadata_annotations(sandbox: &Sandbox) -> HashMap<String, String> {
sandbox
.metadata
.as_ref()
.map(|metadata| metadata.annotations.clone())
.unwrap_or_default()
}
fn update_config_response(
version: u32,
policy_hash: impl Into<String>,
settings_revision: u64,
deleted: bool,
annotations: HashMap<String, String>,
) -> Response<UpdateConfigResponse> {
Response::new(UpdateConfigResponse {
version,
policy_hash: policy_hash.into(),
settings_revision,
deleted,
annotations,
})
}
async fn persist_update_config_annotations(
state: &Arc<ServerState>,
sandbox_id: &str,
expected_resource_version: u64,
annotations: &HashMap<String, String>,
current_annotations: &HashMap<String, String>,
) -> Result<HashMap<String, String>, Status> {
if annotations.is_empty() {
return Ok(current_annotations.clone());
}
if annotations
.iter()
.all(|(key, value)| current_annotations.get(key) == Some(value))
{
return Ok(current_annotations.clone());
}
let annotations = annotations.clone();
let updated = state
.store
.update_message_cas::<Sandbox, _>(sandbox_id, expected_resource_version, |sandbox| {
if let Some(metadata) = sandbox.metadata.as_mut() {
metadata.annotations.extend(annotations.clone());
}
})
.await
.map_err(|e| super::persistence_error_to_status(e, "store update annotations"))?;
Ok(sandbox_metadata_annotations(&updated))
}
async fn persist_existing_policy_projection(
state: &Arc<ServerState>,
sandbox_id: &str,
expected_resource_version: u64,
annotations: &HashMap<String, String>,
current_annotations: &HashMap<String, String>,
backfill_policy: Option<&ProtoSandboxPolicy>,
) -> Result<HashMap<String, String>, Status> {
let annotations_match = annotations
.iter()
.all(|(key, value)| current_annotations.get(key) == Some(value));
if backfill_policy.is_none() && annotations_match {
return Ok(current_annotations.clone());
}
let annotations = annotations.clone();
let backfill_policy = backfill_policy.cloned();
let updated = state
.store
.update_message_cas::<Sandbox, _>(sandbox_id, expected_resource_version, |sandbox| {
let startup_blocked =
crate::policy_store::permits_initial_static_policy_repair(sandbox);
if let Some(policy) = backfill_policy.as_ref()
&& let Some(spec) = sandbox.spec.as_mut()
&& (spec.policy.is_none() || startup_blocked)
{
spec.policy = Some(policy.clone());
}
if let Some(metadata) = sandbox.metadata.as_mut() {
metadata.annotations.extend(annotations.clone());
}
})
.await
.map_err(|e| super::persistence_error_to_status(e, "store policy projection"))?;
Ok(sandbox_metadata_annotations(&updated))
}
async fn resolve_sandbox_by_name_for_principal(
store: &Store,
workspace: &str,
principal: &Principal,
name: &str,
) -> Result<Sandbox, Status> {
let sandbox = store
.get_message_by_name::<Sandbox>(workspace, name)
.await
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?;
match principal {
Principal::Sandbox(_) => {
let Some(sandbox) = sandbox else {
return Err(Status::permission_denied(
"sandbox not found or not owned by caller",
));
};
crate::auth::guard::ensure_sandbox_scope(principal, sandbox.object_id()).map_err(
|status| {
if status.code() == tonic::Code::PermissionDenied {
Status::permission_denied("sandbox not found or not owned by caller")
} else {
status
}
},
)?;
Ok(sandbox)
}
Principal::User(_) => sandbox.ok_or_else(|| Status::not_found("sandbox not found")),
Principal::Peer(_) => Err(Status::permission_denied(
"gateway peer principals may not resolve sandbox configuration",
)),
Principal::Anonymous => Err(Status::unauthenticated(
"sandbox-scoped methods require an authenticated caller",
)),
}
}
// ---------------------------------------------------------------------------
// Config handlers
// ---------------------------------------------------------------------------
pub(super) async fn handle_get_sandbox_config(
state: &Arc<ServerState>,
request: Request<GetSandboxConfigRequest>,
) -> Result<Response<GetSandboxConfigResponse>, Status> {
let principal = super::extract_principal(&request)?;
let sandbox_name = request.get_ref().name.clone();
let workspace = match (&principal, request.get_ref().workspace_scope.as_ref()) {
(Principal::Sandbox(_), None) => String::new(),
(_, selector) => {
crate::auth::workspace_authz::selected_workspace_name(selector)?.to_string()
}
};
let result = handle_get_sandbox_config_inner(state, request).await;
match result {
Err(error)
if matches!(principal, Principal::Sandbox(_))
&& matches!(
error.code(),
tonic::Code::FailedPrecondition | tonic::Code::InvalidArgument
) =>
{
// A malformed stored candidate must not prevent a supervisor
// from registering its startup fence and waiting for repair.
// Do not expose parser payloads or copy malformed policy history.
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
state,
&principal,
&sandbox_name,
&workspace,
MinWorkspaceRole::User,
)
.await?;
Ok(Response::new(GetSandboxConfigResponse {
configuration_admitted: false,
configuration_error: configuration_failure_diagnostic(&error).to_string(),
configuration_instance_id: sandbox
.status
.as_ref()
.and_then(|status| status.configuration_admission.as_ref())
.map_or_else(String::new, |admission| admission.instance_id.clone()),
workspace: sandbox.object_workspace().to_string(),
..Default::default()
}))
}
result => result,
}
}
async fn handle_get_sandbox_config_inner(
state: &Arc<ServerState>,
request: Request<GetSandboxConfigRequest>,
) -> Result<Response<GetSandboxConfigResponse>, Status> {
let principal = super::extract_principal(&request)?;
let req = request.into_inner();
let workspace = match &principal {
Principal::Sandbox(_) if req.workspace_scope.is_none() => "",
_ => crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
};
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
state,
&principal,
&req.name,
workspace,
MinWorkspaceRole::User,
)
.await?;
Ok(Response::new(load_sandbox_config(state, &sandbox).await?))
}
/// Resolve the same effective configuration for authenticated RPCs and trusted
/// readiness snapshots. Callers must authorize external access before entering.
pub(super) async fn load_sandbox_config(
state: &Arc<ServerState>,
sandbox: &Sandbox,
) -> Result<GetSandboxConfigResponse, Status> {
let sandbox_id = sandbox.object_id().to_string();
let workspace = sandbox.object_workspace().to_string();
let sandbox_provider_names = sandbox
.spec
.as_ref()
.map(|spec| spec.providers.clone())
.unwrap_or_default();
let provider_profile_catalog = state
.provider_profile_sources
.snapshot_catalog(state.store.as_ref(), &workspace)
.await?;
let global_settings = load_global_settings(state.store.as_ref()).await?;
let global_policy = decode_policy_from_global_settings(&global_settings)?;
let mut global_policy_version: u32 = 0;
// Try to get the latest policy from the policy history table. Under a
// global override, only the sandbox version metadata is observed; the
// dormant payload is neither decoded nor validated.
let latest = state
.store
.get_latest_policy(&sandbox_id)
.await
.map_err(|e| Status::internal(format!("fetch policy history failed: {e}")))?;
let (mut policy, version, mut policy_hash, policy_source) = if let Some(global_policy) =
global_policy
{
let version = latest
.as_ref()
.map(|record| u32::try_from(record.version).unwrap_or(0))
.filter(|version| *version > 0)
.unwrap_or(1);
let hash = deterministic_policy_hash(&global_policy);
(Some(global_policy), version, hash, PolicySource::Global)
} else if let Some(record) = latest {
let (policy, hash) = canonical_policy_record_identity(&record)?;
debug!(
sandbox_id = %sandbox_id,
version = record.version,
"GetSandboxConfig served from policy history"
);
(
Some(policy),
u32::try_from(record.version).unwrap_or(0),
hash,
PolicySource::Sandbox,
)
} else {
// Lazy backfill: no policy history exists yet.
let spec = sandbox
.spec
.as_ref()
.ok_or_else(|| Status::internal("sandbox has no spec"))?;
match spec.policy.clone() {
None => {
debug!(
sandbox_id = %sandbox_id,
"GetSandboxConfig: no policy configured, returning empty response"
);
(None, 0, String::new(), PolicySource::Sandbox)
}
Some(spec_policy) => {
// Stored specs may predate the current schema. Validate before
// creating policy history so malformed state is never copied or
// marked loaded, and hash the canonical representation.
let spec_policy = validate_and_canonicalize_stored_policy(
spec_policy,
STORED_POLICY_SOURCE_SPEC,
)?;
let hash = deterministic_policy_hash(&spec_policy);
let payload = spec_policy.encode_to_vec();
let policy_id = uuid::Uuid::new_v4().to_string();
if let Err(e) = state
.store
.put_policy_revision(&policy_id, &sandbox_id, &workspace, 1, &payload, &hash)
.await
{
warn!(
sandbox_id = %sandbox_id,
error = %e,
"Failed to backfill policy version 1"
);
} else if let Err(e) = state
.store
.update_policy_status(&sandbox_id, 1, "loaded", None, None)
.await
{
warn!(
sandbox_id = %sandbox_id,
error = %e,
"Failed to mark backfilled policy as loaded"
);
}
info!(
sandbox_id = %sandbox_id,
"GetSandboxConfig served from spec (backfilled version 1)"
);
(Some(spec_policy), 1, hash, PolicySource::Sandbox)
}
}
};
let global_settings = load_global_settings(state.store.as_ref()).await?;
let sandbox_settings =
load_sandbox_settings(state.store.as_ref(), &workspace, sandbox.object_name()).await?;
let provider_records = super::provider::load_provider_environment_records(
state.store.as_ref(),
&workspace,
&sandbox_provider_names,
)
.await?;
let mut provider_policy_context =
provider_policy_context_from_records(&provider_profile_catalog, &provider_records);
if matches!(policy_source, PolicySource::Global)
&& let Ok(Some(global_rev)) = state
.store
.get_latest_policy(GLOBAL_POLICY_SANDBOX_ID)
.await
{
global_policy_version = u32::try_from(global_rev.version).unwrap_or(0);
}
if let Some(source_policy) = policy.as_mut() {
// Never trust provenance supplied by a persisted/user-authored policy.
// The gateway derives it from the attached provider catalog below.
clear_provider_credentialed_markers(source_policy);
}
if !matches!(policy_source, PolicySource::Global)
&& let Some(source_policy) = policy.as_ref()
&& !provider_policy_context.layers.is_empty()
{
let effective_policy =
compose_effective_policy(source_policy, &provider_policy_context.layers);
let effective_policy =
validate_and_canonicalize_policy(effective_policy).map_err(|error| {
Status::failed_precondition(format!(
"provider composition produced an invalid effective policy: {}",
error.message()
))
})?;
validate_policy_safety(&effective_policy).map_err(|error| {
Status::failed_precondition(format!(
"provider composition produced an invalid effective policy: {}",
error.message()
))
})?;
policy_hash = deterministic_policy_hash(&effective_policy);
policy = Some(effective_policy);
}
let policy_credential_bindings = policy_static_credential_endpoint_bindings(policy.as_ref())?;
extend_credentialed_scopes_from_policy_bindings(
&mut provider_policy_context.credentialed_scopes,
&policy_credential_bindings,
&provider_policy_context.endpointless_provider_names,
);
let mut configuration_error = String::new();
if let Some(effective_policy) = policy.as_mut() {
stamp_provider_credentialed_endpoints(
effective_policy,
&provider_policy_context.credentialed_scopes,
);
if let Err(error) = validate_uninspected_credentialed_endpoints(effective_policy) {
configuration_error = bounded_configuration_diagnostic(error.message());
}
policy_hash = deterministic_policy_hash(effective_policy);
}
if let Some(policy) = policy.as_ref() {
state
.middleware_registry
.ensure_policy_middlewares_registered(policy)
.map_err(|error| {
Status::failed_precondition(format!(
"effective policy middleware registration is invalid: {error}"
))
})?;
}
let settings = merge_effective_settings(&global_settings, &sandbox_settings)?;
let supervisor_middleware_services =
state.middleware_registry.required_services(policy.as_ref());
let config_revision = compute_config_revision_with_validation_mode(
policy.as_ref(),
&settings,
policy_source,
&supervisor_middleware_services,
state.config.policy_validation_failure_mode,
state.extension_jwt_issuer.is_some(),
);
if let Some(policy) = policy.as_ref() {
validate_policy_credential_binding_context(
&provider_profile_catalog,
&provider_records,
policy,
&policy_credential_bindings,
)?;
}
let provider_env_revision = compute_provider_env_revision_from_records_and_policy_bindings(
&provider_profile_catalog,
&provider_records,
&policy_credential_bindings,
)?;
Ok(GetSandboxConfigResponse {
configuration_instance_id: sandbox
.status
.as_ref()
.and_then(|status| status.configuration_admission.as_ref())
.map_or_else(String::new, |admission| admission.instance_id.clone()),
configuration_admitted: policy.is_some() && configuration_error.is_empty(),
configuration_error,
policy,
version,
policy_hash,
settings,
config_revision,
policy_source: policy_source.into(),
global_policy_version,
provider_env_revision,
supervisor_middleware_services,
workspace,
policy_validation_failure_mode: state
.config
.policy_validation_failure_mode
.as_str()
.to_string(),
extension_authentication_enabled: state.extension_jwt_issuer.is_some(),
provider_attachment_epoch: sandbox
.spec
.as_ref()
.map(|spec| spec.provider_attachment_epoch.clone())
.unwrap_or_default(),
})
}
#[cfg(test)]
async fn compute_provider_env_revision(
store: &Store,
workspace: &str,
provider_names: &[String],
) -> Result<u64, Status> {
let catalog = ProviderProfileSources::with_default_sources()
.snapshot_catalog(store, workspace)
.await?;
compute_provider_env_revision_with_catalog(store, &catalog, workspace, provider_names).await
}
#[cfg(test)]
pub(super) async fn compute_provider_env_revision_with_catalog(
store: &Store,
catalog: &EffectiveProviderProfileCatalog,
workspace: &str,
provider_names: &[String],
) -> Result<u64, Status> {
compute_provider_env_revision_with_catalog_and_policy_bindings(
store,
catalog,
workspace,
provider_names,
&HashMap::new(),
)
.await
}
async fn compute_provider_env_revision_with_catalog_and_policy_bindings(
store: &Store,
catalog: &EffectiveProviderProfileCatalog,
workspace: &str,
provider_names: &[String],
policy_bindings: &HashMap<String, Vec<StaticCredentialEndpointBinding>>,
) -> Result<u64, Status> {
let mut hasher = Sha256::new();
hasher.update(b"openshell-provider-env-revision-v4");
for provider_name in provider_names {
hasher.update(provider_name.as_bytes());
match store
.get_by_name(Provider::object_type(), workspace, provider_name)
.await
.map_err(|e| {
Status::internal(format!("fetch provider '{provider_name}' failed: {e}"))
})? {
Some(record) => {
hasher.update(record.id.as_bytes());
hasher.update(record.resource_version.to_le_bytes());
let provider = Provider::decode(record.payload.as_slice()).map_err(|e| {
Status::internal(format!("decode provider '{provider_name}' failed: {e}"))
})?;
let refresh_states =
crate::provider_refresh::list_refresh_states_for_provider(store, &record.id)
.await?;
hash_provider_refresh_states(&refresh_states, &mut hasher)?;
hasher.update(provider.r#type.as_bytes());
hash_provider_profile_revision(
catalog,
&provider.r#type,
&provider.profile_workspace,
&mut hasher,
);
let mut credential_keys: Vec<_> = provider.credentials.keys().collect();
credential_keys.sort();
for key in credential_keys {
hasher.update(key.as_bytes());
}
let mut expiry_keys: Vec<_> = provider.credential_expiration_times.keys().collect();
expiry_keys.sort();
for key in expiry_keys {
hasher.update(key.as_bytes());
let value = &provider.credential_expiration_times[key];
hasher.update(value.seconds.to_le_bytes());
hasher.update(value.nanos.to_le_bytes());
}
}
None => {
hasher.update(b"missing");
}
}
}
hash_policy_credential_bindings(policy_bindings, &mut hasher);
let digest = hasher.finalize();
Ok(u64::from_le_bytes(digest[..8].try_into().map_err(
|_| Status::internal("provider env revision digest too short"),
)?))
}
#[cfg(test)]
fn compute_provider_env_revision_from_records(
catalog: &EffectiveProviderProfileCatalog,
records: &[super::provider::ProviderEnvironmentRecord],
) -> Result<u64, Status> {
compute_provider_env_revision_from_records_and_policy_bindings(
catalog,
records,
&HashMap::new(),
)
}
fn compute_provider_env_revision_from_records_and_policy_bindings(
catalog: &EffectiveProviderProfileCatalog,
records: &[super::provider::ProviderEnvironmentRecord],
policy_bindings: &HashMap<String, Vec<StaticCredentialEndpointBinding>>,
) -> Result<u64, Status> {
let mut hasher = Sha256::new();
hasher.update(b"openshell-provider-env-revision-v4");
for record in records {
hasher.update(record.name.as_bytes());
hasher.update(record.object_id.as_bytes());
hasher.update(record.resource_version.to_le_bytes());
let provider = &record.provider;
hash_provider_refresh_states(&record.refresh_states, &mut hasher)?;
hasher.update(provider.r#type.as_bytes());
hash_provider_profile_revision(
catalog,
&provider.r#type,
&provider.profile_workspace,
&mut hasher,
);
let mut credential_keys: Vec<_> = provider.credentials.keys().collect();
credential_keys.sort();
for key in credential_keys {
hasher.update(key.as_bytes());
}
let mut expiry_keys: Vec<_> = provider.credential_expiration_times.keys().collect();
expiry_keys.sort();
for key in expiry_keys {
hasher.update(key.as_bytes());
let value = &provider.credential_expiration_times[key];
hasher.update(value.seconds.to_le_bytes());
hasher.update(value.nanos.to_le_bytes());
}
}
hash_policy_credential_bindings(policy_bindings, &mut hasher);
let digest = hasher.finalize();
Ok(u64::from_le_bytes(digest[..8].try_into().map_err(
|_| Status::internal("provider env revision digest too short"),
)?))
}
fn hash_provider_refresh_states(
states: &[StoredProviderCredentialRefreshState],
hasher: &mut Sha256,
) -> Result<(), Status> {
let mut states = states.iter().collect::<Vec<_>>();
states.sort_by(|left, right| {
left.credential_key
.cmp(&right.credential_key)
.then_with(|| {
left.metadata
.as_ref()
.map(|metadata| metadata.id.as_str())
.cmp(&right.metadata.as_ref().map(|metadata| metadata.id.as_str()))
})
});
for state in states {
hasher.update(b"provider-refresh-state");
hasher.update(state.credential_key.as_bytes());
hasher.update(state.strategy.to_le_bytes());
hasher.update(crate::provider_refresh::effective_authorization_epoch(state)?.as_bytes());
if let Some(metadata) = &state.metadata {
hasher.update(metadata.id.as_bytes());
hasher.update(metadata.resource_version.to_le_bytes());
}
let mut outputs = state.additional_output_keys.iter().collect::<Vec<_>>();
outputs.sort();
for (output, key) in outputs {
hasher.update(output.as_bytes());
hasher.update(key.as_bytes());
}
}
Ok(())
}
fn hash_policy_credential_bindings(
bindings: &HashMap<String, Vec<StaticCredentialEndpointBinding>>,
hasher: &mut Sha256,
) {
let mut provider_names: Vec<_> = bindings.keys().collect();
provider_names.sort();
for provider_name in provider_names {
hasher.update(provider_name.as_bytes());
let mut endpoints = bindings[provider_name].clone();
endpoints.sort_by(|left, right| {
(&left.host, left.port, &left.path).cmp(&(&right.host, right.port, &right.path))
});
for endpoint in endpoints {
hasher.update(endpoint.host.as_bytes());
hasher.update(endpoint.port.to_le_bytes());
hasher.update(endpoint.path.as_bytes());
}
}
}
fn hash_provider_profile_revision(
catalog: &EffectiveProviderProfileCatalog,
provider_type: &str,
profile_workspace: &str,
hasher: &mut Sha256,
) {
catalog.hash_type_profile_revision_for_scope(provider_type, profile_workspace, hasher);
}
#[cfg(test)]
async fn profile_provider_policy_layers(
store: &Store,
workspace: &str,
provider_names: &[String],
) -> Result<Vec<ProviderPolicyLayer>, Status> {
let catalog = ProviderProfileSources::with_default_sources()
.snapshot_catalog(store, workspace)
.await?;
profile_provider_policy_layers_with_catalog(store, &catalog, workspace, provider_names).await
}
#[cfg(test)]
async fn profile_provider_policy_layers_with_catalog(
store: &Store,
catalog: &EffectiveProviderProfileCatalog,
workspace: &str,
provider_names: &[String],
) -> Result<Vec<ProviderPolicyLayer>, Status> {
Ok(
provider_policy_context_with_catalog(store, catalog, workspace, provider_names)
.await?
.layers,
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct CredentialedEndpointScope {
host: String,
ports: Vec<u32>,
path: String,
}
#[derive(Debug, Default)]
struct ProviderPolicyContext {
layers: Vec<ProviderPolicyLayer>,
credentialed_scopes: Vec<CredentialedEndpointScope>,
endpointless_provider_names: HashSet<String>,
}
async fn provider_policy_context_with_catalog(
store: &Store,
catalog: &EffectiveProviderProfileCatalog,
workspace: &str,
provider_names: &[String],
) -> Result<ProviderPolicyContext, Status> {
let records =
super::provider::load_provider_environment_records(store, workspace, provider_names)
.await?;
Ok(provider_policy_context_from_records(catalog, &records))
}
fn provider_policy_context_from_records(
catalog: &EffectiveProviderProfileCatalog,
records: &[super::provider::ProviderEnvironmentRecord],
) -> ProviderPolicyContext {
let mut layers = Vec::new();
let mut credentialed_scopes = Vec::new();
let mut endpointless_provider_names = HashSet::new();
for record in records {
let name = &record.name;
let provider = &record.provider;
let provider_type = provider.r#type.trim();
let Some(profile) = super::provider::get_provider_type_profile_for_scope(
catalog,
provider_type,
&provider.profile_workspace,
) else {
warn!(
provider_name = %name,
provider_type,
"provider type has no profile; skipping provider policy layer"
);
continue;
};
if !super::provider::provider_profile_endpoints_are_active(&profile, provider) {
endpointless_provider_names.insert(name.clone());
continue;
}
let rule_name = openshell_policy::provider_rule_name(provider.object_name());
let mut rule = profile.network_policy_rule(&rule_name);
if rule.endpoints.is_empty() {
endpointless_provider_names.insert(name.clone());
}
if profile.has_credentialed_endpoints() {
for endpoint in &mut rule.endpoints {
endpoint.provider_credentialed = true;
let scope = CredentialedEndpointScope {
host: endpoint.host.to_ascii_lowercase(),
ports: endpoint_ports(endpoint),
path: canonical_endpoint_path(&endpoint.path),
};
if !credentialed_scopes.contains(&scope) {
credentialed_scopes.push(scope);
}
}
}
layers.push(ProviderPolicyLayer {
rule_name: rule_name.clone(),
rule,
});
}
ProviderPolicyContext {
layers,
credentialed_scopes,
endpointless_provider_names,
}
}
fn endpoint_ports(endpoint: &NetworkEndpoint) -> Vec<u32> {
if endpoint.ports.is_empty() {
(endpoint.port > 0)
.then_some(endpoint.port)
.into_iter()
.collect()
} else {
endpoint.ports.clone()
}
}
fn extend_credentialed_scopes_from_policy_bindings(
scopes: &mut Vec<CredentialedEndpointScope>,
bindings: &HashMap<String, Vec<StaticCredentialEndpointBinding>>,
endpointless_provider_names: &HashSet<String>,
) {
for (provider_name, provider_bindings) in bindings {
if !endpointless_provider_names.contains(provider_name) {
continue;
}
for binding in provider_bindings {
let scope = CredentialedEndpointScope {
host: binding.host.to_ascii_lowercase(),
ports: vec![binding.port],
path: canonical_endpoint_path(&binding.path),
};
if !scopes.contains(&scope) {
scopes.push(scope);
}
}
}
}
fn endpoint_matches_credentialed_scope(
endpoint: &NetworkEndpoint,
scope: &CredentialedEndpointScope,
) -> bool {
if !host_patterns_overlap(&endpoint.host, &scope.host).unwrap_or(false) {
return false;
}
let endpoint_ports = endpoint_ports(endpoint);
let port_matches = endpoint_ports.is_empty()
|| scope.ports.is_empty()
|| endpoint_ports.iter().any(|port| scope.ports.contains(port));
port_matches && path_pattern_covers(&scope.path, &canonical_endpoint_path(&endpoint.path))
}
fn canonical_endpoint_path(path: &str) -> String {
match path.trim() {
"" | "**" | "/**" => "/**".to_string(),
path => path.to_string(),
}
}
pub(super) fn clear_provider_credentialed_markers(policy: &mut ProtoSandboxPolicy) {
for rule in policy.network_policies.values_mut() {
for endpoint in &mut rule.endpoints {
endpoint.provider_credentialed = false;
}
}
}
fn stamp_provider_credentialed_endpoints(
policy: &mut ProtoSandboxPolicy,
scopes: &[CredentialedEndpointScope],
) {
for rule in policy.network_policies.values_mut() {
for endpoint in &mut rule.endpoints {
endpoint.provider_credentialed = scopes
.iter()
.any(|scope| endpoint_matches_credentialed_scope(endpoint, scope));
}
}
}
/// A credentialed endpoint whose configured mode disables the L7 inspection a
/// reviewer would expect, without an explicit `allow_uninspected_credentials`.
struct UninspectedCredentialedEndpoint {
rule_name: String,
host: String,
port: u32,
mode: &'static str,
}
/// Scan the effective policy for credentialed endpoints on uninspected modes.
///
/// Explicit opt-ins are logged and skipped. Never logs credential names,
/// placeholders, or secret values.
fn find_uninspected_credentialed_endpoint(
policy: &ProtoSandboxPolicy,
) -> Option<UninspectedCredentialedEndpoint> {
for (rule_name, rule) in &policy.network_policies {
for endpoint in &rule.endpoints {
if !endpoint.provider_credentialed {
continue;
}
let mode = if endpoint.protocol.trim().is_empty() {
"L4-only"
} else if openshell_policy::network_tls_mode_to_str(endpoint.tls) == Some("skip") {
"tls: skip"
} else {
continue;
};
if endpoint.allow_uninspected_credentials {
warn!(
rule_name,
host = %endpoint.host,
ports = ?endpoint_ports(endpoint),
mode,
"credentialed endpoint explicitly allows uninspected traffic"
);
continue;
}
return Some(UninspectedCredentialedEndpoint {
rule_name: rule_name.clone(),
host: endpoint.host.clone(),
port: endpoint_ports(endpoint)
.first()
.copied()
.unwrap_or(endpoint.port),
mode,
});
}
}
None
}
/// Admission gate for policy-authoring paths (create, attach, operator config
/// update). Rejects credentialed endpoints that would lose L7 inspection.
fn validate_uninspected_credentialed_endpoints(policy: &ProtoSandboxPolicy) -> Result<(), Status> {
let Some(violation) = find_uninspected_credentialed_endpoint(policy) else {
return Ok(());
};
warn!(
rule_name = %violation.rule_name,
host = %violation.host,
port = violation.port,
mode = violation.mode,
"rejecting uninspected credentialed endpoint"
);
Err(Status::failed_precondition(format!(
"credentialed endpoint '{}:{}' in rule '{}' uses {}; configure L7 inspection or explicitly set allow_uninspected_credentials: true",
violation.host, violation.port, violation.rule_name, violation.mode
)))
}
pub(super) async fn handle_get_gateway_config(
state: &Arc<ServerState>,
_request: Request<GetGatewayConfigRequest>,
) -> Result<Response<GetGatewayConfigResponse>, Status> {
let global_settings = load_global_settings(state.store.as_ref()).await?;
let settings = materialize_global_settings(&global_settings)?;
Ok(Response::new(GetGatewayConfigResponse {
settings,
settings_revision: global_settings.revision,
}))
}
pub(super) async fn handle_get_sandbox_provider_environment(
state: &Arc<ServerState>,
request: Request<GetSandboxProviderEnvironmentRequest>,
) -> Result<Response<GetSandboxProviderEnvironmentResponse>, Status> {
let sandbox_id = request.get_ref().sandbox_id.clone();
let supports_static_credential_bindings = request.get_ref().supports_static_credential_bindings;
crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?;
drop(request);
let sandbox = state
.store
.get_message::<Sandbox>(&sandbox_id)
.await
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
.ok_or_else(|| Status::not_found("sandbox not found"))?;
let environment =
load_sandbox_provider_environment(state, &sandbox, supports_static_credential_bindings)
.await?;
Ok(Response::new(environment))
}
/// Materialize a privileged provider snapshot after the caller has authorized
/// access. Omission reasons remain separate from a legitimately empty snapshot.
pub(super) async fn load_sandbox_provider_environment(
state: &Arc<ServerState>,
sandbox: &Sandbox,
supports_static_credential_bindings: bool,
) -> Result<GetSandboxProviderEnvironmentResponse, Status> {
let sandbox_id = sandbox.object_id().to_string();
let workspace = sandbox.object_workspace().to_string();
let spec = sandbox
.spec
.as_ref()
.ok_or_else(|| Status::internal("sandbox has no spec"))?;
let provider_names = spec.providers.clone();
let provider_profile_catalog = state
.provider_profile_sources
.snapshot_catalog(state.store.as_ref(), &workspace)
.await?;
let provider_records = super::provider::load_provider_environment_records(
state.store.as_ref(),
&workspace,
&provider_names,
)
.await?;
let effective_policy = current_effective_policy_from_records(
state.as_ref(),
&provider_profile_catalog,
sandbox,
&sandbox_id,
&provider_records,
)
.await?;
let policy_credential_bindings =
policy_static_credential_endpoint_bindings(Some(&effective_policy))?;
validate_policy_credential_binding_context(
&provider_profile_catalog,
&provider_records,
&effective_policy,
&policy_credential_bindings,
)?;
let provider_env_revision = compute_provider_env_revision_from_records_and_policy_bindings(
&provider_profile_catalog,
&provider_records,
&policy_credential_bindings,
)?;
let mut provider_environment =
super::provider::resolve_provider_environment_from_records_with_policy_bindings_and_credentials(
state.store.as_ref(),
&provider_profile_catalog,
&provider_records,
&policy_credential_bindings,
&state.credentials,
Some(&sandbox_id),
)
.await?;
let mut readiness_reason = provider_environment.readiness_reason;
if supports_static_credential_bindings {
let unbound_static_keys = provider_environment
.static_credential_keys
.iter()
.filter(|key| {
!provider_environment
.static_credential_bindings
.contains_key(*key)
})
.cloned()
.collect::<Vec<_>>();
if !unbound_static_keys.is_empty() {
readiness_reason = ProviderReadinessReason::CredentialsWithheld;
}
for key in unbound_static_keys {
warn!(
sandbox_id = %sandbox_id,
key = %key,
"withholding unbound static provider credential from binding-capable supervisor"
);
provider_environment.environment.remove(&key);
provider_environment
.credential_expiration_times
.remove(&key);
provider_environment.static_credential_keys.remove(&key);
}
} else {
if !provider_environment.static_credential_keys.is_empty() {
readiness_reason = ProviderReadinessReason::UnsupportedSupervisor;
}
for key in &provider_environment.static_credential_keys {
provider_environment.environment.remove(key);
provider_environment.credential_expiration_times.remove(key);
}
provider_environment.static_credential_bindings.clear();
}
info!(
sandbox_id = %sandbox_id,
provider_count = provider_names.len(),
env_count = provider_environment.environment.len(),
provider_env_revision,
"GetSandboxProviderEnvironment request completed successfully"
);
let non_secret_environment_keys = provider_environment
.environment
.keys()
.filter(|key| !provider_environment.static_credential_keys.contains(*key))
.cloned()
.collect();
let credential_expiration_times = provider_environment
.credential_expiration_times
.into_iter()
.filter_map(|(key, value)| {
openshell_core::time::optional_timestamp_from_legacy_millis(value)
.ok()
.flatten()
.map(|timestamp| (key, timestamp))
})
.collect();
Ok(GetSandboxProviderEnvironmentResponse {
environment: provider_environment.environment,
files: provider_environment.files,
provider_env_revision,
credential_expiration_times,
dynamic_credentials: provider_environment.dynamic_credentials,
static_credential_bindings: provider_environment.static_credential_bindings,
non_secret_environment_keys,
provider_attachment_epoch: spec.provider_attachment_epoch.clone(),
policy_hash: deterministic_policy_hash(&effective_policy),
readiness_reason: readiness_reason.into(),
})
}
// ---------------------------------------------------------------------------
// Update config handler (policy + settings mutations)
// ---------------------------------------------------------------------------
pub(super) async fn handle_update_config(
state: &Arc<ServerState>,
request: Request<UpdateConfigRequest>,
) -> Result<Response<UpdateConfigResponse>, Status> {
let principal = super::extract_principal(&request)?;
let sandbox_caller = matches!(&principal, Principal::Sandbox(_));
let update = request.get_ref();
let should_emit_policy_failure = should_emit_config_update_policy_telemetry(sandbox_caller)
&& (update.policy.is_some() || !update.merge_operations.is_empty());
let result = Box::pin(handle_update_config_inner(
state,
request,
&principal,
sandbox_caller,
))
.await;
if result.is_err() && should_emit_policy_failure {
emit_sandbox_policy_update_failure();
}
result
}
async fn handle_update_config_inner(
state: &Arc<ServerState>,
request: Request<UpdateConfigRequest>,
principal: &Principal,
sandbox_caller: bool,
) -> Result<Response<UpdateConfigResponse>, Status> {
let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request);
let req = request.into_inner();
validate_annotations(&req.annotations, "annotations")?;
let sandbox = if req.global {
if !req.sandbox.is_empty() || req.workspace_scope.is_some() {
return Err(Status::invalid_argument(
"sandbox and workspace must be omitted when global is true",
));
}
require_platform_admin(&state.admin_role, principal)?;
replay_facts.global()?;
None
} else {
let min_role = if sandbox_caller {
MinWorkspaceRole::User
} else {
MinWorkspaceRole::Admin
};
Some(
super::sandbox::resolve_and_authorize_sandbox_name(
state,
principal,
&req.sandbox,
crate::auth::workspace_authz::selected_workspace_name(
req.workspace_scope.as_ref(),
)?,
min_role,
)
.await?,
)
};
let workspace = sandbox.as_ref().map_or_else(String::new, |sandbox| {
sandbox.object_workspace().to_string()
});
if sandbox_caller {
validate_sandbox_caller_update(&req)?;
}
let key = req.setting_key.trim();
let has_policy = req.policy.is_some();
let has_setting = !key.is_empty();
let has_merge_ops = !req.merge_operations.is_empty();
let mut mutation_count = 0_u8;
mutation_count += u8::from(has_policy);
mutation_count += u8::from(has_setting);
mutation_count += u8::from(has_merge_ops);
if mutation_count > 1 {
return Err(Status::invalid_argument(
"policy, setting_key, and merge_operations are mutually exclusive",
));
}
if mutation_count == 0 {
return Err(Status::invalid_argument(
"one of policy, setting_key, or merge_operations must be provided",
));
}
if req.global {
if !req.annotations.is_empty() {
return Err(Status::invalid_argument(
"annotations are only supported for sandbox-scoped updates",
));
}
let _settings_guard = state.settings_mutex.lock().await;
let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await.map_err(|error| {
super::persistence_error_to_status(error, "acquire policy mutation lock")
})?;
if has_merge_ops {
return Err(Status::invalid_argument(
"merge_operations are not supported for global policy updates",
));
}
if has_policy {
if req.delete_setting {
return Err(Status::invalid_argument(
"delete_setting cannot be combined with policy payload",
));
}
let mut new_policy = req.policy.ok_or_else(|| {
Status::invalid_argument("policy is required for global policy update")
})?;
clear_provider_credentialed_markers(&mut new_policy);
validate_no_reserved_provider_policy_keys(&new_policy)?;
new_policy = validate_and_canonicalize_policy(new_policy)?;
validate_policy_safety(&new_policy)?;
crate::middleware::validate_policy(state.middleware_registry.as_ref(), &new_policy)
.await?;
validate_candidate_effective_policy(&new_policy, &[])?;
if !policy_static_credential_endpoint_bindings(Some(&new_policy))?.is_empty() {
return Err(Status::failed_precondition(
"credential_binding is sandbox-scoped and cannot be used in a global policy",
));
}
let payload = new_policy.encode_to_vec();
let hash = deterministic_policy_hash(&new_policy);
// Global policy determines the report's effective configuration.
// Serialize its writes after validation so a report cannot commit
// evidence derived from the policy this update has replaced.
let latest = state
.store
.get_latest_policy(GLOBAL_POLICY_SANDBOX_ID)
.await
.map_err(|e| Status::internal(format!("fetch latest global policy failed: {e}")))?;
if let Some(ref current) = latest
&& canonical_policy_record_matches_for_deduplication(current, &hash)
&& current.status == "loaded"
{
let mut global_settings = load_global_settings(state.store.as_ref()).await?;
let stored_value = StoredSettingValue::Bytes(hex::encode(&payload));
let changed = upsert_setting_value(
&mut global_settings.settings,
POLICY_SETTING_KEY,
stored_value,
);
if changed {
global_settings.revision = global_settings.revision.wrapping_add(1);
save_global_settings(state.store.as_ref(), &global_settings).await?;
}
return Ok(update_config_response(
u32::try_from(current.version).unwrap_or(0),
hash,
global_settings.revision,
false,
HashMap::new(),
));
}
let next_version = latest.map_or(1, |r| r.version + 1);
let policy_id = uuid::Uuid::new_v4().to_string();
state
.store
.put_policy_revision(
&policy_id,
GLOBAL_POLICY_SANDBOX_ID,
"",
next_version,
&payload,
&hash,
)
.await
.map_err(|e| {
Status::internal(format!("persist global policy revision failed: {e}"))
})?;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64);
let _ = state
.store
.update_policy_status(
GLOBAL_POLICY_SANDBOX_ID,
next_version,
"loaded",
None,
Some(now_ms),
)
.await;
let _ = state
.store
.supersede_older_policies(GLOBAL_POLICY_SANDBOX_ID, next_version)
.await;
let mut global_settings = load_global_settings(state.store.as_ref()).await?;
let stored_value = StoredSettingValue::Bytes(hex::encode(&payload));
let changed = upsert_setting_value(
&mut global_settings.settings,
POLICY_SETTING_KEY,
stored_value,
);
if changed {
global_settings.revision = global_settings.revision.wrapping_add(1);
save_global_settings(state.store.as_ref(), &global_settings).await?;
}
return Ok(update_config_response(
u32::try_from(next_version).unwrap_or(0),
hash,
global_settings.revision,
false,
HashMap::new(),
));
}
// Global setting mutation.
if key == POLICY_SETTING_KEY && !req.delete_setting {
return Err(Status::invalid_argument(
"reserved key 'policy' must be set via the policy field",
));
}
if key != POLICY_SETTING_KEY {
validate_registered_setting_key(key)?;
}
// Deleting global policy changes the report's effective configuration.
// Keep settings -> sandbox lock order for all global policy mutations.
let mut global_settings = load_global_settings(state.store.as_ref()).await?;
let provider_composition_was_enabled =
provider_policy_composition_enabled_in(&global_settings)?;
let changed = if req.delete_setting {
global_settings.settings.remove(key).is_some()
} else {
let setting = req
.setting_value
.as_ref()
.ok_or_else(|| Status::invalid_argument("setting_value is required"))?;
let stored = proto_setting_to_stored(key, setting)?;
upsert_setting_value(&mut global_settings.settings, key, stored)
};
if changed {
let provider_composition_is_enabled =
provider_policy_composition_enabled_in(&global_settings)?;
if !provider_composition_was_enabled && provider_composition_is_enabled {
validate_provider_composition_for_existing_sandboxes(state).await?;
}
global_settings.revision = global_settings.revision.wrapping_add(1);
save_global_settings(state.store.as_ref(), &global_settings).await?;
if req.delete_setting
&& key == POLICY_SETTING_KEY
&& let Ok(Some(latest)) = state
.store
.get_latest_policy(GLOBAL_POLICY_SANDBOX_ID)
.await
{
let _ = state
.store
.supersede_older_policies(GLOBAL_POLICY_SANDBOX_ID, latest.version + 1)
.await;
}
}
return Ok(update_config_response(
0,
String::new(),
global_settings.revision,
req.delete_setting && changed,
HashMap::new(),
));
}
let sandbox = sandbox.expect("non-global config update resolves a sandbox");
let sandbox_id = sandbox.object_id().to_string();
replay_facts.resource(&sandbox)?;
let mut response_annotations = sandbox_metadata_annotations(&sandbox);
if has_setting {
let _settings_guard = state.settings_mutex.lock().await;
let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await.map_err(|error| {
super::persistence_error_to_status(error, "acquire policy mutation lock")
})?;
if key == POLICY_SETTING_KEY {
return Err(Status::invalid_argument(
"reserved key 'policy' must be set via policy commands",
));
}
let global_settings = load_global_settings(state.store.as_ref()).await?;
let globally_managed = global_settings.settings.contains_key(key);
if req.delete_setting {
if globally_managed {
return Err(Status::failed_precondition(format!(
"setting '{key}' is managed globally; delete the global setting first"
)));
}
let mut sandbox_settings =
load_sandbox_settings(state.store.as_ref(), &workspace, sandbox.object_name())
.await?;
let removed = sandbox_settings.settings.remove(key).is_some();
if removed {
sandbox_settings.revision = sandbox_settings.revision.wrapping_add(1);
save_sandbox_settings(
state.store.as_ref(),
&workspace,
sandbox.object_name(),
&sandbox_settings,
)
.await?;
}
response_annotations = persist_update_config_annotations(
state,
&sandbox_id,
req.expected_resource_version,
&req.annotations,
&response_annotations,
)
.await?;
return Ok(update_config_response(
0,
String::new(),
sandbox_settings.revision,
removed,
response_annotations,
));
}
if globally_managed {
return Err(Status::failed_precondition(format!(
"setting '{key}' is managed globally; delete the global setting before sandbox update"
)));
}
let setting = req
.setting_value
.as_ref()
.ok_or_else(|| Status::invalid_argument("setting_value is required"))?;
let stored = proto_setting_to_stored(key, setting)?;
let mut sandbox_settings =
load_sandbox_settings(state.store.as_ref(), &workspace, sandbox.object_name()).await?;
let changed = upsert_setting_value(&mut sandbox_settings.settings, key, stored);
if changed {
sandbox_settings.revision = sandbox_settings.revision.wrapping_add(1);
save_sandbox_settings(
state.store.as_ref(),
&workspace,
sandbox.object_name(),
&sandbox_settings,
)
.await?;
}
response_annotations = persist_update_config_annotations(
state,
&sandbox_id,
req.expected_resource_version,
&req.annotations,
&response_annotations,
)
.await?;
return Ok(update_config_response(
0,
String::new(),
sandbox_settings.revision,
false,
response_annotations,
));
}
let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await.map_err(|error| {
super::persistence_error_to_status(error, "acquire policy mutation lock")
})?;
if has_merge_ops {
let global_settings = load_global_settings(state.store.as_ref()).await?;
if global_settings.settings.contains_key(POLICY_SETTING_KEY) {
return Err(Status::failed_precondition(
"policy is managed globally; delete global policy before sandbox policy update",
));
}
let spec = sandbox
.spec
.as_ref()
.ok_or_else(|| Status::internal("sandbox has no spec"))?;
let merge_ops = parse_merge_operations(&req.merge_operations)?;
validate_merge_operations_for_server(&merge_ops)?;
let merge_validation =
sandbox_policy_merge_validation_data(state, &workspace, &sandbox, &spec.providers)
.await?;
let credential_binding_context = merge_validation.credential_binding_context();
let atomic_context = AtomicPolicyWriteContext {
expected_resource_version: req.expected_resource_version,
provenance: &req.annotations,
annotations: &req.annotations,
};
let baseline_policy = spec.policy.clone();
let (version, hash, updated_sandbox) = apply_merge_operations_with_retry(
state.store.as_ref(),
&sandbox_id,
&workspace,
baseline_policy.as_ref(),
&merge_ops,
PolicyMergeValidationContext {
provider_layers: &merge_validation.provider_layers,
credential_binding: Some(&credential_binding_context),
},
None,
Some(&atomic_context),
)
.await?;
response_annotations = if let Some(updated_sandbox) = updated_sandbox {
sandbox_metadata_annotations(&updated_sandbox)
} else {
persist_existing_policy_projection(
state,
&sandbox_id,
req.expected_resource_version,
&req.annotations,
&response_annotations,
None,
)
.await?
};
state.sandbox_watch_bus.notify(&sandbox_id);
emit_gateway_policy_audit_log(
&sandbox_id,
sandbox.object_name(),
"merged",
format!(
"gateway merged {} incremental policy operation(s)",
merge_ops.len()
),
version,
&hash,
);
for operation in &merge_ops {
emit_gateway_policy_audit_log(
&sandbox_id,
sandbox.object_name(),
"merged",
format!(
"gateway merged incremental policy op: {}",
summarize_cli_policy_merge_op(operation)
),
version,
&hash,
);
}
info!(
sandbox_id = %sandbox_id,
version,
policy_hash = %hash,
operation_count = merge_ops.len(),
"UpdateConfig: merged incremental policy operations"
);
emit_config_update_policy_success(sandbox_caller);
return Ok(update_config_response(
u32::try_from(version).unwrap_or(0),
hash,
0,
false,
response_annotations,
));
}
// Sandbox-scoped policy update.
let mut new_policy = req
.policy
.ok_or_else(|| Status::invalid_argument("policy is required"))?;
clear_provider_credentialed_markers(&mut new_policy);
let global_settings = load_global_settings(state.store.as_ref()).await?;
if global_settings.settings.contains_key(POLICY_SETTING_KEY) {
return Err(Status::failed_precondition(
"policy is managed globally; delete global policy before sandbox policy update",
));
}
let spec = sandbox
.spec
.as_ref()
.ok_or_else(|| Status::internal("sandbox has no spec"))?;
if sandbox_caller {
if strip_provider_rule_names(&mut new_policy) {
debug!(
sandbox_id = %sandbox_id,
"UpdateConfig: stripped provider-derived policy entries from sandbox sync"
);
}
} else {
validate_no_reserved_provider_policy_keys(&new_policy)?;
}
let startup_blocked = crate::policy_store::permits_initial_static_policy_repair(&sandbox);
let should_backfill_policy = if startup_blocked && !sandbox_caller {
// No child has consumed static restrictions yet. A complete replacement
// must be able to repair every field before the first activation.
true
} else if let Some(baseline_policy) = spec.policy.as_ref() {
let comparable_baseline = baseline_policy.clone();
validate_static_fields_unchanged(&comparable_baseline, &new_policy)?;
false
} else {
true
};
new_policy = validate_and_canonicalize_policy(new_policy)?;
let backfill_policy = should_backfill_policy.then(|| new_policy.clone());
validate_policy_safety(&new_policy)?;
crate::middleware::validate_policy(state.middleware_registry.as_ref(), &new_policy).await?;
let provider_layers =
provider_policy_layers_for_sandbox(state, &workspace, &sandbox, &spec.providers).await?;
validate_candidate_effective_policy(&new_policy, &provider_layers)?;
let provider_profile_catalog = state
.provider_profile_sources
.snapshot_catalog(state.store.as_ref(), &workspace)
.await?;
let effective_policy = if provider_layers.is_empty() {
new_policy.clone()
} else {
compose_effective_policy(&new_policy, &provider_layers)
};
validate_policy_credential_bindings_for_sandbox(
state.as_ref(),
&provider_profile_catalog,
&workspace,
&spec.providers,
&effective_policy,
)
.await?;
// Image discovery persists the desired candidate for management repair.
// It never admits workload activation: GetSandboxConfig applies the complete
// composition gate and ReportSandboxConfiguration checks the exact result.
if !sandbox_caller {
validate_candidate_sandbox_credential_policy(
state,
&workspace,
&spec.providers,
Some(&new_policy),
)
.await?;
}
let payload = new_policy.encode_to_vec();
let hash = deterministic_policy_hash(&new_policy);
let (_next_version, committed_annotations) = {
let mut committed = None;
for attempt in 1..=MERGE_RETRY_LIMIT {
let latest = state
.store
.get_latest_policy(&sandbox_id)
.await
.map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?;
if let Some(ref current) = latest
&& canonical_policy_record_matches_for_deduplication(current, &hash)
&& current.provenance == req.annotations
{
response_annotations = persist_existing_policy_projection(
state,
&sandbox_id,
req.expected_resource_version,
&req.annotations,
&response_annotations,
backfill_policy.as_ref(),
)
.await?;
if backfill_policy.is_some() {
info!(
sandbox_id = %sandbox_id,
"UpdateConfig: backfilled spec.policy from sandbox-discovered policy"
);
}
return Ok(update_config_response(
u32::try_from(current.version).unwrap_or(0),
hash,
0,
false,
response_annotations,
));
}
let next_version = latest.as_ref().map_or(1, |record| record.version + 1);
let write = AtomicPolicyRevisionWrite {
id: uuid::Uuid::new_v4().to_string(),
sandbox_id: sandbox_id.clone(),
workspace: workspace.clone(),
version: next_version,
policy_payload: payload.clone(),
policy_hash: hash.clone(),
provenance: req.annotations.clone(),
expected_resource_version: req.expected_resource_version,
annotations: req.annotations.clone(),
backfill_policy: backfill_policy.clone(),
};
match state.store.put_policy_revision_atomic(&write).await {
Ok(updated_sandbox) => {
committed =
Some((next_version, sandbox_metadata_annotations(&updated_sandbox)));
break;
}
Err(error) if error.is_unique_violation_on("objects_version_uq") => {
warn!(
sandbox_id = %sandbox_id,
attempt,
conflicting_version = next_version,
"UpdateConfig: policy version conflict, retrying"
);
tokio::task::yield_now().await;
}
Err(error) => {
return Err(super::persistence_error_to_status(
error,
"persist policy revision",
));
}
}
}
committed.ok_or_else(|| {
Status::aborted(format!(
"UpdateConfig: gave up after {MERGE_RETRY_LIMIT} policy version conflict retries"
))
})?
};
response_annotations = committed_annotations;
state.sandbox_watch_bus.notify(&sandbox_id);
if backfill_policy.is_some() {
info!(
sandbox_id = %sandbox_id,
"UpdateConfig: backfilled spec.policy from sandbox-discovered policy"
);
}
let latest = state
.store
.get_latest_policy(&sandbox_id)
.await
.map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?;
let payload = new_policy.encode_to_vec();
let hash = deterministic_policy_hash(&new_policy);
if let Some(ref current) = latest
&& canonical_policy_record_matches_for_deduplication(current, &hash)
{
return Ok(Response::new(UpdateConfigResponse {
version: u32::try_from(current.version).unwrap_or(0),
policy_hash: hash,
settings_revision: 0,
deleted: false,
annotations: response_annotations,
}));
}
let next_version = latest.map_or(1, |r| r.version + 1);
let policy_id = uuid::Uuid::new_v4().to_string();
state
.store
.put_policy_revision(
&policy_id,
&sandbox_id,
&workspace,
next_version,
&payload,
&hash,
)
.await
.map_err(|e| Status::internal(format!("persist policy revision failed: {e}")))?;
let _ = state
.store
.supersede_older_policies(&sandbox_id, next_version)
.await;
state.sandbox_watch_bus.notify(&sandbox_id);
info!(
sandbox_id = %sandbox_id,
version = next_version,
policy_hash = %hash,
"UpdateConfig: new policy version persisted"
);
emit_full_policy_update_success(sandbox_caller, next_version);
Ok(update_config_response(
u32::try_from(next_version).unwrap_or(0),
hash,
0,
false,
response_annotations,
))
}
// ---------------------------------------------------------------------------
// Policy status handlers
// ---------------------------------------------------------------------------
pub(super) async fn handle_get_sandbox_policy_status(
state: &Arc<ServerState>,
request: Request<GetSandboxPolicyStatusRequest>,
) -> Result<Response<GetSandboxPolicyStatusResponse>, Status> {
let principal = super::extract_principal(&request)?;
let req = request.into_inner();
let sandbox = if req.global {
if !req.sandbox.is_empty() || req.workspace_scope.is_some() {
return Err(Status::invalid_argument(
"sandbox and workspace must be omitted when global is true",
));
}
require_platform_admin(&state.admin_role, &principal)?;
None
} else {
Some(
super::sandbox::resolve_and_authorize_sandbox_name(
state,
&principal,
&req.sandbox,
crate::auth::workspace_authz::selected_workspace_name(
req.workspace_scope.as_ref(),
)?,
MinWorkspaceRole::User,
)
.await?,
)
};
let (policy_id, active_version) = if req.global {
(GLOBAL_POLICY_SANDBOX_ID.to_string(), 0_u32)
} else {
let sandbox = sandbox.as_ref().expect("sandbox query resolved a sandbox");
(
sandbox.object_id().to_string(),
sandbox.current_policy_version(),
)
};
let record = if req.version == 0 {
state
.store
.get_latest_policy(&policy_id)
.await
.map_err(|e| Status::internal(format!("fetch policy failed: {e}")))?
} else {
state
.store
.get_policy_by_version(&policy_id, i64::from(req.version))
.await
.map_err(|e| Status::internal(format!("fetch policy failed: {e}")))?
};
let not_found_msg = if req.global {
"no global policy revision found"
} else {
"no policy revision found for this sandbox"
};
let record = record.ok_or_else(|| Status::not_found(not_found_msg))?;
Ok(Response::new(GetSandboxPolicyStatusResponse {
revision: Some(policy_record_to_revision(&record, true)?),
active_version,
}))
}
pub(super) async fn handle_list_sandbox_policies(
state: &Arc<ServerState>,
request: Request<ListSandboxPoliciesRequest>,
) -> Result<Response<ListSandboxPoliciesResponse>, Status> {
let principal = super::extract_principal(&request)?;
let req = request.into_inner();
let sandbox = if req.global {
if !req.sandbox.is_empty() || req.workspace_scope.is_some() {
return Err(Status::invalid_argument(
"sandbox and workspace must be omitted when global is true",
));
}
require_platform_admin(&state.admin_role, &principal)?;
None
} else {
Some(
super::sandbox::resolve_and_authorize_sandbox_name(
state,
&principal,
&req.sandbox,
crate::auth::workspace_authz::selected_workspace_name(
req.workspace_scope.as_ref(),
)?,
MinWorkspaceRole::User,
)
.await?,
)
};
let policy_id = if req.global {
GLOBAL_POLICY_SANDBOX_ID.to_string()
} else {
let sandbox = sandbox.as_ref().expect("sandbox query resolved a sandbox");
sandbox.object_id().to_string()
};
let pagination = Pagination::new(
req.page_size,
&req.page_token,
"ListSandboxPolicies",
&[
&req.sandbox,
if req.global { "true" } else { "false" },
sandbox
.as_ref()
.map_or("", |sandbox| sandbox.object_workspace()),
],
)?;
let mut records = state
.store
.list_policies_before(
&policy_id,
pagination.page_size() + 1,
pagination.policy_cursor()?,
)
.await
.map_err(|e| Status::internal(format!("list policies failed: {e}")))?;
let page_size = usize::try_from(pagination.page_size())
.map_err(|_| Status::internal("page size does not fit usize"))?;
let has_more = records.len() > page_size;
records.truncate(page_size);
let next_page_token = pagination.next_policy_token(
has_more
.then(|| records.last().map(|record| record.version))
.flatten(),
);
let revisions = records
.iter()
.map(|r| policy_record_to_revision(r, false))
.collect::<Result<Vec<_>, Status>>()?;
Ok(Response::new(ListSandboxPoliciesResponse {
revisions,
next_page_token,
}))
}
fn bounded_configuration_diagnostic(message: &str) -> String {
message
.chars()
.filter(|character| !character.is_control())
.take(512)
.collect()
}
fn configuration_failure_diagnostic(error: &Status) -> &'static str {
let message = error.message();
if message.contains("middleware") {
"Effective middleware configuration is invalid; repair the policy middleware bindings or registered services"
} else if message.contains("credential") || message.contains("provider") {
"Effective provider configuration is invalid; repair credential bindings, attached providers, or their policy layers"
} else {
"Stored policy structure or safety validation failed; submit a complete valid replacement policy"
}
}
fn configuration_generation_matches(
admission: &openshell_core::proto::SandboxConfigurationAdmission,
config: &GetSandboxConfigResponse,
) -> bool {
(
admission.policy_version,
&admission.policy_hash,
admission.config_revision,
admission.provider_env_revision,
) == (
config.version,
&config.policy_hash,
config.config_revision,
config.provider_env_revision,
)
}
pub(super) async fn handle_report_sandbox_configuration(
state: &Arc<ServerState>,
request: Request<openshell_core::proto::ReportSandboxConfigurationRequest>,
) -> Result<Response<openshell_core::proto::ReportSandboxConfigurationResponse>, Status> {
use openshell_core::proto::ConfigurationAdmissionState;
let sandbox_id = request.get_ref().sandbox_id.clone();
crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?;
let mut admission = request
.get_ref()
.admission
.clone()
.ok_or_else(|| Status::invalid_argument("admission is required"))?;
if uuid::Uuid::parse_str(&admission.instance_id).is_err() {
return Err(Status::invalid_argument("instance_id must be a UUID"));
}
let reported = ConfigurationAdmissionState::try_from(admission.state)
.map_err(|_| Status::invalid_argument("invalid admission state"))?;
if reported == ConfigurationAdmissionState::Unspecified {
return Err(Status::invalid_argument("admission state is required"));
}
let _guard = state.compute.sandbox_sync_guard().await.map_err(|error| {
super::persistence_error_to_status(error, "acquire configuration admission lock")
})?;
let mut sandbox = state
.store
.get_message::<Sandbox>(&sandbox_id)
.await
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
.ok_or_else(|| Status::not_found("sandbox not found"))?;
let sandbox_name = sandbox.object_name().to_string();
let workspace = sandbox.object_workspace().to_string();
crate::compute::provisioning_deadline::refresh_configuration(
&state.store,
&mut sandbox,
current_time_ms(),
)
.await
.map_err(Status::internal)?;
if crate::compute::provisioning_deadline::timed_out(&sandbox) {
return Err(Status::failed_precondition(
"provisioning repair window expired; explicitly start the sandbox after cleanup",
));
}
let current = sandbox
.status
.as_ref()
.and_then(|status| status.configuration_admission.as_ref());
if reported == ConfigurationAdmissionState::Pending
&& current.is_some_and(|current| current.instance_id != admission.instance_id)
&& current.map_or("", |current| current.instance_id.as_str())
!= request.get_ref().expected_instance_id
{
return Err(Status::failed_precondition(
"supervisor registration fence has changed",
));
}
if reported != ConfigurationAdmissionState::Pending
&& current.is_none_or(|current| current.instance_id != admission.instance_id)
{
return Err(Status::failed_precondition(
"supervisor configuration instance has changed",
));
}
if reported == ConfigurationAdmissionState::Accepted {
let mut config_request = Request::new(GetSandboxConfigRequest {
name: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace.clone())),
});
*config_request.extensions_mut() = request.extensions().clone();
let config = handle_get_sandbox_config(state, config_request)
.await?
.into_inner();
if !config.configuration_admitted || !configuration_generation_matches(&admission, &config)
{
return Err(Status::aborted(
"configuration changed or is not admitted; fetch and validate again",
));
}
admission.error.clear();
} else if reported == ConfigurationAdmissionState::Rejected {
// Runtime error strings may contain parser payloads. Only gateway-authored
// diagnostics may be exposed verbatim through public sandbox status.
let mut config_request = Request::new(GetSandboxConfigRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
name: sandbox_name,
});
*config_request.extensions_mut() = request.extensions().clone();
admission.error = match handle_get_sandbox_config(state, config_request).await {
Ok(config) if !configuration_generation_matches(&admission, config.get_ref()) => {
return Err(Status::aborted("rejected configuration generation has changed"));
}
Ok(config) if !config.get_ref().configuration_error.is_empty() => config.into_inner().configuration_error,
_ => "Effective configuration could not be activated; replace the policy or repair attached providers".to_string(),
};
if let Some(current) = current
&& current.state == i32::from(ConfigurationAdmissionState::Accepted)
{
// A rejected desired update does not invalidate an accepted runtime.
let error = admission.error;
admission = current.clone();
admission.error = error;
}
} else {
admission.error.clear();
if let Some(current) = current
&& current.instance_id == admission.instance_id
{
admission = current.clone();
}
}
let expected_version = sandbox
.metadata
.as_ref()
.map_or(0, |metadata| metadata.resource_version);
let now_ms = current_time_ms();
let mut provisioning = sandbox
.status
.as_ref()
.and_then(|status| status.provisioning.clone());
if let Some(record) = provisioning.as_mut() {
if !crate::compute::provisioning_deadline::allows_admission(record, now_ms) {
state
.compute
.claim_provisioning_timeout(&sandbox, now_ms)
.await
.map_err(Status::internal)?;
return Err(Status::failed_precondition(
"provisioning repair window expired",
));
}
if reported == ConfigurationAdmissionState::Rejected {
crate::compute::provisioning_deadline::record_rejection(record, now_ms)
.map_err(Status::internal)?;
}
}
let updated = state
.store
.update_message_cas::<Sandbox, _>(&sandbox_id, expected_version, |sandbox| {
sandbox
.status
.get_or_insert_with(Default::default)
.provisioning
.clone_from(&provisioning);
sandbox
.status
.get_or_insert_with(Default::default)
.configuration_admission = Some(admission.clone());
if reported == ConfigurationAdmissionState::Accepted {
sandbox
.status
.as_mut()
.expect("status initialized")
.configuration_activated = Some(true);
}
crate::compute::apply_configuration_readiness(sandbox);
})
.await
.map_err(|error| {
super::persistence_error_to_status(error, "report configuration admission")
})?;
state.sandbox_index.update_from_sandbox(&updated);
state.sandbox_watch_bus.notify(&sandbox_id);
Ok(Response::new(
openshell_core::proto::ReportSandboxConfigurationResponse {},
))
}
pub(super) async fn handle_report_policy_status(
state: &Arc<ServerState>,
request: Request<ReportPolicyStatusRequest>,
) -> Result<Response<ReportPolicyStatusResponse>, Status> {
let sandbox_id = request.get_ref().sandbox_id.clone();
crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?;
let req = request.into_inner();
if req.sandbox_id.is_empty() {
return Err(Status::invalid_argument("sandbox_id is required"));
}
if req.version == 0 {
return Err(Status::invalid_argument("version is required"));
}
let version = i64::from(req.version);
let status_str = match PolicyStatus::try_from(req.status) {
Ok(PolicyStatus::Loaded) => "loaded",
Ok(PolicyStatus::Failed) => "failed",
_ => return Err(Status::invalid_argument("status must be LOADED or FAILED")),
};
let loaded_at_ms = if status_str == "loaded" {
Some(current_time_ms())
} else {
None
};
let load_error = if status_str == "failed" && !req.load_error.is_empty() {
Some(req.load_error.as_str())
} else {
None
};
let updated = state
.store
.update_policy_status(
&req.sandbox_id,
version,
status_str,
load_error,
loaded_at_ms,
)
.await
.map_err(|e| Status::internal(format!("update policy status failed: {e}")))?;
if !updated {
return Err(Status::not_found("policy revision not found"));
}
if status_str == "loaded" {
let _ = state
.store
.supersede_older_policies(&req.sandbox_id, version)
.await;
let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await.map_err(|error| {
super::persistence_error_to_status(error, "acquire policy mutation lock")
})?;
let sandbox = state
.store
.get_message::<Sandbox>(&req.sandbox_id)
.await
.map_err(|error| Status::internal(format!("fetch sandbox failed: {error}")))?
.ok_or_else(|| Status::not_found("sandbox not found"))?;
let endpoint_reset = endpoint_status::endpoint_status_reset_for_loaded_policy(
state.as_ref(),
&sandbox,
version,
)
.await?;
let expected_resource_version = sandbox.get_resource_version();
let version_to_set = req.version;
let updated = state
.store
.update_message_cas::<Sandbox, _>(
&req.sandbox_id,
expected_resource_version,
|sandbox| {
sandbox.set_current_policy_version(version_to_set);
if let Some(endpoints) = &endpoint_reset {
endpoint_status::reconcile_endpoint_statuses(
sandbox,
endpoints,
&HashSet::new(),
&prost_types::Timestamp::default(),
);
}
},
)
.await
.map_err(|e| super::persistence_error_to_status(e, "update current_policy_version"))?;
state.sandbox_index.update_from_sandbox(&updated);
state.sandbox_watch_bus.notify(&req.sandbox_id);
}
info!(
sandbox_id = %req.sandbox_id,
version = req.version,
status = %status_str,
"ReportPolicyStatus: sandbox reported policy load result"
);
Ok(Response::new(ReportPolicyStatusResponse {}))
}
// ---------------------------------------------------------------------------
// Sandbox logs handlers
// ---------------------------------------------------------------------------
pub(super) async fn handle_get_sandbox_logs(
state: &Arc<ServerState>,
request: Request<GetSandboxLogsRequest>,
) -> Result<Response<GetSandboxLogsResponse>, Status> {
let principal = super::extract_principal(&request)?;
let req = request.into_inner();
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
state,
&principal,
&req.sandbox,
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
MinWorkspaceRole::User,
)
.await?;
let sandbox_id = sandbox.object_id();
let lines = if req.lines == 0 { 2000 } else { req.lines };
let tail = state.tracing_log_bus.tail(sandbox_id, lines as usize);
let buffer_total = tail.len() as u32;
if let Some(since_time) = req.since_time.as_ref() {
openshell_core::time::validate_timestamp(since_time)
.map_err(|error| Status::invalid_argument(error.to_string()))?;
}
let since_time = req.since_time;
let logs: Vec<SandboxLogLine> = tail
.into_iter()
.filter_map(|evt| {
if let Some(openshell_core::proto::sandbox_stream_event::Payload::Log(log)) =
evt.event.payload
{
if let Some(since_time) = since_time.as_ref() {
let event_time = log.event_time.as_ref()?;
if openshell_core::time::compare_timestamps(event_time, since_time).ok()?
== std::cmp::Ordering::Less
{
return None;
}
}
if !req.sources.is_empty() && !source_matches(&log.source, &req.sources) {
return None;
}
if !level_matches(&log.level, &req.min_level) {
return None;
}
Some(log)
} else {
None
}
})
.collect();
Ok(Response::new(GetSandboxLogsResponse { logs, buffer_total }))
}
pub(super) async fn handle_push_sandbox_logs(
state: &Arc<ServerState>,
request: Request<tonic::Streaming<PushSandboxLogsRequest>>,
) -> Result<Response<PushSandboxLogsResponse>, Status> {
let principal = request
.extensions()
.get::<Principal>()
.cloned()
.ok_or_else(|| Status::unauthenticated("missing principal"))?;
let mut stream = request.into_inner();
let mut validated_sandbox_id = None;
while let Some(batch) = stream
.message()
.await
.map_err(|e| Status::internal(format!("stream error: {e}")))?
{
if batch.sandbox_id.is_empty() {
continue;
}
ensure_log_stream_sandbox_scope(
state,
&principal,
&batch.sandbox_id,
&mut validated_sandbox_id,
)
.await?;
let logs: Vec<_> = batch.logs.into_iter().take(100).collect();
validate_sandbox_log_timestamps(&logs)?;
for log in logs {
let mut log = log;
log.source = "sandbox".to_string();
log.sandbox_id.clone_from(&batch.sandbox_id);
state.tracing_log_bus.publish_external(log);
}
}
Ok(Response::new(PushSandboxLogsResponse {}))
}
fn validate_sandbox_log_timestamps(logs: &[SandboxLogLine]) -> Result<(), Status> {
for (index, log) in logs.iter().enumerate() {
if let Some(event_time) = log.event_time.as_ref() {
openshell_core::time::validate_timestamp(event_time).map_err(|error| {
Status::invalid_argument(format!("logs[{index}].event_time: {error}"))
})?;
}
}
Ok(())
}
async fn ensure_log_stream_sandbox_scope(
state: &Arc<ServerState>,
principal: &Principal,
sandbox_id: &str,
validated_sandbox_id: &mut Option<String>,
) -> Result<(), Status> {
if let Some(validated) = validated_sandbox_id.as_deref() {
if sandbox_id != validated {
return Err(Status::permission_denied(
"log stream sandbox_id changed after validation",
));
}
return Ok(());
}
crate::auth::guard::ensure_sandbox_scope(principal, sandbox_id)?;
state
.store
.get_message::<Sandbox>(sandbox_id)
.await
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
.ok_or_else(|| Status::not_found("sandbox not found"))?;
*validated_sandbox_id = Some(sandbox_id.to_string());
Ok(())
}
// ---------------------------------------------------------------------------
// Draft policy recommendation handlers
// ---------------------------------------------------------------------------
pub(super) async fn handle_submit_policy_analysis(
state: &Arc<ServerState>,
request: Request<SubmitPolicyAnalysisRequest>,
) -> Result<Response<SubmitPolicyAnalysisResponse>, Status> {
let principal = request
.extensions()
.get::<Principal>()
.cloned()
.ok_or_else(|| Status::unauthenticated("missing principal"))?;
let req = request.into_inner();
let workspace = super::workspace::resolve_workspace(
state.store.as_ref(),
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
)
.await?
.name;
if req.name.is_empty() {
return Err(Status::invalid_argument("name is required"));
}
let sandbox = resolve_sandbox_by_name_for_principal(
state.store.as_ref(),
&workspace,
&principal,
&req.name,
)
.await?;
let sandbox_id = sandbox.object_id().to_string();
for summary in &req.network_activity_summaries {
state
.telemetry
.record_network_activity(&sandbox_id, summary);
}
if req.proposed_chunks.is_empty()
&& req.summaries.is_empty()
&& !req.network_activity_summaries.is_empty()
{
return Ok(Response::new(SubmitPolicyAnalysisResponse {
accepted_chunks: 0,
rejected_chunks: 0,
rejection_reasons: Vec::new(),
accepted_chunk_ids: Vec::new(),
}));
}
// `current_policy` is captured ONCE at the top of the batch and frozen
// for every chunk's delta computation, even if an earlier chunk in the
// batch auto-approves and merges. This is intentional v1 behavior:
// multi-chunk batches with overlapping endpoints would otherwise have
// chunk N+1 fail to see chunk N's contribution, which is a degenerate
// case for the common single-chunk submission shape. If real workloads
// surface a problem with batches that interact across chunks, the right
// fix is to recompute baseline after each successful auto-approve.
let provider_profile_catalog = state
.provider_profile_sources
.snapshot_catalog(state.store.as_ref(), &workspace)
.await?;
let current_policy = current_effective_policy_for_sandbox(
state,
&provider_profile_catalog,
&workspace,
&sandbox,
&sandbox_id,
)
.await?;
let active_policy_version = state
.store
.get_latest_policy(&sandbox_id)
.await
.map_err(|error| Status::internal(format!("fetch latest policy failed: {error}")))?
.map_or(0, |record| record.version);
reconcile_pending_chunks_covered_by_policy(
state,
&sandbox_id,
&current_policy,
active_policy_version,
)
.await?;
let current_base_policy =
current_base_policy_for_sandbox(state.store.as_ref(), &sandbox).await?;
// Auto-approval is an opt-in behavior, sourced from the settings model
// (sandbox or gateway scope) so it can be flipped on a running sandbox
// and managed fleet-wide. Default (no setting, or any value other than
// exact "auto") preserves OpenShell's default-deny posture: every
// proposal lands in `pending` for a human reviewer.
let (auto_approve_enabled, resolved_from) =
resolve_proposal_approval_mode(state.store.as_ref(), &workspace, sandbox.object_name())
.await?;
// The credential set is stable across all chunks in this batch, so build
// it once. v1 captures presence only — no scope modeling — so the prover
// can answer "is there a credential in scope for this host?" but not
// "what action class does that credential authorize?"
let provider_names_for_creds: Vec<String> = sandbox
.spec
.as_ref()
.map(|spec| spec.providers.clone())
.unwrap_or_default();
let credential_set = build_credential_set_for_sandbox_with_catalog(
state.store.as_ref(),
&provider_profile_catalog,
&workspace,
&provider_names_for_creds,
)
.await?;
let merge_validation = sandbox_policy_merge_validation_data_with_catalog(
state,
&workspace,
&sandbox,
&provider_names_for_creds,
&provider_profile_catalog,
)
.await?;
let credential_binding_context = merge_validation.credential_binding_context();
let proposal_validation_context = PolicyMergeValidationContext {
provider_layers: &merge_validation.provider_layers,
credential_binding: Some(&credential_binding_context),
};
let current_version = state
.store
.get_draft_version(&sandbox_id)
.await
.map_err(|e| Status::internal(format!("get draft version failed: {e}")))?;
let draft_version = current_version + 1;
let mut accepted: u32 = 0;
let mut rejected: u32 = 0;
let mut rejection_reasons: Vec<String> = Vec::new();
let mut accepted_chunk_ids: Vec<String> = Vec::new();
for chunk in &req.proposed_chunks {
if chunk.rule_name.is_empty() {
rejected += 1;
rejection_reasons.push("chunk missing rule_name".to_string());
continue;
}
// `_provider_*` is the reserved namespace for rules synthesized from
// provider profiles during composition. Agent submissions that target
// those keys would merge directly into the provider rule and bypass
// the merge.rs guard that splits agent-authored chunks into their
// own rule so the prover sees their contribution honestly. Reject at
// the entry boundary — the agent never has reason to address a
// provider rule by name.
if openshell_policy::is_provider_rule_name(&chunk.rule_name) {
rejected += 1;
rejection_reasons.push(format!(
"chunk '{}' uses reserved '_provider_' rule-name prefix",
chunk.rule_name
));
continue;
}
if chunk.proposed_rule.is_none() {
rejected += 1;
rejection_reasons.push(format!("chunk '{}' missing proposed_rule", chunk.rule_name));
continue;
}
let first_seen_ms = match chunk
.first_seen_time
.as_ref()
.map(openshell_core::time::timestamp_to_millis)
.transpose()
{
Ok(value) => value,
Err(error) => {
rejected += 1;
rejection_reasons.push(format!(
"chunk '{}' has invalid first_seen_time: {error}",
chunk.rule_name
));
continue;
}
};
let last_seen_ms = match chunk
.last_seen_time
.as_ref()
.map(openshell_core::time::timestamp_to_millis)
.transpose()
{
Ok(value) => value,
Err(error) => {
rejected += 1;
rejection_reasons.push(format!(
"chunk '{}' has invalid last_seen_time: {error}",
chunk.rule_name
));
continue;
}
};
let rule_ref = chunk.proposed_rule.as_ref().expect("checked above");
if req.analysis_mode == "agent_authored"
&& let Some(reason) = rule_ref.endpoints.iter().find_map(|endpoint| {
openshell_policy::agent_authored_transport_rejection(
&endpoint.protocol,
openshell_policy::network_tls_mode_to_str(endpoint.tls).unwrap_or(""),
)
})
{
rejected += 1;
rejection_reasons.push(format!("chunk '{}': {reason}", chunk.rule_name));
continue;
}
let incoming_observation_key = rule_ref.endpoints.first().and_then(|endpoint| {
rule_ref.binaries.first().map(|binary| {
(
endpoint.host.to_lowercase(),
endpoint.port as i32,
binary.path.clone(),
)
})
});
let existing_mechanistic = if req.analysis_mode == "mechanistic" {
let chunks = state
.store
.list_draft_chunks(&sandbox_id, None)
.await
.map_err(|error| {
Status::internal(format!("list draft chunks for dedup failed: {error}"))
})?;
incoming_observation_key
.as_ref()
.and_then(|(host, port, binary)| {
chunks.into_iter().find(|existing| {
existing.host == *host
&& existing.port == *port
&& existing.binary == *binary
})
})
} else {
None
};
// A duplicate observation updates the existing pending decision; it
// must not replace an edited rule with the new mapper payload. Build
// and hash the candidate from the stored rule, while retaining the
// incoming observation key for the persistence-layer dedup lookup.
let existing_pending_rule = existing_mechanistic
.as_ref()
.filter(|existing| existing.status == "pending")
.map(decode_draft_chunk_rule)
.transpose()?
.flatten();
let evaluation_rule = existing_pending_rule.as_ref().unwrap_or(rule_ref);
let evaluation_rule_name = existing_mechanistic
.as_ref()
.filter(|existing| existing.status == "pending")
.map_or(chunk.rule_name.as_str(), |existing| {
existing.rule_name.as_str()
});
let evaluation_mode = if existing_pending_rule.is_some() {
"stored"
} else {
req.analysis_mode.as_str()
};
let reusable_validation = existing_mechanistic
.as_ref()
.filter(|existing| existing.status == "pending" && !existing.review_token.is_empty())
.map(|existing| existing.validation_result.as_str());
let mut evaluation = evaluate_proposal_candidate(
&current_base_policy,
&current_policy,
evaluation_rule_name,
evaluation_rule,
evaluation_mode,
&credential_set,
proposal_validation_context,
reusable_validation,
);
if let Some(existing) = &existing_mechanistic
&& !existing.review_token.is_empty()
&& evaluation.review_token != existing.review_token
{
evaluation = evaluate_proposal_candidate(
&current_base_policy,
&current_policy,
evaluation_rule_name,
evaluation_rule,
evaluation_mode,
&credential_set,
proposal_validation_context,
None,
);
}
if req.analysis_mode != "mechanistic" && !evaluation.application_error.is_empty() {
rejected += 1;
rejection_reasons.push(format!(
"chunk '{}': {}",
chunk.rule_name, evaluation.application_error
));
continue;
}
let now_ms = current_time_ms();
let proposed_rule_bytes = evaluation.rule.encode_to_vec();
let (ep_host, ep_port) = rule_ref
.endpoints
.first()
.map(|ep| (ep.host.to_lowercase(), ep.port as i32))
.unwrap_or_default();
let ep_binary = rule_ref
.binaries
.first()
.map(|b| b.path.clone())
.unwrap_or_default();
let record = DraftChunkRecord {
// The handler proposes an id; the store may swap it for an
// existing row's id on dedup. Always trust `effective_id` for
// anything user-facing.
id: uuid::Uuid::new_v4().to_string(),
sandbox_id: sandbox_id.clone(),
draft_version,
status: "pending".to_string(),
rule_name: evaluation.rule_name.clone(),
proposed_rule: proposed_rule_bytes,
rationale: chunk.rationale.clone(),
security_notes: generate_security_notes(&evaluation.rule),
confidence: f64::from(chunk.confidence.clamp(0.0, 1.0)),
created_at_ms: now_ms,
decided_at_ms: None,
host: ep_host,
port: ep_port,
binary: ep_binary,
hit_count: chunk.hit_count.clamp(1, 100),
first_seen_ms: first_seen_ms.unwrap_or(now_ms),
last_seen_ms: last_seen_ms.unwrap_or(now_ms),
validation_result: evaluation.validation_result.clone(),
rejection_reason: String::new(),
application_error: evaluation.application_error.clone(),
review_token: evaluation.review_token.clone(),
current_effective_policy_hash: evaluation.current_hash(),
candidate_effective_policy_hash: evaluation.candidate_hash(),
current_effective_policy: Some(evaluation.current_effective_policy.clone()),
candidate_effective_policy: evaluation.candidate_effective_policy.clone(),
};
// Mechanistic mode dedups N denials targeting the same endpoint
// into one chunk. All other modes (agent-authored proposals, future
// modes) submit each chunk as a distinct row — the redraft loop
// relies on it, and the conservative default for an unknown mode
// is to keep the proposal rather than silently fold it away.
let dedup_key = matches!(req.analysis_mode.as_str(), "mechanistic")
.then(|| crate::policy_store::observation_dedup_key(&record));
let effective_id = state
.store
.put_draft_chunk(&record, dedup_key.as_deref(), &workspace)
.await
.map_err(|e| Status::internal(format!("persist draft chunk failed: {e}")))?;
if effective_id != record.id
&& existing_mechanistic.as_ref().is_some_and(|existing| {
existing.status == "pending" && existing.review_token != evaluation.review_token
})
&& let Some(existing) = existing_mechanistic.as_ref()
{
persist_refreshed_evaluation(state, existing, &evaluation).await?;
}
accepted += 1;
// Implicit supersede: any other pending chunk for the same
// (host, port, binary) in this sandbox is now stale because this
// newer submission covers the same access decision. Auto-reject the
// older chunks with a clear reason. This is what lets the agent
// refine a mechanistic L4 draft into an L7 narrow proposal without
// any explicit `supersedes_chunk_id` plumbing — the gateway figures
// out the relationship by structural overlap.
supersede_other_pending_chunks_for_endpoint(
state,
&sandbox_id,
&effective_id,
&record.host,
record.port,
&record.binary,
)
.await;
// Asymmetric self-reject: if this is a mechanistic proposal that
// arrived AFTER an already-approved agent_authored chunk covered the
// same (host, port, binary), the mechanistic submission is
// redundant — the agent already handled it. Auto-reject so it
// doesn't pile up as approval-queue noise. Agent_authored
// submissions never self-reject; refinement is always allowed.
if req.analysis_mode == "mechanistic" {
self_reject_mechanistic_if_already_covered(
state,
&sandbox_id,
&effective_id,
&record.host,
record.port,
&record.binary,
)
.await;
}
// Auto-approval gate (proposer-agnostic, opt-in): only fire when
// BOTH the prover found nothing new in this proposal's delta AND
// the reviewer opted in via the `proposal_approval_mode` setting
// (gateway or sandbox scope). On any failure (merge conflict,
// status update error), the chunk stays pending so a human can
// review — never silently lose a proposal. The `validation_result`
// literal here is the canonical empty-delta verdict; any other
// string means findings or infrastructure error, both of which
// require human attention.
if auto_approve_enabled
&& let Err(err) = Box::pin(auto_approve_chunk(
state,
&effective_id,
AutoApproveChunkContext {
sandbox: &sandbox,
workspace: &workspace,
source: &req.analysis_mode,
resolved_from,
},
))
.await
{
persist_pending_application_error(state, &effective_id, &err).await;
warn!(
chunk_id = %effective_id,
sandbox_id = %sandbox_id,
error = %err,
"auto-approval failed; chunk remains pending for human review"
);
}
accepted_chunk_ids.push(effective_id);
}
state.sandbox_watch_bus.notify(&sandbox_id);
info!(
sandbox_id = %sandbox_id,
accepted = accepted,
rejected = rejected,
draft_version = draft_version,
summaries = req.summaries.len(),
"SubmitPolicyAnalysis: persisted draft chunks"
);
Ok(Response::new(SubmitPolicyAnalysisResponse {
accepted_chunks: accepted,
rejected_chunks: rejected,
rejection_reasons,
accepted_chunk_ids,
}))
}
pub(super) async fn handle_get_draft_policy(
state: &Arc<ServerState>,
request: Request<GetDraftPolicyRequest>,
) -> Result<Response<GetDraftPolicyResponse>, Status> {
let principal = request
.extensions()
.get::<Principal>()
.cloned()
.ok_or_else(|| Status::unauthenticated("missing principal"))?;
let req = request.into_inner();
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
state,
&principal,
&req.sandbox,
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
MinWorkspaceRole::User,
)
.await?;
let sandbox_id = sandbox.object_id().to_string();
let status_filter = if req.status_filter.is_empty() {
None
} else {
Some(req.status_filter.as_str())
};
let records = state
.store
.list_draft_chunks(&sandbox_id, status_filter)
.await
.map_err(|e| Status::internal(format!("list draft chunks failed: {e}")))?;
let draft_version = state
.store
.get_draft_version(&sandbox_id)
.await
.map_err(|e| Status::internal(format!("get draft version failed: {e}")))?;
let chunks: Vec<PolicyChunk> = records
.into_iter()
.map(|r| draft_chunk_record_to_proto(&r))
.collect::<Result<Vec<_>, _>>()?;
let last_analyzed_time = chunks
.iter()
.filter_map(|chunk| chunk.created_time)
.max_by_key(|value| (value.seconds, value.nanos));
debug!(
sandbox_id = %sandbox_id,
chunk_count = chunks.len(),
draft_version = draft_version,
"GetDraftPolicy: served draft chunks"
);
Ok(Response::new(GetDraftPolicyResponse {
chunks,
rolling_summary: String::new(),
draft_version: u64::try_from(draft_version).unwrap_or(0),
last_analyzed_time,
}))
}
pub(super) async fn handle_approve_draft_chunk(
state: &Arc<ServerState>,
request: Request<ApproveDraftChunkRequest>,
) -> Result<Response<ApproveDraftChunkResponse>, Status> {
let result = handle_approve_draft_chunk_inner(state, request).await;
if result.is_err() {
emit_policy_decision_failure(PolicyDecisionOperation::Approve, 1);
}
result
}
async fn handle_approve_draft_chunk_inner(
state: &Arc<ServerState>,
request: Request<ApproveDraftChunkRequest>,
) -> Result<Response<ApproveDraftChunkResponse>, Status> {
let principal = super::extract_principal(&request)?;
let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request);
let req = request.into_inner();
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
state,
&principal,
&req.sandbox,
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
MinWorkspaceRole::Admin,
)
.await?;
let workspace = sandbox.object_workspace().to_string();
if req.chunk_id.is_empty() {
return Err(Status::invalid_argument("chunk_id is required"));
}
require_no_global_policy(state).await?;
let sandbox_id = sandbox.object_id().to_string();
replay_facts.resource(&sandbox)?;
let chunk = state
.store
.get_draft_chunk(&req.chunk_id)
.await
.map_err(|e| Status::internal(format!("fetch chunk failed: {e}")))?
.ok_or_else(|| Status::not_found("chunk not found"))?;
ensure_chunk_belongs_to_sandbox(&chunk, &sandbox_id)?;
if chunk.status != "pending" && chunk.status != "rejected" {
return Err(Status::failed_precondition(format!(
"chunk status is '{}', expected 'pending' or 'rejected'",
chunk.status
)));
}
require_current_proposal_evaluation(
state,
&workspace,
&sandbox,
&chunk,
Some(&req.review_token),
)
.await?;
info!(
sandbox_id = %sandbox_id,
chunk_id = %req.chunk_id,
rule_name = %chunk.rule_name,
host = %chunk.host,
port = chunk.port,
hit_count = chunk.hit_count,
prev_status = %chunk.status,
"ApproveDraftChunk: merging rule into active policy"
);
let provider_names = sandbox
.spec
.as_ref()
.map(|spec| spec.providers.as_slice())
.unwrap_or_default();
let merge_validation =
sandbox_policy_merge_validation_data(state, &workspace, &sandbox, provider_names).await?;
let credential_binding_context = merge_validation.credential_binding_context();
let merge_result = merge_chunk_into_policy_with_validation(
state.store.as_ref(),
&sandbox_id,
&workspace,
&chunk,
PolicyMergeValidationContext {
provider_layers: &merge_validation.provider_layers,
credential_binding: Some(&credential_binding_context),
},
)
.await;
let (version, hash) = match merge_result {
Ok(result) => result,
Err(status) => {
persist_pending_application_error(state, &req.chunk_id, &status).await;
return Err(status);
}
};
let chunk_summary = summarize_draft_chunk_rule(&chunk)?;
let now_ms = current_time_ms();
clear_pending_application_error(state, &req.chunk_id).await;
state
.store
.update_draft_chunk_status(&req.chunk_id, "approved", Some(now_ms), None)
.await
.map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?;
state.sandbox_watch_bus.notify(&sandbox_id);
if let Err(error) =
reconcile_pending_chunks_after_policy_change(state, &workspace, &sandbox).await
{
warn!(
sandbox_id,
error = %error,
"failed to reconcile pending policy proposals after approval"
);
}
emit_gateway_policy_audit_log(
&sandbox_id,
sandbox.object_name(),
"approved",
format!(
"gateway approved draft chunk {}: {chunk_summary}",
req.chunk_id
),
version,
&hash,
);
info!(
sandbox_id = %sandbox_id,
chunk_id = %req.chunk_id,
rule_name = %chunk.rule_name,
version = version,
policy_hash = %hash,
"ApproveDraftChunk: rule merged successfully"
);
emit_sandbox_policy_update_success();
emit_policy_decision_success(PolicyDecisionOperation::Approve, 1);
Ok(Response::new(ApproveDraftChunkResponse {
policy_version: u32::try_from(version).unwrap_or(0),
policy_hash: hash,
}))
}
pub(super) async fn handle_reject_draft_chunk(
state: &Arc<ServerState>,
request: Request<RejectDraftChunkRequest>,
) -> Result<Response<RejectDraftChunkResponse>, Status> {
let result = handle_reject_draft_chunk_inner(state, request).await;
if result.is_err() {
emit_policy_decision_failure(PolicyDecisionOperation::Reject, 1);
}
result
}
async fn handle_reject_draft_chunk_inner(
state: &Arc<ServerState>,
request: Request<RejectDraftChunkRequest>,
) -> Result<Response<RejectDraftChunkResponse>, Status> {
let principal = super::extract_principal(&request)?;
let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request);
let req = request.into_inner();
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
state,
&principal,
&req.sandbox,
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
MinWorkspaceRole::Admin,
)
.await?;
let workspace = sandbox.object_workspace().to_string();
if req.chunk_id.is_empty() {
return Err(Status::invalid_argument("chunk_id is required"));
}
let sandbox_id = sandbox.object_id().to_string();
replay_facts.resource(&sandbox)?;
let chunk = state
.store
.get_draft_chunk(&req.chunk_id)
.await
.map_err(|e| Status::internal(format!("fetch chunk failed: {e}")))?
.ok_or_else(|| Status::not_found("chunk not found"))?;
ensure_chunk_belongs_to_sandbox(&chunk, &sandbox_id)?;
if chunk.status != "pending" && chunk.status != "approved" {
return Err(Status::failed_precondition(format!(
"chunk status is '{}', expected 'pending' or 'approved'",
chunk.status
)));
}
let was_approved = chunk.status == "approved";
info!(
sandbox_id = %sandbox_id,
chunk_id = %req.chunk_id,
rule_name = %chunk.rule_name,
host = %chunk.host,
port = chunk.port,
reason = %req.reason,
prev_status = %chunk.status,
"RejectDraftChunk: rejecting chunk"
);
if was_approved {
require_no_global_policy(state).await?;
let (version, hash) =
remove_chunk_from_policy(state, &sandbox_id, &workspace, &chunk).await?;
emit_gateway_policy_audit_log(
&sandbox_id,
sandbox.object_name(),
"removed",
format!(
"gateway removed previously approved draft chunk {}: remove-binary {} {}",
req.chunk_id, chunk.rule_name, chunk.binary
),
version,
&hash,
);
emit_sandbox_policy_update_success();
}
let now_ms = current_time_ms();
// Persist the reviewer's free-form `reason` into the chunk's
// `rejection_reason` field so the in-sandbox agent can read it back via
// GetDraftPolicy / policy.local and revise the proposal.
let persisted_reason = if req.reason.is_empty() {
None
} else {
Some(req.reason.as_str())
};
state
.store
.update_draft_chunk_status(&req.chunk_id, "rejected", Some(now_ms), persisted_reason)
.await
.map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?;
state.sandbox_watch_bus.notify(&sandbox_id);
emit_policy_decision_success(PolicyDecisionOperation::Reject, 1);
Ok(Response::new(RejectDraftChunkResponse {}))
}
pub(super) async fn handle_approve_all_draft_chunks(
state: &Arc<ServerState>,
request: Request<ApproveAllDraftChunksRequest>,
) -> Result<Response<ApproveAllDraftChunksResponse>, Status> {
let result = handle_approve_all_draft_chunks_inner(state, request).await;
if result.is_err() {
emit_policy_decision_failure(PolicyDecisionOperation::ApproveAll, 0);
}
result
}
async fn handle_approve_all_draft_chunks_inner(
state: &Arc<ServerState>,
request: Request<ApproveAllDraftChunksRequest>,
) -> Result<Response<ApproveAllDraftChunksResponse>, Status> {
let principal = super::extract_principal(&request)?;
let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request);
let req = request.into_inner();
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
state,
&principal,
&req.sandbox,
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
MinWorkspaceRole::Admin,
)
.await?;
let workspace = sandbox.object_workspace().to_string();
require_no_global_policy(state).await?;
let sandbox_id = sandbox.object_id().to_string();
replay_facts.resource(&sandbox)?;
let pending_chunks = state
.store
.list_draft_chunks(&sandbox_id, Some("pending"))
.await
.map_err(|e| Status::internal(format!("list draft chunks failed: {e}")))?;
if pending_chunks.is_empty() {
return Err(Status::failed_precondition("no pending chunks to approve"));
}
let chunks_to_approve = if req.approvals.is_empty() {
pending_chunks.clone()
} else {
let by_id = pending_chunks
.iter()
.map(|chunk| (chunk.id.as_str(), chunk))
.collect::<HashMap<_, _>>();
let mut selected = Vec::with_capacity(req.approvals.len());
for approval in &req.approvals {
let chunk = by_id.get(approval.chunk_id.as_str()).ok_or_else(|| {
Status::failed_precondition(format!(
"chunk '{}' is not pending; refetch before bulk approval",
approval.chunk_id
))
})?;
selected.push((*chunk).clone());
}
selected
};
let review_tokens = req
.approvals
.iter()
.map(|approval| (approval.chunk_id.as_str(), approval.review_token.as_str()))
.collect::<HashMap<_, _>>();
info!(
sandbox_id = %sandbox_id,
pending_count = pending_chunks.len(),
include_security_flagged = req.include_security_flagged,
"ApproveAllDraftChunks: starting bulk approval"
);
let mut chunks_skipped: u32 = 0;
let provider_names = sandbox
.spec
.as_ref()
.map(|spec| spec.providers.as_slice())
.unwrap_or_default();
let merge_validation =
sandbox_policy_merge_validation_data(state, &workspace, &sandbox, provider_names).await?;
let credential_binding_context = merge_validation.credential_binding_context();
let merge_validation_context = PolicyMergeValidationContext {
provider_layers: &merge_validation.provider_layers,
credential_binding: Some(&credential_binding_context),
};
let mut staged_policy = current_base_policy_for_sandbox(state.store.as_ref(), &sandbox).await?;
let mut expected_effective_hash: Option<String> = None;
let mut accepted = Vec::<(DraftChunkRecord, PolicyMergeOp, String)>::new();
for chunk in &chunks_to_approve {
let security_notes = current_draft_chunk_security_notes(chunk)?;
if !req.include_security_flagged && !security_notes.is_empty() {
info!(
sandbox_id = %sandbox_id,
chunk_id = %chunk.id,
rule_name = %chunk.rule_name,
security_notes = %security_notes,
"ApproveAllDraftChunks: skipping security-flagged chunk"
);
chunks_skipped += 1;
continue;
}
let supplied_token = review_tokens.get(chunk.id.as_str()).copied().unwrap_or("");
let evaluation = match require_current_proposal_evaluation(
state,
&workspace,
&sandbox,
chunk,
Some(supplied_token),
)
.await
{
Ok(evaluation) => evaluation,
Err(status) if status.code() == tonic::Code::FailedPrecondition => {
info!(
sandbox_id = %sandbox_id,
chunk_id = %chunk.id,
reason = %status.message(),
"ApproveAllDraftChunks: skipping stale or invalid candidate"
);
chunks_skipped += 1;
continue;
}
Err(status) => return Err(status),
};
let current_hash = evaluation.current_hash();
if let Some(expected_hash) = expected_effective_hash.as_deref()
&& current_hash != expected_hash
{
return Err(Status::failed_precondition(
"proposal inputs changed during bulk review; refetch and review again",
));
}
info!(
sandbox_id = %sandbox_id,
chunk_id = %chunk.id,
rule_name = %chunk.rule_name,
host = %chunk.host,
port = chunk.port,
"ApproveAllDraftChunks: staging chunk"
);
let operation = PolicyMergeOp::AddRule {
rule_name: evaluation.rule_name,
rule: evaluation.rule,
};
let candidate = match stage_validated_merge_operation(
&staged_policy,
&operation,
merge_validation_context,
) {
Ok(candidate) => candidate,
Err(status) => {
persist_pending_application_error(state, &chunk.id, &status).await;
info!(
sandbox_id = %sandbox_id,
chunk_id = %chunk.id,
reason = %status.message(),
"ApproveAllDraftChunks: skipping chunk that conflicts with the staged batch"
);
chunks_skipped += 1;
continue;
}
};
let chunk_summary = summarize_draft_chunk_rule(chunk)?;
if expected_effective_hash.is_none() {
expected_effective_hash = Some(current_hash);
}
staged_policy = candidate;
accepted.push((chunk.clone(), operation, chunk_summary));
}
let (last_version, last_hash) = if accepted.is_empty() {
(0, String::new())
} else {
// Rebuild the staged candidate from fresh live inputs immediately before
// persistence. This reuses each unchanged chunk's cached prover result;
// it does not silently bind the request to a newly refreshed token.
for (chunk, _, _) in &accepted {
let supplied_token = review_tokens.get(chunk.id.as_str()).copied().unwrap_or("");
require_current_proposal_evaluation(
state,
&workspace,
&sandbox,
chunk,
Some(supplied_token),
)
.await?;
}
let final_merge_validation =
sandbox_policy_merge_validation_data(state, &workspace, &sandbox, provider_names)
.await?;
let final_credential_binding_context = final_merge_validation.credential_binding_context();
let final_validation_context = PolicyMergeValidationContext {
provider_layers: &final_merge_validation.provider_layers,
credential_binding: Some(&final_credential_binding_context),
};
let final_base = current_base_policy_for_sandbox(state.store.as_ref(), &sandbox).await?;
let mut rebuilt_policy = final_base.clone();
for (_, operation, _) in &accepted {
rebuilt_policy = stage_validated_merge_operation(
&rebuilt_policy,
operation,
final_validation_context,
)?;
}
if deterministic_policy_hash(&rebuilt_policy) != deterministic_policy_hash(&staged_policy) {
return Err(Status::failed_precondition(
"proposal inputs changed during bulk review; refetch and review again",
));
}
let operations = accepted
.iter()
.map(|(_, operation, _)| operation.clone())
.collect::<Vec<_>>();
let expected_hash = expected_effective_hash.as_deref().ok_or_else(|| {
Status::failed_precondition("bulk approval has no reviewed policy snapshot")
})?;
match apply_merge_operations_with_retry(
state.store.as_ref(),
&sandbox_id,
&workspace,
Some(&final_base),
&operations,
final_validation_context,
Some(expected_hash),
None,
)
.await
{
Ok((version, hash, _)) => (version, hash),
Err(status) => {
for (chunk, _, _) in &accepted {
persist_pending_application_error(state, &chunk.id, &status).await;
}
return Err(status);
}
}
};
for (chunk, _, chunk_summary) in &accepted {
let now_ms = current_time_ms();
clear_pending_application_error(state, &chunk.id).await;
state
.store
.update_draft_chunk_status(&chunk.id, "approved", Some(now_ms), None)
.await
.map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?;
emit_gateway_policy_audit_log(
&sandbox_id,
sandbox.object_name(),
"approved",
format!("gateway approved draft chunk {}: {chunk_summary}", chunk.id),
last_version,
&last_hash,
);
emit_sandbox_policy_update_success();
}
let chunks_approved = u32::try_from(accepted.len()).unwrap_or(u32::MAX);
state.sandbox_watch_bus.notify(&sandbox_id);
if let Err(error) =
reconcile_pending_chunks_after_policy_change(state, &workspace, &sandbox).await
{
warn!(
sandbox_id,
error = %error,
"failed to reconcile pending policy proposals after bulk approval"
);
}
emit_gateway_policy_audit_log(
&sandbox_id,
sandbox.object_name(),
"merged",
format!(
"gateway bulk-approved {chunks_approved} draft chunk(s) and skipped {chunks_skipped}"
),
last_version,
&last_hash,
);
info!(
sandbox_id = %sandbox_id,
chunks_approved = chunks_approved,
chunks_skipped = chunks_skipped,
version = last_version,
policy_hash = %last_hash,
"ApproveAllDraftChunks: bulk approval complete"
);
emit_policy_decision_success(
PolicyDecisionOperation::ApproveAll,
u64::from(chunks_approved),
);
Ok(Response::new(ApproveAllDraftChunksResponse {
policy_version: u32::try_from(last_version).unwrap_or(0),
policy_hash: last_hash,
chunks_approved,
chunks_skipped,
}))
}
pub(super) async fn handle_edit_draft_chunk(
state: &Arc<ServerState>,
request: Request<EditDraftChunkRequest>,
) -> Result<Response<EditDraftChunkResponse>, Status> {
let principal = super::extract_principal(&request)?;
let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request);
let req = request.into_inner();
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
state,
&principal,
&req.sandbox,
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
MinWorkspaceRole::Admin,
)
.await?;
let workspace = sandbox.object_workspace().to_string();
if req.chunk_id.is_empty() {
return Err(Status::invalid_argument("chunk_id is required"));
}
let proposed_rule = req
.proposed_rule
.ok_or_else(|| Status::invalid_argument("proposed_rule is required"))?;
let sandbox_id = sandbox.object_id().to_string();
replay_facts.resource(&sandbox)?;
let chunk = state
.store
.get_draft_chunk(&req.chunk_id)
.await
.map_err(|e| Status::internal(format!("fetch chunk failed: {e}")))?
.ok_or_else(|| Status::not_found("chunk not found"))?;
ensure_chunk_belongs_to_sandbox(&chunk, &sandbox_id)?;
if chunk.status != "pending" {
return Err(Status::failed_precondition(format!(
"chunk status is '{}', expected 'pending'",
chunk.status
)));
}
let mut edited_chunk = chunk.clone();
edited_chunk.proposed_rule = proposed_rule.encode_to_vec();
edited_chunk.review_token.clear();
edited_chunk.validation_result.clear();
edited_chunk.application_error.clear();
edited_chunk.current_effective_policy = None;
edited_chunk.candidate_effective_policy = None;
let evaluation =
evaluate_stored_chunk_against_live_inputs(state, &workspace, &sandbox, &edited_chunk, None)
.await?;
persist_refreshed_evaluation(state, &edited_chunk, &evaluation).await?;
info!(
chunk_id = %req.chunk_id,
"EditDraftChunk: proposed rule updated"
);
Ok(Response::new(EditDraftChunkResponse {}))
}
pub(super) async fn handle_undo_draft_chunk(
state: &Arc<ServerState>,
request: Request<UndoDraftChunkRequest>,
) -> Result<Response<UndoDraftChunkResponse>, Status> {
let result = handle_undo_draft_chunk_inner(state, request).await;
if result.is_err() {
emit_policy_decision_failure(PolicyDecisionOperation::Undo, 1);
}
result
}
async fn handle_undo_draft_chunk_inner(
state: &Arc<ServerState>,
request: Request<UndoDraftChunkRequest>,
) -> Result<Response<UndoDraftChunkResponse>, Status> {
let principal = super::extract_principal(&request)?;
let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request);
let req = request.into_inner();
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
state,
&principal,
&req.sandbox,
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
MinWorkspaceRole::Admin,
)
.await?;
let workspace = sandbox.object_workspace().to_string();
if req.chunk_id.is_empty() {
return Err(Status::invalid_argument("chunk_id is required"));
}
let sandbox_id = sandbox.object_id().to_string();
replay_facts.resource(&sandbox)?;
let chunk = state
.store
.get_draft_chunk(&req.chunk_id)
.await
.map_err(|e| Status::internal(format!("fetch chunk failed: {e}")))?
.ok_or_else(|| Status::not_found("chunk not found"))?;
ensure_chunk_belongs_to_sandbox(&chunk, &sandbox_id)?;
if chunk.status != "approved" {
return Err(Status::failed_precondition(format!(
"chunk status is '{}', expected 'approved'",
chunk.status
)));
}
info!(
sandbox_id = %sandbox_id,
chunk_id = %req.chunk_id,
rule_name = %chunk.rule_name,
host = %chunk.host,
port = chunk.port,
"UndoDraftChunk: removing rule from active policy"
);
let (version, hash) = remove_chunk_from_policy(state, &sandbox_id, &workspace, &chunk).await?;
// Clear any prior rejection_reason on the way back to "pending" so an
// agent reading the chunk via policy.local cannot see a stale guidance
// string left over from a previous reject → undo round.
state
.store
.update_draft_chunk_status(&req.chunk_id, "pending", None, Some(""))
.await
.map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?;
state.sandbox_watch_bus.notify(&sandbox_id);
emit_gateway_policy_audit_log(
&sandbox_id,
sandbox.object_name(),
"removed",
format!(
"gateway reverted approved draft chunk {}: remove-binary {} {}",
req.chunk_id, chunk.rule_name, chunk.binary
),
version,
&hash,
);
info!(
sandbox_id = %sandbox_id,
chunk_id = %req.chunk_id,
rule_name = %chunk.rule_name,
version = version,
policy_hash = %hash,
"UndoDraftChunk: rule removed, chunk reverted to pending"
);
emit_sandbox_policy_update_success();
emit_policy_decision_success(PolicyDecisionOperation::Undo, 1);
Ok(Response::new(UndoDraftChunkResponse {
policy_version: u32::try_from(version).unwrap_or(0),
policy_hash: hash,
}))
}
pub(super) async fn handle_clear_draft_chunks(
state: &Arc<ServerState>,
request: Request<ClearDraftChunksRequest>,
) -> Result<Response<ClearDraftChunksResponse>, Status> {
let principal = super::extract_principal(&request)?;
let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request);
let req = request.into_inner();
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
state,
&principal,
&req.sandbox,
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
MinWorkspaceRole::Admin,
)
.await?;
let sandbox_id = sandbox.object_id().to_string();
replay_facts.resource(&sandbox)?;
let deleted = state
.store
.delete_draft_chunks(&sandbox_id, "pending")
.await
.map_err(|e| Status::internal(format!("delete draft chunks failed: {e}")))?;
state.sandbox_watch_bus.notify(&sandbox_id);
info!(
sandbox_id = %sandbox_id,
chunks_cleared = deleted,
"ClearDraftChunks: pending chunks cleared"
);
Ok(Response::new(ClearDraftChunksResponse {
chunks_cleared: u32::try_from(deleted).unwrap_or(0),
}))
}
pub(super) async fn handle_get_draft_history(
state: &Arc<ServerState>,
request: Request<GetDraftHistoryRequest>,
) -> Result<Response<GetDraftHistoryResponse>, Status> {
let principal = super::extract_principal(&request)?;
let req = request.into_inner();
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
state,
&principal,
&req.sandbox,
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
MinWorkspaceRole::User,
)
.await?;
let sandbox_id = sandbox.object_id().to_string();
let all_chunks = state
.store
.list_draft_chunks(&sandbox_id, None)
.await
.map_err(|e| Status::internal(format!("list draft chunks failed: {e}")))?;
let mut entries: Vec<DraftHistoryEntry> = Vec::new();
for chunk in &all_chunks {
entries.push(DraftHistoryEntry {
event_time: openshell_core::time::optional_timestamp_from_legacy_millis(
chunk.created_at_ms,
)
.ok()
.flatten(),
event_type: "proposed".to_string(),
description: format!(
"Rule '{}' proposed (confidence: {:.0}%)",
chunk.rule_name,
chunk.confidence * 100.0
),
chunk_id: chunk.id.clone(),
});
if let Some(decided_at) = chunk.decided_at_ms {
entries.push(DraftHistoryEntry {
event_time: openshell_core::time::optional_timestamp_from_legacy_millis(decided_at)
.ok()
.flatten(),
event_type: chunk.status.clone(),
description: format!("Rule '{}' {}", chunk.rule_name, chunk.status),
chunk_id: chunk.id.clone(),
});
}
}
entries.sort_by_key(|entry| {
entry
.event_time
.as_ref()
.map_or((0, 0), |value| (value.seconds, value.nanos))
});
debug!(
sandbox_id = %sandbox_id,
entry_count = entries.len(),
"GetDraftHistory: served draft history"
);
Ok(Response::new(GetDraftHistoryResponse { entries }))
}
// ---------------------------------------------------------------------------
// Policy helper functions
// ---------------------------------------------------------------------------
/// Rebuilds a policy revision's identity from its checked canonical payload.
///
/// `PolicyRecord::policy_hash` is persisted metadata and cannot prove what the
/// payload contains. Decode and validate every record before using its identity
/// so legacy encodings deduplicate semantically and damaged rows fail closed.
fn canonical_policy_record_identity(
record: &PolicyRecord,
) -> Result<(ProtoSandboxPolicy, String), Status> {
let decoded = decode_stored_policy(record.policy_payload.as_slice())
.map_err(|error| Status::internal(format!("decode policy revision failed: {error}")))?;
let policy = validate_and_canonicalize_stored_policy(decoded, STORED_POLICY_SOURCE_HISTORY)?;
let hash = deterministic_policy_hash(&policy);
Ok((policy, hash))
}
/// Compare a stored revision during no-op detection without blocking repair.
///
/// Invalid durable state remains unusable everywhere that loads or serves a
/// policy. A full, already-validated replacement is different: treating an
/// unreadable current row as non-matching lets the write path append a good
/// revision instead of making the corrupt or legacy row permanently terminal.
fn canonical_policy_record_matches_for_deduplication(
record: &PolicyRecord,
expected_hash: &str,
) -> bool {
match canonical_policy_record_identity(record) {
Ok((_, hash)) => hash == expected_hash,
Err(error) => {
warn!(
policy_id = %record.id,
sandbox_id = %record.sandbox_id,
version = record.version,
error = %error,
"Invalid stored policy revision cannot satisfy deduplication; a valid replacement may proceed"
);
false
}
}
}
/// Compute a fingerprint for the effective sandbox configuration.
fn compute_config_revision_with_validation_mode(
policy: Option<&ProtoSandboxPolicy>,
settings: &HashMap<String, EffectiveSetting>,
policy_source: PolicySource,
supervisor_middleware_services: &[openshell_core::proto::SupervisorMiddlewareService],
policy_validation_failure_mode: openshell_core::PolicyValidationFailureMode,
extension_authentication_enabled: bool,
) -> u64 {
let mut hasher = Sha256::new();
hasher.update((policy_source as i32).to_le_bytes());
hasher.update(policy_validation_failure_mode.as_str().as_bytes());
hasher.update([u8::from(extension_authentication_enabled)]);
if let Some(policy) = policy {
hasher.update(deterministic_policy_hash(policy).as_bytes());
}
let mut entries: Vec<_> = settings.iter().collect();
entries.sort_by_key(|(k, _)| k.as_str());
for (key, setting) in entries {
hasher.update(key.as_bytes());
hasher.update(setting.scope.to_le_bytes());
if let Some(value) = setting.value.as_ref().and_then(|v| v.value.as_ref()) {
match value {
setting_value::Value::StringValue(v) => {
hasher.update([0]);
hasher.update(v.as_bytes());
}
setting_value::Value::BoolValue(v) => {
hasher.update([1]);
hasher.update([u8::from(*v)]);
}
setting_value::Value::IntValue(v) => {
hasher.update([2]);
hasher.update(v.to_le_bytes());
}
setting_value::Value::BytesValue(v) => {
hasher.update([3]);
hasher.update(v);
}
}
}
}
let mut middleware = supervisor_middleware_services.iter().collect::<Vec<_>>();
middleware.sort_by(|left, right| left.name.cmp(&right.name));
for service in middleware {
hasher.update(service.encode_to_vec());
}
let digest = hasher.finalize();
let mut bytes = [0_u8; 8];
bytes.copy_from_slice(&digest[..8]);
u64::from_le_bytes(bytes)
}
#[cfg(test)]
fn compute_config_revision(
policy: Option<&ProtoSandboxPolicy>,
settings: &HashMap<String, EffectiveSetting>,
policy_source: PolicySource,
supervisor_middleware_services: &[openshell_core::proto::SupervisorMiddlewareService],
) -> u64 {
compute_config_revision_with_validation_mode(
policy,
settings,
policy_source,
supervisor_middleware_services,
openshell_core::PolicyValidationFailureMode::default(),
false,
)
}
fn decode_draft_chunk_rule(record: &DraftChunkRecord) -> Result<Option<NetworkPolicyRule>, Status> {
if record.proposed_rule.is_empty() {
Ok(None)
} else {
NetworkPolicyRule::decode(record.proposed_rule.as_slice())
.map(Some)
.map_err(|e| Status::internal(format!("decode proposed_rule failed: {e}")))
}
}
fn current_draft_chunk_security_notes(record: &DraftChunkRecord) -> Result<String, Status> {
Ok(decode_draft_chunk_rule(record)?
.as_ref()
.map_or_else(String::new, generate_security_notes))
}
fn draft_chunk_record_to_proto(record: &DraftChunkRecord) -> Result<PolicyChunk, Status> {
let proposed_rule = decode_draft_chunk_rule(record)?;
let security_notes = proposed_rule
.as_ref()
.map_or_else(String::new, generate_security_notes);
Ok(PolicyChunk {
id: record.id.clone(),
status: record.status.clone(),
rule_name: record.rule_name.clone(),
proposed_rule,
rationale: record.rationale.clone(),
security_notes,
confidence: record.confidence as f32,
created_time: openshell_core::time::optional_timestamp_from_legacy_millis(
record.created_at_ms,
)
.ok()
.flatten(),
decided_time: record
.decided_at_ms
.and_then(|value| openshell_core::time::timestamp_from_millis(value).ok()),
hit_count: record.hit_count,
first_seen_time: openshell_core::time::optional_timestamp_from_legacy_millis(
record.first_seen_ms,
)
.ok()
.flatten(),
last_seen_time: openshell_core::time::optional_timestamp_from_legacy_millis(
record.last_seen_ms,
)
.ok()
.flatten(),
binary: record.binary.clone(),
validation_result: record.validation_result.clone(),
rejection_reason: record.rejection_reason.clone(),
application_error: record.application_error.clone(),
review_token: record.review_token.clone(),
current_effective_policy_hash: record.current_effective_policy_hash.clone(),
candidate_effective_policy_hash: record.candidate_effective_policy_hash.clone(),
current_effective_policy: record.current_effective_policy.clone(),
candidate_effective_policy: record.candidate_effective_policy.clone(),
..Default::default()
})
}
fn policy_record_to_revision(
record: &PolicyRecord,
include_policy: bool,
) -> Result<SandboxPolicyRevision, Status> {
let stored_status = match record.status.as_str() {
"pending" => PolicyStatus::Pending,
"loaded" => PolicyStatus::Loaded,
"failed" => PolicyStatus::Failed,
"superseded" => PolicyStatus::Superseded,
_ => PolicyStatus::Unspecified,
};
match canonical_policy_record_identity(record) {
Ok((policy, policy_hash)) => Ok(SandboxPolicyRevision {
version: u32::try_from(record.version).unwrap_or(0),
policy_hash,
status: stored_status.into(),
load_error: record.load_error.clone().unwrap_or_default(),
created_time: openshell_core::time::timestamp_from_millis(record.created_at_ms).ok(),
loaded_time: record
.loaded_at_ms
.and_then(|value| openshell_core::time::timestamp_from_millis(value).ok()),
policy: include_policy.then_some(policy),
provenance: record.provenance.clone(),
}),
Err(error) if !include_policy => {
// History listing is a recovery surface, not an enforcement path.
// Preserve row metadata so one legacy or damaged payload cannot
// hide every usable revision in the page, but blank the untrusted
// hash and mark the projection failed. Detail and runtime callers
// still receive the hard error through the branch below.
let identity_error = format!(
"policy revision is invalid under the current schema: {}",
error.message()
);
let load_error = record.load_error.as_deref().map_or_else(
|| identity_error.clone(),
|stored_error| {
if stored_error.is_empty() {
identity_error.clone()
} else {
format!("{stored_error}; {identity_error}")
}
},
);
Ok(SandboxPolicyRevision {
version: u32::try_from(record.version).unwrap_or(0),
policy_hash: String::new(),
status: PolicyStatus::Failed.into(),
load_error,
created_time: openshell_core::time::timestamp_from_millis(record.created_at_ms)
.ok(),
loaded_time: record
.loaded_at_ms
.and_then(|value| openshell_core::time::timestamp_from_millis(value).ok()),
policy: None,
provenance: record.provenance.clone(),
})
}
Err(error) => Err(error),
}
}
fn allowed_ip_is_internal(entry: &str) -> bool {
use openshell_core::net::{is_always_blocked_net, is_internal_net};
let parsed = entry.parse::<ipnet::IpNet>().or_else(|_| {
entry.parse::<IpAddr>().map(|ip| match ip {
IpAddr::V4(v4) => ipnet::IpNet::V4(ipnet::Ipv4Net::from(v4)),
IpAddr::V6(v6) => ipnet::IpNet::V6(ipnet::Ipv6Net::from(v6)),
})
});
let Ok(net) = parsed else {
return false;
};
// These remain hard validation failures, not warning-only destinations.
if is_always_blocked_net(net) {
return false;
}
is_internal_net(net)
}
/// Re-validate security notes server-side for a proposed policy chunk.
fn generate_security_notes(rule: &NetworkPolicyRule) -> String {
let mut notes = Vec::new();
for endpoint in &rule.endpoints {
let host = endpoint.host.to_lowercase();
if endpoint.allow_uninspected_credentials {
notes.push(format!(
"Endpoint '{host}' explicitly allows credentials on traffic OpenShell cannot inspect or rewrite."
));
}
// Flag destinations that are an internal/private address. Parse the host as
// an IP literal and defer to the canonical RFC-accurate classifier
// (openshell-core net::is_internal_ip) rather than naive string prefixes:
// `starts_with("172.")` wrongly matched 172.0-15 / 172.32-255 (RFC 1918 is
// only 172.16.0.0/12) and missed CGNAT (100.64.0.0/10), IPv6 ULA, etc. The
// Always-blocked destinations are rejected by merge validation instead
// of being presented as advisory findings. See #1777.
let resolves_advisory_internal = host
.parse::<IpAddr>()
.is_ok_and(|ip| is_internal_ip(ip) && !is_always_blocked_ip(ip));
if resolves_advisory_internal {
notes.push(format!(
"Destination '{host}' appears to be an internal/private address."
));
}
if host.contains('*') {
notes.push(format!(
"Host '{host}' contains a wildcard — this may match unintended destinations."
));
}
for allowed_ip in &endpoint.allowed_ips {
if allowed_ip_is_internal(allowed_ip) {
notes.push(format!(
"allowed_ips includes private/internal range '{allowed_ip}'."
));
}
}
if host.trim().is_empty() && !endpoint.allowed_ips.is_empty() {
notes.push(
"allowed_ips allowlist is hostless and may match any hostname resolving within the configured range."
.to_string(),
);
}
let ports = if endpoint.ports.is_empty() {
std::slice::from_ref(&endpoint.port)
} else {
endpoint.ports.as_slice()
};
for raw_port in ports {
let port = u16::try_from(*raw_port).unwrap_or(0);
if port > 49152 {
notes.push(format!(
"Port {port} is in the ephemeral range — this may be a temporary service."
));
}
const DB_PORTS: [u16; 7] = [5432, 3306, 6379, 27017, 9200, 11211, 5672];
if DB_PORTS.contains(&port) {
notes.push(format!(
"Port {port} is a well-known database/service port."
));
}
}
}
notes.join(" ")
}
/// Reject proposed rules whose endpoints or `allowed_ips` target
/// always-blocked addresses (loopback, link-local, unspecified).
///
/// This is defense-in-depth: the proxy blocks these at runtime, so
/// merging them into the active policy would be silently un-enforceable.
fn validate_host_not_always_blocked(host: &str) -> Result<(), Status> {
use openshell_core::net::{is_always_blocked_ip, is_known_metadata_hostname};
use std::net::IpAddr;
let host = host.trim();
// Check if the host is a literal always-blocked IP.
if let Ok(ip) = host.parse::<IpAddr>()
&& is_always_blocked_ip(ip)
{
return Err(Status::invalid_argument(format!(
"proposed rule endpoint host '{host}' is an always-blocked address \
(loopback/link-local/unspecified); the proxy will deny traffic \
to this destination regardless of policy"
)));
}
let host_lc = host.to_lowercase();
if host_lc == "localhost" || host_lc == "localhost." {
return Err(Status::invalid_argument(
"proposed rule endpoint host 'localhost' is always blocked; \
the proxy will deny traffic to loopback regardless of policy"
.to_string(),
));
}
if is_known_metadata_hostname(host) {
return Err(Status::invalid_argument(format!(
"proposed rule endpoint host '{host}' is a known cloud metadata hostname; \
the proxy will deny traffic to this destination regardless of policy"
)));
}
Ok(())
}
fn validate_rule_not_always_blocked(rule: &NetworkPolicyRule) -> Result<(), Status> {
use openshell_core::net::is_always_blocked_net;
use std::net::IpAddr;
for ep in &rule.endpoints {
validate_host_not_always_blocked(&ep.host)?;
// Check allowed_ips entries.
for entry in &ep.allowed_ips {
let parsed = entry.parse::<ipnet::IpNet>().or_else(|_| {
entry.parse::<IpAddr>().map(|ip| match ip {
IpAddr::V4(v4) => ipnet::IpNet::V4(ipnet::Ipv4Net::from(v4)),
IpAddr::V6(v6) => ipnet::IpNet::V6(ipnet::Ipv6Net::from(v6)),
})
});
if let Ok(net) = parsed
&& is_always_blocked_net(net)
{
return Err(Status::invalid_argument(format!(
"proposed rule contains always-blocked `allowed_ips` entry '{entry}'; \
SSRF hardening prevents traffic to these destinations \
regardless of policy"
)));
}
// Invalid entries are not our concern here — the sandbox's
// parse_allowed_ips handles syntax validation.
}
}
Ok(())
}
async fn require_no_global_policy(state: &ServerState) -> Result<(), Status> {
let global = load_global_settings(state.store.as_ref()).await?;
if global.settings.contains_key(POLICY_SETTING_KEY) {
return Err(Status::failed_precondition(
"cannot approve rules while a global policy is active; \
delete the global policy to manage per-sandbox rules",
));
}
Ok(())
}
fn parse_merge_operations(
proto_ops: &[PolicyMergeOperation],
) -> Result<Vec<PolicyMergeOp>, Status> {
proto_ops
.iter()
.enumerate()
.map(|(index, operation)| {
let Some(operation) = operation.operation.as_ref() else {
return Err(Status::invalid_argument(format!(
"merge_operations[{index}] is missing an operation"
)));
};
match operation {
policy_merge_operation::Operation::AddRule(add_rule) => {
let rule_name = add_rule.rule_name.trim();
if rule_name.is_empty() {
return Err(Status::invalid_argument(format!(
"merge_operations[{index}].add_rule.rule_name is required"
)));
}
if add_rule.rule.as_ref().is_none_or(|rule| rule.endpoints.is_empty()) {
return Err(Status::invalid_argument(format!(
"merge_operations[{index}].add_rule.rule must contain at least one endpoint"
)));
}
Ok(PolicyMergeOp::AddRule {
rule_name: rule_name.to_string(),
rule: add_rule.rule.clone().unwrap_or_default(),
})
}
policy_merge_operation::Operation::RemoveEndpoint(remove_endpoint) => {
if remove_endpoint.host.trim().is_empty() || remove_endpoint.port == 0 {
return Err(Status::invalid_argument(format!(
"merge_operations[{index}].remove_endpoint requires host and non-zero port"
)));
}
let rule_name = if remove_endpoint.rule_name.trim().is_empty() {
None
} else {
Some(remove_endpoint.rule_name.trim().to_string())
};
Ok(PolicyMergeOp::RemoveEndpoint {
rule_name,
host: remove_endpoint.host.trim().to_string(),
port: remove_endpoint.port,
})
}
policy_merge_operation::Operation::RemoveRule(remove_rule) => {
let rule_name = remove_rule.rule_name.trim();
if rule_name.is_empty() {
return Err(Status::invalid_argument(format!(
"merge_operations[{index}].remove_rule.rule_name is required"
)));
}
Ok(PolicyMergeOp::RemoveRule {
rule_name: rule_name.to_string(),
})
}
policy_merge_operation::Operation::AddDenyRules(add_deny_rules) => {
parse_proto_add_deny_rules(index, add_deny_rules)
}
policy_merge_operation::Operation::AddAllowRules(add_allow_rules) => {
parse_proto_add_allow_rules(index, add_allow_rules)
}
policy_merge_operation::Operation::RemoveBinary(remove_binary) => {
let rule_name = remove_binary.rule_name.trim();
let binary_path = remove_binary.binary_path.trim();
if rule_name.is_empty() || binary_path.is_empty() {
return Err(Status::invalid_argument(format!(
"merge_operations[{index}].remove_binary requires rule_name and binary_path"
)));
}
Ok(PolicyMergeOp::RemoveBinary {
rule_name: rule_name.to_string(),
binary_path: binary_path.to_string(),
})
}
}
})
.collect()
}
fn parse_proto_add_deny_rules(
index: usize,
add_deny_rules: &ProtoAddDenyRules,
) -> Result<PolicyMergeOp, Status> {
if add_deny_rules.deny_rules.is_empty() {
return Err(Status::invalid_argument(format!(
"merge_operations[{index}].add_deny_rules requires at least one deny rule"
)));
}
Ok(PolicyMergeOp::AddDenyRules {
target: parse_proto_l7_target(index, add_deny_rules.target.as_ref())?,
deny_rules: add_deny_rules.deny_rules.clone(),
})
}
fn parse_proto_add_allow_rules(
index: usize,
add_allow_rules: &ProtoAddAllowRules,
) -> Result<PolicyMergeOp, Status> {
if add_allow_rules.rules.is_empty() {
return Err(Status::invalid_argument(format!(
"merge_operations[{index}].add_allow_rules requires at least one allow rule"
)));
}
if add_allow_rules
.rules
.iter()
.any(|rule| rule.allow.as_ref().is_none())
{
return Err(Status::invalid_argument(format!(
"merge_operations[{index}].add_allow_rules rules must include allow payloads"
)));
}
Ok(PolicyMergeOp::AddAllowRules {
target: parse_proto_l7_target(index, add_allow_rules.target.as_ref())?,
rules: add_allow_rules.rules.clone(),
})
}
fn parse_proto_l7_target(
index: usize,
target: Option<&ProtoL7RuleTarget>,
) -> Result<L7RuleTarget, Status> {
let target = target.ok_or_else(|| {
Status::invalid_argument(format!("merge_operations[{index}] requires an L7 target"))
})?;
// An omitted binary declaration must never become any-binary authority.
// The wire format permits both fields, so enforce the exclusive choice here.
let binaries = match (target.any_binary, target.binaries.is_empty()) {
(true, true) => L7BinaryScope::Any,
(false, false) => L7BinaryScope::Restricted(target.binaries.clone()),
_ => {
return Err(Status::invalid_argument(format!(
"merge_operations[{index}].target requires exactly one of any_binary=true or nonempty binaries"
)));
}
};
let target = L7RuleTarget {
rule_name: target.rule_name.clone(),
host: target.host.clone(),
ports: target.ports.clone(),
path: target.path.clone(),
binaries,
};
target.validate(index).map_err(map_policy_merge_error)?;
Ok(target)
}
fn validate_merge_operations_for_server(operations: &[PolicyMergeOp]) -> Result<(), Status> {
for operation in operations {
match operation {
PolicyMergeOp::AddRule { rule_name, rule } => {
if openshell_policy::is_provider_rule_name(rule_name) {
return Err(Status::invalid_argument(format!(
"merge operation add_rule rule_name '{rule_name}' uses reserved '_provider_' prefix for provider composition"
)));
}
validate_rule_not_always_blocked(rule)?;
}
PolicyMergeOp::AddAllowRules { target, .. } => {
validate_host_not_always_blocked(&target.host)?;
}
_ => {}
}
}
Ok(())
}
fn map_policy_merge_error(error: openshell_policy::PolicyMergeError) -> Status {
match error {
openshell_policy::PolicyMergeError::InvalidOperationPolicy { .. }
| openshell_policy::PolicyMergeError::MissingRuleNameForAddRule
| openshell_policy::PolicyMergeError::EmptyAddRuleEndpoints { .. }
| openshell_policy::PolicyMergeError::InvalidEndpointReference { .. }
| openshell_policy::PolicyMergeError::InvalidL7Target { .. }
| openshell_policy::PolicyMergeError::UnsupportedAccessPreset { .. } => {
Status::invalid_argument(error.to_string())
}
openshell_policy::PolicyMergeError::InvalidInputPolicy { .. }
| openshell_policy::PolicyMergeError::McpContractConflict { .. }
| openshell_policy::PolicyMergeError::NewBinaryWouldInheritAuthorization { .. }
| openshell_policy::PolicyMergeError::ExistingBinariesWouldInheritAuthorization {
..
}
| openshell_policy::PolicyMergeError::UndeclaredPortWouldChange { .. }
| openshell_policy::PolicyMergeError::ConflictingInspectionContracts { .. }
| openshell_policy::PolicyMergeError::AmbiguousL7Target { .. }
| openshell_policy::PolicyMergeError::L7BinaryScopeMismatch { .. }
| openshell_policy::PolicyMergeError::L7PortScopeMismatch { .. }
| openshell_policy::PolicyMergeError::CannotRemoveBinaryFromAnyBinaryScope { .. }
| openshell_policy::PolicyMergeError::EndpointNotFound { .. }
| openshell_policy::PolicyMergeError::EndpointHasNoL7Inspection { .. }
| openshell_policy::PolicyMergeError::UnsupportedEndpointProtocol { .. }
| openshell_policy::PolicyMergeError::EndpointHasNoAllowBase { .. } => {
Status::failed_precondition(error.to_string())
}
openshell_policy::PolicyMergeError::InvalidMergedPolicy { .. } => {
Status::internal(error.to_string())
}
openshell_policy::PolicyMergeError::L7TargetNotFound { .. } => {
Status::not_found(error.to_string())
}
}
}
struct AtomicPolicyWriteContext<'a> {
expected_resource_version: u64,
provenance: &'a HashMap<String, String>,
annotations: &'a HashMap<String, String>,
}
struct PolicyCredentialBindingValidationContext<'a> {
catalog: &'a EffectiveProviderProfileCatalog,
records: &'a [super::provider::ProviderEnvironmentRecord],
credentialed_scopes: &'a [CredentialedEndpointScope],
endpointless_provider_names: &'a HashSet<String>,
}
struct SandboxPolicyMergeValidationData {
provider_layers: Vec<ProviderPolicyLayer>,
catalog: EffectiveProviderProfileCatalog,
records: Vec<super::provider::ProviderEnvironmentRecord>,
credentialed_scopes: Vec<CredentialedEndpointScope>,
endpointless_provider_names: HashSet<String>,
}
impl SandboxPolicyMergeValidationData {
fn credential_binding_context(&self) -> PolicyCredentialBindingValidationContext<'_> {
PolicyCredentialBindingValidationContext {
catalog: &self.catalog,
records: &self.records,
credentialed_scopes: &self.credentialed_scopes,
endpointless_provider_names: &self.endpointless_provider_names,
}
}
}
async fn sandbox_policy_merge_validation_data(
state: &ServerState,
workspace: &str,
sandbox: &Sandbox,
provider_names: &[String],
) -> Result<SandboxPolicyMergeValidationData, Status> {
let catalog = state
.provider_profile_sources
.snapshot_catalog(state.store.as_ref(), workspace)
.await?;
sandbox_policy_merge_validation_data_with_catalog(
state,
workspace,
sandbox,
provider_names,
&catalog,
)
.await
}
async fn sandbox_policy_merge_validation_data_with_catalog(
state: &ServerState,
workspace: &str,
sandbox: &Sandbox,
provider_names: &[String],
catalog: &EffectiveProviderProfileCatalog,
) -> Result<SandboxPolicyMergeValidationData, Status> {
let global_settings = load_global_settings(state.store.as_ref()).await?;
let composition_enabled = provider_policy_composition_enabled_in(&global_settings)?;
let ProviderPolicyContext {
layers,
credentialed_scopes,
endpointless_provider_names,
} = provider_policy_context_with_catalog(
state.store.as_ref(),
catalog,
workspace,
provider_names,
)
.await?;
let provider_layers = if composition_enabled {
layers
} else {
Vec::new()
};
debug!(
sandbox_id = %sandbox.object_id(),
provider_layer_count = provider_layers.len(),
"Composed provider policy and credential context for merge validation"
);
let records = super::provider::load_provider_environment_records(
state.store.as_ref(),
workspace,
provider_names,
)
.await?;
Ok(SandboxPolicyMergeValidationData {
provider_layers,
catalog: catalog.clone(),
records,
credentialed_scopes,
endpointless_provider_names,
})
}
#[derive(Clone, Copy)]
struct PolicyMergeValidationContext<'a> {
provider_layers: &'a [ProviderPolicyLayer],
credential_binding: Option<&'a PolicyCredentialBindingValidationContext<'a>>,
}
fn validate_operator_merged_credential_policy(
effective_policy: &mut ProtoSandboxPolicy,
bindings: &HashMap<String, Vec<StaticCredentialEndpointBinding>>,
context: &PolicyCredentialBindingValidationContext<'_>,
) -> Result<(), Status> {
validate_policy_credential_binding_context(
context.catalog,
context.records,
effective_policy,
bindings,
)?;
let mut credentialed_scopes = context.credentialed_scopes.to_vec();
extend_credentialed_scopes_from_policy_bindings(
&mut credentialed_scopes,
bindings,
context.endpointless_provider_names,
);
clear_provider_credentialed_markers(effective_policy);
stamp_provider_credentialed_endpoints(effective_policy, &credentialed_scopes);
validate_uninspected_credentialed_endpoints(effective_policy)
}
fn stage_validated_merge_operation(
current_policy: &ProtoSandboxPolicy,
operation: &PolicyMergeOp,
validation_context: PolicyMergeValidationContext<'_>,
) -> Result<ProtoSandboxPolicy, Status> {
validate_merge_operations_for_server(std::slice::from_ref(operation))?;
let merged = merge_policy(current_policy.clone(), std::slice::from_ref(operation))
.map_err(map_policy_merge_error)?;
let candidate = merged.policy;
validate_policy_safety(&candidate)?;
validate_candidate_effective_policy(&candidate, validation_context.provider_layers)?;
let mut effective = if validation_context.provider_layers.is_empty() {
candidate.clone()
} else {
compose_effective_policy(&candidate, validation_context.provider_layers)
};
let bindings = policy_static_credential_endpoint_bindings(Some(&effective))?;
if let Some(context) = validation_context.credential_binding {
validate_operator_merged_credential_policy(&mut effective, &bindings, context)?;
}
Ok(candidate)
}
#[allow(clippy::too_many_arguments)]
async fn apply_merge_operations_with_retry(
store: &Store,
sandbox_id: &str,
workspace: &str,
baseline_policy: Option<&ProtoSandboxPolicy>,
operations: &[PolicyMergeOp],
validation_context: PolicyMergeValidationContext<'_>,
expected_current_effective_hash: Option<&str>,
atomic_context: Option<&AtomicPolicyWriteContext<'_>>,
) -> Result<(i64, String, Option<Sandbox>), Status> {
let provider_layers = validation_context.provider_layers;
for attempt in 1..=MERGE_RETRY_LIMIT {
let latest = store
.get_latest_policy(sandbox_id)
.await
.map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?;
let (current_policy, current_hash) = if let Some(ref record) = latest {
let (policy, hash) = canonical_policy_record_identity(record)?;
(policy, Some(hash))
} else {
(baseline_policy.cloned().unwrap_or_default(), None)
};
if let Some(expected_hash) = expected_current_effective_hash {
let mut current_effective = if provider_layers.is_empty() {
current_policy.clone()
} else {
compose_effective_policy(&current_policy, provider_layers)
};
let bindings = policy_static_credential_endpoint_bindings(Some(&current_effective))?;
if let Some(context) = validation_context.credential_binding {
validate_operator_merged_credential_policy(
&mut current_effective,
&bindings,
context,
)?;
}
if deterministic_policy_hash(&current_effective) != expected_hash {
return Err(Status::failed_precondition(
"proposal inputs changed before persistence; refetch and review again",
));
}
}
let merged = merge_policy(current_policy, operations).map_err(map_policy_merge_error)?;
let new_policy = merged.policy;
let hash = deterministic_policy_hash(&new_policy);
if let Some(baseline_policy) = baseline_policy {
validate_static_fields_unchanged(baseline_policy, &new_policy)?;
}
validate_policy_safety(&new_policy)?;
validate_candidate_effective_policy(&new_policy, provider_layers)?;
let mut effective_policy = if provider_layers.is_empty() {
new_policy.clone()
} else {
compose_effective_policy(&new_policy, provider_layers)
};
let bindings = policy_static_credential_endpoint_bindings(Some(&effective_policy))?;
if let Some(context) = validation_context.credential_binding {
validate_operator_merged_credential_policy(&mut effective_policy, &bindings, context)?;
}
if let Some(ref current) = latest
&& current_hash.as_deref() == Some(hash.as_str())
&& atomic_context.is_none_or(|context| current.provenance == *context.provenance)
{
return Ok((current.version, hash, None));
}
if latest.is_none() && !merged.changed {
return Ok((0, hash, None));
}
let payload = new_policy.encode_to_vec();
let next_version = latest.as_ref().map_or(1, |record| record.version + 1);
let policy_id = uuid::Uuid::new_v4().to_string();
let write_result = if let Some(context) = atomic_context {
store
.put_policy_revision_atomic(&AtomicPolicyRevisionWrite {
id: policy_id,
sandbox_id: sandbox_id.to_string(),
workspace: workspace.to_string(),
version: next_version,
policy_payload: payload,
policy_hash: hash.clone(),
provenance: context.provenance.clone(),
expected_resource_version: context.expected_resource_version,
annotations: context.annotations.clone(),
backfill_policy: None,
})
.await
.map(Some)
} else {
store
.put_policy_revision(
&policy_id,
sandbox_id,
workspace,
next_version,
&payload,
&hash,
)
.await
.map(|()| None)
};
match write_result {
Ok(updated_sandbox) => {
if atomic_context.is_none() {
let _ = store
.supersede_older_policies(sandbox_id, next_version)
.await;
}
if attempt > 1 {
info!(
sandbox_id = %sandbox_id,
attempt,
version = next_version,
operation_count = operations.len(),
"apply_merge_operations_with_retry: succeeded after version conflict retry"
);
}
return Ok((next_version, hash, updated_sandbox));
}
Err(e) => {
if e.is_unique_violation_on("objects_version_uq") {
if expected_current_effective_hash.is_some() {
return Err(Status::failed_precondition(
"policy changed while applying reviewed proposal; refetch and review again",
));
}
warn!(
sandbox_id = %sandbox_id,
attempt,
conflicting_version = next_version,
operation_count = operations.len(),
"apply_merge_operations_with_retry: version conflict, retrying"
);
tokio::task::yield_now().await;
continue;
}
return Err(Status::internal(format!(
"persist policy revision failed: {e}"
)));
}
}
}
Err(Status::aborted(format!(
"apply_merge_operations_with_retry: gave up after {MERGE_RETRY_LIMIT} version conflict retries"
)))
}
async fn merge_chunk_into_policy_with_validation(
store: &Store,
sandbox_id: &str,
workspace: &str,
chunk: &DraftChunkRecord,
validation_context: PolicyMergeValidationContext<'_>,
) -> Result<(i64, String), Status> {
let rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice())
.map_err(|e| Status::internal(format!("decode proposed_rule failed: {e}")))?;
let operations = [PolicyMergeOp::AddRule {
rule_name: chunk.rule_name.clone(),
rule,
}];
validate_merge_operations_for_server(&operations)?;
let mut baseline_policy = chunk.current_effective_policy.clone();
if let Some(policy) = &mut baseline_policy {
strip_provider_rule_names(policy);
clear_provider_credentialed_markers(policy);
}
apply_merge_operations_with_retry(
store,
sandbox_id,
workspace,
baseline_policy.as_ref(),
&operations,
validation_context,
(!chunk.current_effective_policy_hash.is_empty())
.then_some(chunk.current_effective_policy_hash.as_str()),
None,
)
.await
.map(|(version, hash, _)| (version, hash))
}
#[cfg(test)]
async fn merge_chunk_into_policy(
store: &Store,
sandbox_id: &str,
workspace: &str,
chunk: &DraftChunkRecord,
provider_layers: &[ProviderPolicyLayer],
) -> Result<(i64, String), Status> {
merge_chunk_into_policy_with_validation(
store,
sandbox_id,
workspace,
chunk,
PolicyMergeValidationContext {
provider_layers,
credential_binding: None,
},
)
.await
}
async fn remove_chunk_from_policy(
state: &ServerState,
sandbox_id: &str,
workspace: &str,
chunk: &DraftChunkRecord,
) -> Result<(i64, String), Status> {
apply_merge_operations_with_retry(
state.store.as_ref(),
sandbox_id,
workspace,
None,
&[PolicyMergeOp::RemoveBinary {
rule_name: chunk.rule_name.clone(),
binary_path: chunk.binary.clone(),
}],
PolicyMergeValidationContext {
provider_layers: &[],
credential_binding: None,
},
None,
None,
)
.await
.map(|(version, hash, _)| (version, hash))
}
// ---------------------------------------------------------------------------
// Settings helpers
// ---------------------------------------------------------------------------
fn validate_registered_setting_key(
key: &str,
) -> Result<&'static settings::RegisteredSetting, Status> {
settings::setting_for_key(key).ok_or_else(|| {
Status::invalid_argument(format!(
"unknown setting key '{key}'. Allowed keys: {}",
settings::registered_keys_csv()
))
})
}
fn proto_setting_to_stored(key: &str, value: &SettingValue) -> Result<StoredSettingValue, Status> {
let setting = validate_registered_setting_key(key)?;
let expected = setting.kind;
let inner = value
.value
.as_ref()
.ok_or_else(|| Status::invalid_argument("setting_value.value is required"))?;
let stored = match (expected, inner) {
(SettingValueKind::String, setting_value::Value::StringValue(v)) => {
// Enforce per-key string whitelist at configure time so typos
// (e.g. `proposal_approval_mode=autom`) get rejected here instead
// of silently falling back to the default at runtime.
if let Err(allowed) = setting.validate_string_value(v) {
return Err(Status::invalid_argument(format!(
"setting '{key}' expects one of [{}]; got '{}'",
allowed.join(", "),
v
)));
}
StoredSettingValue::String(v.clone())
}
(SettingValueKind::Bool, setting_value::Value::BoolValue(v)) => {
StoredSettingValue::Bool(*v)
}
(SettingValueKind::Int, setting_value::Value::IntValue(v)) => StoredSettingValue::Int(*v),
(_, setting_value::Value::BytesValue(_)) => {
return Err(Status::invalid_argument(format!(
"setting '{key}' expects {} value; bytes are not supported for this key",
expected.as_str()
)));
}
(expected_kind, _) => {
return Err(Status::invalid_argument(format!(
"setting '{key}' expects {} value",
expected_kind.as_str()
)));
}
};
Ok(stored)
}
fn stored_setting_to_proto(value: &StoredSettingValue) -> Result<SettingValue, Status> {
let proto = match value {
StoredSettingValue::String(v) => SettingValue {
value: Some(setting_value::Value::StringValue(v.clone())),
},
StoredSettingValue::Bool(v) => SettingValue {
value: Some(setting_value::Value::BoolValue(*v)),
},
StoredSettingValue::Int(v) => SettingValue {
value: Some(setting_value::Value::IntValue(*v)),
},
StoredSettingValue::Bytes(v) => {
let decoded = hex::decode(v)
.map_err(|e| Status::internal(format!("stored bytes decode failed: {e}")))?;
SettingValue {
value: Some(setting_value::Value::BytesValue(decoded)),
}
}
};
Ok(proto)
}
fn upsert_setting_value(
map: &mut BTreeMap<String, StoredSettingValue>,
key: &str,
value: StoredSettingValue,
) -> bool {
match map.get(key) {
Some(existing) if existing == &value => false,
_ => {
map.insert(key.to_string(), value);
true
}
}
}
pub(super) async fn load_global_settings(store: &Store) -> Result<StoredSettings, Status> {
load_settings_record(store, GLOBAL_SETTINGS_OBJECT_TYPE, "", GLOBAL_SETTINGS_NAME).await
}
pub(super) async fn save_global_settings(
store: &Store,
settings: &StoredSettings,
) -> Result<(), Status> {
save_settings_record(
store,
GLOBAL_SETTINGS_OBJECT_TYPE,
"",
GLOBAL_SETTINGS_NAME,
settings,
)
.await
}
pub(super) async fn load_sandbox_settings(
store: &Store,
workspace: &str,
sandbox_name: &str,
) -> Result<StoredSettings, Status> {
load_settings_record(store, SANDBOX_SETTINGS_OBJECT_TYPE, workspace, sandbox_name).await
}
pub(super) async fn save_sandbox_settings(
store: &Store,
workspace: &str,
sandbox_name: &str,
settings: &StoredSettings,
) -> Result<(), Status> {
save_settings_record(
store,
SANDBOX_SETTINGS_OBJECT_TYPE,
workspace,
sandbox_name,
settings,
)
.await
}
async fn load_settings_record(
store: &Store,
object_type: &str,
workspace: &str,
name: &str,
) -> Result<StoredSettings, Status> {
let record = store
.get_by_name(object_type, workspace, name)
.await
.map_err(|e| Status::internal(format!("fetch settings failed: {e}")))?;
if let Some(record) = record {
let mut settings = serde_json::from_slice::<StoredSettings>(&record.payload)
.map_err(|e| Status::internal(format!("decode settings payload failed: {e}")))?;
settings.resource_version = record.resource_version;
for key in settings.settings.keys() {
settings
.change_clocks
.entry(key.clone())
.or_insert_with(|| super::SettingChangeClock {
id: format!("{}:{}:{key}", record.id, record.resource_version),
committed_at_ms: record.updated_at_ms,
});
}
Ok(settings)
} else {
Ok(StoredSettings::default())
}
}
async fn save_settings_record(
store: &Store,
object_type: &str,
workspace: &str,
name: &str,
settings: &StoredSettings,
) -> Result<(), Status> {
use crate::persistence::WriteCondition;
let previous = load_settings_record(store, object_type, workspace, name).await?;
let mut persisted = settings.clone();
persisted.change_clocks = previous.change_clocks.clone();
let now_ms = current_time_ms();
for key in previous.settings.keys().chain(settings.settings.keys()) {
if previous.settings.get(key) != settings.settings.get(key) {
persisted.change_clocks.insert(
key.clone(),
super::SettingChangeClock {
id: uuid::Uuid::new_v4().to_string(),
committed_at_ms: now_ms,
},
);
}
}
let payload = serde_json::to_vec(&persisted)
.map_err(|e| Status::internal(format!("encode settings payload failed: {e}")))?;
let (id, condition) = if settings.resource_version == 0 {
(uuid::Uuid::new_v4().to_string(), WriteCondition::MustCreate)
} else {
let existing = store
.get_by_name(object_type, workspace, name)
.await
.map_err(|e| Status::internal(format!("fetch settings for CAS failed: {e}")))?
.ok_or_else(|| Status::not_found("settings disappeared since load"))?;
(
existing.id,
WriteCondition::MatchResourceVersion(settings.resource_version),
)
};
store
.put_if(object_type, &id, name, workspace, &payload, None, condition)
.await
.map_err(|e| match e {
crate::persistence::PersistenceError::Conflict { .. } => {
Status::aborted("settings were modified concurrently; please retry")
}
crate::persistence::PersistenceError::UniqueViolation { .. } => {
Status::aborted("settings were created concurrently; please retry")
}
other => super::persistence_error_to_status(other, "persist settings"),
})?;
Ok(())
}
fn decode_policy_from_global_settings(
global: &StoredSettings,
) -> Result<Option<ProtoSandboxPolicy>, Status> {
let Some(value) = global.settings.get(POLICY_SETTING_KEY) else {
return Ok(None);
};
let StoredSettingValue::Bytes(encoded) = value else {
return Err(Status::internal(
"global policy setting has invalid value type; expected bytes",
));
};
let raw = hex::decode(encoded)
.map_err(|e| Status::internal(format!("global policy decode failed: {e}")))?;
let policy = decode_stored_policy(raw.as_slice())
.map_err(|e| Status::internal(format!("global policy protobuf decode failed: {e}")))?;
validate_and_canonicalize_stored_policy(policy, STORED_POLICY_SOURCE_GLOBAL).map(Some)
}
/// Validate a decoded stored policy before it is trusted, hashed, or copied.
///
/// Stored rows may have been written by an older schema or damaged outside the
/// normal request path. Treat invalid durable state as a failed precondition and
/// return the canonical value so callers cannot accidentally reuse raw bytes.
fn validate_and_canonicalize_stored_policy(
policy: ProtoSandboxPolicy,
source: &'static str,
) -> Result<ProtoSandboxPolicy, Status> {
openshell_policy::validate_and_canonicalize_sandbox_policy(policy).map_err(|error| {
Status::failed_precondition(format!(
"stored policy source '{source}' is invalid: {error}"
))
})
}
fn merge_effective_settings(
global: &StoredSettings,
sandbox: &StoredSettings,
) -> Result<HashMap<String, EffectiveSetting>, Status> {
let mut merged = HashMap::new();
for registered in settings::REGISTERED_SETTINGS {
merged.insert(
registered.key.to_string(),
EffectiveSetting {
value: None,
scope: SettingScope::Unspecified.into(),
},
);
}
for (key, value) in &sandbox.settings {
if key == POLICY_SETTING_KEY || settings::setting_for_key(key).is_none() {
continue;
}
merged.insert(
key.clone(),
EffectiveSetting {
value: Some(stored_setting_to_proto(value)?),
scope: SettingScope::Sandbox.into(),
},
);
}
for (key, value) in &global.settings {
if key == POLICY_SETTING_KEY || settings::setting_for_key(key).is_none() {
continue;
}
merged.insert(
key.clone(),
EffectiveSetting {
value: Some(stored_setting_to_proto(value)?),
scope: SettingScope::Global.into(),
},
);
}
Ok(merged)
}
fn materialize_global_settings(
global: &StoredSettings,
) -> Result<HashMap<String, SettingValue>, Status> {
let mut materialized = HashMap::new();
for registered in settings::REGISTERED_SETTINGS {
materialized.insert(registered.key.to_string(), SettingValue { value: None });
}
for (key, value) in &global.settings {
if key == POLICY_SETTING_KEY {
continue;
}
if settings::setting_for_key(key).is_none() {
continue;
}
materialized.insert(key.clone(), stored_setting_to_proto(value)?);
}
Ok(materialized)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::identity::{Identity, IdentityProvider};
use crate::auth::principal::{
Principal, SandboxIdentitySource, SandboxPrincipal, UserPrincipal,
};
use crate::grpc::test_support::{authed_request, test_server_state};
/// An in-memory store with the example profiles imported at platform scope.
///
/// Provider profiles are import-only, so a gateway resolves only what an
/// operator imported. Tests that expect `github`, `openai` or
/// `google-cloud` to resolve have to import them first.
async fn test_store() -> Store {
let store = crate::persistence::test_store().await;
for profile in openshell_providers::example_profiles::load_all() {
store
.put_message(&crate::provider_profile_sources::stored_provider_profile(
profile.to_proto(),
))
.await
.expect("store example provider profile");
}
store
}
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tonic::Code;
/// Wrap a request with a user `Principal` so handler scope guards treat
/// the test caller as a CLI user. Most handler tests exercise
/// user-facing behavior and should not trip sandbox equality checks.
fn with_user<T>(mut request: Request<T>) -> Request<T> {
request
.extensions_mut()
.insert(Principal::User(UserPrincipal {
identity: Identity {
subject: "test-user".to_string(),
display_name: None,
roles: vec![],
scopes: vec![],
provider: IdentityProvider::Oidc,
},
}));
request
}
#[test]
fn sandbox_log_timestamp_validation_rejects_malformed_batches_atomically() {
let logs = vec![
SandboxLogLine {
event_time: openshell_core::time::timestamp_from_millis(0).ok(),
..Default::default()
},
SandboxLogLine {
event_time: Some(prost_types::Timestamp {
seconds: 0,
nanos: -1,
}),
..Default::default()
},
];
let error = validate_sandbox_log_timestamps(&logs).unwrap_err();
assert_eq!(error.code(), Code::InvalidArgument);
assert!(error.message().contains("logs[1].event_time"));
}
#[test]
fn stored_policy_decode_migrates_legacy_provenance_and_preserves_unknown_fields() {
#[derive(Clone, PartialEq, Message)]
struct FutureStoredNetworkBinary {
#[prost(string, tag = "1")]
path: String,
#[prost(bool, tag = "2")]
advisor_proposed: bool,
#[prost(string, tag = "99")]
future_metadata: String,
}
#[derive(Clone, PartialEq, Message)]
struct FutureStoredNetworkPolicyRule {
#[prost(string, tag = "1")]
name: String,
#[prost(message, repeated, tag = "2")]
endpoints: Vec<NetworkEndpoint>,
#[prost(message, repeated, tag = "3")]
binaries: Vec<FutureStoredNetworkBinary>,
}
#[derive(Clone, PartialEq, Message)]
struct FutureStoredSandboxPolicy {
#[prost(map = "string, message", tag = "5")]
network_policies: HashMap<String, FutureStoredNetworkPolicyRule>,
}
#[derive(Clone, PartialEq, Message)]
struct LegacyStoredPolicyRevisionPayload {
#[prost(message, optional, tag = "1")]
policy: Option<FutureStoredSandboxPolicy>,
}
let legacy = FutureStoredSandboxPolicy {
network_policies: HashMap::from([(
"cargo_registry".to_string(),
FutureStoredNetworkPolicyRule {
name: "cargo-registry".to_string(),
endpoints: vec![NetworkEndpoint {
host: "index.crates.io".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![
FutureStoredNetworkBinary {
path: "/usr/bin/cargo".to_string(),
advisor_proposed: false,
future_metadata: "keep-explicit".to_string(),
},
FutureStoredNetworkBinary {
path: "/usr/bin/curl".to_string(),
advisor_proposed: true,
future_metadata: "keep-advisor".to_string(),
},
],
},
)]),
};
let wrapped = LegacyStoredPolicyRevisionPayload {
policy: Some(legacy),
}
.encode_to_vec();
let record = crate::policy_store::policy_record_from_parts(
"revision-1".to_string(),
"sandbox-1".to_string(),
1,
"loaded".to_string(),
&wrapped,
1,
)
.unwrap();
let rewrapped = crate::policy_store::policy_payload_from_record(&record).unwrap();
let future_payload = LegacyStoredPolicyRevisionPayload::decode(rewrapped.as_slice())
.expect("rewrapped policy should retain unknown nested fields");
let future_rule = &future_payload.policy.unwrap().network_policies["cargo_registry"];
assert!(!future_rule.binaries[0].advisor_proposed);
assert_eq!(future_rule.binaries[0].future_metadata, "keep-explicit");
assert!(future_rule.binaries[1].advisor_proposed);
assert_eq!(future_rule.binaries[1].future_metadata, "keep-advisor");
let record = crate::policy_store::policy_record_from_parts(
"revision-2".to_string(),
"sandbox-1".to_string(),
1,
"loaded".to_string(),
&rewrapped,
1,
)
.unwrap();
let decoded = decode_stored_policy(&record.policy_payload).unwrap();
let explicit = &decoded.network_policies["cargo_registry"];
assert_eq!(explicit.binaries[0].path, "/usr/bin/cargo");
assert!(!explicit.endpoints[0].advisor_proposed);
let advisor = &decoded.network_policies["cargo_registry__legacy_advisor"];
assert_eq!(advisor.binaries[0].path, "/usr/bin/curl");
assert!(advisor.endpoints[0].advisor_proposed);
let round_tripped = decode_stored_policy(&decoded.encode_to_vec()).unwrap();
assert_eq!(round_tripped, decoded);
}
#[test]
fn agent_authored_candidate_avoids_explicit_rule_name_collision() {
let mut base = ProtoSandboxPolicy::default();
base.network_policies.insert(
"allow_index_crates_io_443".to_string(),
NetworkPolicyRule {
name: "explicit-index".to_string(),
endpoints: vec![NetworkEndpoint {
host: "index.crates.io".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/cargo".to_string(),
}],
},
);
let proposal = NetworkPolicyRule {
name: "allow_index_crates_io_443".to_string(),
endpoints: vec![NetworkEndpoint {
host: "index.crates.io".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
let evaluation = evaluate_proposal_candidate(
&base,
&base,
"allow_index_crates_io_443",
&proposal,
"agent_authored",
&CredentialSet::default(),
PolicyMergeValidationContext {
provider_layers: &[],
credential_binding: None,
},
None,
);
assert!(evaluation.application_error.is_empty());
assert_eq!(evaluation.rule_name, "allow_index_crates_io_443_2");
assert!(evaluation.rule.endpoints[0].advisor_proposed);
let candidate = evaluation.candidate_effective_policy.unwrap();
assert_eq!(
candidate.network_policies["allow_index_crates_io_443"].binaries[0].path,
"/usr/bin/cargo"
);
assert!(
candidate.network_policies["allow_index_crates_io_443_2"].endpoints[0].advisor_proposed
);
}
/// Wrap a request with a sandbox `Principal` bound to `sandbox_id`.
/// Use for tests that exercise sandbox-caller code paths.
#[allow(dead_code)]
pub(super) fn with_sandbox<T>(mut request: Request<T>, sandbox_id: &str) -> Request<T> {
request
.extensions_mut()
.insert(Principal::Sandbox(SandboxPrincipal {
sandbox_id: sandbox_id.to_string(),
source: SandboxIdentitySource::BootstrapJwt {
issuer: "openshell-gateway:test".to_string(),
},
trust_domain: Some("openshell".to_string()),
}));
request
}
#[tokio::test]
async fn provisioning_timeout_rejects_supervisor_registration() {
use openshell_core::proto::{
ConfigurationAdmissionState, ReportSandboxConfigurationRequest,
SandboxConfigurationAdmission, SandboxPhase, SandboxProvisioning,
};
let state = test_server_state().await;
let sandbox_id = "sb-timeout-registration";
let mut sandbox = test_sandbox(
sandbox_id,
"timeout-registration",
openshell_policy::restrictive_default_policy(),
Vec::new(),
);
sandbox.set_phase(SandboxPhase::Error.into());
sandbox.status.as_mut().unwrap().provisioning = Some(SandboxProvisioning {
timeout_time: openshell_core::time::timestamp_from_millis(300_000).ok(),
..Default::default()
});
state.store.put_message(&sandbox).await.unwrap();
let error = handle_report_sandbox_configuration(
&state,
with_sandbox(
Request::new(ReportSandboxConfigurationRequest {
sandbox_id: sandbox_id.into(),
admission: Some(SandboxConfigurationAdmission {
instance_id: uuid::Uuid::new_v4().to_string(),
state: ConfigurationAdmissionState::Pending.into(),
..Default::default()
}),
..Default::default()
}),
sandbox_id,
),
)
.await
.unwrap_err();
assert_eq!(error.code(), Code::FailedPrecondition);
assert!(error.message().contains("repair window expired"));
}
#[tokio::test]
async fn configuration_admission_rejects_stale_generation_and_instance() {
use openshell_core::proto::{
ConfigurationAdmissionState as Admission, ReportSandboxConfigurationRequest,
SandboxConfigurationAdmission,
};
let state = test_server_state().await;
let sandbox_id = "sb-admission";
state
.store
.put_message(&test_sandbox(
sandbox_id,
"admission",
openshell_policy::restrictive_default_policy(),
Vec::new(),
))
.await
.unwrap();
let instance_id = uuid::Uuid::new_v4().to_string();
let report = |admission| {
with_sandbox(
Request::new(ReportSandboxConfigurationRequest {
sandbox_id: sandbox_id.to_string(),
admission: Some(admission),
expected_instance_id: String::new(),
}),
sandbox_id,
)
};
handle_report_sandbox_configuration(
&state,
report(SandboxConfigurationAdmission {
instance_id: instance_id.clone(),
state: Admission::Pending.into(),
..Default::default()
}),
)
.await
.unwrap();
let config = handle_get_sandbox_config(
&state,
with_sandbox(
Request::new(GetSandboxConfigRequest {
name: "admission".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
}),
sandbox_id,
),
)
.await
.unwrap()
.into_inner();
assert!(config.configuration_admitted);
let accepted = SandboxConfigurationAdmission {
instance_id: instance_id.clone(),
state: Admission::Accepted.into(),
policy_version: config.version,
policy_hash: config.policy_hash,
config_revision: config.config_revision,
provider_env_revision: config.provider_env_revision,
error: String::new(),
};
let mut outdated_admission = accepted.clone();
outdated_admission.provider_env_revision =
outdated_admission.provider_env_revision.wrapping_add(1);
assert_eq!(
handle_report_sandbox_configuration(&state, report(outdated_admission))
.await
.unwrap_err()
.code(),
Code::Aborted
);
handle_report_sandbox_configuration(&state, report(accepted.clone()))
.await
.unwrap();
let mut stale_rejection = accepted.clone();
stale_rejection.state = Admission::Rejected.into();
stale_rejection.policy_version += 1;
assert_eq!(
handle_report_sandbox_configuration(&state, report(stale_rejection))
.await
.unwrap_err()
.code(),
Code::Aborted,
"a delayed rejection must not mark the accepted current generation invalid"
);
let restart_instance = uuid::Uuid::new_v4().to_string();
let mut restart = report(SandboxConfigurationAdmission {
instance_id: restart_instance.clone(),
state: Admission::Pending.into(),
..Default::default()
});
restart.get_mut().expected_instance_id = instance_id.clone();
handle_report_sandbox_configuration(&state, restart)
.await
.unwrap();
assert_eq!(
handle_report_sandbox_configuration(
&state,
report(SandboxConfigurationAdmission {
instance_id,
state: Admission::Pending.into(),
..Default::default()
})
)
.await
.unwrap_err()
.code(),
Code::FailedPrecondition,
"delayed old Pending must not reclaim registration"
);
assert_eq!(
handle_report_sandbox_configuration(&state, report(accepted.clone()))
.await
.unwrap_err()
.code(),
Code::FailedPrecondition
);
for admission_state in [Admission::Pending, Admission::Rejected] {
if admission_state == Admission::Rejected {
let mut rejected = accepted.clone();
rejected.instance_id = restart_instance.clone();
rejected.state = Admission::Rejected.into();
handle_report_sandbox_configuration(&state, report(rejected))
.await
.unwrap();
}
let persisted = state
.store
.get_message::<Sandbox>(sandbox_id)
.await
.unwrap()
.unwrap();
assert_eq!(
persisted.status.as_ref().unwrap().configuration_activated,
Some(true)
);
assert!(!crate::policy_store::permits_initial_static_policy_repair(
&persisted
));
let mut replacement = persisted.spec.as_ref().unwrap().policy.clone().unwrap();
replacement.filesystem.as_mut().unwrap().read_only.clear();
let error = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "admission".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(replacement),
..Default::default()
})),
)
.await
.unwrap_err();
assert_eq!(error.code(), Code::InvalidArgument);
assert!(error.message().contains("filesystem"), "{error}");
}
}
#[test]
fn configuration_diagnostic_is_bounded_and_removes_control_characters() {
let diagnostic = bounded_configuration_diagnostic(&format!("rule\n{}", "é".repeat(1000)));
assert_eq!(diagnostic.chars().count(), 512);
assert!(!diagnostic.contains('\n'));
}
#[tokio::test]
async fn configuration_admission_retains_invalid_image_composition_for_repair() {
use openshell_core::proto::{
ConfigurationAdmissionState as Admission, ReportSandboxConfigurationRequest,
SandboxConfigurationAdmission,
};
let state = test_server_state().await;
let sandbox_id = "sb-image-admission";
let mut sandbox = test_sandbox(
sandbox_id,
"image-admission",
ProtoSandboxPolicy::default(),
vec!["work-github".to_string()],
);
sandbox.spec.as_mut().unwrap().policy = None;
sandbox.status.as_mut().unwrap().configuration_activated = Some(false);
state
.store
.put_message(&test_provider("work-github", "github"))
.await
.unwrap();
state.store.put_message(&sandbox).await.unwrap();
let instance_id = uuid::Uuid::new_v4().to_string();
handle_report_sandbox_configuration(
&state,
with_sandbox(
Request::new(ReportSandboxConfigurationRequest {
sandbox_id: sandbox_id.to_string(),
expected_instance_id: String::new(),
admission: Some(SandboxConfigurationAdmission {
instance_id,
state: Admission::Pending.into(),
..Default::default()
}),
}),
sandbox_id,
),
)
.await
.unwrap();
let image = test_policy_with_rule("image_github", "api.github.com");
handle_update_config(
&state,
with_sandbox(
Request::new(UpdateConfigRequest {
sandbox: "image-admission".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(image),
..Default::default()
}),
sandbox_id,
),
)
.await
.expect("image candidate remains available for repair");
let rejected = handle_get_sandbox_config(
&state,
with_sandbox(
Request::new(GetSandboxConfigRequest {
name: "image-admission".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
}),
sandbox_id,
),
)
.await
.unwrap()
.into_inner();
assert!(!rejected.configuration_admitted);
assert!(rejected.configuration_error.contains("image_github"));
assert!(!rejected.configuration_error.contains("ghp-test"));
assert!(rejected.policy.is_some());
handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "image-admission".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(openshell_policy::restrictive_default_policy()),
..Default::default()
})),
)
.await
.expect("operator can replace static sections before first launch");
let repaired = handle_get_sandbox_config(
&state,
with_sandbox(
Request::new(GetSandboxConfigRequest {
name: "image-admission".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
}),
sandbox_id,
),
)
.await
.unwrap()
.into_inner();
assert!(
repaired.configuration_admitted,
"{}",
repaired.configuration_error
);
}
fn security_notes_for_host(host: &str) -> String {
generate_security_notes(&NetworkPolicyRule {
endpoints: vec![NetworkEndpoint {
host: host.to_string(),
port: 80,
..Default::default()
}],
..Default::default()
})
}
pub(super) fn mcp_policy_with_versions(versions: &[&str]) -> ProtoSandboxPolicy {
let mut policy = openshell_policy::restrictive_default_policy();
policy.network_policies.insert(
"mcp".to_string(),
NetworkPolicyRule {
name: "mcp".to_string(),
endpoints: vec![NetworkEndpoint {
host: "mcp.example.com".to_string(),
port: 443,
protocol: "mcp".to_string(),
rules: vec![L7Rule {
allow: Some(openshell_core::proto::L7Allow {
method: "tools/list".to_string(),
..Default::default()
}),
}],
mcp: Some(openshell_core::proto::McpOptions {
versions: versions.iter().map(ToString::to_string).collect(),
..Default::default()
}),
..Default::default()
}],
..Default::default()
},
);
policy
}
fn mcp_policy_without_options() -> ProtoSandboxPolicy {
let mut policy = mcp_policy_with_versions(&[]);
policy
.network_policies
.get_mut("mcp")
.expect("MCP test rule")
.endpoints
.first_mut()
.expect("MCP test endpoint")
.mcp = None;
policy
}
fn legacy_non_mcp_policy_with_mcp_options() -> ProtoSandboxPolicy {
let mut policy = mcp_policy_with_versions(&[]);
let endpoint = policy
.network_policies
.get_mut("mcp")
.expect("MCP test rule")
.endpoints
.first_mut()
.expect("MCP test endpoint");
endpoint.protocol = "rest".to_string();
endpoint.mcp = Some(openshell_core::proto::McpOptions {
strict_tool_names: Some(true),
..Default::default()
});
policy
}
fn mcp_versions(policy: &ProtoSandboxPolicy) -> &[String] {
policy
.network_policies
.get("mcp")
.and_then(|rule| rule.endpoints.first())
.and_then(|endpoint| endpoint.mcp.as_ref())
.map(|mcp| mcp.versions.as_slice())
.expect("canonical MCP test endpoint options")
}
fn defaulted_mcp_policy_cases() -> [(&'static str, ProtoSandboxPolicy); 3] {
[
("omitted-options", mcp_policy_without_options()),
("empty-versions", mcp_policy_with_versions(&[])),
(
"explicit-default",
mcp_policy_with_versions(&["2025-11-25"]),
),
]
}
/// Install a global policy through the normal write path and return the
/// canonical value that subsequent read paths must serve.
async fn install_test_global_policy(state: &Arc<ServerState>) -> ProtoSandboxPolicy {
handle_update_config(
state,
with_user(Request::new(UpdateConfigRequest {
global: true,
policy: Some(mcp_policy_with_versions(&[])),
..Default::default()
})),
)
.await
.expect("test global policy update must succeed");
let settings = load_global_settings(state.store.as_ref())
.await
.expect("test global settings lookup");
decode_policy_from_global_settings(&settings)
.expect("test global policy must decode")
.expect("test global policy must be present")
}
#[tokio::test]
async fn list_sandbox_policies_traverses_multiple_pages_exactly_once() {
let state = test_server_state().await;
let policy = ProtoSandboxPolicy::default();
let payload = policy.encode_to_vec();
for version in 1..=7 {
state
.store
.put_policy_revision(
&format!("global-policy-{version}"),
GLOBAL_POLICY_SANDBOX_ID,
"",
version,
&payload,
&format!("hash-{version}"),
)
.await
.expect("store policy revision");
}
let mut versions = Vec::new();
let mut page_token = String::new();
let mut page_size = 2;
loop {
let page = handle_list_sandbox_policies(
&state,
authed_request(ListSandboxPoliciesRequest {
page_size,
page_token,
global: true,
..Default::default()
}),
)
.await
.unwrap()
.into_inner();
versions.extend(page.revisions.into_iter().map(|revision| revision.version));
if page.next_page_token.is_empty() {
break;
}
page_token = page.next_page_token;
page_size = 3;
}
assert_eq!(versions, vec![7, 6, 5, 4, 3, 2, 1]);
}
#[tokio::test]
async fn list_global_policies_rejects_sandbox_name() {
let state = test_server_state().await;
let policy = ProtoSandboxPolicy::default();
let payload = policy.encode_to_vec();
for version in 1..=2 {
state
.store
.put_policy_revision(
&format!("global-policy-{version}"),
GLOBAL_POLICY_SANDBOX_ID,
"",
version,
&payload,
&format!("hash-{version}"),
)
.await
.expect("store policy revision");
}
let first = handle_list_sandbox_policies(
&state,
authed_request(ListSandboxPoliciesRequest {
page_size: 1,
global: true,
..Default::default()
}),
)
.await
.unwrap()
.into_inner();
assert!(!first.next_page_token.is_empty());
let error = handle_list_sandbox_policies(
&state,
authed_request(ListSandboxPoliciesRequest {
page_size: 1,
page_token: first.next_page_token,
global: true,
sandbox: "different".to_string(),
..Default::default()
}),
)
.await
.unwrap_err();
assert_eq!(error.code(), Code::InvalidArgument);
}
#[tokio::test]
async fn get_sandbox_config_rejects_invalid_spec_policy_before_history_backfill() {
let state = test_server_state().await;
let cases = [
(
"duplicate",
mcp_policy_with_versions(&["2025-11-25", "2025-11-25"]),
),
("unsupported", mcp_policy_with_versions(&["latest"])),
];
for (case, policy) in cases {
let sandbox_id = format!("stored-invalid-{case}");
state
.store
.put_message(&test_sandbox(
&sandbox_id,
&format!("stored-invalid-{case}"),
policy,
Vec::new(),
))
.await
.expect("store legacy sandbox spec");
let error = handle_get_sandbox_config_inner(
&state,
with_sandbox(
Request::new(GetSandboxConfigRequest {
name: sandbox_id.clone(),
workspace_scope: None,
}),
&sandbox_id,
),
)
.await
.expect_err("invalid stored spec must fail before history backfill");
assert_eq!(error.code(), Code::FailedPrecondition, "{case}");
assert!(
error.message().contains(STORED_POLICY_SOURCE_SPEC),
"{case}"
);
assert!(
state
.store
.get_latest_policy(&sandbox_id)
.await
.expect("policy history lookup")
.is_none(),
"{case} must not create policy history"
);
}
}
#[tokio::test]
async fn get_sandbox_config_backfills_canonical_spec_policy_bytes_and_hash() {
let state = test_server_state().await;
let sandbox_id = "stored-canonical-backfill";
let raw = mcp_policy_with_versions(&["2025-11-25", "2025-03-26", "2025-06-18"]);
let canonical = validate_and_canonicalize_policy(raw.clone())
.expect("supported stored MCP policy must canonicalize");
state
.store
.put_message(&test_sandbox(
sandbox_id,
"stored-canonical-backfill",
raw,
Vec::new(),
))
.await
.expect("store legacy sandbox spec");
let response = handle_get_sandbox_config(
&state,
with_sandbox(
Request::new(GetSandboxConfigRequest {
name: sandbox_id.to_string(),
workspace_scope: None,
}),
sandbox_id,
),
)
.await
.expect("valid stored spec must backfill")
.into_inner();
let canonical_hash = deterministic_policy_hash(&canonical);
assert_eq!(response.policy.as_ref(), Some(&canonical));
assert_eq!(response.policy_hash, canonical_hash);
assert_eq!(response.version, 1);
let persisted = state
.store
.get_latest_policy(sandbox_id)
.await
.expect("policy history lookup")
.expect("canonical history revision");
assert_eq!(persisted.policy_payload, canonical.encode_to_vec());
assert_eq!(persisted.policy_hash, canonical_hash);
assert_eq!(persisted.status, "loaded");
}
#[tokio::test]
async fn get_sandbox_config_backfills_defaulted_mcp_policy_as_canonical_bytes_and_hash() {
let state = test_server_state().await;
let canonical = validate_and_canonicalize_policy(mcp_policy_with_versions(&["2025-11-25"]))
.expect("explicit default MCP policy must canonicalize");
assert_eq!(mcp_versions(&canonical), &["2025-11-25".to_string()]);
let canonical_payload = canonical.encode_to_vec();
let canonical_hash = deterministic_policy_hash(&canonical);
let cases = [
("omitted-options", mcp_policy_without_options()),
("empty-versions", mcp_policy_with_versions(&[])),
(
"explicit-default",
mcp_policy_with_versions(&["2025-11-25"]),
),
];
for (case, raw) in cases {
let sandbox_id = format!("stored-default-{case}");
state
.store
.put_message(&test_sandbox(&sandbox_id, &sandbox_id, raw, Vec::new()))
.await
.expect("store legacy sandbox spec");
let response = handle_get_sandbox_config(
&state,
with_sandbox(
Request::new(GetSandboxConfigRequest {
name: sandbox_id.clone(),
workspace_scope: None,
}),
&sandbox_id,
),
)
.await
.expect("defaulted stored spec must backfill")
.into_inner();
assert_eq!(response.policy.as_ref(), Some(&canonical), "{case}");
assert_eq!(response.policy_hash, canonical_hash, "{case}");
assert_eq!(response.version, 1, "{case}");
assert_eq!(
mcp_versions(response.policy.as_ref().expect("backfilled policy")),
&["2025-11-25".to_string()],
"{case}"
);
let persisted = state
.store
.get_latest_policy(&sandbox_id)
.await
.expect("policy history lookup")
.expect("canonical history revision");
assert_eq!(persisted.policy_payload, canonical_payload, "{case}");
assert_eq!(persisted.policy_hash, canonical_hash, "{case}");
assert_eq!(persisted.status, "loaded", "{case}");
}
}
#[tokio::test]
async fn legacy_defaulted_mcp_policy_canonicalizes_for_current_base_and_history_export() {
let store = test_store().await;
let canonical = validate_and_canonicalize_policy(mcp_policy_with_versions(&["2025-11-25"]))
.expect("explicit default MCP policy must canonicalize");
let canonical_hash = deterministic_policy_hash(&canonical);
let cases = [
("omitted-options", mcp_policy_without_options()),
("empty-versions", mcp_policy_with_versions(&[])),
(
"explicit-default",
mcp_policy_with_versions(&["2025-11-25"]),
),
];
for (case, raw) in cases {
let sandbox_id = format!("legacy-default-history-{case}");
let sandbox = test_sandbox(&sandbox_id, &sandbox_id, raw.clone(), Vec::new());
let spec_base = current_base_policy_for_sandbox(&store, &sandbox)
.await
.expect("legacy spec policy must canonicalize");
assert_eq!(spec_base, canonical, "{case}");
assert_eq!(
mcp_versions(&spec_base),
&["2025-11-25".to_string()],
"{case}"
);
store
.put_policy_revision(
&format!("legacy-default-history-revision-{case}"),
&sandbox_id,
"default",
1,
&raw.encode_to_vec(),
"legacy-uncanonicalized-hash",
)
.await
.expect("store legacy policy history");
let history_base = current_base_policy_for_sandbox(&store, &sandbox)
.await
.expect("legacy history policy must canonicalize");
assert_eq!(history_base, canonical, "{case}");
let record = store
.get_latest_policy(&sandbox_id)
.await
.expect("policy history lookup")
.expect("legacy policy history");
let revision = policy_record_to_revision(&record, true)
.expect("legacy history export must canonicalize");
assert_eq!(revision.policy_hash, canonical_hash, "{case}");
let exported = revision.policy.expect("exported history policy");
assert_eq!(exported, canonical, "{case}");
assert_eq!(
mcp_versions(&exported),
&["2025-11-25".to_string()],
"{case}"
);
let listed = policy_record_to_revision(&record, false)
.expect("legacy history list projection must canonicalize");
assert_eq!(listed.policy_hash, canonical_hash, "{case}");
assert!(listed.policy.is_none(), "{case}");
}
}
#[tokio::test]
async fn invalid_legacy_latest_history_fails_closed_but_remains_listable() {
let state = test_server_state().await;
let sandbox_id = "stored-invalid-history";
let valid = validate_and_canonicalize_policy(mcp_policy_with_versions(&[]))
.expect("valid history policy must canonicalize");
let valid_hash = deterministic_policy_hash(&valid);
state
.store
.put_message(&test_sandbox(
sandbox_id,
"stored-invalid-history",
valid.clone(),
Vec::new(),
))
.await
.expect("store sandbox");
state
.store
.put_policy_revision(
"stored-valid-history-revision",
sandbox_id,
"default",
1,
&valid.encode_to_vec(),
&valid_hash,
)
.await
.expect("store valid history");
let invalid = legacy_non_mcp_policy_with_mcp_options();
state
.store
.put_policy_revision(
"stored-invalid-history-revision",
sandbox_id,
"default",
2,
&invalid.encode_to_vec(),
"uncanonicalized-hash",
)
.await
.expect("store legacy invalid history");
let rejected = handle_get_sandbox_config(
&state,
with_sandbox(
Request::new(GetSandboxConfigRequest {
name: sandbox_id.to_string(),
workspace_scope: None,
}),
sandbox_id,
),
)
.await
.expect("invalid latest history must remain repairable")
.into_inner();
assert!(!rejected.configuration_admitted);
assert!(rejected.policy.is_none());
assert!(!rejected.configuration_error.is_empty());
let record = state
.store
.get_latest_policy(sandbox_id)
.await
.expect("policy history lookup")
.expect("invalid policy history");
let listed_invalid = policy_record_to_revision(&record, false)
.expect("list projection must preserve invalid legacy history metadata");
assert_eq!(listed_invalid.version, 2);
assert_eq!(listed_invalid.status, PolicyStatus::Failed as i32);
assert!(listed_invalid.policy_hash.is_empty());
assert!(listed_invalid.policy.is_none());
assert!(
listed_invalid
.load_error
.contains(STORED_POLICY_SOURCE_HISTORY)
);
let detail_error = handle_get_sandbox_policy_status(
&state,
with_user(Request::new(GetSandboxPolicyStatusRequest {
sandbox: "stored-invalid-history".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
version: 2,
..Default::default()
})),
)
.await
.expect_err("invalid history detail must remain fail-closed");
assert_eq!(detail_error.code(), Code::FailedPrecondition);
assert!(
detail_error
.message()
.contains(STORED_POLICY_SOURCE_HISTORY)
);
let listed = handle_list_sandbox_policies(
&state,
with_user(Request::new(ListSandboxPoliciesRequest {
sandbox: "stored-invalid-history".to_string(),
page_size: 10,
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
..Default::default()
})),
)
.await
.expect("one invalid revision must not hide the rest of the history")
.into_inner();
assert_eq!(listed.revisions.len(), 2);
assert_eq!(listed.revisions[0], listed_invalid);
assert_eq!(listed.revisions[1].version, 1);
assert_eq!(listed.revisions[1].policy_hash, valid_hash);
assert_eq!(listed.revisions[1].status, PolicyStatus::Pending as i32);
assert!(listed.revisions[1].load_error.is_empty());
assert!(listed.revisions[1].policy.is_none());
}
#[tokio::test]
async fn valid_global_policy_overrides_invalid_legacy_history() {
let state = test_server_state().await;
let global_policy = install_test_global_policy(&state).await;
let sandbox_id = "global-overrides-invalid-history";
let sandbox = test_sandbox(
sandbox_id,
sandbox_id,
ProtoSandboxPolicy::default(),
Vec::new(),
);
state
.store
.put_message(&sandbox)
.await
.expect("store sandbox");
let invalid = legacy_non_mcp_policy_with_mcp_options();
state
.store
.put_policy_revision(
"global-overrides-invalid-history-revision",
sandbox_id,
"default",
2,
&invalid.encode_to_vec(),
"untrusted-legacy-hash",
)
.await
.expect("store invalid legacy policy history");
let response = handle_get_sandbox_config(
&state,
with_sandbox(
Request::new(GetSandboxConfigRequest {
name: sandbox_id.to_string(),
workspace_scope: None,
}),
sandbox_id,
),
)
.await
.expect("valid global policy must override invalid local history")
.into_inner();
assert_eq!(response.policy.as_ref(), Some(&global_policy));
assert_eq!(
response.policy_hash,
deterministic_policy_hash(&global_policy)
);
assert_eq!(response.policy_source, PolicySource::Global as i32);
assert_eq!(response.version, 2);
assert_eq!(response.global_policy_version, 1);
let catalog = state
.provider_profile_sources
.snapshot_catalog(state.store.as_ref(), "default")
.await
.expect("provider profile catalog");
let effective = current_effective_policy_for_sandbox(
state.as_ref(),
&catalog,
"default",
&sandbox,
sandbox_id,
)
.await
.expect("effective-policy lookup must bypass invalid local history");
assert_eq!(effective, global_policy);
}
#[tokio::test]
async fn valid_global_policy_overrides_invalid_legacy_spec_without_backfill() {
let state = test_server_state().await;
let global_policy = install_test_global_policy(&state).await;
let sandbox_id = "global-overrides-invalid-spec";
let sandbox = test_sandbox(
sandbox_id,
sandbox_id,
legacy_non_mcp_policy_with_mcp_options(),
Vec::new(),
);
state
.store
.put_message(&sandbox)
.await
.expect("store sandbox with invalid legacy spec");
let response = handle_get_sandbox_config(
&state,
with_sandbox(
Request::new(GetSandboxConfigRequest {
name: sandbox_id.to_string(),
workspace_scope: None,
}),
sandbox_id,
),
)
.await
.expect("valid global policy must override invalid local spec")
.into_inner();
assert_eq!(response.policy.as_ref(), Some(&global_policy));
assert_eq!(
response.policy_hash,
deterministic_policy_hash(&global_policy)
);
assert_eq!(response.policy_source, PolicySource::Global as i32);
assert_eq!(response.version, 1);
assert_eq!(response.global_policy_version, 1);
assert!(
state
.store
.get_latest_policy(sandbox_id)
.await
.expect("policy history lookup")
.is_none(),
"global override must not backfill invalid dormant spec state"
);
let catalog = state
.provider_profile_sources
.snapshot_catalog(state.store.as_ref(), "default")
.await
.expect("provider profile catalog");
let effective = current_effective_policy_for_sandbox(
state.as_ref(),
&catalog,
"default",
&sandbox,
sandbox_id,
)
.await
.expect("effective-policy lookup must bypass invalid local spec");
assert_eq!(effective, global_policy);
assert!(
state
.store
.get_latest_policy(sandbox_id)
.await
.expect("policy history lookup")
.is_none(),
"effective-policy lookup must not create policy history"
);
}
#[tokio::test]
async fn canonical_mcp_version_order_produces_identical_policy_bytes_and_hashes() {
let state = test_server_state().await;
let forward = mcp_policy_with_versions(&["2025-03-26", "2025-06-18", "2025-11-25"]);
let forward =
validate_and_canonicalize_policy(forward).expect("forward policy must validate");
let reverse = mcp_policy_with_versions(&["2025-11-25", "2025-06-18", "2025-03-26"]);
let reverse =
validate_and_canonicalize_policy(reverse).expect("reverse policy must validate");
assert_eq!(forward.encode_to_vec(), reverse.encode_to_vec());
assert_eq!(
deterministic_policy_hash(&forward),
deterministic_policy_hash(&reverse)
);
handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
global: true,
policy: Some(mcp_policy_with_versions(&[
"2025-11-25",
"2025-06-18",
"2025-03-26",
])),
..Default::default()
})),
)
.await
.expect("global policy ingress must accept supported versions");
let persisted = state
.store
.get_latest_policy(GLOBAL_POLICY_SANDBOX_ID)
.await
.expect("global policy lookup")
.expect("global policy revision");
assert_eq!(persisted.policy_payload, forward.encode_to_vec());
assert_eq!(persisted.policy_hash, deterministic_policy_hash(&forward));
}
#[tokio::test]
async fn global_policy_ingress_persists_defaulted_mcp_versions_identically() {
let state = test_server_state().await;
let canonical = mcp_policy_with_versions(&["2025-11-25"]);
let canonical = validate_and_canonicalize_policy(canonical)
.expect("explicit default MCP policy must canonicalize");
let canonical_payload = canonical.encode_to_vec();
let canonical_hash = deterministic_policy_hash(&canonical);
let cases = [
("omitted-options", mcp_policy_without_options()),
("empty-versions", mcp_policy_with_versions(&[])),
(
"explicit-default",
mcp_policy_with_versions(&["2025-11-25"]),
),
];
for (case, policy) in cases {
let response = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
global: true,
policy: Some(policy),
..Default::default()
})),
)
.await
.expect("defaulted global policy ingress must succeed")
.into_inner();
assert_eq!(response.version, 1, "{case}");
assert_eq!(response.policy_hash, canonical_hash, "{case}");
let persisted = state
.store
.get_latest_policy(GLOBAL_POLICY_SANDBOX_ID)
.await
.expect("global policy lookup")
.expect("global policy revision");
assert_eq!(persisted.policy_payload, canonical_payload, "{case}");
assert_eq!(persisted.policy_hash, canonical_hash, "{case}");
let global_settings = load_global_settings(state.store.as_ref())
.await
.expect("global settings lookup");
let stored_policy = decode_policy_from_global_settings(&global_settings)
.expect("stored global policy must decode")
.expect("stored global policy");
assert_eq!(stored_policy, canonical, "{case}");
assert_eq!(
mcp_versions(&stored_policy),
&["2025-11-25".to_string()],
"{case}"
);
}
}
#[tokio::test]
async fn policy_record_identity_global_deduplicates_defaulted_mcp_history() {
for (case, legacy_policy) in defaulted_mcp_policy_cases() {
let state = test_server_state().await;
let canonical = mcp_policy_with_versions(&["2025-11-25"]);
let canonical = validate_and_canonicalize_policy(canonical)
.expect("explicit default MCP policy must canonicalize");
let canonical_hash = deterministic_policy_hash(&canonical);
state
.store
.put_policy_revision(
&format!("global-legacy-default-{case}"),
GLOBAL_POLICY_SANDBOX_ID,
"",
1,
&legacy_policy.encode_to_vec(),
"untrusted-legacy-hash",
)
.await
.expect("store legacy global policy revision");
state
.store
.update_policy_status(
GLOBAL_POLICY_SANDBOX_ID,
1,
"loaded",
None,
Some(current_time_ms()),
)
.await
.expect("mark legacy global policy loaded");
let response = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
global: true,
policy: Some(mcp_policy_with_versions(&["2025-11-25"])),
..Default::default()
})),
)
.await
.expect("semantic default must reuse the legacy global revision")
.into_inner();
assert_eq!(response.version, 1, "{case}");
assert_eq!(response.policy_hash, canonical_hash, "{case}");
let revisions = state
.store
.list_policies(GLOBAL_POLICY_SANDBOX_ID, 10, 0)
.await
.expect("list global policy revisions");
assert_eq!(revisions.len(), 1, "{case}");
let settings = load_global_settings(state.store.as_ref())
.await
.expect("load canonical global setting");
assert_eq!(
decode_policy_from_global_settings(&settings)
.expect("decode canonical global setting")
.expect("global policy setting"),
canonical,
"{case}"
);
}
}
#[tokio::test]
async fn policy_record_identity_sandbox_deduplicates_defaulted_mcp_history() {
let state = test_server_state().await;
let canonical = mcp_policy_with_versions(&["2025-11-25"]);
let canonical = validate_and_canonicalize_policy(canonical)
.expect("explicit default MCP policy must canonicalize");
let canonical_hash = deterministic_policy_hash(&canonical);
for (case, legacy_policy) in defaulted_mcp_policy_cases() {
let sandbox_id = format!("identity-default-{case}");
let sandbox_name = format!("identity-default-{case}");
state
.store
.put_message(&test_sandbox(
&sandbox_id,
&sandbox_name,
canonical.clone(),
Vec::new(),
))
.await
.expect("store sandbox");
state
.store
.put_policy_revision(
&format!("policy-identity-default-{case}"),
&sandbox_id,
"default",
1,
&legacy_policy.encode_to_vec(),
"untrusted-legacy-hash",
)
.await
.expect("store legacy sandbox policy revision");
let response = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(mcp_policy_with_versions(&["2025-11-25"])),
..Default::default()
})),
)
.await
.expect("semantic default must reuse the legacy sandbox revision")
.into_inner();
assert_eq!(response.version, 1, "{case}");
assert_eq!(response.policy_hash, canonical_hash, "{case}");
let revisions = state
.store
.list_policies(&sandbox_id, 10, 0)
.await
.expect("list sandbox policy revisions");
assert_eq!(revisions.len(), 1, "{case}");
}
}
#[tokio::test]
async fn policy_record_identity_merge_deduplicates_defaulted_mcp_history() {
let store = test_store().await;
let canonical = validate_and_canonicalize_policy(mcp_policy_with_versions(&["2025-11-25"]))
.expect("explicit default MCP policy must canonicalize");
let canonical_hash = deterministic_policy_hash(&canonical);
for (case, legacy_policy) in defaulted_mcp_policy_cases() {
let sandbox_id = format!("identity-merge-default-{case}");
store
.put_policy_revision(
&format!("policy-identity-merge-default-{case}"),
&sandbox_id,
"default",
1,
&legacy_policy.encode_to_vec(),
"untrusted-legacy-hash",
)
.await
.expect("store legacy merge base");
let (version, hash, _) = apply_merge_operations_with_retry(
&store,
&sandbox_id,
"default",
None,
&[],
PolicyMergeValidationContext {
provider_layers: &[],
credential_binding: None,
},
None,
None,
)
.await
.expect("empty merge must reuse the semantic policy revision");
assert_eq!(version, 1, "{case}");
assert_eq!(hash, canonical_hash, "{case}");
let revisions = store
.list_policies(&sandbox_id, 10, 0)
.await
.expect("list merge policy revisions");
assert_eq!(revisions.len(), 1, "{case}");
}
}
#[tokio::test]
async fn policy_record_identity_ignores_stale_matching_metadata() {
let state = test_server_state().await;
let candidate = mcp_policy_with_versions(&["2025-11-25"]);
let candidate = validate_and_canonicalize_policy(candidate)
.expect("candidate MCP policy must canonicalize");
let candidate_hash = deterministic_policy_hash(&candidate);
let different = mcp_policy_with_versions(&["2025-06-18"]);
let different = validate_and_canonicalize_policy(different)
.expect("different MCP policy must canonicalize");
let sandbox_id = "identity-stale-metadata";
let sandbox_name = "identity-stale-metadata";
state
.store
.put_message(&test_sandbox(
sandbox_id,
sandbox_name,
candidate.clone(),
Vec::new(),
))
.await
.expect("store sandbox");
state
.store
.put_policy_revision(
"policy-identity-stale-metadata",
sandbox_id,
"default",
1,
&different.encode_to_vec(),
&candidate_hash,
)
.await
.expect("store policy with stale matching metadata");
let response = 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(candidate.clone()),
..Default::default()
})),
)
.await
.expect("different payload must create a new revision")
.into_inner();
assert_eq!(response.version, 2);
assert_eq!(response.policy_hash, candidate_hash);
let latest = state
.store
.get_latest_policy(sandbox_id)
.await
.expect("load latest policy")
.expect("new policy revision");
assert_eq!(latest.version, 2);
assert_eq!(latest.policy_payload, candidate.encode_to_vec());
}
#[tokio::test]
async fn valid_full_replacement_recovers_from_malformed_payload_with_matching_metadata() {
let state = test_server_state().await;
let candidate = mcp_policy_with_versions(&["2025-11-25"]);
let candidate = validate_and_canonicalize_policy(candidate)
.expect("candidate MCP policy must canonicalize");
let candidate_hash = deterministic_policy_hash(&candidate);
let malformed = mcp_policy_with_versions(&["2025-11-25", "2025-11-25"]);
let sandbox_id = "identity-malformed-payload";
let sandbox_name = "identity-malformed-payload";
state
.store
.put_message(&test_sandbox(
sandbox_id,
sandbox_name,
candidate.clone(),
Vec::new(),
))
.await
.expect("store sandbox");
state
.store
.put_policy_revision(
"policy-identity-malformed-payload",
sandbox_id,
"default",
1,
&malformed.encode_to_vec(),
&candidate_hash,
)
.await
.expect("store malformed policy payload");
let response = 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(candidate.clone()),
..Default::default()
})),
)
.await
.expect("valid full replacement must repair malformed latest history")
.into_inner();
assert_eq!(response.version, 2);
assert_eq!(response.policy_hash, candidate_hash);
let revisions = state
.store
.list_policies(sandbox_id, 10, 0)
.await
.expect("list sandbox policy revisions");
assert_eq!(revisions.len(), 2);
assert_eq!(revisions[0].version, 2);
assert_eq!(revisions[0].policy_payload, candidate.encode_to_vec());
assert_eq!(revisions[0].policy_hash, candidate_hash);
assert_eq!(revisions[1].version, 1);
}
#[test]
fn provider_credentialed_stamping_matches_host_ports_and_paths() {
let mut policy = ProtoSandboxPolicy {
network_policies: HashMap::from([(
"test".to_string(),
NetworkPolicyRule {
endpoints: vec![
NetworkEndpoint {
host: "api.example.com".to_string(),
port: 443,
path: "/mcp/private/**".to_string(),
provider_credentialed: true,
..Default::default()
},
NetworkEndpoint {
host: "api.example.com".to_string(),
port: 8443,
path: "/mcp/private/**".to_string(),
provider_credentialed: true,
..Default::default()
},
NetworkEndpoint {
host: "*.api.example.com".to_string(),
port: 443,
path: "/mcp/private/**".to_string(),
provider_credentialed: true,
..Default::default()
},
NetworkEndpoint {
host: "api.example.com".to_string(),
port: 443,
path: "/mcp/public/**".to_string(),
provider_credentialed: true,
..Default::default()
},
],
..Default::default()
},
)]),
..Default::default()
};
let scopes = vec![CredentialedEndpointScope {
host: "*.example.com".to_string(),
ports: vec![443],
path: "/mcp/private/**".to_string(),
}];
clear_provider_credentialed_markers(&mut policy);
stamp_provider_credentialed_endpoints(&mut policy, &scopes);
let endpoints = &policy.network_policies["test"].endpoints;
assert!(endpoints[0].provider_credentialed);
assert!(!endpoints[1].provider_credentialed);
assert!(!endpoints[2].provider_credentialed);
assert!(!endpoints[3].provider_credentialed);
}
#[test]
fn policy_bindings_add_scopes_only_for_attached_endpointless_providers() {
let mut scopes = vec![CredentialedEndpointScope {
host: "profile.example.com".to_string(),
ports: vec![443],
path: "/**".to_string(),
}];
let bindings = HashMap::from([
(
"bound".to_string(),
vec![
StaticCredentialEndpointBinding {
host: "API.Bound.Example".to_string(),
port: 8443,
path: "/v1".to_string(),
},
StaticCredentialEndpointBinding {
host: "api.bound.example".to_string(),
port: 8443,
path: "/v2".to_string(),
},
],
),
(
"endpointful".to_string(),
vec![StaticCredentialEndpointBinding {
host: "profile-bound.example".to_string(),
port: 443,
path: String::new(),
}],
),
(
"unattached".to_string(),
vec![StaticCredentialEndpointBinding {
host: "unattached.example".to_string(),
port: 443,
path: String::new(),
}],
),
]);
extend_credentialed_scopes_from_policy_bindings(
&mut scopes,
&bindings,
&HashSet::from(["bound".to_string()]),
);
assert_eq!(
scopes,
vec![
CredentialedEndpointScope {
host: "profile.example.com".to_string(),
ports: vec![443],
path: "/**".to_string(),
},
CredentialedEndpointScope {
host: "api.bound.example".to_string(),
ports: vec![8443],
path: "/v1".to_string(),
},
CredentialedEndpointScope {
host: "api.bound.example".to_string(),
ports: vec![8443],
path: "/v2".to_string(),
},
]
);
}
#[test]
fn credentialed_l4_and_tls_skip_require_explicit_opt_in() {
let endpoint = |protocol: &str, tls: &str, allow: bool| NetworkEndpoint {
host: "api.vendor.example".to_string(),
port: 443,
protocol: protocol.to_string(),
tls: openshell_policy::network_tls_mode_from_str(tls).expect("valid test TLS mode")
as i32,
provider_credentialed: true,
allow_uninspected_credentials: allow,
..Default::default()
};
let policy = |endpoint| ProtoSandboxPolicy {
network_policies: HashMap::from([(
"vendor".to_string(),
NetworkPolicyRule {
endpoints: vec![endpoint],
..Default::default()
},
)]),
..Default::default()
};
assert!(
validate_uninspected_credentialed_endpoints(&policy(endpoint("", "", false))).is_err()
);
assert!(
validate_uninspected_credentialed_endpoints(&policy(endpoint("rest", "skip", false)))
.is_err()
);
assert!(
validate_uninspected_credentialed_endpoints(&policy(endpoint("", "", true))).is_ok()
);
let mut plain = endpoint("", "", false);
plain.provider_credentialed = false;
assert!(validate_uninspected_credentialed_endpoints(&policy(plain)).is_ok());
}
#[test]
fn security_notes_flag_allow_uninspected_credentials() {
let notes = generate_security_notes(&NetworkPolicyRule {
endpoints: vec![NetworkEndpoint {
host: "api.vendor.example".to_string(),
port: 443,
allow_uninspected_credentials: true,
..Default::default()
}],
..Default::default()
});
assert!(notes.contains("cannot inspect or rewrite"));
}
#[test]
fn security_notes_use_canonical_internal_ip_classifier() {
// RFC 1918 is 172.16.0.0/12 only: the old starts_with("172.") prefix
// wrongly flagged 172.15/172.32 and missed CGNAT (100.64.0.0/10). #1777.
assert!(security_notes_for_host("172.16.0.1").contains("internal/private"));
assert!(!security_notes_for_host("172.15.0.1").contains("internal/private"));
assert!(!security_notes_for_host("172.32.0.1").contains("internal/private"));
assert!(security_notes_for_host("100.64.0.1").contains("internal/private"));
assert!(security_notes_for_host("10.0.0.1").contains("internal/private"));
assert!(security_notes_for_host("192.168.1.1").contains("internal/private"));
assert!(!security_notes_for_host("8.8.8.8").contains("internal/private"));
// Hostnames that merely start with a private-range prefix must NOT be
// flagged: classification parses an IP literal, not a string prefix. #1824.
assert!(!security_notes_for_host("10.example.com").contains("internal/private"));
assert!(!security_notes_for_host("172.example.com").contains("internal/private"));
// IPv6 ULA (fc00::/7, RFC 4193) is internal/private.
assert!(security_notes_for_host("fd00::1").contains("internal/private"));
}
#[test]
fn security_notes_exclude_always_blocked_destinations() {
let notes = generate_security_notes(&NetworkPolicyRule {
endpoints: vec![
NetworkEndpoint {
host: "127.0.0.1".to_string(),
port: 443,
..Default::default()
},
NetworkEndpoint {
host: "169.254.169.254".to_string(),
port: 443,
..Default::default()
},
NetworkEndpoint {
host: "localhost".to_string(),
port: 443,
..Default::default()
},
NetworkEndpoint {
host: "metadata.google.internal".to_string(),
port: 443,
allowed_ips: vec![
"127.0.0.0/8".to_string(),
"169.254.0.0/16".to_string(),
"::/128".to_string(),
],
..Default::default()
},
],
..Default::default()
});
assert!(notes.is_empty(), "{notes}");
}
#[test]
fn security_notes_flag_private_allowed_ips() {
let notes = generate_security_notes(&NetworkPolicyRule {
endpoints: vec![NetworkEndpoint {
host: "service.example.com".to_string(),
port: 443,
allowed_ips: vec!["10.0.0.0/8".to_string()],
..Default::default()
}],
..Default::default()
});
assert!(notes.contains("allowed_ips includes private/internal range '10.0.0.0/8'."));
}
#[test]
fn security_notes_flag_cidr_overlapping_cgnat() {
let notes = generate_security_notes(&NetworkPolicyRule {
endpoints: vec![NetworkEndpoint {
host: "service.example.com".to_string(),
port: 443,
allowed_ips: vec!["100.0.0.0/9".to_string()],
..Default::default()
}],
..Default::default()
});
assert!(notes.contains("allowed_ips includes private/internal range '100.0.0.0/9'."));
}
#[test]
fn security_notes_do_not_flag_large_public_allowed_ips() {
let notes = generate_security_notes(&NetworkPolicyRule {
endpoints: vec![NetworkEndpoint {
host: "service.example.com".to_string(),
port: 443,
allowed_ips: vec!["8.8.0.0/16".to_string(), "8.8.8.0/24".to_string()],
..Default::default()
}],
..Default::default()
});
assert!(notes.is_empty(), "{notes}");
}
#[test]
fn security_notes_flag_hostless_allowed_ips() {
let notes = generate_security_notes(&NetworkPolicyRule {
endpoints: vec![NetworkEndpoint {
allowed_ips: vec!["192.168.0.0/16".to_string()],
port: 443,
..Default::default()
}],
..Default::default()
});
assert!(notes.contains("allowed_ips includes private/internal range '192.168.0.0/16'."));
assert!(notes.contains(
"allowed_ips allowlist is hostless and may match any hostname resolving within the configured range."
));
}
#[test]
fn security_notes_inspect_all_endpoints_and_effective_ports() {
let notes = generate_security_notes(&NetworkPolicyRule {
endpoints: vec![
NetworkEndpoint {
host: "service.example.com".to_string(),
port: 443,
..Default::default()
},
NetworkEndpoint {
host: "*.internal.example".to_string(),
port: 3306,
ports: vec![5432, 50_000],
allowed_ips: vec!["172.16.0.0/12".to_string()],
..Default::default()
},
],
..Default::default()
});
assert!(notes.contains("Host '*.internal.example' contains a wildcard"));
assert!(notes.contains("allowed_ips includes private/internal range '172.16.0.0/12'."));
assert!(notes.contains("Port 5432 is a well-known database/service port."));
assert!(notes.contains("Port 50000 is in the ephemeral range"));
assert!(!notes.contains("Port 3306"));
}
#[test]
fn sandbox_caller_update_validation_allows_sandbox_policy_sync() {
let req = UpdateConfigRequest {
sandbox: "sandbox-1".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(ProtoSandboxPolicy::default()),
..Default::default()
};
assert!(validate_sandbox_caller_update(&req).is_ok());
}
#[test]
fn sandbox_caller_update_validation_rejects_global_mutation() {
let req = UpdateConfigRequest {
global: true,
policy: Some(ProtoSandboxPolicy::default()),
..Default::default()
};
let err = validate_sandbox_caller_update(&req).unwrap_err();
assert_eq!(err.code(), Code::PermissionDenied);
}
#[test]
fn sandbox_caller_update_validation_rejects_setting_mutation() {
let req = UpdateConfigRequest {
sandbox: "sandbox-1".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
setting_key: "inference.model".to_string(),
setting_value: Some(SettingValue { value: None }),
..Default::default()
};
let err = validate_sandbox_caller_update(&req).unwrap_err();
assert_eq!(err.code(), Code::PermissionDenied);
}
#[test]
fn sandbox_caller_detected_from_principal_extension() {
use crate::auth::principal::{Principal, SandboxIdentitySource, SandboxPrincipal};
let mut req = Request::new(());
req.extensions_mut()
.insert(Principal::Sandbox(SandboxPrincipal {
sandbox_id: "test-sandbox".to_string(),
source: SandboxIdentitySource::BootstrapJwt {
issuer: "openshell-gateway:test".to_string(),
},
trust_domain: None,
}));
assert!(is_sandbox_caller(&req));
}
#[test]
fn user_principal_not_treated_as_sandbox_caller() {
use crate::auth::identity::{Identity, IdentityProvider};
use crate::auth::principal::{Principal, UserPrincipal};
let mut req = Request::new(());
req.extensions_mut().insert(Principal::User(UserPrincipal {
identity: Identity {
subject: "alice".to_string(),
display_name: None,
roles: vec![],
scopes: vec![],
provider: IdentityProvider::Oidc,
},
}));
assert!(!is_sandbox_caller(&req));
}
#[tokio::test]
async fn get_sandbox_logs_authorizes_persisted_sandbox_workspace() {
use openshell_core::proto::datamodel::v1::ObjectMeta;
use openshell_core::proto::{WorkspaceMember, WorkspaceRole};
let mut state = test_server_state().await;
Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".to_string();
let sandbox = Sandbox {
metadata: Some(ObjectMeta {
id: "sandbox-b-id".to_string(),
name: "sandbox-b".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "workspace-b".to_string(),
deletion_time: None,
}),
..Sandbox::default()
};
state.store.put_message(&sandbox).await.unwrap();
let member = WorkspaceMember {
metadata: Some(ObjectMeta {
id: "member-a-id".to_string(),
name: "test-user".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
principal_subject: "test-user".to_string(),
role: WorkspaceRole::User.into(),
};
state.store.put_message(&member).await.unwrap();
let error = handle_get_sandbox_logs(
&state,
with_user(Request::new(GetSandboxLogsRequest {
sandbox: "sandbox-b".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
..GetSandboxLogsRequest::default()
})),
)
.await
.unwrap_err();
assert_eq!(
error.code(),
Code::NotFound,
"cross-workspace sandbox access must return NotFound to prevent CWE-203 oracle"
);
}
#[tokio::test]
async fn get_sandbox_logs_rejects_invalid_since_time() {
let state = test_server_state().await;
let sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sandbox-id".to_string(),
name: "sandbox".to_string(),
workspace: "default".to_string(),
..Default::default()
}),
..Default::default()
};
state.store.put_message(&sandbox).await.unwrap();
let error = handle_get_sandbox_logs(
&state,
with_user(Request::new(GetSandboxLogsRequest {
sandbox: "sandbox".to_string(),
since_time: Some(prost_types::Timestamp {
seconds: 253_402_300_800,
nanos: 0,
}),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
..Default::default()
})),
)
.await
.unwrap_err();
assert_eq!(error.code(), Code::InvalidArgument);
}
#[tokio::test]
async fn get_sandbox_logs_filters_with_nanosecond_precision() {
let state = test_server_state().await;
let sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sandbox-id".to_string(),
name: "sandbox".to_string(),
workspace: "default".to_string(),
..Default::default()
}),
..Default::default()
};
state.store.put_message(&sandbox).await.unwrap();
for (timestamp, message) in [
(
prost_types::Timestamp {
seconds: -1,
nanos: 999_999_999,
},
"pre-epoch",
),
(
prost_types::Timestamp {
seconds: 0,
nanos: 0,
},
"epoch",
),
] {
state.tracing_log_bus.publish_external(SandboxLogLine {
sandbox_id: "sandbox-id".to_string(),
event_time: Some(timestamp),
message: message.to_string(),
..Default::default()
});
}
for (nanos, message) in [(100, "before"), (900, "at-boundary"), (950, "after")] {
state.tracing_log_bus.publish_external(SandboxLogLine {
sandbox_id: "sandbox-id".to_string(),
event_time: Some(prost_types::Timestamp { seconds: 1, nanos }),
message: message.to_string(),
..Default::default()
});
}
let response = handle_get_sandbox_logs(
&state,
with_user(Request::new(GetSandboxLogsRequest {
sandbox: "sandbox".to_string(),
since_time: Some(prost_types::Timestamp {
seconds: 1,
nanos: 900,
}),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(
response
.logs
.iter()
.map(|log| log.message.as_str())
.collect::<Vec<_>>(),
vec!["at-boundary", "after"]
);
let response = handle_get_sandbox_logs(
&state,
with_user(Request::new(GetSandboxLogsRequest {
sandbox: "sandbox".to_string(),
since_time: Some(prost_types::Timestamp {
seconds: 0,
nanos: 0,
}),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
let messages = response
.logs
.iter()
.map(|log| log.message.as_str())
.collect::<Vec<_>>();
assert!(!messages.contains(&"pre-epoch"));
assert!(messages.contains(&"epoch"));
}
#[tokio::test]
async fn submit_policy_analysis_rejects_only_chunks_with_invalid_timestamps() {
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule};
let state = test_server_state().await;
let sandbox_name = "invalid-policy-chunk-timestamps";
state
.store
.put_message(&test_sandbox(
"sb-invalid-policy-chunk-timestamps",
sandbox_name,
ProtoSandboxPolicy::default(),
vec![],
))
.await
.unwrap();
let chunk = |name: &str| PolicyChunk {
rule_name: name.to_string(),
proposed_rule: Some(NetworkPolicyRule {
name: name.to_string(),
endpoints: vec![NetworkEndpoint {
host: format!("{name}.example.com"),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
}),
..Default::default()
};
let invalid = prost_types::Timestamp {
seconds: 253_402_300_800,
nanos: 0,
};
let mut invalid_first = chunk("invalid_first");
invalid_first.first_seen_time = Some(invalid);
let mut invalid_last = chunk("invalid_last");
invalid_last.last_seen_time = Some(invalid);
let response = 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: vec![invalid_first, invalid_last, chunk("valid_absent")],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(response.accepted_chunks, 1);
assert_eq!(response.rejected_chunks, 2);
assert!(
response
.rejection_reasons
.iter()
.any(|reason| reason.contains("invalid first_seen_time"))
);
assert!(
response
.rejection_reasons
.iter()
.any(|reason| reason.contains("invalid last_seen_time"))
);
}
#[tokio::test]
async fn update_config_global_requires_platform_admin() {
use openshell_core::proto::datamodel::v1::ObjectMeta;
use openshell_core::proto::{WorkspaceMember, WorkspaceRole};
let mut state = test_server_state().await;
Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".to_string();
let member = WorkspaceMember {
metadata: Some(ObjectMeta {
id: "default-admin-member-id".to_string(),
name: "test-user".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
principal_subject: "test-user".to_string(),
role: WorkspaceRole::Admin.into(),
};
state.store.put_message(&member).await.unwrap();
let error = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
request_id: String::new(),
global: true,
setting_key: "log_level".to_string(),
delete_setting: true,
..UpdateConfigRequest::default()
})),
)
.await
.unwrap_err();
assert_eq!(error.code(), Code::PermissionDenied);
}
#[tokio::test]
async fn global_policy_reads_require_platform_admin() {
let mut state = test_server_state().await;
Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".to_string();
let get_error = handle_get_sandbox_policy_status(
&state,
with_user(Request::new(GetSandboxPolicyStatusRequest {
global: true,
..GetSandboxPolicyStatusRequest::default()
})),
)
.await
.unwrap_err();
assert_eq!(get_error.code(), Code::PermissionDenied);
assert!(get_error.message().contains("platform admin role required"));
let list_error = handle_list_sandbox_policies(
&state,
with_user(Request::new(ListSandboxPoliciesRequest {
global: true,
..ListSandboxPoliciesRequest::default()
})),
)
.await
.unwrap_err();
assert_eq!(list_error.code(), Code::PermissionDenied);
assert!(
list_error
.message()
.contains("platform admin role required")
);
}
#[tokio::test]
async fn global_policy_requests_reject_workspace_selectors() {
let state = test_server_state().await;
let update_error = handle_update_config(
&state,
authed_request(UpdateConfigRequest {
global: true,
sandbox: String::new(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
..Default::default()
}),
)
.await
.unwrap_err();
assert_eq!(update_error.code(), Code::InvalidArgument);
let get_error = handle_get_sandbox_policy_status(
&state,
authed_request(GetSandboxPolicyStatusRequest {
global: true,
sandbox: String::new(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
..Default::default()
}),
)
.await
.unwrap_err();
assert_eq!(get_error.code(), Code::InvalidArgument);
let list_error = handle_list_sandbox_policies(
&state,
authed_request(ListSandboxPoliciesRequest {
global: true,
sandbox: String::new(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
..Default::default()
}),
)
.await
.unwrap_err();
assert_eq!(list_error.code(), Code::InvalidArgument);
}
#[tokio::test]
async fn update_config_rejects_missing_principal() {
let state = test_server_state().await;
let error = handle_update_config(
&state,
Request::new(UpdateConfigRequest {
request_id: String::new(),
global: true,
setting_key: "log_level".to_string(),
delete_setting: true,
..UpdateConfigRequest::default()
}),
)
.await
.unwrap_err();
assert_eq!(error.code(), Code::Internal);
assert_eq!(error.message(), "missing principal");
}
#[test]
fn merge_operation_validation_rejects_reserved_provider_add_rule_name() {
let err = validate_merge_operations_for_server(&[PolicyMergeOp::AddRule {
rule_name: "_provider_work_github".to_string(),
rule: NetworkPolicyRule::default(),
}])
.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
assert!(err.message().contains("_provider_work_github"));
assert!(err.message().contains("reserved '_provider_' prefix"));
}
fn l7_scope_target() -> ProtoL7RuleTarget {
ProtoL7RuleTarget {
rule_name: "selected".to_string(),
host: "api.example.com".to_string(),
ports: vec![443, 8443],
path: Some(String::new()),
binaries: vec![
NetworkBinary {
path: "/usr/bin/curl".to_string(),
},
NetworkBinary {
path: "/usr/bin/python3".to_string(),
},
],
any_binary: false,
}
}
fn l7_scope_operation(target: Option<ProtoL7RuleTarget>, deny: bool) -> PolicyMergeOperation {
let operation = if deny {
policy_merge_operation::Operation::AddDenyRules(ProtoAddDenyRules {
target,
deny_rules: vec![L7DenyRule {
method: "POST".to_string(),
path: "/admin".to_string(),
..Default::default()
}],
})
} else {
policy_merge_operation::Operation::AddAllowRules(ProtoAddAllowRules {
target,
rules: vec![L7Rule {
allow: Some(openshell_core::proto::L7Allow {
method: "POST".to_string(),
path: "/admin".to_string(),
..Default::default()
}),
}],
})
};
PolicyMergeOperation {
operation: Some(operation),
}
}
fn l7_scope_policy() -> ProtoSandboxPolicy {
let endpoint = NetworkEndpoint {
host: "api.example.com".to_string(),
port: 443,
ports: vec![443, 8443],
protocol: "rest".to_string(),
access: openshell_core::proto::NetworkAccessPreset::ReadOnly as i32,
..Default::default()
};
let selected = NetworkPolicyRule {
name: "selected".to_string(),
endpoints: vec![
endpoint.clone(),
NetworkEndpoint {
path: "/v1/**".to_string(),
..endpoint.clone()
},
],
binaries: l7_scope_target().binaries,
};
let sibling = NetworkPolicyRule {
name: "sibling".to_string(),
endpoints: vec![endpoint],
binaries: vec![NetworkBinary {
path: "/usr/bin/wget".to_string(),
}],
};
validate_and_canonicalize_policy(ProtoSandboxPolicy {
network_policies: HashMap::from([
("selected".to_string(), selected),
("sibling".to_string(), sibling),
]),
..Default::default()
})
.expect("L7 scope fixture must be a valid policy")
}
#[test]
fn l7_target_scope_ingress_requires_target_and_exclusive_binary_declaration() {
for deny in [false, true] {
let missing = parse_merge_operations(&[l7_scope_operation(None, deny)]).unwrap_err();
assert_eq!(missing.code(), Code::InvalidArgument);
let mut omitted = l7_scope_target();
omitted.binaries.clear();
let mut contradictory = l7_scope_target();
contradictory.any_binary = true;
for target in [omitted, contradictory] {
let error = parse_merge_operations(&[l7_scope_operation(Some(target), deny)])
.expect_err("binary scope must be explicit and exclusive");
assert_eq!(error.code(), Code::InvalidArgument);
assert!(error.message().contains("exactly one"));
}
let mut any = l7_scope_target();
any.any_binary = true;
any.binaries.clear();
let operations =
parse_merge_operations(&[l7_scope_operation(Some(any), deny)]).unwrap();
let (PolicyMergeOp::AddAllowRules { target, .. }
| PolicyMergeOp::AddDenyRules { target, .. }) = &operations[0]
else {
panic!("L7 operation expected");
};
assert_eq!(target.binaries, L7BinaryScope::Any);
assert_eq!(target.path.as_deref(), Some(""));
}
}
#[test]
fn l7_target_scope_ingress_rejects_malformed_targets() {
let mut invalid_targets = Vec::new();
let mut target = l7_scope_target();
target.rule_name.clear();
invalid_targets.push(target);
let mut target = l7_scope_target();
target.rule_name = "_provider_example".to_string();
invalid_targets.push(target);
let mut target = l7_scope_target();
target.host = "https://api.example.com".to_string();
invalid_targets.push(target);
for ports in [vec![], vec![0], vec![65536]] {
let mut target = l7_scope_target();
target.ports = ports;
invalid_targets.push(target);
}
let mut target = l7_scope_target();
target.binaries[0].path.clear();
invalid_targets.push(target);
for target in invalid_targets {
for deny in [false, true] {
let error =
parse_merge_operations(&[l7_scope_operation(Some(target.clone()), deny)])
.expect_err("malformed L7 target must fail at request ingress");
assert_eq!(error.code(), Code::InvalidArgument, "{target:?}");
}
}
}
#[test]
fn l7_target_scope_ingress_preserves_existing_payload_guards() {
for operation in [
policy_merge_operation::Operation::AddAllowRules(ProtoAddAllowRules {
target: Some(l7_scope_target()),
rules: vec![],
}),
policy_merge_operation::Operation::AddDenyRules(ProtoAddDenyRules {
target: Some(l7_scope_target()),
deny_rules: vec![],
}),
policy_merge_operation::Operation::AddAllowRules(ProtoAddAllowRules {
target: Some(l7_scope_target()),
rules: vec![L7Rule { allow: None }],
}),
] {
let error = parse_merge_operations(&[PolicyMergeOperation {
operation: Some(operation),
}])
.expect_err("target presence does not make an empty payload valid");
assert_eq!(error.code(), Code::InvalidArgument);
}
}
#[tokio::test]
async fn l7_target_scope_update_config_rejects_batch_without_persistence() {
let state = test_server_state().await;
let policy = l7_scope_policy();
let sandbox_id = "l7-scope-rejected";
let mut sandbox = test_sandbox(sandbox_id, sandbox_id, policy.clone(), Vec::new());
sandbox.metadata.as_mut().unwrap().annotations =
HashMap::from([("example.com/owner".to_string(), "original".to_string())]);
state.store.put_message(&sandbox).await.unwrap();
state
.store
.put_policy_revision(
"l7-scope-seed",
sandbox_id,
"default",
1,
&policy.encode_to_vec(),
&deterministic_policy_hash(&policy),
)
.await
.unwrap();
let before = state
.store
.get_message_by_name::<Sandbox>("default", sandbox_id)
.await
.unwrap()
.unwrap();
let before_revision = state
.store
.get_latest_policy(sandbox_id)
.await
.unwrap()
.unwrap();
let mut missing_binary = l7_scope_target();
missing_binary.binaries.pop();
let mut missing_port = l7_scope_target();
missing_port.ports = vec![443];
let mut ambiguous = l7_scope_target();
ambiguous.path = None;
let mut missing_rule = l7_scope_target();
missing_rule.rule_name = "absent".to_string();
let mut wrong_any = l7_scope_target();
wrong_any.binaries.clear();
wrong_any.any_binary = true;
let cases = [
(None, Code::InvalidArgument, "target"),
(
Some(missing_binary),
Code::FailedPrecondition,
"/usr/bin/python3",
),
(Some(missing_port), Code::FailedPrecondition, "8443"),
(Some(ambiguous), Code::FailedPrecondition, "/v1/**"),
(Some(missing_rule), Code::NotFound, "absent"),
(Some(wrong_any), Code::FailedPrecondition, "/usr/bin/curl"),
];
for (target, code, detail) in cases {
for deny in [false, true] {
// The first operation is valid and changes the private candidate.
// A failure in the second must roll back policy and metadata together.
let error = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: sandbox_id.to_string(),
merge_operations: vec![
l7_scope_operation(Some(l7_scope_target()), false),
l7_scope_operation(target.clone(), deny),
],
expected_resource_version: before
.metadata
.as_ref()
.unwrap()
.resource_version,
annotations: HashMap::from([(
"example.com/change".to_string(),
"rejected".to_string(),
)]),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
..Default::default()
})),
)
.await
.expect_err("incomplete or ambiguous target must reject the batch");
assert_eq!(error.code(), code, "{detail}, deny={deny}: {error}");
assert!(error.message().contains(detail), "{error}");
let after = state
.store
.get_message_by_name::<Sandbox>("default", sandbox_id)
.await
.unwrap()
.unwrap();
assert_eq!(
after, before,
"rejected batch must preserve all sandbox metadata and spec"
);
let revision = state
.store
.get_latest_policy(sandbox_id)
.await
.unwrap()
.unwrap();
assert_eq!(revision.version, before_revision.version);
assert_eq!(revision.policy_hash, before_revision.policy_hash);
assert_eq!(revision.policy_payload, before_revision.policy_payload);
assert_eq!(
state
.store
.list_policies(sandbox_id, 10, 0)
.await
.unwrap()
.len(),
1
);
}
}
}
#[tokio::test]
async fn l7_target_scope_update_config_changes_only_selected_endpoint() {
for deny in [false, true] {
let state = test_server_state().await;
let policy = l7_scope_policy();
let sandbox_id = "l7-scope-selected";
state
.store
.put_message(&test_sandbox(
sandbox_id,
sandbox_id,
policy.clone(),
Vec::new(),
))
.await
.unwrap();
let initial_hash = deterministic_policy_hash(&policy);
state
.store
.put_policy_revision(
"l7-scope-seed",
sandbox_id,
"default",
1,
&policy.encode_to_vec(),
&initial_hash,
)
.await
.unwrap();
let before = state
.store
.get_message_by_name::<Sandbox>("default", sandbox_id)
.await
.unwrap()
.unwrap();
let mut target = l7_scope_target();
target.host = "API.EXAMPLE.COM".to_string();
target.ports = vec![8443, 443, 8443];
target.binaries.reverse();
target.binaries.push(target.binaries[0].clone());
let response = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: sandbox_id.to_string(),
merge_operations: vec![l7_scope_operation(Some(target), deny)],
expected_resource_version: before.metadata.as_ref().unwrap().resource_version,
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
..Default::default()
})),
)
.await
.expect("complete explicit scope must resolve the selected endpoint")
.into_inner();
assert_eq!(response.version, 2);
assert_ne!(response.policy_hash, initial_hash);
let revision = state
.store
.get_latest_policy(sandbox_id)
.await
.unwrap()
.unwrap();
let after = ProtoSandboxPolicy::decode(revision.policy_payload.as_slice()).unwrap();
assert_eq!(
after.network_policies["sibling"],
policy.network_policies["sibling"]
);
let before_rule = &policy.network_policies["selected"];
let after_rule = &after.network_policies["selected"];
assert_eq!(after_rule.binaries, before_rule.binaries);
assert_eq!(after_rule.endpoints.len(), before_rule.endpoints.len());
assert_eq!(
after_rule
.endpoints
.iter()
.find(|endpoint| !endpoint.path.is_empty()),
before_rule
.endpoints
.iter()
.find(|endpoint| !endpoint.path.is_empty()),
);
let endpoint = after_rule
.endpoints
.iter()
.find(|endpoint| endpoint.path.is_empty())
.unwrap();
if deny {
assert!(
endpoint
.deny_rules
.iter()
.any(|rule| rule.method == "POST" && rule.path == "/admin")
);
} else {
assert!(endpoint.rules.iter().any(|rule| {
rule.allow
.as_ref()
.is_some_and(|allow| allow.method == "POST" && allow.path == "/admin")
}));
}
assert_eq!(
state
.store
.list_policies(sandbox_id, 10, 0)
.await
.unwrap()
.len(),
2
);
assert_eq!(revision.policy_hash, response.policy_hash);
assert_eq!(revision.policy_hash, deterministic_policy_hash(&after));
}
}
#[test]
fn policy_merge_error_mapping_distinguishes_request_shape_from_state_conflicts() {
let invalid_operation =
map_policy_merge_error(openshell_policy::PolicyMergeError::InvalidOperationPolicy {
operation_index: 0,
violations: Vec::new(),
});
assert_eq!(invalid_operation.code(), Code::InvalidArgument);
let empty =
map_policy_merge_error(openshell_policy::PolicyMergeError::EmptyAddRuleEndpoints {
operation_index: 0,
rule_name: "empty".to_string(),
});
assert_eq!(empty.code(), Code::InvalidArgument);
let contract =
map_policy_merge_error(openshell_policy::PolicyMergeError::McpContractConflict {
operation_index: 1,
host: "mcp.example.com".to_string(),
port: 443,
existing: "mcp(max_body_bytes=65536)".to_string(),
incoming: "mcp(max_body_bytes=131072)".to_string(),
});
assert_eq!(contract.code(), Code::FailedPrecondition);
let inheritance = map_policy_merge_error(
openshell_policy::PolicyMergeError::NewBinaryWouldInheritAuthorization {
operation_index: 2,
rule_name: "existing".to_string(),
binary_scope: "binary '/usr/bin/client'".to_string(),
host: "mcp.example.com".to_string(),
ports: vec![443],
},
);
assert_eq!(inheritance.code(), Code::FailedPrecondition);
// The proposer has to know which binary scope triggered the rejection.
assert!(inheritance.message().contains("/usr/bin/client"));
let existing_scope = map_policy_merge_error(
openshell_policy::PolicyMergeError::ExistingBinariesWouldInheritAuthorization {
operation_index: 3,
rule_name: "existing".to_string(),
host: "api.example.com".to_string(),
ports: vec![443],
undeclared_binaries: vec!["/usr/bin/other".to_string()],
},
);
assert_eq!(existing_scope.code(), Code::FailedPrecondition);
// The proposer has to know which binaries to add, so the remediation
// detail must survive into the status message.
assert!(existing_scope.message().contains("/usr/bin/other"));
let invalid_target =
map_policy_merge_error(openshell_policy::PolicyMergeError::InvalidL7Target {
operation_index: 0,
reason: "ports are required".to_string(),
});
assert_eq!(invalid_target.code(), Code::InvalidArgument);
let missing =
map_policy_merge_error(openshell_policy::PolicyMergeError::L7TargetNotFound {
rule_name: "missing".to_string(),
host: "api.example.com".to_string(),
ports: vec![443],
path: None,
});
assert_eq!(missing.code(), Code::NotFound);
// Ambiguous or incomplete declarations describe a well-formed request
// the current policy forbids, so they are preconditions.
let ambiguous =
map_policy_merge_error(openshell_policy::PolicyMergeError::AmbiguousL7Target {
rule_name: "selected".to_string(),
host: "api.example.com".to_string(),
ports: vec![443],
paths: vec!["/v1/**".to_string(), "/v2/**".to_string()],
});
assert_eq!(ambiguous.code(), Code::FailedPrecondition);
assert!(ambiguous.message().contains("/v1/**"));
assert!(ambiguous.message().contains("/v2/**"));
let binary_scope =
map_policy_merge_error(openshell_policy::PolicyMergeError::L7BinaryScopeMismatch {
rule_name: "selected".to_string(),
expected: vec!["/usr/bin/curl".to_string(), "/usr/bin/python3".to_string()],
declared: vec!["/usr/bin/curl".to_string()],
});
assert_eq!(binary_scope.code(), Code::FailedPrecondition);
assert!(binary_scope.message().contains("/usr/bin/python3"));
let port_scope =
map_policy_merge_error(openshell_policy::PolicyMergeError::L7PortScopeMismatch {
rule_name: "selected".to_string(),
host: "api.example.com".to_string(),
expected: vec![443, 8443],
declared: vec![443],
});
assert_eq!(port_scope.code(), Code::FailedPrecondition);
assert!(port_scope.message().contains("8443"));
let undeclared_port = map_policy_merge_error(
openshell_policy::PolicyMergeError::UndeclaredPortWouldChange {
operation_index: 5,
rule_name: "existing".to_string(),
host: "api.example.com".to_string(),
ports: vec![8443],
},
);
assert_eq!(undeclared_port.code(), Code::FailedPrecondition);
// The proposer has to know which port to declare.
assert!(undeclared_port.message().contains("8443"));
let mcp_conflict = map_policy_merge_error(
openshell_policy::PolicyMergeError::ConflictingInspectionContracts {
host: "mcp.example.com".to_string(),
port: 443,
contracts: vec![
"mcp(strict_tool_names=true, allow_all_known_mcp_methods=false, max_body_bytes=65536)"
.to_string(),
"mcp(strict_tool_names=true, allow_all_known_mcp_methods=false, max_body_bytes=131072)"
.to_string(),
],
},
);
assert_eq!(mcp_conflict.code(), Code::FailedPrecondition);
assert!(mcp_conflict.message().contains("131072"));
let any_binary = map_policy_merge_error(
openshell_policy::PolicyMergeError::CannotRemoveBinaryFromAnyBinaryScope {
operation_index: 4,
rule_name: "wide".to_string(),
binary_path: "/usr/bin/untrusted".to_string(),
},
);
assert_eq!(any_binary.code(), Code::FailedPrecondition);
assert!(any_binary.message().contains("/usr/bin/untrusted"));
let invalid_input =
map_policy_merge_error(openshell_policy::PolicyMergeError::InvalidInputPolicy {
violations: Vec::new(),
});
assert_eq!(invalid_input.code(), Code::FailedPrecondition);
let invalid_merged =
map_policy_merge_error(openshell_policy::PolicyMergeError::InvalidMergedPolicy {
violations: Vec::new(),
});
assert_eq!(invalid_merged.code(), Code::Internal);
}
// ---- Sandbox IDOR guard (issue #1354) ----
#[tokio::test]
async fn cross_sandbox_get_sandbox_config_denied() {
use openshell_core::proto::{SandboxPhase, SandboxSpec};
let state = test_server_state().await;
// Two sandboxes; the caller is principal of A, the request body
// references B.
for (id, name) in [("sb-a", "sandbox-a"), ("sb-b", "sandbox-b")] {
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: id.to_string(),
name: name.to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Provisioning as i32);
state.store.put_message(&sandbox).await.unwrap();
}
let req = with_sandbox(
Request::new(GetSandboxConfigRequest {
name: "sb-b".to_string(),
workspace_scope: None,
}),
"sb-a",
);
let err = handle_get_sandbox_config(&state, req)
.await
.expect_err("cross-sandbox call must be denied");
assert_eq!(err.code(), Code::PermissionDenied);
}
#[tokio::test]
async fn missing_sandbox_get_sandbox_config_matches_foreign_sandbox_denial() {
let state = test_server_state().await;
let req = with_sandbox(
Request::new(GetSandboxConfigRequest {
name: "missing-sandbox".to_string(),
workspace_scope: None,
}),
"sb-a",
);
let err = handle_get_sandbox_config(&state, req)
.await
.expect_err("missing sandbox must not be distinguishable from a foreign sandbox");
assert_eq!(err.code(), Code::PermissionDenied);
assert_eq!(err.message(), "sandbox not found or not owned by caller");
}
#[tokio::test]
async fn same_sandbox_get_sandbox_config_allowed() {
use openshell_core::proto::{SandboxPhase, SandboxSpec};
let state = test_server_state().await;
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-self".to_string(),
name: "self".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Provisioning as i32);
state.store.put_message(&sandbox).await.unwrap();
let req = with_sandbox(
Request::new(GetSandboxConfigRequest {
name: "self".to_string(),
workspace_scope: None,
}),
"sb-self",
);
handle_get_sandbox_config(&state, req)
.await
.expect("matching principal must be allowed");
}
#[tokio::test]
async fn sandbox_get_sandbox_config_rejects_all_workspaces_selector() {
let state = test_server_state().await;
let req = with_sandbox(
Request::new(GetSandboxConfigRequest {
name: "self".to_string(),
workspace_scope: Some(openshell_core::proto::all_workspaces_selector()),
}),
"sb-self",
);
let err = handle_get_sandbox_config(&state, req)
.await
.expect_err("single-sandbox bootstrap must reject all_workspaces");
assert_eq!(err.code(), Code::InvalidArgument);
assert_eq!(
err.message(),
"all_workspaces is not supported by this request"
);
}
#[tokio::test]
async fn cross_sandbox_submit_policy_analysis_denied() {
use openshell_core::proto::{SandboxPhase, SandboxSpec};
let state = test_server_state().await;
for (id, name) in [("sb-a", "sandbox-a"), ("sb-b", "sandbox-b")] {
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: id.to_string(),
name: name.to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Provisioning as i32);
state.store.put_message(&sandbox).await.unwrap();
}
let req = with_sandbox(
Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: "sandbox-b".to_string(),
..Default::default()
}),
"sb-a",
);
let err = handle_submit_policy_analysis(&state, req)
.await
.expect_err("cross-sandbox submit must be denied");
assert_eq!(err.code(), Code::PermissionDenied);
}
#[tokio::test]
async fn cross_sandbox_get_draft_policy_denied() {
use openshell_core::proto::{SandboxPhase, SandboxSpec};
let state = test_server_state().await;
for (id, name) in [("sb-a", "sandbox-a"), ("sb-b", "sandbox-b")] {
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: id.to_string(),
name: name.to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Provisioning as i32);
state.store.put_message(&sandbox).await.unwrap();
}
let req = with_sandbox(
Request::new(GetDraftPolicyRequest {
sandbox: "sandbox-b".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
}),
"sb-a",
);
let err = handle_get_draft_policy(&state, req)
.await
.expect_err("cross-sandbox draft read must be denied");
assert_eq!(err.code(), Code::PermissionDenied);
}
#[tokio::test]
async fn sandbox_update_config_missing_name_matches_foreign_sandbox_denial() {
let state = test_server_state().await;
let req = with_sandbox(
Request::new(UpdateConfigRequest {
sandbox: "missing-sandbox".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(ProtoSandboxPolicy::default()),
..Default::default()
}),
"sb-a",
);
let err = handle_update_config(&state, req)
.await
.expect_err("missing name must not leak existence to sandbox callers");
assert_eq!(err.code(), Code::PermissionDenied);
}
#[tokio::test]
async fn sandbox_submit_policy_analysis_missing_name_returns_permission_denied() {
let state = test_server_state().await;
let req = with_sandbox(
Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: "missing-sandbox".to_string(),
..Default::default()
}),
"sb-a",
);
let err = handle_submit_policy_analysis(&state, req)
.await
.expect_err("missing name must not leak existence to sandbox callers");
assert_eq!(err.code(), Code::PermissionDenied);
}
#[tokio::test]
async fn sandbox_get_draft_policy_missing_name_matches_foreign_sandbox_denial() {
let state = test_server_state().await;
let req = with_sandbox(
Request::new(GetDraftPolicyRequest {
sandbox: "missing-sandbox".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
}),
"sb-a",
);
let err = handle_get_draft_policy(&state, req)
.await
.expect_err("missing name must not leak existence to sandbox callers");
assert_eq!(err.code(), Code::PermissionDenied);
}
#[tokio::test]
async fn user_principal_can_read_any_sandbox_config() {
// RBAC was the user gate; the IDOR guard must NOT trip for users.
use openshell_core::proto::{SandboxPhase, SandboxSpec};
let state = test_server_state().await;
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-x".to_string(),
name: "x".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Provisioning as i32);
state.store.put_message(&sandbox).await.unwrap();
let req = with_user(Request::new(GetSandboxConfigRequest {
name: "x".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
}));
handle_get_sandbox_config(&state, req)
.await
.expect("user principal must succeed");
}
#[tokio::test]
async fn log_stream_scope_rejects_sandbox_id_change_after_validation() {
let state = test_server_state().await;
for id in ["sb-a", "sb-b"] {
let sandbox = test_sandbox(id, id, ProtoSandboxPolicy::default(), vec![]);
state.store.put_message(&sandbox).await.unwrap();
}
let req = with_sandbox(Request::new(()), "sb-a");
let principal = req.extensions().get::<Principal>().unwrap().clone();
let mut validated = None;
ensure_log_stream_sandbox_scope(&state, &principal, "sb-a", &mut validated)
.await
.expect("first frame should validate");
let err = ensure_log_stream_sandbox_scope(&state, &principal, "sb-b", &mut validated)
.await
.expect_err("later frame must not switch sandbox ids");
assert_eq!(err.code(), Code::PermissionDenied);
}
#[tokio::test]
async fn log_stream_scope_rejects_missing_sandbox() {
let state = test_server_state().await;
let req = with_sandbox(Request::new(()), "sb-a");
let principal = req.extensions().get::<Principal>().unwrap().clone();
let mut validated = None;
let err = ensure_log_stream_sandbox_scope(&state, &principal, "sb-a", &mut validated)
.await
.expect_err("missing sandbox must not validate");
assert_eq!(err.code(), Code::NotFound);
}
#[test]
fn sandbox_caller_policy_sync_does_not_emit_policy_update_telemetry() {
assert!(!should_emit_config_update_policy_telemetry(true));
assert!(should_emit_config_update_policy_telemetry(false));
}
#[test]
fn first_policy_revision_does_not_emit_policy_update_telemetry() {
assert!(!should_emit_full_policy_update_telemetry(false, 1));
assert!(!should_emit_full_policy_update_telemetry(true, 2));
assert!(should_emit_full_policy_update_telemetry(false, 2));
}
// ---- Sandbox without policy ----
#[tokio::test]
async fn sandbox_without_policy_stores_successfully() {
use openshell_core::proto::{SandboxPhase, SandboxSpec};
let store = test_store().await;
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-no-policy".to_string(),
name: "no-policy-sandbox".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Provisioning as i32);
store.put_message(&sandbox).await.unwrap();
let loaded = store
.get_message::<Sandbox>("sb-no-policy")
.await
.unwrap()
.unwrap();
assert!(loaded.spec.unwrap().policy.is_none());
}
fn test_provider(name: &str, provider_type: &str) -> Provider {
Provider {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: format!("provider-{name}"),
name: name.to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
r#type: provider_type.to_string(),
credentials: std::iter::once(("GITHUB_TOKEN".to_string(), "ghp-test".to_string()))
.collect(),
config: HashMap::new(),
credential_expiration_times: HashMap::new(),
profile_workspace: "default".to_string(),
credential_handles: HashMap::new(),
}
}
fn test_aws_provider(name: &str, provider_type: &str) -> Provider {
let mut provider = test_provider(name, provider_type);
provider.credentials = [
("AWS_ACCESS_KEY_ID".to_string(), "AKIATEST".to_string()),
(
"AWS_SECRET_ACCESS_KEY".to_string(),
"test-secret".to_string(),
),
]
.into_iter()
.collect();
provider
}
fn test_policy_with_rule(rule_name: &str, host: &str) -> ProtoSandboxPolicy {
ProtoSandboxPolicy {
network_policies: std::iter::once((
rule_name.to_string(),
NetworkPolicyRule {
name: rule_name.to_string(),
endpoints: vec![NetworkEndpoint {
host: host.to_string(),
port: 443,
..Default::default()
}],
..Default::default()
},
))
.collect(),
..Default::default()
}
}
fn test_policy_with_credential_binding(
rule_name: &str,
host: &str,
provider: &str,
) -> ProtoSandboxPolicy {
let mut policy = test_policy_with_rule(rule_name, host);
policy
.network_policies
.get_mut(rule_name)
.unwrap()
.endpoints[0]
.credential_binding = Some(openshell_core::proto::NetworkCredentialBinding {
provider: provider.to_string(),
});
policy
}
fn test_sigv4_policy(host: &str, provider: Option<&str>) -> ProtoSandboxPolicy {
let mut policy = test_policy_with_rule("aws", host);
let endpoint = &mut policy.network_policies.get_mut("aws").unwrap().endpoints[0];
endpoint.protocol = "rest".to_string();
endpoint.access = openshell_core::proto::NetworkAccessPreset::Full as i32;
endpoint.credential_signing = "sigv4".to_string();
endpoint.signing_service = "s3".to_string();
endpoint.credential_binding =
provider.map(|provider| openshell_core::proto::NetworkCredentialBinding {
provider: provider.to_string(),
});
policy
}
fn test_ambiguous_policy() -> ProtoSandboxPolicy {
let mut left = test_policy_with_rule("left", "api.example.com");
left.network_policies.get_mut("left").unwrap().endpoints[0].tls =
openshell_core::proto::NetworkTlsMode::Skip as i32;
let right = test_policy_with_rule("right", "api.example.com");
left.network_policies.extend(right.network_policies);
left
}
pub(super) fn test_sandbox(
id: &str,
name: &str,
policy: ProtoSandboxPolicy,
providers: Vec<String>,
) -> Sandbox {
use openshell_core::proto::{SandboxPhase, SandboxSpec};
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: id.to_string(),
name: name.to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: Some(policy),
providers,
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
sandbox
}
async fn get_sandbox_policy(state: &Arc<ServerState>, sandbox_id: &str) -> ProtoSandboxPolicy {
let sandbox = state
.store
.get_message::<Sandbox>(sandbox_id)
.await
.expect("sandbox lookup")
.expect("sandbox exists");
handle_get_sandbox_config(
state,
with_user(Request::new(GetSandboxConfigRequest {
name: sandbox.object_name().to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
(sandbox.object_workspace()).to_string(),
)),
})),
)
.await
.unwrap()
.into_inner()
.policy
.expect("sandbox config should include policy")
}
#[tokio::test]
async fn request_paths_use_one_provider_profile_snapshot() {
use openshell_core::proto::{
ProviderCredentialTokenGrant, ProviderProfile, ProviderProfileCategory,
ProviderProfileCredential,
};
fn snapshot_profile(
id: &str,
host: &str,
credential_env: &str,
token_endpoint: &str,
) -> ProviderProfile {
ProviderProfile {
id: id.to_string(),
display_name: id.to_string(),
category: ProviderProfileCategory::Other as i32,
credentials: vec![ProviderProfileCredential {
name: "access_token".to_string(),
env_vars: vec![credential_env.to_string()],
auth_style: "bearer".to_string(),
header_name: "authorization".to_string(),
token_grant: Some(ProviderCredentialTokenGrant {
token_endpoint: token_endpoint.to_string(),
audience: "api://snapshot-test".to_string(),
..Default::default()
}),
..Default::default()
}],
endpoints: vec![NetworkEndpoint {
host: host.to_string(),
port: 443,
protocol: "rest".to_string(),
access: openshell_core::proto::NetworkAccessPreset::Full as i32,
..Default::default()
}],
..Default::default()
}
}
let fetch_count = Arc::new(AtomicUsize::new(0));
let state = test_server_state().await;
let mut state = Arc::into_inner(state).expect("test state should be uniquely owned");
state.provider_profile_sources = ProviderProfileSources::from_test_snapshot_sequence(
vec![
(
"revision-a".to_string(),
vec![
snapshot_profile(
"moving-a",
"one.revision-a.example",
"TOKEN_A",
"https://auth.revision-a.example/token-a",
),
snapshot_profile(
"moving-b",
"two.revision-a.example",
"TOKEN_B",
"https://auth.revision-a.example/token-b",
),
],
),
(
"revision-b".to_string(),
vec![
snapshot_profile(
"moving-a",
"one.revision-b.example",
"TOKEN_A",
"https://auth.revision-b.example/token-a",
),
snapshot_profile(
"moving-b",
"two.revision-b.example",
"TOKEN_B",
"https://auth.revision-b.example/token-b",
),
],
),
],
Arc::clone(&fetch_count),
);
let state = Arc::new(state);
let mut provider_a = test_provider("provider-a", "moving-a");
provider_a.credentials = HashMap::from([("TOKEN_A".to_string(), "a".to_string())]);
let mut provider_b = test_provider("provider-b", "moving-b");
provider_b.credentials = HashMap::from([("TOKEN_B".to_string(), "b".to_string())]);
state.store.put_message(&provider_a).await.unwrap();
state.store.put_message(&provider_b).await.unwrap();
state
.store
.put_message(&test_sandbox(
"sb-snapshot-consistency",
"snapshot-consistency",
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
vec!["provider-a".to_string(), "provider-b".to_string()],
))
.await
.unwrap();
let first = handle_get_sandbox_config(
&state,
with_user(Request::new(GetSandboxConfigRequest {
name: "snapshot-consistency".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(fetch_count.load(Ordering::SeqCst), 1);
let first_policy = first.policy.unwrap();
assert!(first_policy.network_policies.values().any(|rule| {
rule.endpoints
.iter()
.any(|endpoint| endpoint.host == "one.revision-a.example")
}));
assert!(first_policy.network_policies.values().any(|rule| {
rule.endpoints
.iter()
.any(|endpoint| endpoint.host == "two.revision-a.example")
}));
let second = handle_get_sandbox_config(
&state,
with_user(Request::new(GetSandboxConfigRequest {
name: "snapshot-consistency".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(fetch_count.load(Ordering::SeqCst), 2);
assert_ne!(first.provider_env_revision, second.provider_env_revision);
let second_policy = second.policy.unwrap();
assert!(second_policy.network_policies.values().any(|rule| {
rule.endpoints
.iter()
.any(|endpoint| endpoint.host == "one.revision-b.example")
}));
assert!(!second_policy.network_policies.values().any(|rule| {
rule.endpoints
.iter()
.any(|endpoint| endpoint.host == "one.revision-a.example")
}));
let environment = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-snapshot-consistency".to_string(),
supports_static_credential_bindings: true,
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(fetch_count.load(Ordering::SeqCst), 3);
assert_eq!(
environment.provider_env_revision,
second.provider_env_revision
);
assert_eq!(environment.dynamic_credentials.len(), 2);
assert!(environment.dynamic_credentials.values().all(|credential| {
credential
.token_grant
.as_ref()
.is_some_and(|grant| grant.token_endpoint.contains("revision-b"))
}));
handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: "snapshot-consistency".to_string(),
analysis_mode: "agent_authored".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "snapshot_consistency_test".to_string(),
proposed_rule: Some(NetworkPolicyRule {
name: "snapshot_consistency_test".to_string(),
endpoints: vec![NetworkEndpoint {
host: "proposal.example.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
}),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap();
assert_eq!(fetch_count.load(Ordering::SeqCst), 4);
}
#[tokio::test]
async fn provider_policy_layers_skip_unknown_provider_types() {
let store = test_store().await;
store
.put_message(&test_provider("custom-provider", "custom"))
.await
.unwrap();
let layers =
profile_provider_policy_layers(&store, "default", &["custom-provider".to_string()])
.await
.unwrap();
assert!(layers.is_empty());
}
#[tokio::test]
async fn provider_policy_layers_resolve_user_profile_for_normalized_provider_type() {
let store = test_store().await;
store
.put_message(&test_provider("custom-provider", "generic"))
.await
.unwrap();
store
.put_message(&StoredProviderProfile {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "profile-generic".to_string(),
name: "generic".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
profile: Some(openshell_core::proto::ProviderProfile {
files: Vec::new(),
id: "generic".to_string(),
resource_version: 0,
annotations: HashMap::new(),
display_name: "Generic Override".to_string(),
description: String::new(),
category: openshell_core::proto::ProviderProfileCategory::Other as i32,
credentials: Vec::new(),
endpoints: vec![NetworkEndpoint {
host: "backdoor.example".to_string(),
port: 443,
..Default::default()
}],
binaries: Vec::new(),
inference_capable: false,
discovery: None,
source: String::new(),
scope: String::new(),
}),
})
.await
.unwrap();
let layers =
profile_provider_policy_layers(&store, "default", &["custom-provider".to_string()])
.await
.unwrap();
assert_eq!(layers.len(), 1);
assert_eq!(layers[0].rule.endpoints[0].host, "backdoor.example");
}
#[tokio::test]
async fn provider_policy_layers_prefer_exact_imported_alias_profile() {
let store = test_store().await;
store
.put_message(&test_provider("enterprise-github", "gh"))
.await
.unwrap();
store
.put_message(&StoredProviderProfile {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "profile-gh".to_string(),
name: "gh".to_string(),
workspace: "default".to_string(),
..Default::default()
}),
profile: Some(openshell_core::proto::ProviderProfile {
id: "gh".to_string(),
display_name: "Enterprise GitHub".to_string(),
endpoints: vec![NetworkEndpoint {
host: "github.enterprise.example".to_string(),
port: 443,
..Default::default()
}],
..Default::default()
}),
})
.await
.unwrap();
let layers =
profile_provider_policy_layers(&store, "default", &["enterprise-github".to_string()])
.await
.unwrap();
assert_eq!(layers.len(), 1);
assert_eq!(layers[0].rule.endpoints.len(), 1);
assert_eq!(
layers[0].rule.endpoints[0].host,
"github.enterprise.example"
);
}
#[tokio::test]
async fn provider_policy_layers_include_custom_provider_profiles() {
let store = test_store().await;
store
.put_message(&test_provider("work-custom", "custom-api"))
.await
.unwrap();
store
.put_message(&StoredProviderProfile {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "profile-custom-api".to_string(),
name: "custom-api".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
profile: Some(openshell_core::proto::ProviderProfile {
files: Vec::new(),
id: "custom-api".to_string(),
resource_version: 0,
annotations: HashMap::new(),
display_name: "Custom API".to_string(),
description: String::new(),
category: openshell_core::proto::ProviderProfileCategory::Other as i32,
credentials: Vec::new(),
endpoints: vec![NetworkEndpoint {
host: "api.custom.example".to_string(),
protocol: "rest".to_string(),
ports: vec![443, 8443],
allowed_ips: vec!["10.0.0.0/24".to_string()],
rules: vec![L7Rule {
allow: Some(openshell_core::proto::L7Allow {
method: "GET".to_string(),
path: "/v1/**".to_string(),
..Default::default()
}),
}],
allow_encoded_slash: true,
path: "/v1".to_string(),
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/custom".to_string(),
}],
inference_capable: false,
discovery: None,
source: String::new(),
scope: String::new(),
}),
})
.await
.unwrap();
let layers =
profile_provider_policy_layers(&store, "default", &["work-custom".to_string()])
.await
.unwrap();
assert_eq!(layers.len(), 1);
assert_eq!(layers[0].rule_name, "_provider_work_custom");
assert_eq!(layers[0].rule.endpoints[0].host, "api.custom.example");
assert_eq!(layers[0].rule.endpoints[0].ports, vec![443, 8443]);
assert_eq!(layers[0].rule.endpoints[0].rules.len(), 1);
assert_eq!(layers[0].rule.endpoints[0].allowed_ips, vec!["10.0.0.0/24"]);
assert!(layers[0].rule.endpoints[0].allow_encoded_slash);
assert_eq!(layers[0].rule.endpoints[0].path, "/v1");
assert_eq!(layers[0].rule.binaries[0].path, "/usr/bin/custom");
}
#[tokio::test]
async fn provider_policy_layers_normalize_custom_provider_type_ids() {
let store = test_store().await;
store
.put_message(&test_provider("work-custom", " Custom-API "))
.await
.unwrap();
store
.put_message(&StoredProviderProfile {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "profile-custom-api".to_string(),
name: "custom-api".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
profile: Some(openshell_core::proto::ProviderProfile {
files: Vec::new(),
id: "custom-api".to_string(),
resource_version: 0,
annotations: HashMap::new(),
display_name: "Custom API".to_string(),
description: String::new(),
category: openshell_core::proto::ProviderProfileCategory::Other as i32,
credentials: Vec::new(),
endpoints: vec![NetworkEndpoint {
host: "api.custom.example".to_string(),
port: 443,
..Default::default()
}],
binaries: Vec::new(),
inference_capable: false,
discovery: None,
source: String::new(),
scope: String::new(),
}),
})
.await
.unwrap();
let layers =
profile_provider_policy_layers(&store, "default", &["work-custom".to_string()])
.await
.unwrap();
assert_eq!(layers.len(), 1);
assert_eq!(layers[0].rule.endpoints[0].host, "api.custom.example");
}
#[tokio::test]
async fn provider_policy_layers_include_known_provider_profiles() {
let store = test_store().await;
store
.put_message(&test_provider("work-github", "github"))
.await
.unwrap();
let layers =
profile_provider_policy_layers(&store, "default", &["work-github".to_string()])
.await
.unwrap();
assert_eq!(layers.len(), 1);
assert_eq!(layers[0].rule_name, "_provider_work_github");
assert_eq!(layers[0].rule.endpoints.len(), 3);
assert!(
layers[0]
.rule
.endpoints
.iter()
.all(|endpoint| endpoint.provider_credentialed)
);
assert!(
layers[0]
.rule
.endpoints
.iter()
.any(|endpoint| endpoint.host == "api.github.com")
);
assert!(
layers[0].rule.endpoints.iter().any(|endpoint| {
endpoint.host == "api.github.com"
&& endpoint.protocol == "graphql"
&& endpoint.path == "/graphql"
&& endpoint.access
== openshell_core::proto::NetworkAccessPreset::ReadOnly as i32
}),
"github provider policy should include read-only GraphQL endpoint"
);
assert!(
layers[0].rule.endpoints.iter().all(|endpoint| {
// API endpoints stay read-only; the github.com git transport
// carries explicit rules so clone/fetch works (see #1769).
if endpoint.host == "github.com" {
endpoint.access == 0
} else {
endpoint.access == openshell_core::proto::NetworkAccessPreset::ReadOnly as i32
}
}),
"github API endpoints should be read-only; git transport uses explicit rules"
);
// Pin the exact composed rule set for the git transport. Clone/fetch
// needs GET */info/refs then POST */git-upload-pack; a broader or extra
// POST rule (e.g. POST **) would also permit push (git-receive-pack), so
// matching the whole set fails on any such regression (see #1769).
let git_transport = layers[0]
.rule
.endpoints
.iter()
.find(|endpoint| endpoint.host == "github.com")
.expect("composed policy should include the github.com git transport");
let mut allowed: Vec<(&str, &str)> = git_transport
.rules
.iter()
.map(|rule| {
let allow = rule
.allow
.as_ref()
.expect("git transport rules must be allow rules");
(allow.method.as_str(), allow.path.as_str())
})
.collect();
allowed.sort_unstable();
let mut expected = vec![
("GET", "**"),
("HEAD", "**"),
("OPTIONS", "**"),
("POST", "/**/git-upload-pack"),
];
expected.sort_unstable();
assert_eq!(
allowed, expected,
"composed git transport allow rules must be exactly the read-only \
methods plus POST */git-upload-pack; a broader POST rule would \
enable push (git-receive-pack)"
);
assert!(
git_transport.deny_rules.is_empty(),
"composed git transport should block push via its narrow allow set, not deny rules"
);
}
#[tokio::test]
async fn provider_policy_layers_skip_public_vendor_endpoints_for_alternate_upstreams() {
let store = test_store().await;
let mut openai = test_provider("alternate-openai", "openai");
openai.config.insert(
"OPENAI_BASE_URL".to_string(),
"https://api.example.com/v1".to_string(),
);
let mut anthropic = test_provider("alternate-anthropic", "anthropic");
anthropic.config.insert(
"ANTHROPIC_BASE_URL".to_string(),
"https://api.example.com/v1".to_string(),
);
store.put_message(&openai).await.unwrap();
store.put_message(&anthropic).await.unwrap();
let layers = profile_provider_policy_layers(
&store,
"default",
&[
"alternate-openai".to_string(),
"alternate-anthropic".to_string(),
],
)
.await
.unwrap();
assert!(layers.is_empty());
}
#[tokio::test]
async fn provider_policy_layers_respect_profile_workspace_scope() {
let store = test_store().await;
let make_stored_profile = |id: &str, workspace: &str, host: &str| StoredProviderProfile {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: format!("profile-{id}-{workspace}"),
name: id.to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: workspace.to_string(),
deletion_time: None,
}),
profile: Some(openshell_core::proto::ProviderProfile {
id: id.to_string(),
display_name: format!("{host} profile"),
endpoints: vec![NetworkEndpoint {
host: host.to_string(),
port: 443,
..Default::default()
}],
..Default::default()
}),
};
store
.put_message(&make_stored_profile(
"scope-api",
"",
"platform.example.com",
))
.await
.unwrap();
store
.put_message(&make_stored_profile(
"scope-api",
"default",
"workspace.example.com",
))
.await
.unwrap();
let mut global_provider = test_provider("global-prov", "scope-api");
global_provider.profile_workspace = String::new();
store.put_message(&global_provider).await.unwrap();
let normal_provider = test_provider("normal-prov", "scope-api");
store.put_message(&normal_provider).await.unwrap();
let global_layers =
profile_provider_policy_layers(&store, "default", &["global-prov".to_string()])
.await
.unwrap();
assert_eq!(global_layers.len(), 1);
assert_eq!(
global_layers[0].rule.endpoints[0].host, "platform.example.com",
"provider with profile_workspace='' should resolve the platform profile"
);
let normal_layers =
profile_provider_policy_layers(&store, "default", &["normal-prov".to_string()])
.await
.unwrap();
assert_eq!(normal_layers.len(), 1);
assert_eq!(
normal_layers[0].rule.endpoints[0].host, "workspace.example.com",
"provider with profile_workspace='default' should resolve the workspace profile"
);
}
#[tokio::test]
async fn sandbox_config_always_composes_provider_layers() {
let state = test_server_state().await;
state
.store
.put_message(&test_provider("work-github", "github"))
.await
.unwrap();
state
.store
.put_message(&test_sandbox(
"sb-v2-enabled",
"v2-enabled",
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
vec!["work-github".to_string()],
))
.await
.unwrap();
let effective_policy = get_sandbox_policy(&state, "sb-v2-enabled").await;
assert!(
effective_policy
.network_policies
.contains_key("sandbox_only")
);
assert!(
effective_policy
.network_policies
.contains_key("_provider_work_github")
);
assert!(
effective_policy
.network_policies
.get("_provider_work_github")
.unwrap()
.endpoints
.iter()
.any(|endpoint| endpoint.host == "api.github.com")
);
}
#[tokio::test]
async fn sandbox_config_materializes_default_mcp_version_after_provider_composition() {
use openshell_core::proto::{ProviderProfile, ProviderProfileCategory};
let state = test_server_state().await;
state
.store
.put_message(&StoredProviderProfile {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "profile-mcp-default".to_string(),
name: "mcp-default".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
profile: Some(ProviderProfile {
id: "mcp-default".to_string(),
display_name: "MCP default".to_string(),
category: ProviderProfileCategory::Other as i32,
endpoints: vec![NetworkEndpoint {
host: "mcp.example.com".to_string(),
port: 443,
protocol: "mcp".to_string(),
mcp: None,
rules: vec![L7Rule {
allow: Some(openshell_core::proto::L7Allow {
method: "tools/list".to_string(),
..Default::default()
}),
}],
..Default::default()
}],
..Default::default()
}),
})
.await
.expect("store versionless MCP provider profile");
state
.store
.put_message(&test_provider("work-mcp-default", "mcp-default"))
.await
.expect("store MCP provider");
state
.store
.put_message(&test_sandbox(
"sb-mcp-default-composed",
"mcp-default-composed",
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
vec!["work-mcp-default".to_string()],
))
.await
.expect("store MCP sandbox");
let response = handle_get_sandbox_config(
&state,
with_user(Request::new(GetSandboxConfigRequest {
name: "mcp-default-composed".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
})),
)
.await
.expect("provider-composed MCP policy must materialize")
.into_inner();
let effective_policy = response.policy.expect("effective composed policy");
let endpoint = effective_policy
.network_policies
.values()
.flat_map(|rule| &rule.endpoints)
.find(|endpoint| endpoint.host == "mcp.example.com")
.expect("composed MCP endpoint");
assert_eq!(
endpoint
.mcp
.as_ref()
.expect("canonical MCP options")
.versions,
["2025-11-25".to_string()]
);
assert_eq!(
response.policy_hash,
deterministic_policy_hash(&effective_policy)
);
}
#[test]
fn candidate_effective_policy_rejects_provider_endpoint_ambiguity() {
let base = test_policy_with_rule("base", "api.example.com");
let mut provider_rule = test_policy_with_rule("provider", "api.example.com")
.network_policies
.remove("provider")
.unwrap();
provider_rule.endpoints[0].tls = openshell_core::proto::NetworkTlsMode::Skip as i32;
let layers = [ProviderPolicyLayer {
rule_name: "_provider_test".to_string(),
rule: provider_rule,
}];
let error = validate_candidate_effective_policy(&base, &layers)
.expect_err("provider composition must reject endpoint ambiguity");
assert_eq!(error.code(), Code::FailedPrecondition);
assert!(error.message().contains("api.example.com"));
assert!(error.message().contains("tls"));
}
#[tokio::test]
async fn update_config_rejects_ambiguous_policy_before_persisting_revision() {
let state = test_server_state().await;
let mut sandbox = test_sandbox(
"sb-ambiguous-update",
"ambiguous-update",
ProtoSandboxPolicy::default(),
Vec::new(),
);
sandbox.spec.as_mut().unwrap().policy = None;
state.store.put_message(&sandbox).await.unwrap();
let error = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "ambiguous-update".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(test_ambiguous_policy()),
..Default::default()
})),
)
.await
.expect_err("ambiguous policy must fail before persistence");
assert_eq!(error.code(), Code::FailedPrecondition);
assert!(error.message().contains("ambiguity validation failed"));
assert!(
state
.store
.get_latest_policy("sb-ambiguous-update")
.await
.unwrap()
.is_none(),
"invalid policy must not leave a revision in history"
);
}
#[tokio::test]
async fn update_config_rejects_credential_binding_to_unattached_provider() {
let state = test_server_state().await;
let mut sandbox = test_sandbox(
"sb-unattached-binding",
"unattached-binding",
ProtoSandboxPolicy::default(),
Vec::new(),
);
sandbox.spec.as_mut().unwrap().policy = None;
state.store.put_message(&sandbox).await.unwrap();
let error = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "unattached-binding".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(test_policy_with_credential_binding(
"cloud",
"api.cloud.example",
"missing-provider",
)),
..Default::default()
})),
)
.await
.expect_err("unattached provider binding must fail before persistence");
assert_eq!(error.code(), Code::FailedPrecondition);
assert!(error.message().contains("not attached"));
assert!(
state
.store
.get_latest_policy("sb-unattached-binding")
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn update_config_rejects_policy_binding_for_endpointful_profile() {
let state = test_server_state().await;
state
.store
.put_message(&test_provider("work-github", "github"))
.await
.unwrap();
let mut sandbox = test_sandbox(
"sb-double-binding",
"double-binding",
ProtoSandboxPolicy::default(),
vec!["work-github".to_string()],
);
sandbox.spec.as_mut().unwrap().policy = None;
state.store.put_message(&sandbox).await.unwrap();
let error = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "double-binding".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(test_policy_with_credential_binding(
"cloud",
"api.cloud.example",
"work-github",
)),
..Default::default()
})),
)
.await
.expect_err("profile and policy must not both define credential endpoints");
assert_eq!(error.code(), Code::FailedPrecondition);
assert!(error.message().contains("already defines endpoints"));
}
#[tokio::test]
async fn update_config_gates_uninspected_endpointless_credential_binding() {
use openshell_core::proto::{ProviderProfile, ProviderProfileCategory};
let state = test_server_state().await;
state
.store
.put_message(&StoredProviderProfile {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "profile-endpointless-gating".to_string(),
name: "endpointless-gating".to_string(),
workspace: "default".to_string(),
..Default::default()
}),
profile: Some(ProviderProfile {
id: "endpointless-gating".to_string(),
display_name: "Endpointless Gating".to_string(),
category: ProviderProfileCategory::Other as i32,
endpoints: Vec::new(),
..Default::default()
}),
})
.await
.unwrap();
state
.store
.put_message(&test_provider("work-endpointless", "endpointless-gating"))
.await
.unwrap();
let mut sandbox = test_sandbox(
"sb-endpointless-gating",
"endpointless-gating",
ProtoSandboxPolicy::default(),
vec!["work-endpointless".to_string()],
);
sandbox.spec.as_mut().unwrap().policy = None;
state.store.put_message(&sandbox).await.unwrap();
let l4 =
test_policy_with_credential_binding("bound", "api.bound.example", "work-endpointless");
let add_bound_rule = |policy: &ProtoSandboxPolicy| PolicyMergeOperation {
operation: Some(policy_merge_operation::Operation::AddRule(
openshell_core::proto::AddNetworkRule {
rule_name: "bound".to_string(),
rule: Some(policy.network_policies["bound"].clone()),
},
)),
};
let l4_error = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "endpointless-gating".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(l4.clone()),
..Default::default()
})),
)
.await
.expect_err("L4-only credential binding must be rejected");
assert_eq!(l4_error.code(), Code::FailedPrecondition);
assert!(l4_error.message().contains("L4-only"));
let mut tls_skip = l4.clone();
let tls_endpoint = &mut tls_skip
.network_policies
.get_mut("bound")
.unwrap()
.endpoints[0];
tls_endpoint.protocol = "rest".to_string();
tls_endpoint.access = openshell_core::proto::NetworkAccessPreset::Full as i32;
tls_endpoint.tls = openshell_core::proto::NetworkTlsMode::Skip as i32;
let tls_error = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "endpointless-gating".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(tls_skip.clone()),
..Default::default()
})),
)
.await
.expect_err("tls: skip credential binding must be rejected");
assert_eq!(tls_error.code(), Code::FailedPrecondition);
assert!(tls_error.message().contains("tls: skip"));
let merge_l4_error = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "endpointless-gating".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
merge_operations: vec![add_bound_rule(&l4)],
..Default::default()
})),
)
.await
.expect_err("L4-only credential binding merge must be rejected");
assert_eq!(merge_l4_error.code(), Code::FailedPrecondition);
assert!(merge_l4_error.message().contains("L4-only"));
let merge_tls_error = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "endpointless-gating".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
merge_operations: vec![add_bound_rule(&tls_skip)],
..Default::default()
})),
)
.await
.expect_err("tls: skip credential binding merge must be rejected");
assert_eq!(merge_tls_error.code(), Code::FailedPrecondition);
assert!(merge_tls_error.message().contains("tls: skip"));
assert!(
state
.store
.get_latest_policy("sb-endpointless-gating")
.await
.unwrap()
.is_none(),
"rejected policies must not leave a revision in history"
);
let mut opted_in = l4;
opted_in
.network_policies
.get_mut("bound")
.unwrap()
.endpoints[0]
.allow_uninspected_credentials = true;
handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "endpointless-gating".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
merge_operations: vec![add_bound_rule(&opted_in)],
..Default::default()
})),
)
.await
.expect("explicit opt-in must admit the endpointless credential binding merge");
assert!(
state
.store
.get_latest_policy("sb-endpointless-gating")
.await
.unwrap()
.is_some()
);
// Wait for SQLx's SQLite worker threads to stop before the test process exits;
state.store.close_for_test().await;
}
#[tokio::test]
async fn update_config_rejects_sigv4_without_credential_source_before_persisting_revision() {
let state = test_server_state().await;
let mut sandbox = test_sandbox(
"sb-signing-no-source",
"signing-no-source",
ProtoSandboxPolicy::default(),
Vec::new(),
);
sandbox.spec.as_mut().unwrap().policy = None;
state.store.put_message(&sandbox).await.unwrap();
let error = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "signing-no-source".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(test_sigv4_policy("s3.amazonaws.com", None)),
..Default::default()
})),
)
.await
.expect_err("SigV4 policy without an AWS credential source must fail before persistence");
assert_eq!(error.code(), Code::FailedPrecondition);
assert!(
error
.message()
.contains("no resolvable AWS credential source")
);
assert!(
state
.store
.get_latest_policy("sb-signing-no-source")
.await
.unwrap()
.is_none(),
"invalid policy must not leave a revision in history"
);
}
#[tokio::test]
async fn update_config_rejects_sigv4_for_unbound_endpointless_aws_profile() {
let state = test_server_state().await;
state
.store
.put_message(&test_aws_provider("aws-prod", "aws"))
.await
.unwrap();
let mut sandbox = test_sandbox(
"sb-signing-unbound-aws",
"signing-unbound-aws",
ProtoSandboxPolicy::default(),
vec!["aws-prod".to_string()],
);
sandbox.spec.as_mut().unwrap().policy = None;
state.store.put_message(&sandbox).await.unwrap();
let error = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "signing-unbound-aws".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(test_sigv4_policy("s3.amazonaws.com", None)),
..Default::default()
})),
)
.await
.expect_err("endpointless AWS profile must be bound explicitly");
assert_eq!(error.code(), Code::FailedPrecondition);
assert!(
error
.message()
.contains("no resolvable AWS credential source")
);
}
#[tokio::test]
async fn update_config_accepts_sigv4_bound_to_endpointless_aws_profile() {
let state = test_server_state().await;
state
.store
.put_message(&test_aws_provider("aws-prod", "aws"))
.await
.unwrap();
let mut sandbox = test_sandbox(
"sb-signing-bound-aws",
"signing-bound-aws",
ProtoSandboxPolicy::default(),
vec!["aws-prod".to_string()],
);
sandbox.spec.as_mut().unwrap().policy = None;
state.store.put_message(&sandbox).await.unwrap();
handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "signing-bound-aws".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(test_sigv4_policy("s3.amazonaws.com", Some("aws-prod"))),
..Default::default()
})),
)
.await
.expect("bound endpointless AWS profile supplies SigV4 credentials");
assert!(
state
.store
.get_latest_policy("sb-signing-bound-aws")
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn update_config_accepts_sigv4_covered_by_endpointful_aws_profile() {
let state = test_server_state().await;
state
.store
.put_message(&test_aws_provider("s3-prod", "aws-s3"))
.await
.unwrap();
let mut sandbox = test_sandbox(
"sb-signing-profile-endpoint",
"signing-profile-endpoint",
ProtoSandboxPolicy::default(),
vec!["s3-prod".to_string()],
);
sandbox.spec.as_mut().unwrap().policy = None;
state.store.put_message(&sandbox).await.unwrap();
let mut policy = test_sigv4_policy("bucket.s3.amazonaws.com", None);
let endpoint = &mut policy.network_policies.get_mut("aws").unwrap().endpoints[0];
endpoint.access = openshell_core::proto::NetworkAccessPreset::ReadWrite as i32;
endpoint.enforcement = openshell_core::proto::NetworkEnforcementMode::Enforce as i32;
handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "signing-profile-endpoint".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(policy),
..Default::default()
})),
)
.await
.expect("endpoint-bearing AWS profile covers the signed endpoint");
}
#[tokio::test]
async fn update_config_rejects_sigv4_outside_endpointful_aws_profile_boundary() {
let state = test_server_state().await;
state
.store
.put_message(&test_aws_provider("s3-prod", "aws-s3"))
.await
.unwrap();
let mut sandbox = test_sandbox(
"sb-signing-profile-mismatch",
"signing-profile-mismatch",
ProtoSandboxPolicy::default(),
vec!["s3-prod".to_string()],
);
sandbox.spec.as_mut().unwrap().policy = None;
state.store.put_message(&sandbox).await.unwrap();
let error = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "signing-profile-mismatch".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(test_sigv4_policy("api.example.com", None)),
..Default::default()
})),
)
.await
.expect_err("profile endpoint boundary must cover a signed endpoint");
assert_eq!(error.code(), Code::FailedPrecondition);
assert!(
error
.message()
.contains("no resolvable AWS credential source")
);
}
#[tokio::test]
async fn merge_operations_reject_ambiguity_before_persisting_revision() {
let state = test_server_state().await;
let mut policy = test_ambiguous_policy();
policy.network_policies.get_mut("left").unwrap().endpoints[0].path = "/v1/*".to_string();
policy.network_policies.get_mut("right").unwrap().endpoints[0].path =
"/v1/users".to_string();
let operations = policy
.network_policies
.into_iter()
.map(|(rule_name, rule)| PolicyMergeOp::AddRule { rule_name, rule })
.collect::<Vec<_>>();
let error = apply_merge_operations_with_retry(
state.store.as_ref(),
"sb-ambiguous-merge",
"default",
None,
&operations,
PolicyMergeValidationContext {
provider_layers: &[],
credential_binding: None,
},
None,
None,
)
.await
.expect_err("ambiguous merge must fail before persistence");
assert_eq!(error.code(), Code::FailedPrecondition);
assert!(
state
.store
.get_latest_policy("sb-ambiguous-merge")
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn provider_attachment_preflight_rejects_composed_ambiguity() {
use openshell_core::proto::{ProviderProfile, ProviderProfileCategory};
let state = test_server_state().await;
state
.store
.put_message(&StoredProviderProfile {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "profile-ambiguous".to_string(),
name: "ambiguous".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
profile: Some(ProviderProfile {
id: "ambiguous".to_string(),
display_name: "Ambiguous".to_string(),
category: ProviderProfileCategory::Other as i32,
endpoints: vec![NetworkEndpoint {
host: "api.example.com".to_string(),
port: 443,
tls: openshell_core::proto::NetworkTlsMode::Skip as i32,
..Default::default()
}],
..Default::default()
}),
})
.await
.unwrap();
state
.store
.put_message(&test_provider("candidate-provider", "ambiguous"))
.await
.unwrap();
let sandbox = test_sandbox(
"sb-provider-ambiguity",
"provider-ambiguity",
test_policy_with_rule("base", "api.example.com"),
Vec::new(),
);
state.store.put_message(&sandbox).await.unwrap();
let error = super::super::sandbox::handle_attach_sandbox_provider(
&state,
authed_request(openshell_core::proto::AttachSandboxProviderRequest {
request_id: String::new(),
sandbox: "provider-ambiguity".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
provider: "candidate-provider".to_string(),
expected_resource_version: 0,
}),
)
.await
.expect_err("provider attachment must validate the composed policy");
assert_eq!(error.code(), Code::FailedPrecondition);
assert!(error.message().contains("tls"));
let stored = state
.store
.get_message_by_name::<Sandbox>("default", "provider-ambiguity")
.await
.unwrap()
.unwrap();
assert!(stored.spec.unwrap().providers.is_empty());
}
#[tokio::test]
async fn sandbox_config_rejects_invalid_provider_composed_policy() {
use openshell_core::proto::{
MiddlewareEndpointSelector, NetworkMiddlewareConfig, ProviderProfile,
ProviderProfileCategory,
};
let state = test_server_state().await;
let profile = StoredProviderProfile {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "profile-tls-skip".to_string(),
name: "tls-skip".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
annotations: HashMap::new(),
resource_version: 0,
workspace: "default".to_string(),
deletion_time: None,
}),
profile: Some(ProviderProfile {
id: "tls-skip".to_string(),
display_name: "TLS skip".to_string(),
category: ProviderProfileCategory::Other as i32,
endpoints: vec![NetworkEndpoint {
host: "api.example.com".to_string(),
port: 443,
tls: openshell_core::proto::NetworkTlsMode::Skip as i32,
..Default::default()
}],
..Default::default()
}),
};
state.store.put_message(&profile).await.unwrap();
state
.store
.put_message(&test_provider("work-tls-skip", "tls-skip"))
.await
.unwrap();
let policy = ProtoSandboxPolicy {
network_middlewares: HashMap::from([(
"redactor".to_string(),
NetworkMiddlewareConfig {
middleware: "openshell/regex".to_string(),
on_error: "fail_closed".to_string(),
endpoints: Some(MiddlewareEndpointSelector {
include: vec!["api.example.com".to_string()],
exclude: Vec::new(),
}),
..Default::default()
},
)]),
..Default::default()
};
state
.store
.put_message(&test_sandbox(
"sb-invalid-composed-policy",
"invalid-composed-policy",
policy,
vec!["work-tls-skip".to_string()],
))
.await
.unwrap();
let error = handle_get_sandbox_config(
&state,
with_user(Request::new(GetSandboxConfigRequest {
name: "invalid-composed-policy".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
})),
)
.await
.expect_err("invalid composed policy must not be delivered");
assert_eq!(error.code(), Code::FailedPrecondition);
assert!(error.message().contains("provider composition"));
assert!(error.message().contains("tls: skip"));
}
#[tokio::test]
async fn sandbox_config_skips_profileless_provider_types() {
let state = test_server_state().await;
state
.store
.put_message(&test_provider("legacy-generic", "generic"))
.await
.unwrap();
state
.store
.put_message(&test_provider("custom-provider", "custom"))
.await
.unwrap();
state
.store
.put_message(&test_sandbox(
"sb-profileless",
"profileless",
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
vec!["legacy-generic".to_string(), "custom-provider".to_string()],
))
.await
.unwrap();
let effective_policy = get_sandbox_policy(&state, "sb-profileless").await;
assert_eq!(effective_policy.network_policies.len(), 1);
assert!(
effective_policy
.network_policies
.contains_key("sandbox_only")
);
}
#[tokio::test]
async fn sandbox_config_composition_is_jit_and_does_not_persist_provider_layers() {
let state = test_server_state().await;
state
.store
.put_message(&test_provider("work-github", "github"))
.await
.unwrap();
state
.store
.put_message(&test_sandbox(
"sb-jit",
"jit",
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
vec!["work-github".to_string()],
))
.await
.unwrap();
let effective_policy = get_sandbox_policy(&state, "sb-jit").await;
assert!(
effective_policy
.network_policies
.contains_key("_provider_work_github")
);
let persisted = state
.store
.get_latest_policy("sb-jit")
.await
.unwrap()
.expect("sandbox policy should be lazily backfilled");
let persisted_policy = ProtoSandboxPolicy::decode(persisted.policy_payload.as_slice())
.expect("persisted sandbox policy should decode");
assert!(
persisted_policy
.network_policies
.contains_key("sandbox_only")
);
assert!(
!persisted_policy
.network_policies
.contains_key("_provider_work_github")
);
}
#[tokio::test]
async fn sandbox_config_uses_updated_custom_provider_profile_without_rewriting_provider() {
use crate::grpc::provider::handle_update_provider_profiles;
use openshell_core::proto::{
ProviderProfile, ProviderProfileCategory, ProviderProfileImportItem,
UpdateProviderProfilesRequest,
};
fn stored_profile(host: &str) -> StoredProviderProfile {
StoredProviderProfile {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "profile-custom-policy".to_string(),
name: "custom-policy".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
profile: Some(ProviderProfile {
files: Vec::new(),
id: "custom-policy".to_string(),
resource_version: 0,
annotations: HashMap::new(),
display_name: "Custom Policy".to_string(),
description: String::new(),
category: ProviderProfileCategory::Other as i32,
credentials: Vec::new(),
endpoints: vec![NetworkEndpoint {
host: host.to_string(),
port: 443,
..Default::default()
}],
binaries: Vec::new(),
inference_capable: false,
discovery: None,
source: String::new(),
scope: String::new(),
}),
}
}
let state = test_server_state().await;
state
.store
.put_message(&stored_profile("api.before.example"))
.await
.unwrap();
let provider = test_provider("work-custom", "custom-policy");
state.store.put_message(&provider).await.unwrap();
state
.store
.put_message(&test_sandbox(
"sb-custom-policy-update",
"custom-policy-update",
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
vec!["work-custom".to_string()],
))
.await
.unwrap();
let before_policy = get_sandbox_policy(&state, "sb-custom-policy-update").await;
assert!(
before_policy.network_policies["_provider_work_custom"]
.endpoints
.iter()
.any(|endpoint| endpoint.host == "api.before.example")
);
let mut updated_profile = stored_profile("api.after.example").profile.unwrap();
updated_profile.resource_version = state
.store
.get_message_by_name::<StoredProviderProfile>("default", "custom-policy")
.await
.unwrap()
.unwrap()
.metadata
.as_ref()
.unwrap()
.resource_version;
let response = handle_update_provider_profiles(
&state,
with_user(Request::new(UpdateProviderProfilesRequest {
request_id: String::new(),
profile: Some(ProviderProfileImportItem {
profile: Some(updated_profile),
source: "custom-policy.yaml".to_string(),
}),
expected_resource_version: 0,
id: "custom-policy".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
})),
)
.await
.unwrap()
.into_inner();
assert!(response.updated);
let after_policy = get_sandbox_policy(&state, "sb-custom-policy-update").await;
let provider_rule = &after_policy.network_policies["_provider_work_custom"];
assert!(
provider_rule
.endpoints
.iter()
.any(|endpoint| endpoint.host == "api.after.example")
);
assert!(
!provider_rule
.endpoints
.iter()
.any(|endpoint| endpoint.host == "api.before.example")
);
let persisted_provider: Provider = state
.store
.get_message_by_name::<Provider>("default", "work-custom")
.await
.unwrap()
.unwrap();
assert_eq!(persisted_provider.r#type, provider.r#type);
assert_eq!(persisted_provider.credentials, provider.credentials);
let persisted_policy = state
.store
.get_latest_policy("sb-custom-policy-update")
.await
.unwrap()
.expect("sandbox policy should be lazily backfilled");
let persisted_policy =
ProtoSandboxPolicy::decode(persisted_policy.policy_payload.as_slice())
.expect("persisted sandbox policy should decode");
assert!(
persisted_policy
.network_policies
.contains_key("sandbox_only")
);
assert!(
!persisted_policy
.network_policies
.contains_key("_provider_work_custom")
);
}
#[tokio::test]
async fn sandbox_config_composes_user_and_provider_rules() {
let state = test_server_state().await;
state
.store
.put_message(&test_provider("work-github", "github"))
.await
.unwrap();
let mut policy = test_policy_with_rule("custom_github", "api.github.com");
let endpoint = &mut policy
.network_policies
.get_mut("custom_github")
.expect("custom rule")
.endpoints[0];
endpoint.protocol = "rest".to_string();
endpoint.access = openshell_core::proto::NetworkAccessPreset::ReadOnly as i32;
state
.store
.put_message(&test_sandbox(
"sb-overlap",
"overlap",
policy,
vec!["work-github".to_string()],
))
.await
.unwrap();
let effective_policy = get_sandbox_policy(&state, "sb-overlap").await;
assert!(
effective_policy
.network_policies
.contains_key("custom_github")
);
assert!(
effective_policy
.network_policies
.contains_key("_provider_work_github")
);
assert_eq!(
effective_policy
.network_policies
.get("custom_github")
.unwrap()
.endpoints[0]
.host,
"api.github.com"
);
}
#[tokio::test]
async fn provider_environment_resolution_is_stable_across_policy_composition() {
use openshell_core::proto::GetSandboxProviderEnvironmentRequest;
let state = test_server_state().await;
state
.store
.put_message(&test_provider("work-github", "github"))
.await
.unwrap();
state
.store
.put_message(&test_sandbox(
"sb-provider-env",
"provider-env",
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
vec!["work-github".to_string()],
))
.await
.unwrap();
let legacy_env = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-provider-env".to_string(),
supports_static_credential_bindings: true,
})),
)
.await
.unwrap()
.into_inner()
.environment;
let v2_env = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-provider-env".to_string(),
supports_static_credential_bindings: true,
})),
)
.await
.unwrap()
.into_inner()
.environment;
assert_eq!(legacy_env, v2_env);
assert_eq!(v2_env.get("GITHUB_TOKEN"), Some(&"ghp-test".to_string()));
}
#[tokio::test]
async fn provider_files_do_not_block_legacy_provider_environment_requests() {
use openshell_core::proto::{
GetSandboxProviderEnvironmentRequest, ProviderProfile, ProviderProfileCategory,
ProviderProfileFile,
};
let state = test_server_state().await;
state
.store
.put_message(&StoredProviderProfile {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "profile-config-only".to_string(),
name: "config-only".to_string(),
workspace: "default".to_string(),
..Default::default()
}),
profile: Some(ProviderProfile {
id: "config-only".to_string(),
display_name: "Config only".to_string(),
category: ProviderProfileCategory::Other as i32,
files: vec![ProviderProfileFile {
path: "client.toml".to_string(),
content: "endpoint = '{{config.endpoint}}'".to_string(),
env_var: "CLIENT_CONFIG_FILE".to_string(),
}],
..Default::default()
}),
})
.await
.unwrap();
let mut file_provider = test_provider("work-config", "config-only");
file_provider.credentials.clear();
file_provider
.config
.insert("endpoint".to_string(), "https://config.example".to_string());
state.store.put_message(&file_provider).await.unwrap();
state
.store
.put_message(&test_provider("work-github", "github"))
.await
.unwrap();
state
.store
.put_message(&test_sandbox(
"sb-files-and-credentials",
"files-and-credentials",
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
vec!["work-config".to_string(), "work-github".to_string()],
))
.await
.unwrap();
// An older supervisor sends this request without a provider-file
// capability field and ignores the additive files response field.
let response = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-files-and-credentials".to_string(),
supports_static_credential_bindings: true,
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(
response.environment.get("GITHUB_TOKEN"),
Some(&"ghp-test".to_string())
);
assert_eq!(
response.environment.get("CLIENT_CONFIG_FILE"),
Some(&"/run/openshell/providers/work-config/client.toml".to_string())
);
assert_eq!(
response
.files
.get("/run/openshell/providers/work-config/client.toml"),
Some(&"endpoint = 'https://config.example'".to_string())
);
}
#[tokio::test]
async fn provider_readiness_snapshot_uses_baseline_static_binding_contract() {
let state = test_server_state().await;
state
.store
.put_message(&test_provider("work-github", "github"))
.await
.unwrap();
let mut sandbox = test_sandbox(
"sb-static-ready",
"static-ready",
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
vec!["work-github".to_string()],
);
let attachment_epoch = uuid::Uuid::new_v4().to_string();
sandbox.spec.as_mut().unwrap().provider_attachment_epoch = attachment_epoch.clone();
state.store.put_message(&sandbox).await.unwrap();
// Static binding support is sufficient to install this snapshot. The
// environment and policy must describe the same attachment authority.
let response = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-static-ready".to_string(),
supports_static_credential_bindings: true,
})),
)
.await
.unwrap()
.into_inner();
let config = load_sandbox_config(&state, &sandbox).await.unwrap();
assert_eq!(
response.readiness_reason,
ProviderReadinessReason::Unspecified as i32
);
assert_eq!(response.provider_attachment_epoch, attachment_epoch);
assert_eq!(
response.provider_attachment_epoch,
config.provider_attachment_epoch
);
assert_eq!(response.provider_env_revision, config.provider_env_revision);
assert_eq!(response.policy_hash, config.policy_hash);
assert!(response.environment.contains_key("GITHUB_TOKEN"));
assert!(
response
.static_credential_bindings
.contains_key("GITHUB_TOKEN")
);
assert!(
!response
.non_secret_environment_keys
.iter()
.any(|key| key == "GITHUB_TOKEN")
);
}
#[tokio::test]
async fn provider_environment_withholds_static_credentials_from_legacy_supervisors() {
use openshell_core::proto::GetSandboxProviderEnvironmentRequest;
let state = test_server_state().await;
state
.store
.put_message(&test_provider("work-github", "github"))
.await
.unwrap();
state
.store
.put_message(&test_sandbox(
"sb-legacy-provider-env",
"legacy-provider-env",
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
vec!["work-github".to_string()],
))
.await
.unwrap();
let response = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-legacy-provider-env".to_string(),
supports_static_credential_bindings: false,
})),
)
.await
.unwrap()
.into_inner();
assert!(!response.environment.contains_key("GITHUB_TOKEN"));
assert!(response.static_credential_bindings.is_empty());
assert_eq!(
response.readiness_reason,
ProviderReadinessReason::UnsupportedSupervisor as i32
);
}
#[tokio::test]
async fn provider_environment_withholds_unbound_static_credentials_independently() {
use openshell_core::proto::GetSandboxProviderEnvironmentRequest;
let state = test_server_state().await;
state
.store
.put_message(&test_provider("work-github", "github"))
.await
.unwrap();
let mut profileless_openai = test_provider("gateway-openai", "legacy-openai");
profileless_openai.credentials =
HashMap::from([("OPENAI_API_KEY".to_string(), "openai-secret".to_string())]);
state.store.put_message(&profileless_openai).await.unwrap();
state
.store
.put_message(&test_sandbox(
"sb-unbound-provider-env",
"unbound-provider-env",
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
vec!["work-github".to_string(), "gateway-openai".to_string()],
))
.await
.unwrap();
let response = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-unbound-provider-env".to_string(),
supports_static_credential_bindings: true,
})),
)
.await
.unwrap()
.into_inner();
assert!(!response.environment.contains_key("OPENAI_API_KEY"));
assert_eq!(
response.readiness_reason,
ProviderReadinessReason::CredentialsWithheld as i32
);
assert!(
!response
.static_credential_bindings
.contains_key("OPENAI_API_KEY")
);
assert_eq!(
response.environment.get("GITHUB_TOKEN"),
Some(&"ghp-test".to_string())
);
assert!(
response
.static_credential_bindings
.get("GITHUB_TOKEN")
.is_some_and(|binding| !binding.endpoints.is_empty())
);
}
#[tokio::test]
async fn provider_environment_uses_policy_binding_for_endpointless_profile() {
use openshell_core::proto::{
GetSandboxConfigRequest, GetSandboxProviderEnvironmentRequest,
NetworkCredentialBinding, ProviderProfile, ProviderProfileCategory,
};
let state = test_server_state().await;
state
.store
.put_message(&StoredProviderProfile {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "profile-endpointless".to_string(),
name: "endpointless".to_string(),
workspace: "default".to_string(),
..Default::default()
}),
profile: Some(ProviderProfile {
id: "endpointless".to_string(),
display_name: "Endpointless".to_string(),
category: ProviderProfileCategory::Other as i32,
credentials: vec![openshell_core::proto::ProviderProfileCredential {
name: "cloud_token".to_string(),
env_vars: vec!["CLOUD_TOKEN".to_string()],
..Default::default()
}],
endpoints: Vec::new(),
..Default::default()
}),
})
.await
.unwrap();
let mut provider = test_provider("work-cloud", "endpointless");
provider.credentials =
HashMap::from([("CLOUD_TOKEN".to_string(), "cloud-secret".to_string())]);
state.store.put_message(&provider).await.unwrap();
let mut policy = test_policy_with_rule("cloud_api", "api.cloud.example");
policy
.network_policies
.get_mut("cloud_api")
.unwrap()
.endpoints[0]
.credential_binding = Some(NetworkCredentialBinding {
provider: "work-cloud".to_string(),
});
let bound_endpoint = &mut policy
.network_policies
.get_mut("cloud_api")
.unwrap()
.endpoints[0];
bound_endpoint.protocol = "rest".to_string();
bound_endpoint.access = openshell_core::proto::NetworkAccessPreset::Full as i32;
openshell_policy::ensure_sandbox_process_identity(&mut policy);
state
.store
.put_message(&test_sandbox(
"sb-policy-binding",
"policy-binding",
policy.clone(),
vec!["work-cloud".to_string()],
))
.await
.unwrap();
let config = handle_get_sandbox_config(
&state,
with_user(Request::new(GetSandboxConfigRequest {
name: "policy-binding".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
})),
)
.await
.unwrap()
.into_inner();
let environment = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-policy-binding".to_string(),
supports_static_credential_bindings: true,
})),
)
.await
.unwrap()
.into_inner();
assert!(
config.policy.as_ref().unwrap().network_policies["cloud_api"].endpoints[0]
.provider_credentialed,
"config delivery must derive provenance from the endpointless binding"
);
assert_eq!(
environment.environment.get("CLOUD_TOKEN"),
Some(&"cloud-secret".to_string())
);
assert_eq!(
environment.static_credential_bindings["CLOUD_TOKEN"].endpoints,
vec![StaticCredentialEndpointBinding {
host: "api.cloud.example".to_string(),
port: 443,
path: String::new(),
}]
);
assert_eq!(
config.provider_env_revision, environment.provider_env_revision,
"config and provider environment must advertise one atomic revision"
);
let mut next_policy = policy;
next_policy
.network_policies
.get_mut("cloud_api")
.unwrap()
.endpoints[0]
.host = "api2.cloud.example".to_string();
handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "policy-binding".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(next_policy.clone()),
..Default::default()
})),
)
.await
.expect("policy binding update must succeed");
let next_config = handle_get_sandbox_config(
&state,
with_user(Request::new(GetSandboxConfigRequest {
name: "policy-binding".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
})),
)
.await
.unwrap()
.into_inner();
let next_environment = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-policy-binding".to_string(),
supports_static_credential_bindings: true,
})),
)
.await
.unwrap()
.into_inner();
assert!(
next_config.policy.as_ref().unwrap().network_policies["cloud_api"].endpoints[0]
.provider_credentialed
);
assert_ne!(
config.provider_env_revision, next_config.provider_env_revision,
"changing the policy binding must rotate the provider environment revision"
);
assert_eq!(
next_config.provider_env_revision,
next_environment.provider_env_revision
);
assert_eq!(
next_environment.static_credential_bindings["CLOUD_TOKEN"].endpoints[0].host,
"api2.cloud.example"
);
let mut unbound_policy = next_policy;
unbound_policy
.network_policies
.get_mut("cloud_api")
.unwrap()
.endpoints[0]
.credential_binding = None;
handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "policy-binding".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(unbound_policy),
..Default::default()
})),
)
.await
.expect("removing a policy binding must succeed");
let unbound_config = handle_get_sandbox_config(
&state,
with_user(Request::new(GetSandboxConfigRequest {
name: "policy-binding".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
})),
)
.await
.unwrap()
.into_inner();
let unbound_environment = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-policy-binding".to_string(),
supports_static_credential_bindings: true,
})),
)
.await
.unwrap()
.into_inner();
assert!(
!unbound_config.policy.as_ref().unwrap().network_policies["cloud_api"].endpoints[0]
.provider_credentialed,
"removing the binding must clear the derived provenance"
);
assert_ne!(
next_environment.provider_env_revision, unbound_environment.provider_env_revision,
"removing the binding must rotate the provider environment revision"
);
assert!(
!unbound_environment.environment.contains_key("CLOUD_TOKEN"),
"removing the only binding must withhold the static credential"
);
assert!(
!unbound_environment
.static_credential_bindings
.contains_key("CLOUD_TOKEN"),
"an endpointless profile must not emit incomplete binding metadata"
);
}
#[tokio::test]
async fn invalid_static_binding_does_not_suppress_valid_dynamic_credentials() {
use openshell_core::proto::{
GetSandboxProviderEnvironmentRequest, ProviderCredentialTokenGrant, ProviderProfile,
ProviderProfileCategory, ProviderProfileCredential,
};
let state = test_server_state().await;
let mut invalid_static = test_provider("invalid-static", "profile-without-endpoints");
invalid_static.credentials =
HashMap::from([("INVALID_TOKEN".to_string(), "static-secret".to_string())]);
let dynamic = test_provider("dynamic", "custom-dynamic");
let dynamic_profile = StoredProviderProfile {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "profile-custom-dynamic".to_string(),
name: "custom-dynamic".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
profile: Some(ProviderProfile {
id: "custom-dynamic".to_string(),
display_name: "Custom Dynamic".to_string(),
category: ProviderProfileCategory::Other as i32,
credentials: vec![ProviderProfileCredential {
name: "access_token".to_string(),
auth_style: "bearer".to_string(),
header_name: "authorization".to_string(),
token_grant: Some(ProviderCredentialTokenGrant {
token_endpoint: "https://auth.example.test/token".to_string(),
audience: "api://default".to_string(),
..Default::default()
}),
..Default::default()
}],
endpoints: vec![NetworkEndpoint {
host: "api.dynamic.example.test".to_string(),
port: 443,
path: "/**".to_string(),
protocol: "rest".to_string(),
access: openshell_core::proto::NetworkAccessPreset::Full as i32,
..Default::default()
}],
..Default::default()
}),
};
state.store.put_message(&invalid_static).await.unwrap();
state.store.put_message(&dynamic).await.unwrap();
state.store.put_message(&dynamic_profile).await.unwrap();
state
.store
.put_message(&test_sandbox(
"sb-mixed-provider-env",
"mixed-provider-env",
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
vec!["invalid-static".to_string(), "dynamic".to_string()],
))
.await
.unwrap();
let response = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-mixed-provider-env".to_string(),
supports_static_credential_bindings: true,
})),
)
.await
.expect("mixed snapshot must be returned")
.into_inner();
assert!(
!response.environment.contains_key("INVALID_TOKEN"),
"an unbound static credential must be withheld before the supervisor snapshot"
);
assert!(
!response
.static_credential_bindings
.contains_key("INVALID_TOKEN"),
"an unbound static credential must not emit incomplete binding metadata"
);
assert!(
!response.dynamic_credentials.is_empty(),
"valid dynamic credentials must survive an unrelated static binding failure"
);
}
#[tokio::test]
async fn provider_environment_withholds_token_exchange_subject_credential() {
use openshell_core::proto::{
GetSandboxProviderEnvironmentRequest, ProviderCredentialTokenGrant,
ProviderCredentialTokenGrantSubjectToken, ProviderCredentialTokenGrantType,
ProviderProfile, ProviderProfileCategory, ProviderProfileCredential,
};
let state = test_server_state().await;
let profile = StoredProviderProfile {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "profile-token-exchange-subject".to_string(),
name: "token-exchange-subject".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
profile: Some(ProviderProfile {
id: "token-exchange-subject".to_string(),
display_name: "Token Exchange Subject".to_string(),
category: ProviderProfileCategory::Other as i32,
credentials: vec![
ProviderProfileCredential {
name: "subject_token".to_string(),
..Default::default()
},
ProviderProfileCredential {
name: "access_token".to_string(),
auth_style: "bearer".to_string(),
header_name: "authorization".to_string(),
token_grant: Some(ProviderCredentialTokenGrant {
grant_type: ProviderCredentialTokenGrantType::TokenExchange as i32,
token_endpoint: "https://auth.example.test/token".to_string(),
audience: "api://exchange".to_string(),
subject_token: Some(ProviderCredentialTokenGrantSubjectToken {
source: "provider_credential".to_string(),
credential: "subject_token".to_string(),
subject_token_type: "urn:ietf:params:oauth:token-type:access_token"
.to_string(),
}),
..Default::default()
}),
..Default::default()
},
],
endpoints: vec![NetworkEndpoint {
host: "api.exchange.example.test".to_string(),
port: 443,
protocol: "rest".to_string(),
access: openshell_core::proto::NetworkAccessPreset::Full as i32,
..Default::default()
}],
..Default::default()
}),
};
state.store.put_message(&profile).await.unwrap();
let mut provider = test_provider("exchange-provider", "token-exchange-subject");
provider.credentials = HashMap::from([(
"subject_token".to_string(),
"raw-gateway-oidc-token".to_string(),
)]);
provider.credential_expiration_times = HashMap::from([(
"subject_token".to_string(),
openshell_core::time::timestamp_from_millis(current_time_ms() + 60_000).unwrap(),
)]);
state.store.put_message(&provider).await.unwrap();
state
.store
.put_message(&test_sandbox(
"sb-token-exchange-subject",
"token-exchange-subject",
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
vec!["exchange-provider".to_string()],
))
.await
.unwrap();
let response = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-token-exchange-subject".to_string(),
supports_static_credential_bindings: true,
})),
)
.await
.unwrap()
.into_inner();
assert!(
!response.environment.contains_key("subject_token"),
"token-exchange subject credentials must not enter sandbox environment material"
);
assert!(
!response
.static_credential_bindings
.contains_key("subject_token"),
"token-exchange subject credentials must not own workload placeholders"
);
assert!(
!response
.credential_expiration_times
.contains_key("subject_token"),
"withheld subject credentials must not emit sandbox expiry metadata"
);
let dynamic_access_token = response
.dynamic_credentials
.values()
.find(|credential| credential.name == "access_token")
.expect("dynamic access_token credential should remain available");
assert_eq!(
dynamic_access_token
.token_grant
.as_ref()
.and_then(|grant| grant.subject_token.as_ref())
.map(|subject| subject.credential.as_str()),
Some("subject_token")
);
}
#[tokio::test]
async fn provider_env_revision_changes_on_consecutive_provider_updates_without_delay() {
use openshell_core::proto::GetSandboxProviderEnvironmentRequest;
let state = test_server_state().await;
let mut provider = test_provider("work-github", "github");
state.store.put_message(&provider).await.unwrap();
let first_resource_version = state
.store
.get_by_name(Provider::object_type(), "default", "work-github")
.await
.unwrap()
.unwrap()
.resource_version;
state
.store
.put_message(&test_sandbox(
"sb-provider-revision",
"provider-revision",
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
vec!["work-github".to_string()],
))
.await
.unwrap();
let first = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-provider-revision".to_string(),
supports_static_credential_bindings: true,
})),
)
.await
.unwrap()
.into_inner();
provider
.credentials
.insert("GITHUB_TOKEN".to_string(), "rotated".to_string());
state
.store
.put_if(
Provider::object_type(),
provider.object_id(),
provider.object_name(),
provider.object_workspace(),
&provider.encode_to_vec(),
None,
crate::persistence::WriteCondition::Unconditional,
)
.await
.unwrap();
let second_resource_version = state
.store
.get_by_name(Provider::object_type(), "default", "work-github")
.await
.unwrap()
.unwrap()
.resource_version;
assert_ne!(
first_resource_version, second_resource_version,
"consecutive writes must advance the authoritative resource version"
);
let second = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-provider-revision".to_string(),
supports_static_credential_bindings: true,
})),
)
.await
.unwrap()
.into_inner();
assert_ne!(
first.provider_env_revision, second.provider_env_revision,
"provider object updates must trigger sandbox credential refresh"
);
assert_eq!(
second.environment.get("GITHUB_TOKEN"),
Some(&"rotated".to_string())
);
}
#[tokio::test]
async fn provider_environment_revision_and_payload_share_immutable_record_snapshot() {
use openshell_core::proto::{
ProviderCredentialTokenGrant, ProviderProfile, ProviderProfileCategory,
ProviderProfileCredential,
};
fn dynamic_profile(
id: &str,
endpoint_host: &str,
token_endpoint: &str,
) -> StoredProviderProfile {
StoredProviderProfile {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: format!("profile-{id}"),
name: id.to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
profile: Some(ProviderProfile {
id: id.to_string(),
display_name: id.to_string(),
category: ProviderProfileCategory::Other as i32,
credentials: vec![ProviderProfileCredential {
name: "access_token".to_string(),
env_vars: vec!["GITHUB_TOKEN".to_string()],
auth_style: "bearer".to_string(),
header_name: "authorization".to_string(),
token_grant: Some(ProviderCredentialTokenGrant {
token_endpoint: token_endpoint.to_string(),
audience: "api://snapshot".to_string(),
..Default::default()
}),
..Default::default()
}],
endpoints: vec![NetworkEndpoint {
host: endpoint_host.to_string(),
port: 443,
path: "/**".to_string(),
protocol: "rest".to_string(),
access: openshell_core::proto::NetworkAccessPreset::Full as i32,
..Default::default()
}],
..Default::default()
}),
}
}
let state = test_server_state().await;
state
.store
.put_message(&dynamic_profile(
"snapshot-a",
"api.snapshot-a.example",
"https://auth.snapshot-a.example/token",
))
.await
.unwrap();
state
.store
.put_message(&dynamic_profile(
"snapshot-b",
"api.snapshot-b.example",
"https://auth.snapshot-b.example/token",
))
.await
.unwrap();
let catalog = state
.provider_profile_sources
.snapshot_catalog(state.store.as_ref(), "default")
.await
.unwrap();
let mut first_provider = test_provider("replaceable", "snapshot-a");
first_provider.metadata.as_mut().unwrap().id = "provider-identity-a".to_string();
first_provider.credentials =
HashMap::from([("GITHUB_TOKEN".to_string(), "secret-a".to_string())]);
state.store.put_message(&first_provider).await.unwrap();
let provider_names = vec!["replaceable".to_string()];
let first_records = crate::grpc::provider::load_provider_environment_records(
state.store.as_ref(),
"default",
&provider_names,
)
.await
.unwrap();
let first_revision =
compute_provider_env_revision_from_records(&catalog, &first_records).unwrap();
let mut next_version_records = first_records.clone();
next_version_records[0].resource_version += 1;
assert_ne!(
first_revision,
compute_provider_env_revision_from_records(&catalog, &next_version_records).unwrap(),
"resource version alone must advance the provider environment revision"
);
state
.store
.delete_by_name(Provider::object_type(), "default", "replaceable")
.await
.unwrap();
let mut replacement = test_provider("replaceable", "snapshot-b");
replacement.metadata.as_mut().unwrap().id = "provider-identity-b".to_string();
replacement.credentials =
HashMap::from([("GITHUB_TOKEN".to_string(), "secret-b".to_string())]);
state.store.put_message(&replacement).await.unwrap();
let first_environment = crate::grpc::provider::resolve_provider_environment_from_records(
state.store.as_ref(),
&catalog,
&first_records,
)
.await
.unwrap();
assert_eq!(
first_environment.environment.get("GITHUB_TOKEN"),
Some(&"secret-a".to_string())
);
assert_eq!(
first_environment
.static_credential_bindings
.get("GITHUB_TOKEN")
.map(|binding| binding.credential_identity.as_str()),
Some("provider-identity-a:GITHUB_TOKEN")
);
assert_eq!(first_environment.dynamic_credentials.len(), 1);
assert!(
first_environment
.dynamic_credentials
.values()
.all(|credential| {
credential.token_grant.as_ref().is_some_and(|grant| {
grant.token_endpoint == "https://auth.snapshot-a.example/token"
})
}),
"dynamic grants must come from the first loaded provider snapshot"
);
let replacement_records = crate::grpc::provider::load_provider_environment_records(
state.store.as_ref(),
"default",
&provider_names,
)
.await
.unwrap();
let replacement_revision =
compute_provider_env_revision_from_records(&catalog, &replacement_records).unwrap();
let replacement_environment =
crate::grpc::provider::resolve_provider_environment_from_records(
state.store.as_ref(),
&catalog,
&replacement_records,
)
.await
.unwrap();
assert_ne!(first_revision, replacement_revision);
assert_eq!(
replacement_environment.environment.get("GITHUB_TOKEN"),
Some(&"secret-b".to_string())
);
assert_eq!(
replacement_environment
.static_credential_bindings
.get("GITHUB_TOKEN")
.map(|binding| binding.credential_identity.as_str()),
Some("provider-identity-b:GITHUB_TOKEN")
);
assert_eq!(replacement_environment.dynamic_credentials.len(), 1);
assert!(
replacement_environment
.dynamic_credentials
.values()
.all(|credential| {
credential.token_grant.as_ref().is_some_and(|grant| {
grant.token_endpoint == "https://auth.snapshot-b.example/token"
})
}),
"dynamic grants must change only after loading the replacement record"
);
}
#[tokio::test]
async fn provider_env_revision_changes_when_custom_profile_token_grant_changes() {
use crate::grpc::provider::handle_update_provider_profiles;
use openshell_core::proto::{
ProviderCredentialTokenGrant, ProviderProfile, ProviderProfileCategory,
ProviderProfileCredential, ProviderProfileImportItem, UpdateProviderProfilesRequest,
};
use std::time::Duration;
fn token_grant_profile(token_endpoint: &str) -> StoredProviderProfile {
StoredProviderProfile {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "profile-custom-token".to_string(),
name: "custom-token".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
profile: Some(ProviderProfile {
files: Vec::new(),
id: "custom-token".to_string(),
resource_version: 0,
annotations: HashMap::new(),
display_name: "Custom Token".to_string(),
description: String::new(),
category: ProviderProfileCategory::Other as i32,
credentials: vec![ProviderProfileCredential {
name: "access_token".to_string(),
auth_style: "bearer".to_string(),
header_name: "authorization".to_string(),
token_grant: Some(ProviderCredentialTokenGrant {
token_endpoint: token_endpoint.to_string(),
audience: "api://default".to_string(),
..Default::default()
}),
..Default::default()
}],
endpoints: vec![NetworkEndpoint {
host: "api.custom.example".to_string(),
port: 443,
protocol: "rest".to_string(),
access: openshell_core::proto::NetworkAccessPreset::Full as i32,
..Default::default()
}],
binaries: Vec::new(),
inference_capable: false,
discovery: None,
source: String::new(),
scope: String::new(),
}),
}
}
let state = test_server_state().await;
state
.store
.put_message(&test_provider("work-custom-token", "custom-token"))
.await
.unwrap();
state
.store
.put_message(&token_grant_profile("https://auth.example.com/token"))
.await
.unwrap();
let first = compute_provider_env_revision(
state.store.as_ref(),
"default",
&["work-custom-token".to_string()],
)
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(2)).await;
let mut rotated_profile = token_grant_profile("https://auth.example.com/rotated-token")
.profile
.unwrap();
rotated_profile.resource_version = state
.store
.get_message_by_name::<StoredProviderProfile>("default", "custom-token")
.await
.unwrap()
.unwrap()
.metadata
.as_ref()
.unwrap()
.resource_version;
handle_update_provider_profiles(
&state,
with_user(Request::new(UpdateProviderProfilesRequest {
request_id: String::new(),
profile: Some(ProviderProfileImportItem {
profile: Some(rotated_profile),
source: "custom-token.yaml".to_string(),
}),
expected_resource_version: 0,
id: "custom-token".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
})),
)
.await
.unwrap();
let second = compute_provider_env_revision(
state.store.as_ref(),
"default",
&["work-custom-token".to_string()],
)
.await
.unwrap();
assert_ne!(
first, second,
"custom provider profile updates must trigger sandbox dynamic credential refresh"
);
}
#[tokio::test]
async fn platform_profile_narrowing_changes_platform_provider_revision_when_shadowed() {
use crate::persistence::WriteCondition;
use openshell_core::proto::{ProviderProfile, ProviderProfileCategory};
fn stored_profile(workspace: &str, path: &str) -> StoredProviderProfile {
StoredProviderProfile {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: format!(
"profile-scoped-revision-{}",
if workspace.is_empty() {
"platform"
} else {
workspace
}
),
name: "scoped-revision".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: workspace.to_string(),
deletion_time: None,
}),
profile: Some(ProviderProfile {
id: "scoped-revision".to_string(),
display_name: format!("{workspace} scoped revision"),
category: ProviderProfileCategory::Other as i32,
endpoints: vec![NetworkEndpoint {
host: "api.example.test".to_string(),
port: 443,
path: path.to_string(),
..Default::default()
}],
..Default::default()
}),
}
}
let store = test_store().await;
store.put_message(&stored_profile("", "/**")).await.unwrap();
store
.put_message(&stored_profile("default", "/workspace/**"))
.await
.unwrap();
let mut provider = test_provider("platform-scoped", "scoped-revision");
provider.profile_workspace = String::new();
store.put_message(&provider).await.unwrap();
let first =
compute_provider_env_revision(&store, "default", &["platform-scoped".to_string()])
.await
.unwrap();
let mut platform = store
.get_message_by_name::<StoredProviderProfile>("", "scoped-revision")
.await
.unwrap()
.unwrap();
let metadata = platform.metadata.as_ref().unwrap();
let object_id = metadata.id.clone();
let resource_version = metadata.resource_version;
platform.profile.as_mut().unwrap().endpoints[0].path = "/v1/**".to_string();
store
.put_if(
StoredProviderProfile::object_type(),
&object_id,
"scoped-revision",
"",
&platform.encode_to_vec(),
None,
WriteCondition::MatchResourceVersion(resource_version),
)
.await
.unwrap();
let second =
compute_provider_env_revision(&store, "default", &["platform-scoped".to_string()])
.await
.unwrap();
assert_ne!(
first, second,
"narrowing the selected platform fallback must refresh sandbox credentials"
);
}
#[tokio::test]
async fn sandbox_config_and_provider_env_follow_attached_provider_lifecycle() {
use crate::grpc::sandbox::{
handle_attach_sandbox_provider, handle_detach_sandbox_provider,
};
use openshell_core::proto::{
AttachSandboxProviderRequest, DetachSandboxProviderRequest,
GetSandboxProviderEnvironmentRequest,
};
let state = test_server_state().await;
state
.store
.put_message(&test_provider("work-github", "github"))
.await
.unwrap();
state
.store
.put_message(&test_sandbox(
"sb-attach-lifecycle",
"attach-lifecycle",
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
Vec::new(),
))
.await
.unwrap();
let baseline_policy = get_sandbox_policy(&state, "sb-attach-lifecycle").await;
assert!(
!baseline_policy
.network_policies
.contains_key("_provider_work_github")
);
let baseline_env = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-attach-lifecycle".to_string(),
supports_static_credential_bindings: true,
})),
)
.await
.unwrap()
.into_inner();
handle_attach_sandbox_provider(
&state,
with_user(Request::new(AttachSandboxProviderRequest {
request_id: String::new(),
sandbox: "attach-lifecycle".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
provider: "work-github".to_string(),
expected_resource_version: 0,
})),
)
.await
.unwrap();
let attached_policy = get_sandbox_policy(&state, "sb-attach-lifecycle").await;
assert!(
attached_policy
.network_policies
.contains_key("_provider_work_github")
);
let attached_env = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-attach-lifecycle".to_string(),
supports_static_credential_bindings: true,
})),
)
.await
.unwrap()
.into_inner();
assert_ne!(
baseline_env.provider_env_revision,
attached_env.provider_env_revision
);
assert_eq!(
attached_env.environment.get("GITHUB_TOKEN"),
Some(&"ghp-test".to_string())
);
handle_detach_sandbox_provider(
&state,
authed_request(DetachSandboxProviderRequest {
request_id: String::new(),
sandbox: "attach-lifecycle".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
provider: "work-github".to_string(),
expected_resource_version: 0,
}),
)
.await
.unwrap();
let detached_policy = get_sandbox_policy(&state, "sb-attach-lifecycle").await;
assert!(
!detached_policy
.network_policies
.contains_key("_provider_work_github")
);
let detached_env = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-attach-lifecycle".to_string(),
supports_static_credential_bindings: true,
})),
)
.await
.unwrap()
.into_inner();
assert_ne!(
attached_env.provider_env_revision,
detached_env.provider_env_revision
);
assert!(!detached_env.environment.contains_key("GITHUB_TOKEN"));
}
#[tokio::test]
async fn custom_imported_profile_policy_and_env_follow_attach_detach_lifecycle() {
use crate::grpc::provider::handle_import_provider_profiles;
use crate::grpc::sandbox::{
handle_attach_sandbox_provider, handle_detach_sandbox_provider,
};
use openshell_core::proto::{
AttachSandboxProviderRequest, DetachSandboxProviderRequest,
GetSandboxProviderEnvironmentRequest, ImportProviderProfilesRequest, NetworkBinary,
ProviderProfile, ProviderProfileCategory, ProviderProfileCredential,
ProviderProfileImportItem,
};
let state = test_server_state().await;
handle_import_provider_profiles(
&state,
authed_request(ImportProviderProfilesRequest {
request_id: String::new(),
profiles: vec![ProviderProfileImportItem {
source: "custom-api.yaml".to_string(),
profile: Some(ProviderProfile {
files: Vec::new(),
id: "custom-api".to_string(),
resource_version: 0,
annotations: HashMap::new(),
display_name: "Custom API".to_string(),
description: String::new(),
category: ProviderProfileCategory::Other as i32,
credentials: vec![ProviderProfileCredential {
name: "api_key".to_string(),
env_vars: vec!["CUSTOM_API_KEY".to_string()],
auth_style: "bearer".to_string(),
header_name: "authorization".to_string(),
required: true,
..Default::default()
}],
endpoints: vec![NetworkEndpoint {
host: "api.custom.example".to_string(),
port: 443,
protocol: "rest".to_string(),
rules: vec![L7Rule {
allow: Some(openshell_core::proto::L7Allow {
method: "GET".to_string(),
path: "/v1/**".to_string(),
..Default::default()
}),
}],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/custom".to_string(),
}],
inference_capable: false,
discovery: None,
source: String::new(),
scope: String::new(),
}),
}],
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
}),
)
.await
.unwrap();
let mut provider = test_provider("work-custom", "custom-api");
provider.credentials =
std::iter::once(("CUSTOM_API_KEY".to_string(), "custom-secret".to_string())).collect();
state.store.put_message(&provider).await.unwrap();
state
.store
.put_message(&test_sandbox(
"sb-attach-lifecycle",
"attach-lifecycle",
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
Vec::new(),
))
.await
.unwrap();
let baseline_policy = get_sandbox_policy(&state, "sb-attach-lifecycle").await;
assert!(
!baseline_policy
.network_policies
.contains_key("_provider_work_custom")
);
let baseline_env = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-attach-lifecycle".to_string(),
supports_static_credential_bindings: true,
})),
)
.await
.unwrap()
.into_inner();
handle_attach_sandbox_provider(
&state,
with_user(Request::new(AttachSandboxProviderRequest {
request_id: String::new(),
sandbox: "attach-lifecycle".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
provider: "work-custom".to_string(),
expected_resource_version: 0,
})),
)
.await
.unwrap();
let attached_policy = get_sandbox_policy(&state, "sb-attach-lifecycle").await;
let custom_rule = attached_policy
.network_policies
.get("_provider_work_custom")
.expect("custom provider rule should be composed after attach");
assert_eq!(custom_rule.endpoints[0].host, "api.custom.example");
assert_eq!(custom_rule.endpoints[0].protocol, "rest");
assert_eq!(custom_rule.endpoints[0].rules.len(), 1);
assert_eq!(custom_rule.binaries[0].path, "/usr/bin/custom");
let attached_env = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-attach-lifecycle".to_string(),
supports_static_credential_bindings: true,
})),
)
.await
.unwrap()
.into_inner();
assert_ne!(
baseline_env.provider_env_revision,
attached_env.provider_env_revision
);
assert_eq!(
attached_env.environment.get("CUSTOM_API_KEY"),
Some(&"custom-secret".to_string())
);
handle_detach_sandbox_provider(
&state,
authed_request(DetachSandboxProviderRequest {
request_id: String::new(),
sandbox: "attach-lifecycle".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
provider: "work-custom".to_string(),
expected_resource_version: 0,
}),
)
.await
.unwrap();
let detached_policy = get_sandbox_policy(&state, "sb-attach-lifecycle").await;
assert!(
!detached_policy
.network_policies
.contains_key("_provider_work_custom")
);
let detached_env = handle_get_sandbox_provider_environment(
&state,
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
sandbox_id: "sb-attach-lifecycle".to_string(),
supports_static_credential_bindings: true,
})),
)
.await
.unwrap()
.into_inner();
assert_ne!(
attached_env.provider_env_revision,
detached_env.provider_env_revision
);
assert!(!detached_env.environment.contains_key("CUSTOM_API_KEY"));
}
#[tokio::test]
async fn global_policy_suppresses_provider_profile_layers() {
use openshell_core::proto::{
GetSandboxConfigRequest, NetworkEndpoint, NetworkPolicyRule, SandboxPhase,
SandboxPolicy, SandboxSpec,
};
let state = test_server_state().await;
state
.store
.put_message(&test_provider("work-github", "github"))
.await
.unwrap();
let sandbox_policy = SandboxPolicy {
network_policies: std::iter::once((
"sandbox_only".to_string(),
NetworkPolicyRule {
name: "sandbox_only".to_string(),
endpoints: vec![NetworkEndpoint {
host: "sandbox.example.com".to_string(),
port: 443,
..Default::default()
}],
..Default::default()
},
))
.collect(),
..Default::default()
};
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-global-profile".to_string(),
name: "global-profile-sandbox".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: Some(sandbox_policy),
providers: vec!["work-github".to_string()],
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
let global_policy = SandboxPolicy {
network_policies: std::iter::once((
"global_only".to_string(),
NetworkPolicyRule {
name: "global_only".to_string(),
endpoints: vec![NetworkEndpoint {
host: "global.example.com".to_string(),
port: 443,
..Default::default()
}],
..Default::default()
},
))
.collect(),
..Default::default()
};
let global_settings = StoredSettings {
revision: 1,
settings: std::iter::once((
POLICY_SETTING_KEY.to_string(),
StoredSettingValue::Bytes(hex::encode(global_policy.encode_to_vec())),
))
.collect(),
..Default::default()
};
save_global_settings(state.store.as_ref(), &global_settings)
.await
.unwrap();
let response = handle_get_sandbox_config(
&state,
with_user(Request::new(GetSandboxConfigRequest {
name: "global-profile-sandbox".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
})),
)
.await
.unwrap()
.into_inner();
let effective_policy = response.policy.expect("global policy should be returned");
assert_eq!(response.policy_source, PolicySource::Global as i32);
assert!(
effective_policy
.network_policies
.contains_key("global_only")
);
assert!(
!effective_policy
.network_policies
.contains_key("sandbox_only")
);
assert!(
!effective_policy
.network_policies
.contains_key("_provider_work_github")
);
}
#[tokio::test]
async fn sandbox_policy_backfill_on_update_when_no_baseline() {
use openshell_core::proto::{FilesystemPolicy, LandlockPolicy, SandboxPhase, SandboxSpec};
let store = test_store().await;
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-backfill".to_string(),
name: "backfill-sandbox".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Provisioning as i32);
store.put_message(&sandbox).await.unwrap();
let new_policy = ProtoSandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
include_workdir: true,
read_only: vec!["/usr".into()],
read_write: vec!["/tmp".into()],
}),
landlock: Some(LandlockPolicy {
compatibility: "best_effort".into(),
}),
process: Some(openshell_core::proto::ProcessPolicy {
run_as_user: "sandbox".into(),
run_as_group: "sandbox".into(),
}),
..Default::default()
};
let mut sandbox = store
.get_message::<Sandbox>("sb-backfill")
.await
.unwrap()
.unwrap();
if let Some(ref mut spec) = sandbox.spec {
spec.policy = Some(new_policy.clone());
}
store.put_message(&sandbox).await.unwrap();
let loaded = store
.get_message::<Sandbox>("sb-backfill")
.await
.unwrap()
.unwrap();
let policy = loaded.spec.unwrap().policy.unwrap();
assert_eq!(policy.version, 1);
assert!(policy.filesystem.is_some());
assert_eq!(policy.process.unwrap().run_as_user, "sandbox");
}
/// Test helper: pin the proposal approval mode for a sandbox via the
/// settings model, mirroring what `openshell settings set <name>
/// proposal_approval_mode <mode>` would do at runtime.
async fn seed_sandbox_approval_mode(state: &Arc<ServerState>, sandbox_name: &str, mode: &str) {
let mut settings = load_sandbox_settings(state.store.as_ref(), "default", sandbox_name)
.await
.unwrap();
settings.settings.insert(
settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(),
StoredSettingValue::String(mode.to_string()),
);
settings.revision = settings.revision.wrapping_add(1);
save_sandbox_settings(state.store.as_ref(), "default", sandbox_name, &settings)
.await
.unwrap();
}
/// Test helper: pin the gateway-wide proposal approval mode, mirroring
/// `openshell settings set --global proposal_approval_mode <mode>`.
async fn seed_global_approval_mode(state: &Arc<ServerState>, mode: &str) {
let mut settings = load_global_settings(state.store.as_ref()).await.unwrap();
settings.settings.insert(
settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(),
StoredSettingValue::String(mode.to_string()),
);
settings.revision = settings.revision.wrapping_add(1);
save_global_settings(state.store.as_ref(), &settings)
.await
.unwrap();
}
#[tokio::test]
async fn approve_all_applies_multiple_independent_chunks_and_reuses_cached_validation() {
let state = test_server_state().await;
let sandbox_id = "sb-approve-all-multiple";
let sandbox_name = "approve-all-multiple";
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", "/usr/bin/curl"),
("beta", "beta.example.com", "/usr/bin/wget"),
]
.into_iter()
.map(|(name, host, binary)| 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: binary.to_string(),
}],
}),
..Default::default()
})
.collect(),
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(submit.accepted_chunk_ids.len(), 2);
let mut chunks = Vec::new();
for (index, chunk_id) in submit.accepted_chunk_ids.iter().enumerate() {
let mut chunk = state
.store
.get_draft_chunk(chunk_id)
.await
.unwrap()
.unwrap();
let proposed_rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice()).unwrap();
assert!(
proposed_rule.endpoints[0].advisor_proposed,
"the gateway must stamp advisor endpoint provenance"
);
chunk.validation_result = format!("prover: cached sentinel {index}");
assert!(
state
.store
.update_draft_chunk_evaluation(&chunk)
.await
.unwrap()
);
chunks.push(chunk);
}
let approved = handle_approve_all_draft_chunks(
&state,
with_user(Request::new(ApproveAllDraftChunksRequest {
sandbox: sandbox_name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
approvals: chunks
.iter()
.map(|chunk| openshell_core::proto::DraftChunkApproval {
chunk_id: chunk.id.clone(),
review_token: chunk.review_token.clone(),
})
.collect(),
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(approved.chunks_approved, 2);
assert_eq!(approved.chunks_skipped, 0);
assert_eq!(approved.policy_version, 1);
let revision = state
.store
.get_latest_policy(sandbox_id)
.await
.unwrap()
.unwrap();
let policy = ProtoSandboxPolicy::decode(revision.policy_payload.as_slice()).unwrap();
assert!(policy.network_policies.contains_key("alpha"));
assert!(policy.network_policies.contains_key("beta"));
assert!(
policy
.network_policies
.values()
.all(|rule| rule.endpoints[0].advisor_proposed)
);
for (index, chunk) in chunks.iter().enumerate() {
let stored = state
.store
.get_draft_chunk(&chunk.id)
.await
.unwrap()
.unwrap();
assert_eq!(stored.status, "approved");
assert_eq!(
stored.validation_result,
format!("prover: cached sentinel {index}")
);
}
}
#[tokio::test]
async fn approve_all_skips_later_endpoint_conflict_and_applies_compatible_prefix() {
let state = test_server_state().await;
let sandbox_id = "sb-approve-all-conflict";
let sandbox_name = "approve-all-conflict";
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: vec![
PolicyChunk {
rule_name: "inspected".to_string(),
proposed_rule: Some(NetworkPolicyRule {
name: "inspected".to_string(),
endpoints: vec![NetworkEndpoint {
host: "shared.example.com".to_string(),
port: 443,
protocol: "rest".to_string(),
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce
as i32,
access: openshell_core::proto::NetworkAccessPreset::ReadOnly as i32,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
}),
..Default::default()
},
PolicyChunk {
rule_name: "conflicting".to_string(),
proposed_rule: Some(NetworkPolicyRule {
name: "conflicting".to_string(),
endpoints: vec![NetworkEndpoint {
host: "shared.example.com".to_string(),
port: 443,
protocol: "graphql".to_string(),
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce
as i32,
access: openshell_core::proto::NetworkAccessPreset::ReadOnly as i32,
advisor_proposed: true,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/wget".to_string(),
}],
}),
..Default::default()
},
],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(submit.accepted_chunk_ids.len(), 2);
let chunks = futures::future::try_join_all(
submit
.accepted_chunk_ids
.iter()
.map(|id| state.store.get_draft_chunk(id)),
)
.await
.unwrap()
.into_iter()
.map(Option::unwrap)
.collect::<Vec<_>>();
let approved = handle_approve_all_draft_chunks(
&state,
with_user(Request::new(ApproveAllDraftChunksRequest {
sandbox: sandbox_name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
approvals: chunks
.iter()
.map(|chunk| openshell_core::proto::DraftChunkApproval {
chunk_id: chunk.id.clone(),
review_token: chunk.review_token.clone(),
})
.collect(),
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(approved.chunks_approved, 1);
assert_eq!(approved.chunks_skipped, 1);
assert_eq!(
state
.store
.get_draft_chunk(&chunks[0].id)
.await
.unwrap()
.unwrap()
.status,
"approved"
);
let skipped = state
.store
.get_draft_chunk(&chunks[1].id)
.await
.unwrap()
.unwrap();
assert_eq!(skipped.status, "pending");
assert!(!skipped.application_error.is_empty());
let revision = state
.store
.get_latest_policy(sandbox_id)
.await
.unwrap()
.unwrap();
let policy = ProtoSandboxPolicy::decode(revision.policy_payload.as_slice()).unwrap();
assert!(policy.network_policies.contains_key("inspected"));
assert!(!policy.network_policies.contains_key("conflicting"));
}
#[tokio::test]
async fn reviewed_batch_operations_stale_snapshot_apply_nothing() {
let state = test_server_state().await;
let sandbox_id = "sb-approve-all-stale";
let sandbox_name = "approve-all-stale";
state
.store
.put_message(&test_sandbox(
sandbox_id,
sandbox_name,
ProtoSandboxPolicy::default(),
vec![],
))
.await
.unwrap();
let reviewed_policy = ProtoSandboxPolicy::default();
let reviewed_hash = deterministic_policy_hash(&reviewed_policy);
let changed_policy = test_policy_with_rule("concurrent", "concurrent.example.com");
let changed_hash = deterministic_policy_hash(&changed_policy);
state
.store
.put_policy_revision(
"concurrent-policy",
sandbox_id,
"default",
1,
&changed_policy.encode_to_vec(),
&changed_hash,
)
.await
.unwrap();
let operations = [
PolicyMergeOp::AddRule {
rule_name: "alpha".to_string(),
rule: NetworkPolicyRule {
name: "alpha".to_string(),
endpoints: vec![NetworkEndpoint {
host: "alpha.example.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
},
},
PolicyMergeOp::AddRule {
rule_name: "beta".to_string(),
rule: NetworkPolicyRule {
name: "beta".to_string(),
endpoints: vec![NetworkEndpoint {
host: "beta.example.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/wget".to_string(),
}],
},
},
];
let error = apply_merge_operations_with_retry(
state.store.as_ref(),
sandbox_id,
"default",
Some(&reviewed_policy),
&operations,
PolicyMergeValidationContext {
provider_layers: &[],
credential_binding: None,
},
Some(&reviewed_hash),
None,
)
.await
.expect_err("a stale reviewed snapshot must reject the complete batch");
assert_eq!(error.code(), Code::FailedPrecondition);
let latest = state
.store
.get_latest_policy(sandbox_id)
.await
.unwrap()
.unwrap();
assert_eq!(latest.policy_hash, changed_hash);
let policy = ProtoSandboxPolicy::decode(latest.policy_payload.as_slice()).unwrap();
assert!(policy.network_policies.contains_key("concurrent"));
assert!(!policy.network_policies.contains_key("alpha"));
assert!(!policy.network_policies.contains_key("beta"));
}
#[tokio::test]
async fn approve_all_skips_private_allowed_ips_unless_included() {
let state = test_server_state().await;
let sandbox_name = "private-allowed-ips";
state
.store
.put_message(&test_sandbox(
"sb-private-allowed-ips",
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: vec![PolicyChunk {
rule_name: "private_service".to_string(),
proposed_rule: Some(NetworkPolicyRule {
name: "private_service".to_string(),
endpoints: vec![NetworkEndpoint {
host: "service.example.com".to_string(),
port: 443,
allowed_ips: vec!["10.0.0.0/8".to_string()],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
}),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
let chunk_id = submit.accepted_chunk_ids[0].clone();
let chunk = state
.store
.get_draft_chunk(&chunk_id)
.await
.unwrap()
.unwrap();
assert!(
chunk
.security_notes
.contains("allowed_ips includes private/internal range '10.0.0.0/8'.")
);
let skipped = handle_approve_all_draft_chunks(
&state,
with_user(Request::new(ApproveAllDraftChunksRequest {
request_id: String::new(),
sandbox: sandbox_name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
include_security_flagged: false,
approvals: vec![openshell_core::proto::DraftChunkApproval {
chunk_id: chunk_id.clone(),
review_token: chunk.review_token.clone(),
}],
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(skipped.chunks_approved, 0);
assert_eq!(skipped.chunks_skipped, 1);
assert_eq!(
state
.store
.get_draft_chunk(&chunk_id)
.await
.unwrap()
.unwrap()
.status,
"pending"
);
let approved = handle_approve_all_draft_chunks(
&state,
with_user(Request::new(ApproveAllDraftChunksRequest {
request_id: String::new(),
sandbox: sandbox_name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
include_security_flagged: true,
approvals: vec![openshell_core::proto::DraftChunkApproval {
chunk_id: chunk_id.clone(),
review_token: chunk.review_token.clone(),
}],
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(approved.chunks_approved, 1);
assert_eq!(approved.chunks_skipped, 0);
assert_eq!(
state
.store
.get_draft_chunk(&chunk_id)
.await
.unwrap()
.unwrap()
.status,
"approved"
);
// Wait for SQLx's SQLite worker threads to stop before the test process exits;
state.store.close_for_test().await;
}
#[tokio::test]
async fn edit_recomputes_security_notes_before_read_and_approve_all() {
let state = test_server_state().await;
let sandbox_id = "sb-edit-security-notes";
let sandbox_name = "edit-security-notes";
state
.store
.put_message(&test_sandbox(
sandbox_id,
sandbox_name,
ProtoSandboxPolicy::default(),
vec![],
))
.await
.unwrap();
let safe_rule = NetworkPolicyRule {
name: "edited_service".to_string(),
endpoints: vec![NetworkEndpoint {
host: "service.example.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
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: vec![PolicyChunk {
rule_name: "edited_service".to_string(),
proposed_rule: Some(safe_rule.clone()),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
let chunk_id = submit.accepted_chunk_ids[0].clone();
let mut private_rule = safe_rule;
private_rule.endpoints[0].allowed_ips = vec!["10.0.0.0/8".to_string()];
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(private_rule),
})),
)
.await
.unwrap();
let stored = state
.store
.get_draft_chunk(&chunk_id)
.await
.unwrap()
.unwrap();
assert!(stored.security_notes.is_empty());
let draft = 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: String::new(),
})),
)
.await
.unwrap()
.into_inner();
assert!(
draft.chunks[0]
.security_notes
.contains("allowed_ips includes private/internal range '10.0.0.0/8'.")
);
let skipped = handle_approve_all_draft_chunks(
&state,
with_user(Request::new(ApproveAllDraftChunksRequest {
sandbox: sandbox_name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
include_security_flagged: false,
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(skipped.chunks_approved, 0);
assert_eq!(skipped.chunks_skipped, 1);
assert_eq!(
state
.store
.get_draft_chunk(&chunk_id)
.await
.unwrap()
.unwrap()
.status,
"pending"
);
}
#[tokio::test]
async fn approve_all_recomputes_empty_legacy_security_notes() {
let state = test_server_state().await;
let sandbox_id = "sb-stale-security-notes";
let sandbox_name = "stale-security-notes";
state
.store
.put_message(&test_sandbox(
sandbox_id,
sandbox_name,
ProtoSandboxPolicy::default(),
vec![],
))
.await
.unwrap();
let rule = NetworkPolicyRule {
name: "legacy_private".to_string(),
endpoints: vec![NetworkEndpoint {
host: "service.example.com".to_string(),
port: 443,
allowed_ips: vec!["10.0.0.0/8".to_string()],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
let mut chunk = pending_draft_chunk("legacy-private", sandbox_id);
chunk.rule_name = rule.name.clone();
chunk.proposed_rule = rule.encode_to_vec();
chunk.host = "service.example.com".to_string();
chunk.port = 443;
state
.store
.put_draft_chunk(&chunk, None, "default")
.await
.unwrap();
let skipped = handle_approve_all_draft_chunks(
&state,
with_user(Request::new(ApproveAllDraftChunksRequest {
sandbox: sandbox_name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
include_security_flagged: false,
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(skipped.chunks_approved, 0);
assert_eq!(skipped.chunks_skipped, 1);
assert_eq!(
state
.store
.get_draft_chunk(&chunk.id)
.await
.unwrap()
.unwrap()
.status,
"pending"
);
}
#[tokio::test]
async fn security_flagged_empty_delta_does_not_auto_approve() {
let state = test_server_state().await;
let sandbox_name = "security-flagged-auto";
state
.store
.put_message(&test_sandbox(
"sb-security-flagged-auto",
sandbox_name,
ProtoSandboxPolicy::default(),
vec![],
))
.await
.unwrap();
seed_sandbox_approval_mode(&state, sandbox_name, "auto").await;
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: "mechanistic".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "private_service".to_string(),
proposed_rule: Some(NetworkPolicyRule {
name: "private_service".to_string(),
endpoints: vec![NetworkEndpoint {
host: "service.example.com".to_string(),
port: 443,
allowed_ips: vec!["10.0.0.0/8".to_string()],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
}),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap();
let draft = 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: String::new(),
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(draft.chunks[0].validation_result, "prover: no new findings");
assert_eq!(draft.chunks[0].status, "pending");
assert!(
draft.chunks[0]
.security_notes
.contains("allowed_ips includes private/internal range '10.0.0.0/8'.")
);
}
#[tokio::test]
async fn mechanistic_dedup_auto_approval_rechecks_edited_stored_rule() {
let state = test_server_state().await;
let sandbox_id = "sb-stored-prover-verdict";
let sandbox_name = "stored-prover-verdict";
state
.store
.put_message(&test_provider("github-pat", "github"))
.await
.unwrap();
state
.store
.put_message(&test_sandbox(
sandbox_id,
sandbox_name,
ProtoSandboxPolicy::default(),
vec!["github-pat".to_string()],
))
.await
.unwrap();
// The first observation is clean and remains pending in the default
// manual mode. Its normalized endpoint columns become the dedup key.
let safe_rule = NetworkPolicyRule {
name: "safe_service".to_string(),
endpoints: vec![NetworkEndpoint {
host: "service.example.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
let first = 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: "mechanistic".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: safe_rule.name.clone(),
proposed_rule: Some(safe_rule.clone()),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
let chunk_id = first.accepted_chunk_ids[0].clone();
let original = state
.store
.get_draft_chunk(&chunk_id)
.await
.unwrap()
.unwrap();
assert_eq!(original.status, "pending");
assert_eq!(original.validation_result, "prover: no new findings");
// Editing only replaces the stored protobuf payload; the normalized
// dedup columns remain service.example.com:443 + /usr/bin/curl. This
// replacement has no advisory notes but does add credentialed reach.
let finding_rule = NetworkPolicyRule {
name: safe_rule.name.clone(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
..Default::default()
}],
binaries: safe_rule.binaries.clone(),
};
assert!(generate_security_notes(&finding_rule).is_empty());
let credential_set = CredentialSet {
credentials: vec![Credential {
name: "github-pat".to_string(),
cred_type: "github".to_string(),
scopes: Vec::new(),
injected_via: String::new(),
target_hosts: vec!["api.github.com".to_string()],
}],
api_registries: HashMap::new(),
};
assert!(
validation_result_for_agent_proposal(
ProtoSandboxPolicy::default(),
&finding_rule.name,
&finding_rule,
&credential_set,
)
.contains("credential_reach_expansion")
);
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(finding_rule),
})),
)
.await
.unwrap();
seed_sandbox_approval_mode(&state, sandbox_name, "auto").await;
// The duplicate incoming proposal is clean. Dedup returns the edited
// row, so auto-approval must evaluate that stored payload instead.
let duplicate = 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: "mechanistic".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: safe_rule.name.clone(),
proposed_rule: Some(safe_rule),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(duplicate.accepted_chunk_ids, vec![chunk_id.clone()]);
let stored = state
.store
.get_draft_chunk(&chunk_id)
.await
.unwrap()
.unwrap();
assert_eq!(stored.status, "pending");
let stored_rule = NetworkPolicyRule::decode(stored.proposed_rule.as_slice()).unwrap();
assert_eq!(stored_rule.endpoints[0].host, "api.github.com");
assert!(generate_security_notes(&stored_rule).is_empty());
assert!(
state
.store
.get_latest_policy(sandbox_id)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn draft_chunk_handler_lifecycle_round_trip() {
use openshell_core::proto::{
GetDraftPolicyRequest, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec,
};
let state = test_server_state().await;
// Attach a github provider so the proposal below has a credential in
// scope for api.github.com. This causes the prover to emit a HIGH
// finding (L4 + credential in scope), keeping the chunk pending so
// the manual approve/reject lifecycle this test exercises is
// reachable. Without a provider, the proposal would auto-approve and
// the lifecycle assertions would no longer apply.
state
.store
.put_message(&test_provider("github-pat", "github"))
.await
.unwrap();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-draft-flow".to_string(),
name: "draft-flow".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
providers: vec!["github-pat".to_string()],
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
let sandbox_name = sandbox.object_name().to_string();
let proposed_rule = NetworkPolicyRule {
name: "allow_github".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
allow_uninspected_credentials: true,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
let submit = handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
proposed_chunks: vec![PolicyChunk {
rule_name: "allow_github".to_string(),
proposed_rule: Some(proposed_rule.clone()),
rationale: "observed denied request".to_string(),
confidence: 0.85,
hit_count: 3,
first_seen_time: openshell_core::time::timestamp_from_millis(100).ok(),
last_seen_time: openshell_core::time::timestamp_from_millis(200).ok(),
binary: "/usr/bin/curl".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(submit.accepted_chunks, 1);
assert_eq!(submit.rejected_chunks, 0);
assert_eq!(submit.accepted_chunk_ids.len(), 1);
assert!(!submit.accepted_chunk_ids[0].is_empty());
let draft_policy = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(draft_policy.draft_version, 1);
assert_eq!(draft_policy.chunks.len(), 1);
// The proposal explicitly opts in to L4 credentials. The prover emits
// a HIGH finding and the security note keeps the chunk pending for the
// manual approve path this test exercises.
assert_eq!(draft_policy.chunks[0].status, "pending");
let chunk_id = draft_policy.chunks[0].id.clone();
let review_token = draft_policy.chunks[0].review_token.clone();
let approve = handle_approve_draft_chunk(
&state,
authed_request(ApproveDraftChunkRequest {
request_id: String::new(),
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
chunk_id: chunk_id.clone(),
review_token,
}),
)
.await
.unwrap()
.into_inner();
assert_eq!(approve.policy_version, 1);
assert!(!approve.policy_hash.is_empty());
let history_after_approve = handle_get_draft_history(
&state,
authed_request(GetDraftHistoryRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
}),
)
.await
.unwrap()
.into_inner();
assert_eq!(history_after_approve.entries.len(), 2);
assert_eq!(history_after_approve.entries[0].event_type, "proposed");
assert_eq!(history_after_approve.entries[1].event_type, "approved");
assert_eq!(history_after_approve.entries[1].chunk_id, chunk_id);
let policies_after_approve = handle_list_sandbox_policies(
&state,
authed_request(ListSandboxPoliciesRequest {
sandbox: sandbox_name.clone(),
page_size: 10,
page_token: String::new(),
global: false,
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
}),
)
.await
.unwrap()
.into_inner();
assert_eq!(policies_after_approve.revisions.len(), 1);
assert_eq!(policies_after_approve.revisions[0].version, 1);
let undo = handle_undo_draft_chunk(
&state,
authed_request(UndoDraftChunkRequest {
request_id: String::new(),
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
chunk_id: chunk_id.clone(),
}),
)
.await
.unwrap()
.into_inner();
assert_eq!(undo.policy_version, 2);
assert!(!undo.policy_hash.is_empty());
let draft_policy_after_undo = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(draft_policy_after_undo.chunks.len(), 1);
assert_eq!(draft_policy_after_undo.chunks[0].status, "pending");
let history_after_undo = handle_get_draft_history(
&state,
authed_request(GetDraftHistoryRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
}),
)
.await
.unwrap()
.into_inner();
assert_eq!(history_after_undo.entries.len(), 1);
assert_eq!(history_after_undo.entries[0].event_type, "proposed");
let policies_after_undo = handle_list_sandbox_policies(
&state,
authed_request(ListSandboxPoliciesRequest {
sandbox: sandbox_name.clone(),
page_size: 10,
page_token: String::new(),
global: false,
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
}),
)
.await
.unwrap()
.into_inner();
assert_eq!(policies_after_undo.revisions.len(), 2);
assert_eq!(policies_after_undo.revisions[0].version, 2);
assert_eq!(policies_after_undo.revisions[1].version, 1);
let cleared = handle_clear_draft_chunks(
&state,
authed_request(ClearDraftChunksRequest {
request_id: String::new(),
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
}),
)
.await
.unwrap()
.into_inner();
assert_eq!(cleared.chunks_cleared, 1);
let draft_policy_after_clear = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
assert!(draft_policy_after_clear.chunks.is_empty());
let history_after_clear = handle_get_draft_history(
&state,
authed_request(GetDraftHistoryRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
}),
)
.await
.unwrap()
.into_inner();
assert!(history_after_clear.entries.is_empty());
}
/// A reviewer's free-form rejection reason must round-trip through
/// persistence and surface on the chunk via `GetDraftPolicy`, so the
/// in-sandbox agent can read the guidance and redraft. The MVP-v2 agent
/// feedback loop hangs off this guarantee.
#[tokio::test]
async fn reject_with_reason_persists_into_chunk_for_agent_readback() {
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec};
let state = test_server_state().await;
let sandbox_name = "agent-feedback-loop".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-feedback".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
let proposed_rule = NetworkPolicyRule {
name: "allow_example".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.example.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
let submit = handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
proposed_chunks: vec![PolicyChunk {
rule_name: "allow_example".to_string(),
proposed_rule: Some(proposed_rule),
rationale: "agent intent".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
let chunk_id = submit.accepted_chunk_ids[0].clone();
let guidance = "scope to docs/ paths only, not all repo contents";
handle_reject_draft_chunk(
&state,
authed_request(RejectDraftChunkRequest {
request_id: String::new(),
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
chunk_id: chunk_id.clone(),
reason: guidance.to_string(),
}),
)
.await
.unwrap();
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
let rejected = draft
.chunks
.iter()
.find(|c| c.id == chunk_id)
.expect("rejected chunk should still be visible");
assert_eq!(rejected.status, "rejected");
assert_eq!(
rejected.rejection_reason, guidance,
"reviewer's free-form reason must round-trip into the chunk for agent readback"
);
// The prover now runs on every proposal regardless of analysis_mode.
// For this rule (L4 to api.example.com, no provider attached, no
// credential in scope), v1 calibration emits no finding — so the
// verdict is the clean "no new findings" string, not empty.
assert_eq!(rejected.validation_result, "prover: no new findings");
}
#[tokio::test]
async fn agent_authored_exact_l7_proposal_gets_prover_pass_verdict() {
use openshell_core::proto::{
FilesystemPolicy, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, SandboxPhase,
SandboxPolicy, SandboxSpec,
};
let state = test_server_state().await;
let sandbox_name = "agent-l7-verdict".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-agent-l7-verdict".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: Some(SandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
read_write: vec!["/sandbox".to_string()],
..Default::default()
}),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
// Opt this sandbox into auto-approval via the settings model — same
// path the CLI's `--approval-mode auto` exercises — to test the
// empty-delta → approved path.
seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await;
let proposed_rule = NetworkPolicyRule {
name: "github_contents_write".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
protocol: "rest".to_string(),
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
rules: vec![L7Rule {
allow: Some(L7Allow {
method: "PUT".to_string(),
path: "/repos/org/repo/contents/demo/file.md".to_string(),
..Default::default()
}),
}],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
analysis_mode: "agent_authored".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "github_contents_write".to_string(),
proposed_rule: Some(proposed_rule),
rationale: "write one demo file".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap();
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
let verdict = &draft.chunks[0].validation_result;
assert_eq!(
verdict, "prover: no new findings",
"exact L7 PUT against an inspected endpoint should not introduce \
any new findings over baseline; got: {verdict}"
);
// Auto-approval gate: empty delta + sandbox opted into auto mode →
// status flips to approved without human action. The canonical
// happy path for agent speed.
assert_eq!(
draft.chunks[0].status, "approved",
"empty-delta agent-authored proposal under auto mode must auto-approve; \
got status: {}",
draft.chunks[0].status
);
}
/// Implicit supersede: when a refined agent-authored proposal lands for
/// the same `(host, port, binary)` as a pending mechanistic chunk, the
/// older mechanistic chunk is auto-rejected with a "superseded by
/// chunk X" reason. This is the refinement loop without a
/// `supersedes_chunk_id` field — structural overlap is enough.
#[tokio::test]
async fn agent_authored_submission_supersedes_pending_mechanistic_for_same_endpoint() {
use openshell_core::proto::{
FilesystemPolicy, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, SandboxPhase,
SandboxPolicy, SandboxSpec,
};
let state = test_server_state().await;
// github provider attached so the mechanistic L4 lands a HIGH
// finding and stays pending.
state
.store
.put_message(&test_provider("github-pat", "github"))
.await
.unwrap();
let sandbox_name = "supersede-flow".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-supersede-flow".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: Some(SandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
read_write: vec!["/sandbox".to_string()],
..Default::default()
}),
..Default::default()
}),
providers: vec!["github-pat".to_string()],
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
// Step 1: mechanistic submits a broad L4 grant; the prover flags it
// HIGH, so it lands in pending.
let mechanistic_rule = NetworkPolicyRule {
name: "allow_api_github_com_443".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
allow_uninspected_credentials: true,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
let mechanistic_submit = handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
analysis_mode: "mechanistic".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "allow_api_github_com_443".to_string(),
proposed_rule: Some(mechanistic_rule),
rationale: "Allow /usr/bin/curl to connect to api.github.com:443.".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
let mechanistic_chunk_id = mechanistic_submit.accepted_chunk_ids[0].clone();
// Sanity-check: the mechanistic chunk is pending and carries a HIGH
// finding.
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
let mech = draft
.chunks
.iter()
.find(|c| c.id == mechanistic_chunk_id)
.expect("mechanistic chunk present");
assert_eq!(mech.status, "pending");
// The attached GitHub profile already grants credentialed reach for
// this host, so the mechanistic proposal does not expand reach.
assert!(
!mech
.validation_result
.contains("credential_reach_expansion"),
"profile-composed reach should prevent a duplicate expansion finding; got: {}",
mech.validation_result
);
// Step 2: the agent refines into a narrow L7 proposal for the SAME
// (host, port, binary). Under the v1 calibration, an L7 PUT on a
// host where the binary already had credentialed reach (read-only)
// emits a capability_expansion finding (new method on already-
// reached host) rather than a fresh reach expansion. The agent
// chunk stays pending for human review. The mechanistic chunk gets
// auto-rejected as superseded regardless of the agent chunk's own
// validation verdict — supersede is unconditional on `(host, port,
// binary)` overlap.
let agent_rule = NetworkPolicyRule {
name: "github_contents_put".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
protocol: "rest".to_string(),
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
rules: vec![L7Rule {
allow: Some(L7Allow {
method: "PUT".to_string(),
path: "/repos/owner/name/contents/path/file.md".to_string(),
..Default::default()
}),
}],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
let agent_submit = handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
analysis_mode: "agent_authored".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "github_contents_put".to_string(),
proposed_rule: Some(agent_rule),
rationale: "refined L7 scope for the demo write".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
let agent_chunk_id = agent_submit.accepted_chunk_ids[0].clone();
let draft_after = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
let agent = draft_after
.chunks
.iter()
.find(|c| c.id == agent_chunk_id)
.expect("agent chunk present");
let mech_after = draft_after
.chunks
.iter()
.find(|c| c.id == mechanistic_chunk_id)
.expect("mechanistic chunk should still be visible (with new status)");
assert_eq!(
agent.status, "pending",
"agent-authored L7 PUT with credential in scope must land in pending; \
the baseline policy has no pre-existing rule for curl on api.github.com \
so the agent's chunk grants brand-new credentialed reach. got: {}",
agent.status
);
assert!(
agent
.validation_result
.contains("credential_reach_expansion"),
"agent chunk should carry credential_reach_expansion (new credentialed reach \
on api.github.com); got: {}",
agent.validation_result
);
assert_eq!(
mech_after.status, "rejected",
"older mechanistic chunk for same (host, port, binary) should be superseded; \
got: {}",
mech_after.status
);
assert!(
mech_after.rejection_reason.contains(&agent_chunk_id),
"rejection reason should cite the superseding chunk id; got: {}",
mech_after.rejection_reason
);
assert!(
mech_after.rejection_reason.contains("superseded"),
"rejection reason should explain the supersede; got: {}",
mech_after.rejection_reason
);
}
/// Auto-approval is **proposer-agnostic**: a mechanistic proposal whose
/// prover delta is empty auto-approves the same way an agent-authored one
/// does. Source provenance is preserved in the audit trail (OCSF event
/// `source=mechanistic`) but does not change the safety decision.
#[tokio::test]
async fn mechanistic_proposal_with_empty_delta_also_auto_approves() {
use openshell_core::proto::{
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
SandboxSpec,
};
let state = test_server_state().await;
let sandbox_name = "mechanistic-clean".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-mechanistic-clean".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: Some(SandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
read_write: vec!["/sandbox".to_string()],
..Default::default()
}),
..Default::default()
}),
// No providers → no credential in scope for the proposed host.
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
// Opt into auto mode via the settings model to test the
// proposer-agnostic gate.
seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await;
let proposed_rule = NetworkPolicyRule {
name: "anon_l4".to_string(),
endpoints: vec![NetworkEndpoint {
host: "example.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
analysis_mode: "mechanistic".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "anon_l4".to_string(),
proposed_rule: Some(proposed_rule),
rationale: "Allow /usr/bin/curl to connect to example.com:443.".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap();
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
let verdict = &draft.chunks[0].validation_result;
assert_eq!(verdict, "prover: no new findings");
assert_eq!(
draft.chunks[0].status, "approved",
"empty-delta mechanistic proposal under auto mode must auto-approve \
(proposer-agnostic); got status: {}",
draft.chunks[0].status
);
}
#[tokio::test]
async fn mechanistic_existing_multi_port_rest_endpoint_auto_approves_narrow_overlay() {
use openshell_core::proto::{
NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPhase, SandboxPolicy,
SandboxSpec,
};
let state = test_server_state().await;
let sandbox_name = "mechanistic-existing-rest".to_string();
let mut base_policy = SandboxPolicy::default();
base_policy.network_policies.insert(
"cargo_registry".to_string(),
NetworkPolicyRule {
name: "cargo-registry".to_string(),
endpoints: vec![NetworkEndpoint {
host: "index.crates.io".to_string(),
port: 80,
ports: vec![80, 443],
protocol: "rest".to_string(),
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
access: openshell_core::proto::NetworkAccessPreset::ReadOnly as i32,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/cargo".to_string(),
}],
},
);
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-mechanistic-existing-rest".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: Some(base_policy),
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await;
let advisor_binary = NetworkBinary {
path: "/usr/bin/curl".to_string(),
};
handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
analysis_mode: "mechanistic".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "allow_index_crates_io_443".to_string(),
proposed_rule: Some(NetworkPolicyRule {
name: "allow_index_crates_io_443".to_string(),
endpoints: vec![NetworkEndpoint {
host: "index.crates.io".to_string(),
port: 443,
advisor_proposed: true,
..Default::default()
}],
binaries: vec![advisor_binary],
}),
hit_count: 1,
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap();
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
let chunk = &draft.chunks[0];
assert_eq!(
chunk.status, "approved",
"application error: {}; prover: {}",
chunk.application_error, chunk.validation_result
);
assert_eq!(chunk.rule_name, "allow_index_crates_io_443");
assert_eq!(chunk.validation_result, "prover: no new findings");
assert!(chunk.application_error.is_empty());
assert!(!chunk.review_token.is_empty());
let canonical = chunk.proposed_rule.as_ref().unwrap();
assert_eq!(canonical.endpoints[0].protocol, "rest");
assert_eq!(
canonical.endpoints[0].access,
openshell_core::proto::NetworkAccessPreset::ReadOnly as i32
);
assert!(
canonical.endpoints[0].advisor_proposed,
"a new advisor overlay must retain proposal provenance"
);
let revision = state
.store
.get_latest_policy("sb-mechanistic-existing-rest")
.await
.unwrap()
.expect("auto approval persisted a policy revision");
let applied = ProtoSandboxPolicy::decode(revision.policy_payload.as_slice()).unwrap();
let cargo_rule = &applied.network_policies["cargo_registry"];
assert_eq!(cargo_rule.endpoints.len(), 1);
assert_eq!(cargo_rule.endpoints[0].ports, vec![80, 443]);
assert_eq!(cargo_rule.endpoints[0].protocol, "rest");
assert_eq!(
cargo_rule.endpoints[0].access,
openshell_core::proto::NetworkAccessPreset::ReadOnly as i32
);
assert_eq!(cargo_rule.binaries.len(), 1);
assert_eq!(cargo_rule.binaries[0].path, "/usr/bin/cargo");
let curl_rule = &applied.network_policies["allow_index_crates_io_443"];
assert_eq!(curl_rule.endpoints.len(), 1);
assert_eq!(curl_rule.endpoints[0].ports, vec![443]);
assert_eq!(curl_rule.endpoints[0].protocol, "rest");
assert_eq!(
curl_rule.endpoints[0].access,
openshell_core::proto::NetworkAccessPreset::ReadOnly as i32
);
assert!(
curl_rule.endpoints[0].advisor_proposed,
"the persisted advisor overlay must retain proposal provenance"
);
assert_eq!(curl_rule.binaries.len(), 1);
assert_eq!(curl_rule.binaries[0].path, "/usr/bin/curl");
}
#[tokio::test]
async fn malformed_graphql_candidate_is_rejected_before_reviewer_inbox() {
use openshell_core::proto::{
L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPhase,
SandboxPolicy, SandboxSpec,
};
let state = test_server_state().await;
let sandbox_name = "invalid-graphql-preflight".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-invalid-graphql-preflight".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
workspace: "default".to_string(),
..Default::default()
}),
spec: Some(SandboxSpec {
policy: Some(SandboxPolicy::default()),
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
let response = handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
analysis_mode: "agent_authored".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "bad_graphql".to_string(),
proposed_rule: Some(NetworkPolicyRule {
name: "bad-graphql".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.example.com".to_string(),
port: 443,
protocol: "graphql".to_string(),
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce
as i32,
rules: vec![L7Rule {
allow: Some(L7Allow {
// Runtime requires an operation type for
// GraphQL rules; this intentionally omits it.
fields: vec!["viewer".to_string()],
..Default::default()
}),
}],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
}),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(response.accepted_chunks, 0);
assert_eq!(response.rejected_chunks, 1);
assert!(response.rejection_reasons[0].contains("operation_type"));
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
assert!(draft.chunks.is_empty());
}
#[tokio::test]
async fn changed_policy_inputs_refresh_token_and_require_fresh_review() {
use openshell_core::proto::{
NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPhase, SandboxPolicy,
SandboxSpec,
};
let state = test_server_state().await;
let sandbox_name = "stale-review-token".to_string();
let sandbox_id = "sb-stale-review-token";
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: sandbox_id.to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
workspace: "default".to_string(),
..Default::default()
}),
spec: Some(SandboxSpec {
policy: Some(SandboxPolicy::default()),
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).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.clone(),
analysis_mode: "agent_authored".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "example".to_string(),
proposed_rule: Some(NetworkPolicyRule {
name: "example".to_string(),
endpoints: vec![NetworkEndpoint {
host: "example.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
}),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
let chunk_id = &submit.accepted_chunk_ids[0];
let before = state
.store
.get_draft_chunk(chunk_id)
.await
.unwrap()
.unwrap();
assert!(!before.review_token.is_empty());
// The cached verdict is deliberately replaced with a sentinel while
// retaining its token. With unchanged inputs, evaluation must carry
// that value through instead of invoking the prover again.
let mut cached = before.clone();
cached.validation_result = "prover: cached sentinel".to_string();
assert!(
state
.store
.update_draft_chunk_evaluation(&cached)
.await
.unwrap()
);
let unchanged = require_current_proposal_evaluation(
&state,
"default",
&sandbox,
&cached,
Some(&cached.review_token),
)
.await
.unwrap();
assert_eq!(unchanged.validation_result, "prover: cached sentinel");
let mut changed_base = SandboxPolicy::default();
changed_base.network_policies.insert(
"unrelated".to_string(),
NetworkPolicyRule {
name: "unrelated".to_string(),
endpoints: vec![NetworkEndpoint {
host: "unrelated.example".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/wget".to_string(),
}],
},
);
let changed_hash = deterministic_policy_hash(&changed_base);
state
.store
.put_policy_revision(
"stale-token-policy",
sandbox_id,
"default",
1,
&changed_base.encode_to_vec(),
&changed_hash,
)
.await
.unwrap();
let error = handle_approve_draft_chunk(
&state,
with_user(Request::new(ApproveDraftChunkRequest {
request_id: String::new(),
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
chunk_id: chunk_id.clone(),
review_token: before.review_token.clone(),
})),
)
.await
.expect_err("changed base policy must invalidate the reviewed token");
assert_eq!(error.code(), Code::FailedPrecondition);
assert!(error.message().contains("inputs changed"));
let refreshed = state
.store
.get_draft_chunk(chunk_id)
.await
.unwrap()
.unwrap();
assert_eq!(refreshed.status, "pending");
assert_ne!(refreshed.review_token, before.review_token);
assert_eq!(
state
.store
.get_latest_policy(sandbox_id)
.await
.unwrap()
.unwrap()
.policy_hash,
changed_hash
);
}
#[tokio::test]
async fn policy_change_reconciles_pending_chunk_already_covered_by_live_policy() {
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule};
let state = test_server_state().await;
let sandbox_id = "sb-covered-pending";
let sandbox_name = "covered-pending";
state
.store
.put_message(&test_sandbox(
sandbox_id,
sandbox_name,
ProtoSandboxPolicy::default(),
Vec::new(),
))
.await
.unwrap();
let rule = NetworkPolicyRule {
name: "example".to_string(),
endpoints: vec![NetworkEndpoint {
host: "example.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
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: vec![PolicyChunk {
rule_name: "example".to_string(),
proposed_rule: Some(rule.clone()),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
let chunk_id = &submit.accepted_chunk_ids[0];
let mut live = ProtoSandboxPolicy::default();
live.network_policies.insert("example".to_string(), rule);
assert_eq!(
reconcile_pending_chunks_covered_by_policy(&state, sandbox_id, &live, 7)
.await
.unwrap(),
1
);
let reconciled = state
.store
.get_draft_chunk(chunk_id)
.await
.unwrap()
.unwrap();
assert_eq!(reconciled.status, "rejected");
assert_eq!(
reconciled.rejection_reason,
"covered by active policy revision 7"
);
}
/// `protocol: rest, access: full` on a host where the binary had no
/// prior credentialed reach: the prover emits
/// `credential_reach_expansion`. (The per-method `capability_expansion`
/// paths are suppressed by the gateway delta because the reach is
/// new; one finding describes the change, not eight.)
#[tokio::test]
async fn agent_authored_l7_full_with_credential_emits_reach_expansion() {
use openshell_core::proto::{
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
SandboxSpec,
};
let state = test_server_state().await;
state
.store
.put_message(&test_provider("github-pat", "github"))
.await
.unwrap();
let sandbox_name = "l7-full-with-cred".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-l7-full-with-cred".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: Some(SandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
read_write: vec!["/sandbox".to_string()],
..Default::default()
}),
..Default::default()
}),
providers: vec!["github-pat".to_string()],
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await;
// L7-annotated (protocol: rest, enforce) but access: full — no
// method/path bound. Credential in scope.
let proposed_rule = NetworkPolicyRule {
name: "github_l7_full".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
protocol: "rest".to_string(),
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
access: openshell_core::proto::NetworkAccessPreset::Full as i32,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
analysis_mode: "agent_authored".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "github_l7_full".to_string(),
proposed_rule: Some(proposed_rule),
rationale: "broad L7 dressing".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap();
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
let verdict = &draft.chunks[0].validation_result;
assert!(
verdict.contains("credential_reach_expansion"),
"L7 `access: full` on a host the binary did not previously reach must emit \
credential_reach_expansion; got: {verdict}"
);
// Capability_expansion paths for the same (binary, host:port) are
// suppressed when the reach itself is new — one finding, not many.
assert!(
!verdict.contains("capability_expansion"),
"capability_expansion must be suppressed when reach itself is new; got: {verdict}"
);
assert_eq!(
draft.chunks[0].status, "pending",
"any prover finding must keep the chunk in pending despite auto mode; got: {}",
draft.chunks[0].status
);
}
/// Acceptance criterion #7: default approval mode is manual. A sandbox
/// with no `proposal_approval_mode` setting at either scope must NOT
/// auto-approve empty-delta proposals; the chunk lands in `pending` for
/// human review. This is the default-deny safeguard: auto-approval is
/// an explicit opt-in, not a global behavior change shipped under a
/// feature.
#[tokio::test]
async fn empty_delta_does_not_auto_approve_when_mode_unset() {
use openshell_core::proto::{
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
SandboxSpec,
};
let state = test_server_state().await;
let sandbox_name = "default-manual-mode".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-default-manual-mode".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: Some(SandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
read_write: vec!["/sandbox".to_string()],
..Default::default()
}),
..Default::default()
}),
// No approval-mode setting seeded at sandbox or gateway
// scope — the resolver must treat absence as "manual".
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
let proposed_rule = NetworkPolicyRule {
name: "anon_l4".to_string(),
endpoints: vec![NetworkEndpoint {
host: "example.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
analysis_mode: "agent_authored".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "anon_l4".to_string(),
proposed_rule: Some(proposed_rule),
rationale: "un-credentialed L4 — prover sees no finding".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap();
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
let verdict = &draft.chunks[0].validation_result;
assert_eq!(
verdict, "prover: no new findings",
"prover should still emit no findings; gate is downstream",
);
assert_eq!(
draft.chunks[0].status, "pending",
"default (unset) proposal_approval_mode must not auto-approve; \
chunk should wait for human review. got status: {}",
draft.chunks[0].status
);
}
/// Unknown `proposal_approval_mode` strings (typos, future-mode values
/// the gateway doesn't yet know about) fall back to manual. This locks
/// in forward-compat: a future CLI that learns about `"auto_on_low_risk"`
/// can never accidentally bypass an older gateway's review gate just by
/// virtue of an unrecognized value defaulting to "auto."
#[tokio::test]
async fn empty_delta_does_not_auto_approve_when_mode_unknown_string() {
use openshell_core::proto::{
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
SandboxSpec,
};
let state = test_server_state().await;
let sandbox_name = "unknown-mode".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-unknown-mode".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: Some(SandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
read_write: vec!["/sandbox".to_string()],
..Default::default()
}),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
// A future-CLI value the current gateway doesn't recognize.
seed_sandbox_approval_mode(&state, &sandbox_name, "auto_on_low_risk").await;
let proposed_rule = NetworkPolicyRule {
name: "anon_l4".to_string(),
endpoints: vec![NetworkEndpoint {
host: "example.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
analysis_mode: "agent_authored".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "anon_l4".to_string(),
proposed_rule: Some(proposed_rule),
rationale: "un-credentialed L4".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap();
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(
draft.chunks[0].status, "pending",
"unknown approval-mode strings must fall back to manual; \
only the literal \"auto\" opts in. got: {}",
draft.chunks[0].status
);
}
/// Explicit `"manual"` is equivalent to the unset default — chunk lands
/// in pending even with empty delta.
#[tokio::test]
async fn empty_delta_does_not_auto_approve_when_mode_explicit_manual() {
use openshell_core::proto::{
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
SandboxSpec,
};
let state = test_server_state().await;
let sandbox_name = "explicit-manual-mode".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-explicit-manual-mode".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: Some(SandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
read_write: vec!["/sandbox".to_string()],
..Default::default()
}),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
seed_sandbox_approval_mode(&state, &sandbox_name, "manual").await;
let proposed_rule = NetworkPolicyRule {
name: "anon_l4".to_string(),
endpoints: vec![NetworkEndpoint {
host: "example.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
analysis_mode: "agent_authored".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "anon_l4".to_string(),
proposed_rule: Some(proposed_rule),
rationale: "un-credentialed L4 — prover sees no finding".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap();
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(
draft.chunks[0].status, "pending",
"explicit manual mode must equal default mode — no auto-approval; \
got: {}",
draft.chunks[0].status
);
}
/// Gateway-scope `proposal_approval_mode = "auto"` enables auto-approval
/// for any sandbox under that gateway, with no per-sandbox setting
/// required. This is the fleet-wide opt-in path — a reviewer flips the
/// gateway setting once and every sandbox without an explicit override
/// gets prover-gated auto-approval.
#[tokio::test]
async fn empty_delta_auto_approves_from_gateway_scope_setting() {
use openshell_core::proto::{
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
SandboxSpec,
};
let state = test_server_state().await;
let sandbox_name = "gateway-auto-mode".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-gateway-auto-mode".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: Some(SandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
read_write: vec!["/sandbox".to_string()],
..Default::default()
}),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
// Fleet-wide opt-in — no sandbox-scope setting.
seed_global_approval_mode(&state, "auto").await;
let proposed_rule = NetworkPolicyRule {
name: "anon_l4".to_string(),
endpoints: vec![NetworkEndpoint {
host: "example.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
analysis_mode: "agent_authored".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "anon_l4".to_string(),
proposed_rule: Some(proposed_rule),
rationale: "un-credentialed L4 — empty delta".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap();
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(
draft.chunks[0].status, "approved",
"empty-delta proposal must auto-approve when the gateway-scope \
setting is \"auto\" and no sandbox-scope override exists. got: {}",
draft.chunks[0].status
);
}
/// Gateway scope wins over sandbox scope. A reviewer can pin manual mode
/// fleet-wide; a per-sandbox `"auto"` value is silently ignored. Matches
/// the existing settings precedence convention (global wins, sandbox is
/// the per-sandbox override only when no global is set).
#[tokio::test]
async fn gateway_manual_overrides_sandbox_auto() {
use openshell_core::proto::{
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
SandboxSpec,
};
let state = test_server_state().await;
let sandbox_name = "gateway-pinned-manual".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-gateway-pinned-manual".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: Some(SandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
read_write: vec!["/sandbox".to_string()],
..Default::default()
}),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
// Gateway pins manual; the sandbox-scope override is supplied (test
// helper bypasses the UpdateConfig precondition, simulating the
// before-pin state) to prove the resolver still picks the gateway
// value.
seed_global_approval_mode(&state, "manual").await;
seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await;
let proposed_rule = NetworkPolicyRule {
name: "anon_l4".to_string(),
endpoints: vec![NetworkEndpoint {
host: "example.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
analysis_mode: "agent_authored".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "anon_l4".to_string(),
proposed_rule: Some(proposed_rule),
rationale: "un-credentialed L4 — empty delta".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap();
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(
draft.chunks[0].status, "pending",
"gateway-scope \"manual\" must win over sandbox-scope \"auto\"; \
got: {}",
draft.chunks[0].status
);
}
/// Agent submissions targeting a `_provider_*` rule name are rejected at
/// the submit boundary. Provider-synthesized rules are a reserved
/// namespace; an agent that addresses one by name could otherwise
/// circumvent the merge guard that splits agent contributions into their
/// own rule (so the prover sees them honestly).
#[tokio::test]
async fn submit_rejects_reserved_provider_rule_name_prefix() {
use openshell_core::proto::{
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
SandboxSpec,
};
let state = test_server_state().await;
let sandbox_name = "reject-provider-prefix".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-reject-provider-prefix".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: Some(SandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
read_write: vec!["/sandbox".to_string()],
..Default::default()
}),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
let proposed_rule = NetworkPolicyRule {
name: "github".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
allow_uninspected_credentials: true,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
let response = handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
analysis_mode: "agent_authored".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "_provider_work_github".to_string(),
proposed_rule: Some(proposed_rule),
rationale: "should be rejected — addresses provider rule by name".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(response.accepted_chunks, 0, "chunk must be rejected");
assert_eq!(response.rejected_chunks, 1);
assert!(
response
.rejection_reasons
.iter()
.any(|r| r.contains("_provider_")),
"rejection reason must cite the reserved-prefix rule. got: {:?}",
response.rejection_reasons,
);
}
#[tokio::test]
async fn agent_authored_submit_rejects_native_tcp_and_tls_skip_but_allows_explicit_proxy() {
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule};
let state = test_server_state().await;
let sandbox_name = "reject-agent-raw-transports";
state
.store
.put_message(&test_sandbox(
"sb-reject-agent-raw-transports",
sandbox_name,
ProtoSandboxPolicy::default(),
vec![],
))
.await
.unwrap();
let endpoint = |protocol: &str, tls: &str| NetworkEndpoint {
host: "api.example.com".to_string(),
port: 443,
protocol: protocol.to_string(),
tls: openshell_policy::network_tls_mode_from_str(tls)
.expect("test TLS mode must be valid") as i32,
..Default::default()
};
let chunk = |name: &str, endpoint: NetworkEndpoint| PolicyChunk {
rule_name: name.to_string(),
proposed_rule: Some(NetworkPolicyRule {
name: name.to_string(),
endpoints: vec![endpoint],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
}),
..Default::default()
};
let response = 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: vec![
chunk("native_tcp", endpoint("tcp", "")),
chunk("raw_tls", endpoint("", "skip")),
chunk("explicit_proxy", endpoint("", "")),
],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(response.accepted_chunks, 1);
assert_eq!(response.rejected_chunks, 2);
assert_eq!(response.rejection_reasons.len(), 2);
assert!(
response
.rejection_reasons
.iter()
.any(|reason| reason.contains("protocol tcp"))
);
assert!(
response
.rejection_reasons
.iter()
.any(|reason| reason.contains("tls: skip"))
);
let draft = 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: String::new(),
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(draft.chunks.len(), 1);
assert_eq!(draft.chunks[0].rule_name, "explicit_proxy");
}
#[tokio::test]
async fn approve_draft_chunk_rejects_stored_reserved_provider_rule_name() {
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule};
let state = test_server_state().await;
let sandbox_id = "sb-approve-provider-prefix";
let sandbox_name = "approve-provider-prefix";
state
.store
.put_message(&test_sandbox(
sandbox_id,
sandbox_name,
ProtoSandboxPolicy::default(),
vec![],
))
.await
.unwrap();
let proposed_rule = NetworkPolicyRule {
name: "_provider_work_github".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
let chunk = DraftChunkRecord {
id: "chunk-provider-prefix".to_string(),
sandbox_id: sandbox_id.to_string(),
draft_version: 1,
status: "pending".to_string(),
rule_name: "_provider_work_github".to_string(),
proposed_rule: proposed_rule.encode_to_vec(),
rationale: "stored legacy/proposal chunk should not approve".to_string(),
security_notes: String::new(),
confidence: 1.0,
created_at_ms: 0,
decided_at_ms: None,
host: "api.github.com".to_string(),
port: 443,
binary: "/usr/bin/curl".to_string(),
hit_count: 1,
first_seen_ms: 0,
last_seen_ms: 0,
validation_result: String::new(),
rejection_reason: String::new(),
..Default::default()
};
state
.store
.put_draft_chunk(&chunk, None, "default")
.await
.expect("draft chunk should persist");
let err = handle_approve_draft_chunk(
&state,
with_user(Request::new(ApproveDraftChunkRequest {
sandbox: sandbox_name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
chunk_id: chunk.id.clone(),
..Default::default()
})),
)
.await
.expect_err("reserved provider rule names must be rejected at approval");
assert_eq!(err.code(), Code::InvalidArgument);
assert!(err.message().contains("_provider_work_github"));
assert!(err.message().contains("reserved '_provider_' prefix"));
let stored_chunk = state
.store
.get_draft_chunk(&chunk.id)
.await
.unwrap()
.expect("chunk should still exist");
assert_eq!(stored_chunk.status, "pending");
assert!(
state
.store
.get_latest_policy(sandbox_id)
.await
.unwrap()
.is_none(),
"failed approval must not persist a policy revision"
);
}
/// v1 calibration row: **L4 with a credential in scope → HIGH finding.**
/// The sandbox has a github provider attached, so a credential is in
/// scope for api.github.com. A broad L4 proposal therefore lands in
/// pending with a HIGH finding.
#[tokio::test]
async fn agent_authored_l4_proposal_with_credential_records_high_finding() {
use openshell_core::proto::{
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
SandboxSpec,
};
let state = test_server_state().await;
// Attach a github provider so a credential is in scope for api.github.com.
state
.store
.put_message(&test_provider("github-pat", "github"))
.await
.unwrap();
let sandbox_name = "agent-l4-with-cred".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-agent-l4-with-cred".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: Some(SandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
read_write: vec!["/sandbox".to_string()],
..Default::default()
}),
..Default::default()
}),
providers: vec!["github-pat".to_string()],
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
let proposed_rule = NetworkPolicyRule {
name: "github_l4".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
allow_uninspected_credentials: true,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
analysis_mode: "agent_authored".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "github_l4".to_string(),
proposed_rule: Some(proposed_rule),
rationale: "broad fallback".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap();
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
let verdict = &draft.chunks[0].validation_result;
let first_line = verdict.lines().next().unwrap_or("");
assert!(
first_line.starts_with("prover: ") && first_line.contains("new finding"),
"expected first line like `prover: N new finding(s)`, got: {verdict}"
);
assert!(
verdict.contains("credential_reach_expansion"),
"L4 + credential in scope emits credential_reach_expansion (the binary gains \
credentialed reach to a new host:port); got: {verdict}"
);
assert!(
verdict.contains("api.github.com:443"),
"expected the finding line to cite the proposed endpoint, got: {verdict}"
);
}
/// v1 calibration row: **L4 with NO credential in scope → no finding.**
/// Without an attached provider, no credential targets api.github.com,
/// so the prover treats the L4 grant as bounded (no privileged action
/// available) and emits nothing. The proposal verdict reads
/// `prover: no new findings`, eligible for auto-approval.
#[tokio::test]
async fn agent_authored_l4_proposal_without_credential_emits_no_finding() {
use openshell_core::proto::{
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
SandboxSpec,
};
let state = test_server_state().await;
let sandbox_name = "agent-l4-no-cred".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-agent-l4-no-cred".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: Some(SandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
read_write: vec!["/sandbox".to_string()],
..Default::default()
}),
..Default::default()
}),
// No providers — credential set will be empty.
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
let proposed_rule = NetworkPolicyRule {
name: "anon_l4".to_string(),
endpoints: vec![NetworkEndpoint {
host: "example.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
analysis_mode: "agent_authored".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "anon_l4".to_string(),
proposed_rule: Some(proposed_rule),
rationale: "no privileged access available".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap();
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
let verdict = &draft.chunks[0].validation_result;
assert_eq!(
verdict, "prover: no new findings",
"L4 grant with no credential in scope is bounded in v1; got: {verdict}"
);
}
/// v1 calibration row: **link-local host → HIGH finding regardless of
/// credentials.** Even with no provider attached, a proposal targeting
/// `169.254.169.254` (AWS IMDS / cloud metadata) emits a HIGH finding.
/// This is the one categorical safety floor v1 ships.
#[tokio::test]
async fn agent_authored_link_local_proposal_records_high_finding() {
use openshell_core::proto::{
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
SandboxSpec,
};
let state = test_server_state().await;
let sandbox_name = "agent-link-local".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-agent-link-local".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: Some(SandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
read_write: vec!["/sandbox".to_string()],
..Default::default()
}),
..Default::default()
}),
// Deliberately no provider — link-local should still fire.
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
let proposed_rule = NetworkPolicyRule {
name: "metadata_endpoint".to_string(),
endpoints: vec![NetworkEndpoint {
host: "169.254.169.254".to_string(),
port: 80,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
analysis_mode: "agent_authored".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "metadata_endpoint".to_string(),
proposed_rule: Some(proposed_rule),
rationale: "agent is curious about IMDS".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap();
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
let verdict = &draft.chunks[0].validation_result;
assert!(
verdict.contains("link_local_reach"),
"link-local proposal must emit link_local_reach regardless of credentials; \
got: {verdict}"
);
assert!(
verdict.contains("169.254.169.254"),
"finding line must cite the link-local host; got: {verdict}"
);
}
#[tokio::test]
async fn agent_authored_validation_uses_profile_composed_effective_policy() {
use openshell_core::proto::{
FilesystemPolicy, L7Allow, L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint,
ProviderProfile, ProviderProfileCategory, SandboxPhase, SandboxPolicy, SandboxSpec,
};
let state = test_server_state().await;
state
.store
.put_message(&test_provider("work-custom", "custom-api"))
.await
.unwrap();
state
.store
.put_message(&StoredProviderProfile {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "profile-custom-api".to_string(),
name: "custom-api".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
profile: Some(ProviderProfile {
files: Vec::new(),
id: "custom-api".to_string(),
resource_version: 0,
annotations: HashMap::new(),
display_name: "Custom API".to_string(),
description: String::new(),
category: ProviderProfileCategory::Other as i32,
credentials: Vec::new(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
protocol: "rest".to_string(),
access: openshell_core::proto::NetworkAccessPreset::Full as i32,
deny_rules: vec![L7DenyRule {
method: "DELETE".to_string(),
path: "/repos/*".to_string(),
..Default::default()
}],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
inference_capable: false,
discovery: None,
source: String::new(),
scope: String::new(),
}),
})
.await
.unwrap();
let sandbox_id = "sb-agent-provider-effective-policy";
let sandbox_name = "agent-provider-effective-policy".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: sandbox_id.to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: Some(SandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
read_write: vec!["/sandbox".to_string()],
..Default::default()
}),
..Default::default()
}),
providers: vec!["work-custom".to_string()],
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
let proposed_rule = NetworkPolicyRule {
name: "github_contents_write".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
protocol: "rest".to_string(),
// Match the provider-owned endpoint contract so this test
// exercises prover composition rather than a deterministic
// application failure.
enforcement: openshell_core::proto::NetworkEnforcementMode::Audit as i32,
rules: vec![L7Rule {
allow: Some(L7Allow {
method: "PUT".to_string(),
path: "/repos/org/repo/contents/demo/file.md".to_string(),
..Default::default()
}),
}],
advisor_proposed: true,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
let submit = handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
analysis_mode: "agent_authored".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "github_contents_write".to_string(),
proposed_rule: Some(proposed_rule),
rationale: "write one demo file".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(submit.accepted_chunks, 1);
assert_eq!(submit.rejected_chunks, 0);
let chunk_id = submit.accepted_chunk_ids[0].clone();
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
let chunk = draft
.chunks
.iter()
.find(|chunk| chunk.id == chunk_id)
.expect("provider-overlap proposal should reach the draft inbox");
assert_eq!(chunk.status, "pending");
let verdict = &chunk.validation_result;
let first_line = verdict.lines().next().unwrap_or("");
assert!(
first_line.starts_with("prover: "),
"validation should run end-to-end against the providers-v2 composed \
effective policy and produce a prover verdict; got: {verdict}"
);
assert!(
!verdict.contains("validation unavailable"),
"providers-v2 composition must not break the prover pipeline; \
got: {verdict}"
);
handle_approve_draft_chunk(
&state,
authed_request(ApproveDraftChunkRequest {
request_id: String::new(),
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
chunk_id,
review_token: chunk.review_token.clone(),
}),
)
.await
.expect("provider-overlap proposal should approve");
let stored = state
.store
.get_latest_policy(sandbox_id)
.await
.unwrap()
.expect("approval should persist a base-policy revision");
let base_policy = ProtoSandboxPolicy::decode(stored.policy_payload.as_slice()).unwrap();
assert!(
base_policy
.network_policies
.contains_key("github_contents_write")
);
assert!(
!base_policy
.network_policies
.contains_key("_provider_work_custom"),
"provider-composed rules must not be copied into the mutable base policy"
);
let effective_policy = get_sandbox_policy(&state, sandbox_id).await;
let provider_rule = &effective_policy.network_policies["_provider_work_custom"];
assert_eq!(
provider_rule.endpoints[0].access,
openshell_core::proto::NetworkAccessPreset::Full as i32
);
assert_eq!(provider_rule.endpoints[0].deny_rules.len(), 1);
assert!(!provider_rule.endpoints[0].advisor_proposed);
assert!(
effective_policy
.network_policies
.contains_key("github_contents_write")
);
}
/// End-to-end loop test against the v1 calibration and the auto-approval
/// gate. Mirrors the two-path flow in `examples/agent-driven-policy-management`:
///
/// 1. Un-credentialed L7 proposal (raw.githubusercontent.com GET) →
/// prover sees no findings → sandbox in `auto` mode → chunk
/// auto-approves without human action.
///
/// 2. Credentialed L7 proposal (api.github.com PUT) → prover sees
/// `github_token` in scope, emits MEDIUM → chunk lands in pending
/// for human review even under `auto` mode.
///
/// This is the deterministic counterpart of the demo's product UX
/// claim: "narrow safe = free, narrow credentialed = one approval."
#[tokio::test]
async fn full_loop_under_v2_auto_mode_splits_credentialed_and_uncredentialed() {
use openshell_core::proto::{
FilesystemPolicy, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, SandboxPhase,
SandboxPolicy, SandboxSpec,
};
let state = test_server_state().await;
// Github provider attached: a credential ends up in scope for
// api.github.com (PUT proposal flags MEDIUM). raw.githubusercontent.com
// is not declared by any provider, so the bootstrap fetch is
// un-credentialed and auto-approves.
state
.store
.put_message(&test_provider("github-pat", "github"))
.await
.unwrap();
let sandbox_name = "full-loop-v2".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-full-loop-v2".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: Some(SandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
read_write: vec!["/sandbox".to_string()],
..Default::default()
}),
..Default::default()
}),
providers: vec!["github-pat".to_string()],
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await;
// ── Step 1: un-credentialed GET → expected auto-approve ──
let uncredentialed_rule = NetworkPolicyRule {
name: "github_raw_openapi_get".to_string(),
endpoints: vec![NetworkEndpoint {
host: "raw.githubusercontent.com".to_string(),
port: 443,
protocol: "rest".to_string(),
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
rules: vec![L7Rule {
allow: Some(L7Allow {
method: "GET".to_string(),
path: "/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json"
.to_string(),
..Default::default()
}),
}],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
let step1 = handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
analysis_mode: "agent_authored".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "github_raw_openapi_get".to_string(),
proposed_rule: Some(uncredentialed_rule),
rationale: "fetch the public github openapi description".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
let step1_chunk_id = step1.accepted_chunk_ids[0].clone();
// ── Step 2: credentialed PUT → expected MEDIUM, pending ──
let credentialed_rule = NetworkPolicyRule {
name: "github_contents_put".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
protocol: "rest".to_string(),
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
rules: vec![L7Rule {
allow: Some(L7Allow {
method: "PUT".to_string(),
path: "/repos/owner/name/contents/path/file.md".to_string(),
..Default::default()
}),
}],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
let step2 = handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
analysis_mode: "agent_authored".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "github_contents_put".to_string(),
proposed_rule: Some(credentialed_rule),
rationale: "write the demo file via the GitHub Contents API".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
let step2_chunk_id = step2.accepted_chunk_ids[0].clone();
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
let step1_chunk = draft
.chunks
.iter()
.find(|c| c.id == step1_chunk_id)
.expect("step1 chunk present");
let step2_chunk = draft
.chunks
.iter()
.find(|c| c.id == step2_chunk_id)
.expect("step2 chunk present");
assert_eq!(
step1_chunk.status, "approved",
"un-credentialed L7 proposal under v2 + auto mode must auto-approve; got: {}",
step1_chunk.status
);
assert_eq!(
step1_chunk.validation_result, "prover: no new findings",
"un-credentialed L7 verdict should be `no new findings`; got: {}",
step1_chunk.validation_result
);
assert_eq!(
step2_chunk.status, "pending",
"credentialed L7 PUT under v2 + auto mode must stay pending; got: {}",
step2_chunk.status
);
// This test's spec policy has no pre-existing rule for curl on
// api.github.com, so the agent's chunk grants brand-new
// credentialed reach: the finding is credential_reach_expansion,
// not capability_expansion. (The capability_expansion path is
// suppressed by the delta because the reach is new — one finding
// per change, not two.) The demo's policy.template.yaml has
// github_api_readonly which exercises the capability_expansion
// path; that's covered by the supersede test above.
assert!(
step2_chunk
.validation_result
.contains("credential_reach_expansion"),
"credentialed PUT on a host the binary did not previously reach must carry \
credential_reach_expansion; got: {}",
step2_chunk.validation_result
);
assert!(
!step2_chunk
.validation_result
.contains("capability_expansion"),
"capability_expansion must be suppressed when reach itself is new; got: {}",
step2_chunk.validation_result
);
}
/// Two agent-authored proposals targeting the same host/port/binary must
/// each persist as a distinct chunk. The mechanistic-mode dedup
/// (`host|port|binary`) is wrong for agent intent: the redraft loop
/// relies on the second submission landing as its own chunk so the
/// reviewer can decide on it independently. Regression test for the bug
/// where Flow B of `e2e/policy-advisor/wait-smoke.sh` saw a fresh
/// `chunk_id` returned from submit but `RejectDraftChunk` could not
/// find it because the SQL ON CONFLICT had silently kept the prior row.
#[tokio::test]
async fn agent_authored_submits_for_same_endpoint_do_not_dedup() {
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec};
let state = test_server_state().await;
let sandbox_name = "redraft-loop".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-redraft".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
// Two proposals with the same host|port|binary (so the mechanistic
// dedup_key would collide) but distinct rule names and L7 paths —
// proves the gateway distinguishes them by intentional act and not
// by payload hash. If a future dedup-by-payload-hash regression
// landed, this test would still fail because the chunk_ids would
// still need to be distinct.
let make_rule = |rule_name: &str| NetworkPolicyRule {
name: rule_name.to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.example.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
let submit_one = |rule_name: &str, rule: NetworkPolicyRule| {
let state = state.clone();
let sandbox_name = sandbox_name.clone();
let rule_name = rule_name.to_string();
async move {
handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name,
analysis_mode: "agent_authored".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name,
proposed_rule: Some(rule),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner()
}
};
let first = submit_one("allow_first", make_rule("allow_first")).await;
let second = submit_one("allow_second", make_rule("allow_second")).await;
assert_eq!(first.accepted_chunk_ids.len(), 1);
assert_eq!(second.accepted_chunk_ids.len(), 1);
assert_ne!(
first.accepted_chunk_ids[0], second.accepted_chunk_ids[0],
"second agent-authored proposal for the same endpoint must get its own chunk_id, not dedup"
);
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
let ids: Vec<_> = draft.chunks.iter().map(|c| c.id.as_str()).collect();
assert!(
ids.contains(&first.accepted_chunk_ids[0].as_str())
&& ids.contains(&second.accepted_chunk_ids[0].as_str()),
"both reported chunk_ids must be persisted; got: {ids:?}"
);
// Reject the second by id to prove the gateway can actually find
// what the submit response claimed to have created — this is the
// exact path the smoke test exercises end-to-end.
handle_reject_draft_chunk(
&state,
authed_request(RejectDraftChunkRequest {
request_id: String::new(),
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
chunk_id: second.accepted_chunk_ids[0].clone(),
reason: "redraft test".to_string(),
}),
)
.await
.expect("reject must find the chunk_id the submit response just promised");
}
/// Complement to the agent-authored test above: mechanistic-mode
/// submissions for the same endpoint must STILL dedup. The
/// observation-driven path relies on N denials folding into one chunk
/// instead of N near-identical chunks. Lock the behavior in so a future
/// change to the dedup branch doesn't accidentally also turn off
/// mechanistic dedup.
#[tokio::test]
async fn mechanistic_submits_for_same_endpoint_dedup_into_one_chunk() {
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec};
let state = test_server_state().await;
let sandbox_name = "mechanistic-dedup".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-mech-dedup".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
let proposed_rule = NetworkPolicyRule {
name: "allow_example".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.example.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
let submit_one = || {
let state = state.clone();
let sandbox_name = sandbox_name.clone();
let rule = proposed_rule.clone();
async move {
handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name,
analysis_mode: "mechanistic".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "allow_example".to_string(),
proposed_rule: Some(rule),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner()
}
};
let first = submit_one().await;
let second = submit_one().await;
assert_eq!(first.accepted_chunk_ids.len(), 1);
assert_eq!(second.accepted_chunk_ids.len(), 1);
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(
draft.chunks.len(),
1,
"two mechanistic submits for the same host|port|binary must dedup; got {} chunks",
draft.chunks.len()
);
// Both submits must report the same effective id — the id of the
// one row that actually exists in the DB. Before the dedup fix the
// second submit would return a freshly-generated UUID that was
// never persisted; this assertion locks the contract down.
let stored_id = &draft.chunks[0].id;
assert_eq!(
&first.accepted_chunk_ids[0], stored_id,
"first submit's reported id must match the stored chunk"
);
assert_eq!(
&second.accepted_chunk_ids[0], stored_id,
"second submit must report the same id as the first (dedup fold-in), not a fresh UUID"
);
}
/// Regression: a mechanistic denial flush for an endpoint already covered
/// by an auto-approved mechanistic chunk must NOT flip that chunk to
/// `rejected`. The dedup upsert returns the approved row's own id; before
/// the self-exclusion + pending guard the self-reject scan matched the row
/// against itself and rejected it, corrupting the governance ledger (status
/// read `rejected`) while the merged rule stayed enforced.
#[tokio::test]
async fn resubmitted_mechanistic_endpoint_keeps_approved_chunk() {
use openshell_core::proto::{
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
SandboxSpec,
};
let state = test_server_state().await;
let sandbox_name = "mechanistic-reapprove".to_string();
let sandbox_id = "sb-mechanistic-reapprove";
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: sandbox_id.to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: std::collections::HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: Some(SandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
read_write: vec!["/sandbox".to_string()],
..Default::default()
}),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
// No providers in scope + auto mode → empty-delta prover verdict → the
// first mechanistic submit auto-approves and merges the rule.
seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await;
let proposed_rule = NetworkPolicyRule {
name: "allow_example_8080".to_string(),
endpoints: vec![NetworkEndpoint {
host: "example.com".to_string(),
port: 8080,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
let submit_one = || {
let state = state.clone();
let sandbox_name = sandbox_name.clone();
let rule = proposed_rule.clone();
async move {
handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name,
analysis_mode: "mechanistic".to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "allow_example_8080".to_string(),
proposed_rule: Some(rule),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner()
}
};
// First submit: auto-approves the endpoint.
submit_one().await;
let after_first = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(after_first.chunks.len(), 1);
assert_eq!(
after_first.chunks[0].status, "approved",
"first mechanistic submit under auto mode must auto-approve; got {}",
after_first.chunks[0].status
);
// Second submit for the SAME endpoint: the dedup upsert returns the
// approved row's own id. The chunk must stay approved, not self-reject.
submit_one().await;
let after_second = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(
after_second.chunks.len(),
1,
"resubmit must dedup into the one existing row"
);
let chunk = &after_second.chunks[0];
assert_eq!(
chunk.status, "approved",
"resubmitting a mechanistic denial for an already-approved endpoint must not flip \
the approved chunk to rejected; got status {}",
chunk.status
);
assert!(
chunk.rejection_reason.is_empty(),
"approved chunk must carry no rejection reason; got {:?}",
chunk.rejection_reason
);
assert_eq!(
chunk.hit_count, 2,
"the redundant denial must fold into hit_count, not a status flip"
);
// The rule must remain merged in the active policy — the ledger and the
// enforced policy must agree.
let latest = state
.store
.get_latest_policy(sandbox_id)
.await
.unwrap()
.expect("auto-approve must have persisted a policy revision");
let policy = SandboxPolicy::decode(latest.policy_payload.as_slice()).unwrap();
assert!(
policy.network_policies.contains_key("allow_example_8080"),
"approved rule must stay merged after resubmit; keys: {:?}",
policy.network_policies.keys().collect::<Vec<_>>()
);
}
fn pending_draft_chunk(id: &str, sandbox_id: &str) -> DraftChunkRecord {
DraftChunkRecord {
id: id.to_string(),
sandbox_id: sandbox_id.to_string(),
draft_version: 1,
status: "pending".to_string(),
rule_name: "allow_endpoint".to_string(),
proposed_rule: Vec::new(),
rationale: String::new(),
security_notes: String::new(),
confidence: 0.5,
created_at_ms: 1_000,
decided_at_ms: None,
host: "10.0.0.5".to_string(),
port: 8080,
binary: "/usr/bin/curl".to_string(),
hit_count: 1,
first_seen_ms: 1_000,
last_seen_ms: 1_000,
validation_result: String::new(),
rejection_reason: String::new(),
..Default::default()
}
}
#[tokio::test]
async fn conditionally_reject_transitions_pending_chunk() {
let store = test_store().await;
store
.put_draft_chunk(
&pending_draft_chunk("cas-pending", "sb-cas-pending"),
None,
"default",
)
.await
.unwrap();
let changed = store
.conditionally_reject_draft_chunk("cas-pending", 5, "covered by approved chunk cover-1")
.await
.unwrap();
assert!(changed, "a pending chunk must transition to rejected");
let stored = store.get_draft_chunk("cas-pending").await.unwrap().unwrap();
assert_eq!(stored.status, "rejected");
assert_eq!(stored.decided_at_ms, Some(5));
assert_eq!(stored.rejection_reason, "covered by approved chunk cover-1");
}
#[tokio::test]
async fn conditionally_reject_leaves_approved_chunk_untouched() {
let store = test_store().await;
store
.put_draft_chunk(
&pending_draft_chunk("cas-approved", "sb-cas-approved"),
None,
"default",
)
.await
.unwrap();
assert!(
store
.update_draft_chunk_status("cas-approved", "approved", Some(1), None)
.await
.unwrap()
);
let changed = store
.conditionally_reject_draft_chunk("cas-approved", 2, "covered")
.await
.unwrap();
assert!(
!changed,
"an approved chunk must not be conditionally rejected"
);
let stored = store
.get_draft_chunk("cas-approved")
.await
.unwrap()
.unwrap();
assert_eq!(
stored.status, "approved",
"the approved status must survive a losing conditional reject"
);
assert!(stored.rejection_reason.is_empty());
}
#[tokio::test]
async fn conditionally_reject_loses_race_to_approval() {
let store = test_store().await;
store
.put_draft_chunk(
&pending_draft_chunk("cas-race", "sb-cas-race"),
None,
"default",
)
.await
.unwrap();
let observed = store.get_draft_chunk("cas-race").await.unwrap().unwrap();
assert_eq!(observed.status, "pending");
assert!(
store
.update_draft_chunk_status("cas-race", "approved", Some(10), None)
.await
.unwrap()
);
let changed = store
.conditionally_reject_draft_chunk("cas-race", 11, "covered")
.await
.unwrap();
assert!(!changed);
let stored = store.get_draft_chunk("cas-race").await.unwrap().unwrap();
assert_eq!(
stored.status, "approved",
"an approval that commits before the conditional reject must win; the ledger must \
never read rejected while the merged rule stays enforced"
);
}
/// Undo of an approve must clear any `rejection_reason` left over from a
/// prior reject. Without this, the in-sandbox agent reading chunks via
/// `policy.local` cannot tell "pending and never rejected" from "pending
/// but previously rejected with this stale guidance." The only path that
/// lands a non-empty reason on a pending chunk is reject → re-approve →
/// undo, so the test walks that sequence.
#[tokio::test]
async fn undo_after_reject_clears_stale_rejection_reason() {
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec};
let state = test_server_state().await;
let sandbox_name = "undo-clears-reason".to_string();
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-undo-clears".to_string(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
let proposed_rule = NetworkPolicyRule {
name: "allow_example".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.example.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
let submit = handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_name.clone(),
proposed_chunks: vec![PolicyChunk {
rule_name: "allow_example".to_string(),
proposed_rule: Some(proposed_rule),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
let chunk_id = submit.accepted_chunk_ids[0].clone();
let review_token = state
.store
.get_draft_chunk(&chunk_id)
.await
.unwrap()
.unwrap()
.review_token;
handle_reject_draft_chunk(
&state,
authed_request(RejectDraftChunkRequest {
request_id: String::new(),
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
chunk_id: chunk_id.clone(),
reason: "scope too broad".to_string(),
}),
)
.await
.unwrap();
handle_approve_draft_chunk(
&state,
authed_request(ApproveDraftChunkRequest {
request_id: String::new(),
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
chunk_id: chunk_id.clone(),
review_token,
}),
)
.await
.unwrap();
handle_undo_draft_chunk(
&state,
authed_request(UndoDraftChunkRequest {
request_id: String::new(),
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
chunk_id: chunk_id.clone(),
}),
)
.await
.unwrap();
let draft = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
let restored = draft
.chunks
.iter()
.find(|c| c.id == chunk_id)
.expect("chunk should still be present after undo");
assert_eq!(restored.status, "pending");
assert!(
restored.rejection_reason.is_empty(),
"undo must clear stale rejection_reason; got: {:?}",
restored.rejection_reason
);
// Wait for SQLx's SQLite worker threads to stop before the test process exits;
state.store.close_for_test().await;
}
#[tokio::test]
async fn draft_chunk_handlers_reject_cross_sandbox_chunk_ids() {
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec};
let state = test_server_state().await;
// Attach a github provider so the explicitly opted-in L4 proposal
// below has a credential in scope and stays pending, keeping this
// cross-sandbox approve check reachable.
state
.store
.put_message(&test_provider("github-pat", "github"))
.await
.unwrap();
let mut sandbox_a = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-draft-owner".to_string(),
name: "draft-owner".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
providers: vec!["github-pat".to_string()],
..Default::default()
}),
..Default::default()
};
sandbox_a.set_phase(SandboxPhase::Ready as i32);
let mut sandbox_b = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-draft-other".to_string(),
name: "draft-other".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_001).ok(),
labels: std::collections::HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
..Default::default()
}),
..Default::default()
};
sandbox_b.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox_a).await.unwrap();
state.store.put_message(&sandbox_b).await.unwrap();
let proposed_rule = NetworkPolicyRule {
name: "allow_github".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
allow_uninspected_credentials: true,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
handle_submit_policy_analysis(
&state,
with_user(Request::new(SubmitPolicyAnalysisRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: sandbox_a.object_name().to_string(),
proposed_chunks: vec![PolicyChunk {
rule_name: "allow_example".to_string(),
proposed_rule: Some(proposed_rule.clone()),
rationale: "observed denied request".to_string(),
confidence: 0.85,
hit_count: 3,
first_seen_time: openshell_core::time::timestamp_from_millis(100).ok(),
last_seen_time: openshell_core::time::timestamp_from_millis(200).ok(),
binary: "/usr/bin/curl".to_string(),
..Default::default()
}],
..Default::default()
})),
)
.await
.unwrap();
let draft_policy = handle_get_draft_policy(
&state,
with_user(Request::new(GetDraftPolicyRequest {
sandbox: sandbox_a.object_name().to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
status_filter: String::new(),
})),
)
.await
.unwrap()
.into_inner();
let chunk_id = draft_policy.chunks[0].id.clone();
let review_token = draft_policy.chunks[0].review_token.clone();
let other_name = sandbox_b.object_name().to_string();
let approve_err = handle_approve_draft_chunk(
&state,
authed_request(ApproveDraftChunkRequest {
request_id: String::new(),
sandbox: other_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
chunk_id: chunk_id.clone(),
review_token: String::new(),
}),
)
.await
.unwrap_err();
assert_eq!(approve_err.code(), Code::NotFound);
let reject_err = handle_reject_draft_chunk(
&state,
authed_request(RejectDraftChunkRequest {
request_id: String::new(),
sandbox: other_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
chunk_id: chunk_id.clone(),
reason: "wrong sandbox".to_string(),
}),
)
.await
.unwrap_err();
assert_eq!(reject_err.code(), Code::NotFound);
let edit_err = handle_edit_draft_chunk(
&state,
authed_request(EditDraftChunkRequest {
request_id: String::new(),
sandbox: other_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
chunk_id: chunk_id.clone(),
proposed_rule: Some(proposed_rule.clone()),
}),
)
.await
.unwrap_err();
assert_eq!(edit_err.code(), Code::NotFound);
handle_approve_draft_chunk(
&state,
authed_request(ApproveDraftChunkRequest {
request_id: String::new(),
sandbox: sandbox_a.object_name().to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
chunk_id: chunk_id.clone(),
review_token,
}),
)
.await
.unwrap();
let undo_err = handle_undo_draft_chunk(
&state,
authed_request(UndoDraftChunkRequest {
request_id: String::new(),
sandbox: other_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
chunk_id,
}),
)
.await
.unwrap_err();
assert_eq!(undo_err.code(), Code::NotFound);
// Wait for SQLx's SQLite worker threads to stop before the test process exits;
state.store.close_for_test().await;
}
#[test]
fn build_gateway_policy_audit_event_formats_ocsf_config_line() {
let message = build_gateway_policy_audit_event(
"sb-123",
"demo-sandbox",
"merged",
"gateway merged incremental policy op: add-allow api.github.com:443 [POST /repos/*/issues]",
7,
"sha256:testhash",
&[],
)
.format_shorthand();
assert_eq!(
message,
"CONFIG:MERGED [INFO] gateway merged incremental policy op: add-allow api.github.com:443 [POST /repos/*/issues] [version:v7 hash:sha256:testhash]"
);
}
/// Auto-approval audit messages carry `auto=true`, `source=<mode>`, and
/// `prover_delta=empty` as extra unmapped fields so a reviewer can
/// reconstruct the safety reasoning without needing to grep the chunk
/// table. The message text itself says "auto-approved: no new prover
/// findings" — never "safe" — because the claim is about the prover's
/// reasoning, not the world.
#[test]
fn build_gateway_policy_audit_event_carries_auto_approve_provenance() {
let extra = [
("auto", "true".to_string()),
("source", "agent_authored".to_string()),
("prover_delta", "empty".to_string()),
];
let message = build_gateway_policy_audit_event(
"sb-123",
"demo-sandbox",
"approved",
"auto-approved: no new prover findings (source=agent_authored) — chunk abc: add-rule x",
12,
"sha256:autohash",
&extra,
)
.format_shorthand();
assert!(
message.contains("CONFIG:APPROVED"),
"auto-approval reuses CONFIG:APPROVED; got: {message}"
);
assert!(
message.contains("auto-approved: no new prover findings"),
"audit copy must say `no new prover findings`, not `safe`; got: {message}"
);
assert!(
message.contains("auto:true"),
"missing auto field: {message}"
);
assert!(
message.contains("source:agent_authored"),
"missing source field: {message}"
);
assert!(
message.contains("prover_delta:empty"),
"missing prover_delta field: {message}"
);
}
#[tokio::test]
async fn gateway_policy_audit_events_reach_ocsf_jsonl() {
use tracing_subscriber::prelude::*;
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("events.jsonl");
let config = toml::from_str(&format!(
"path = {:?}\nrotation = 'never'\n",
path.display().to_string()
))
.unwrap();
let log = crate::ocsf_log::OcsfLog::start(config).unwrap();
let subscriber = tracing_subscriber::registry().with(log.layer());
tracing::subscriber::with_default(subscriber, || {
emit_gateway_policy_audit_log(
"sb-123",
"demo-sandbox",
"approved",
"approved chunk abc",
7,
"sha256:manual",
);
emit_gateway_policy_auto_approve_audit_log(
"sb-123",
"demo-sandbox",
"auto-approved: no new prover findings",
8,
"sha256:auto",
"mechanistic",
"gateway",
);
});
log.shutdown().await;
let events: Vec<serde_json::Value> = std::fs::read_to_string(path)
.unwrap()
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
assert_eq!(events.len(), 2);
for event in &events {
assert_eq!(event["metadata"]["product"]["name"], "OpenShell Gateway");
assert_eq!(event["container"]["uid"], "sb-123");
assert!(event["container"].get("image").is_none());
}
assert_eq!(events[0]["unmapped"]["policy_hash"], "sha256:manual");
assert!(events[0]["unmapped"].get("auto").is_none());
assert_eq!(events[1]["unmapped"]["policy_hash"], "sha256:auto");
assert_eq!(events[1]["unmapped"]["auto"], "true");
assert_eq!(events[1]["unmapped"]["resolved_from"], "gateway");
}
#[test]
fn summarize_cli_policy_merge_op_formats_rest_allow_rules() {
let operation = PolicyMergeOp::AddAllowRules {
target: L7RuleTarget {
rule_name: "github".to_string(),
host: "api.github.com".to_string(),
ports: vec![443],
path: Some(String::new()),
binaries: L7BinaryScope::Restricted(vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}]),
},
rules: vec![L7Rule {
allow: Some(openshell_core::proto::L7Allow {
method: "POST".to_string(),
path: "/repos/*/issues".to_string(),
command: String::new(),
query: HashMap::new(),
operation_type: String::new(),
operation_name: String::new(),
fields: Vec::new(),
params: HashMap::default(),
}),
}],
};
assert_eq!(
summarize_cli_policy_merge_op(&operation),
"add-allow api.github.com:443 rule=github endpoint-path=Some(\"\") binaries=[/usr/bin/curl] [POST /repos/*/issues]"
);
}
#[test]
fn summarize_cli_policy_merge_op_formats_endpoint_additions() {
let operation = PolicyMergeOp::AddRule {
rule_name: "github_api".to_string(),
rule: NetworkPolicyRule {
name: "github_api".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
protocol: "rest".to_string(),
access: openshell_core::proto::NetworkAccessPreset::ReadOnly as i32,
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
},
};
assert_eq!(
summarize_cli_policy_merge_op(&operation),
"add-endpoint github_api endpoints=[api.github.com:443 protocol=rest access=read-only enforcement=enforce] binaries=[/usr/bin/curl]"
);
}
#[test]
fn summarize_cli_policy_merge_op_formats_websocket_credential_rewrite() {
let operation = PolicyMergeOp::AddRule {
rule_name: "realtime_api".to_string(),
rule: NetworkPolicyRule {
name: "realtime_api".to_string(),
endpoints: vec![NetworkEndpoint {
host: "realtime.example.com".to_string(),
port: 443,
protocol: "websocket".to_string(),
access: openshell_core::proto::NetworkAccessPreset::ReadWrite as i32,
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
websocket_credential_rewrite: true,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/node".to_string(),
}],
},
};
assert_eq!(
summarize_cli_policy_merge_op(&operation),
"add-endpoint realtime_api endpoints=[realtime.example.com:443 protocol=websocket access=read-write enforcement=enforce websocket_credential_rewrite=true] binaries=[/usr/bin/node]"
);
}
#[test]
fn summarize_cli_policy_merge_op_formats_request_body_credential_rewrite() {
let operation = PolicyMergeOp::AddRule {
rule_name: "slack_api".to_string(),
rule: NetworkPolicyRule {
name: "slack_api".to_string(),
endpoints: vec![NetworkEndpoint {
host: "slack.com".to_string(),
port: 443,
protocol: "rest".to_string(),
access: openshell_core::proto::NetworkAccessPreset::ReadWrite as i32,
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
request_body_credential_rewrite: true,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/node".to_string(),
}],
},
};
assert_eq!(
summarize_cli_policy_merge_op(&operation),
"add-endpoint slack_api endpoints=[slack.com:443 protocol=rest access=read-write enforcement=enforce request_body_credential_rewrite=true] binaries=[/usr/bin/node]"
);
}
// ---- merge_chunk_into_policy ----
#[tokio::test]
async fn merge_chunk_into_policy_adds_first_network_rule_to_empty_policy() {
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule};
let store = test_store().await;
let rule = NetworkPolicyRule {
name: "google".to_string(),
endpoints: vec![NetworkEndpoint {
host: "google.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
let chunk = DraftChunkRecord {
id: "chunk-1".to_string(),
sandbox_id: "sb-empty".to_string(),
draft_version: 1,
status: "pending".to_string(),
rule_name: "google".to_string(),
proposed_rule: rule.encode_to_vec(),
rationale: String::new(),
security_notes: String::new(),
confidence: 1.0,
created_at_ms: 0,
decided_at_ms: None,
host: "google.com".to_string(),
port: 443,
binary: "/usr/bin/curl".to_string(),
hit_count: 1,
first_seen_ms: 0,
last_seen_ms: 0,
validation_result: String::new(),
rejection_reason: String::new(),
..Default::default()
};
let (version, _) =
merge_chunk_into_policy(&store, &chunk.sandbox_id, "default", &chunk, &[])
.await
.unwrap();
assert_eq!(version, 1);
let latest = store
.get_latest_policy(&chunk.sandbox_id)
.await
.unwrap()
.expect("policy revision should be persisted");
let policy = openshell_core::proto::SandboxPolicy::decode(latest.policy_payload.as_slice())
.expect("policy payload should decode");
let stored_rule = policy
.network_policies
.get("google")
.expect("merged rule should be present");
assert_eq!(stored_rule.endpoints[0].host, "google.com");
assert_eq!(stored_rule.endpoints[0].port, 443);
assert_eq!(stored_rule.binaries[0].path, "/usr/bin/curl");
}
#[tokio::test]
async fn merge_chunk_merges_into_existing_rule_by_host_port() {
use openshell_core::proto::{
NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy,
};
let store = test_store().await;
let sandbox_id = "sb-merge";
let initial_policy = SandboxPolicy {
network_policies: std::iter::once((
"test_server".to_string(),
NetworkPolicyRule {
name: "test_server".to_string(),
endpoints: vec![NetworkEndpoint {
host: "192.168.1.100".to_string(),
port: 8567,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
},
))
.collect(),
..Default::default()
};
store
.put_policy_revision(
"p-seed",
sandbox_id,
"default",
1,
&initial_policy.encode_to_vec(),
"seed-hash",
)
.await
.unwrap();
let proposed = NetworkPolicyRule {
name: "allow_192_168_1_100_8567".to_string(),
endpoints: vec![NetworkEndpoint {
host: "192.168.1.100".to_string(),
port: 8567,
allowed_ips: vec!["192.168.1.100".to_string()],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
let chunk = DraftChunkRecord {
id: "chunk-merge".to_string(),
sandbox_id: sandbox_id.to_string(),
draft_version: 1,
status: "pending".to_string(),
rule_name: "allow_192_168_1_100_8567".to_string(),
proposed_rule: proposed.encode_to_vec(),
rationale: String::new(),
security_notes: String::new(),
confidence: 0.3,
created_at_ms: 0,
decided_at_ms: None,
host: "192.168.1.100".to_string(),
port: 8567,
binary: "/usr/bin/curl".to_string(),
hit_count: 1,
first_seen_ms: 0,
last_seen_ms: 0,
validation_result: String::new(),
rejection_reason: String::new(),
..Default::default()
};
let (version, _) = merge_chunk_into_policy(&store, sandbox_id, "default", &chunk, &[])
.await
.unwrap();
assert_eq!(version, 2);
let latest = store
.get_latest_policy(sandbox_id)
.await
.unwrap()
.expect("policy revision should be persisted");
let policy = SandboxPolicy::decode(latest.policy_payload.as_slice()).unwrap();
assert_eq!(
policy.network_policies.len(),
1,
"expected 1 rule, got {}: {:?}",
policy.network_policies.len(),
policy.network_policies.keys().collect::<Vec<_>>()
);
let rule = policy
.network_policies
.get("test_server")
.expect("original rule name 'test_server' should be preserved");
assert_eq!(rule.endpoints[0].host, "192.168.1.100");
assert_eq!(rule.endpoints[0].allowed_ips, vec!["192.168.1.100"]);
}
#[tokio::test]
async fn merge_chunk_new_host_port_inserts_new_entry() {
use openshell_core::proto::{
NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy,
};
let store = test_store().await;
let sandbox_id = "sb-new";
let initial_policy = SandboxPolicy {
network_policies: std::iter::once((
"existing_rule".to_string(),
NetworkPolicyRule {
name: "existing_rule".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.example.com".to_string(),
port: 443,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
},
))
.collect(),
..Default::default()
};
store
.put_policy_revision(
"p-seed",
sandbox_id,
"default",
1,
&initial_policy.encode_to_vec(),
"seed-hash",
)
.await
.unwrap();
let proposed = NetworkPolicyRule {
name: "allow_10_0_0_5_8080".to_string(),
endpoints: vec![NetworkEndpoint {
host: "10.0.0.5".to_string(),
port: 8080,
allowed_ips: vec!["10.0.0.5".to_string()],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
};
let chunk = DraftChunkRecord {
id: "chunk-new".to_string(),
sandbox_id: sandbox_id.to_string(),
draft_version: 1,
status: "pending".to_string(),
rule_name: "allow_10_0_0_5_8080".to_string(),
proposed_rule: proposed.encode_to_vec(),
rationale: String::new(),
security_notes: String::new(),
confidence: 0.3,
created_at_ms: 0,
decided_at_ms: None,
host: "10.0.0.5".to_string(),
port: 8080,
binary: "/usr/bin/curl".to_string(),
hit_count: 1,
first_seen_ms: 0,
last_seen_ms: 0,
validation_result: String::new(),
rejection_reason: String::new(),
..Default::default()
};
let (version, _) = merge_chunk_into_policy(&store, sandbox_id, "default", &chunk, &[])
.await
.unwrap();
assert_eq!(version, 2);
let latest = store.get_latest_policy(sandbox_id).await.unwrap().unwrap();
let policy = SandboxPolicy::decode(latest.policy_payload.as_slice()).unwrap();
assert_eq!(policy.network_policies.len(), 2);
assert!(policy.network_policies.contains_key("existing_rule"));
assert!(policy.network_policies.contains_key("allow_10_0_0_5_8080"));
}
#[tokio::test]
async fn concurrent_merge_batches_preserve_both_updates() {
use openshell_core::proto::{
L7Allow, L7DenyRule, L7Rule, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy,
};
let store = test_store().await;
let sandbox_id = "sb-concurrent-merge";
let initial_policy = SandboxPolicy {
network_policies: std::iter::once((
"github".to_string(),
NetworkPolicyRule {
name: "github".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
ports: vec![443],
protocol: "rest".to_string(),
access: openshell_core::proto::NetworkAccessPreset::ReadOnly as i32,
..Default::default()
}],
..Default::default()
},
))
.collect(),
..Default::default()
};
store
.put_policy_revision(
"p-seed",
sandbox_id,
"default",
1,
&initial_policy.encode_to_vec(),
"seed-hash",
)
.await
.unwrap();
let add_allow = [PolicyMergeOp::AddAllowRules {
target: L7RuleTarget {
rule_name: "github".to_string(),
host: "api.github.com".to_string(),
ports: vec![443],
path: None,
binaries: L7BinaryScope::Any,
},
rules: vec![L7Rule {
allow: Some(L7Allow {
method: "POST".to_string(),
path: "/repos/*/issues".to_string(),
command: String::new(),
query: HashMap::new(),
operation_type: String::new(),
operation_name: String::new(),
fields: Vec::new(),
params: HashMap::default(),
}),
}],
}];
let add_deny = [PolicyMergeOp::AddDenyRules {
target: L7RuleTarget {
rule_name: "github".to_string(),
host: "api.github.com".to_string(),
ports: vec![443],
path: None,
binaries: L7BinaryScope::Any,
},
deny_rules: vec![L7DenyRule {
method: "POST".to_string(),
path: "/admin".to_string(),
query: HashMap::new(),
..Default::default()
}],
}];
let (left, right) = tokio::join!(
apply_merge_operations_with_retry(
&store,
sandbox_id,
"default",
None,
&add_allow,
PolicyMergeValidationContext {
provider_layers: &[],
credential_binding: None,
},
None,
None
),
apply_merge_operations_with_retry(
&store,
sandbox_id,
"default",
None,
&add_deny,
PolicyMergeValidationContext {
provider_layers: &[],
credential_binding: None,
},
None,
None
),
);
let mut versions = vec![left.unwrap().0, right.unwrap().0];
versions.sort_unstable();
assert_eq!(versions, vec![2, 3]);
let latest = store.get_latest_policy(sandbox_id).await.unwrap().unwrap();
assert_eq!(latest.version, 3);
let policy = SandboxPolicy::decode(latest.policy_payload.as_slice()).unwrap();
let endpoint = &policy.network_policies["github"].endpoints[0];
assert!(endpoint.access == 0);
assert_eq!(endpoint.rules.len(), 4);
assert_eq!(endpoint.deny_rules.len(), 1);
assert_eq!(endpoint.deny_rules[0].path, "/admin");
}
// ---- validate_rule_not_always_blocked ----
#[test]
fn validate_rule_rejects_loopback_allowed_ips() {
use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule};
let rule = NetworkPolicyRule {
name: "bad".to_string(),
endpoints: vec![NetworkEndpoint {
host: "example.com".to_string(),
port: 80,
allowed_ips: vec!["127.0.0.1".to_string()],
..Default::default()
}],
..Default::default()
};
let result = validate_rule_not_always_blocked(&rule);
assert!(result.is_err());
let status = result.unwrap_err();
assert_eq!(status.code(), Code::InvalidArgument);
assert!(status.message().contains("always-blocked"));
}
#[test]
fn validate_rule_rejects_link_local_allowed_ips() {
use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule};
let rule = NetworkPolicyRule {
name: "bad".to_string(),
endpoints: vec![NetworkEndpoint {
host: "example.com".to_string(),
port: 80,
allowed_ips: vec!["169.254.169.254".to_string()],
..Default::default()
}],
..Default::default()
};
let result = validate_rule_not_always_blocked(&rule);
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("always-blocked"));
}
#[test]
fn validate_rule_rejects_always_blocked_host() {
use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule};
let rule = NetworkPolicyRule {
name: "bad".to_string(),
endpoints: vec![NetworkEndpoint {
host: "127.0.0.1".to_string(),
port: 80,
..Default::default()
}],
..Default::default()
};
let result = validate_rule_not_always_blocked(&rule);
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("always-blocked"));
}
#[test]
fn validate_rule_rejects_localhost_host() {
use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule};
let rule = NetworkPolicyRule {
name: "bad".to_string(),
endpoints: vec![NetworkEndpoint {
host: "localhost".to_string(),
port: 8080,
..Default::default()
}],
..Default::default()
};
let result = validate_rule_not_always_blocked(&rule);
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("always blocked"));
}
#[test]
fn validate_rule_rejects_known_metadata_hostname() {
use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule};
let rule = NetworkPolicyRule {
name: "bad".to_string(),
endpoints: vec![NetworkEndpoint {
host: "METADATA.GOOGLE.INTERNAL.".to_string(),
port: 80,
..Default::default()
}],
..Default::default()
};
let result = validate_rule_not_always_blocked(&rule);
assert!(result.is_err());
let status = result.unwrap_err();
assert_eq!(status.code(), Code::InvalidArgument);
assert!(status.message().contains("cloud metadata hostname"));
}
#[test]
fn validate_merge_operations_rejects_add_allow_for_known_metadata_hostname() {
let operation = PolicyMergeOp::AddAllowRules {
target: L7RuleTarget {
rule_name: "metadata".to_string(),
host: "metadata.google.internal".to_string(),
ports: vec![80],
path: None,
binaries: L7BinaryScope::Any,
},
rules: vec![L7Rule {
allow: Some(openshell_core::proto::L7Allow {
method: "GET".to_string(),
path: "/computeMetadata/v1/**".to_string(),
command: String::new(),
query: HashMap::new(),
operation_type: String::new(),
operation_name: String::new(),
fields: Vec::new(),
params: HashMap::default(),
}),
}],
};
let result = validate_merge_operations_for_server(&[operation]);
assert!(result.is_err());
let status = result.unwrap_err();
assert_eq!(status.code(), Code::InvalidArgument);
assert!(status.message().contains("cloud metadata hostname"));
}
#[test]
fn validate_rule_accepts_rfc1918_allowed_ips() {
use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule};
let rule = NetworkPolicyRule {
name: "good".to_string(),
endpoints: vec![NetworkEndpoint {
host: "internal.corp".to_string(),
port: 443,
allowed_ips: vec!["10.0.5.0/24".to_string()],
..Default::default()
}],
..Default::default()
};
let result = validate_rule_not_always_blocked(&rule);
assert!(result.is_ok());
}
#[test]
fn validate_rule_accepts_public_host() {
use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule};
let rule = NetworkPolicyRule {
name: "good".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
..Default::default()
}],
..Default::default()
};
let result = validate_rule_not_always_blocked(&rule);
assert!(result.is_ok());
}
// ---- Settings tests ----
#[test]
fn merge_effective_settings_includes_unset_registered_keys() {
let global = StoredSettings::default();
let sandbox = StoredSettings::default();
let merged = merge_effective_settings(&global, &sandbox).unwrap();
for registered in settings::REGISTERED_SETTINGS {
let setting = merged
.get(registered.key)
.unwrap_or_else(|| panic!("missing registered key {}", registered.key));
assert!(
setting.value.is_none(),
"expected unset value for {}",
registered.key
);
assert_eq!(setting.scope, SettingScope::Unspecified as i32);
}
}
#[test]
fn materialize_global_settings_includes_unset_registered_keys() {
let global = StoredSettings::default();
let materialized = materialize_global_settings(&global).unwrap();
for registered in settings::REGISTERED_SETTINGS {
let setting = materialized
.get(registered.key)
.unwrap_or_else(|| panic!("missing registered key {}", registered.key));
assert!(
setting.value.is_none(),
"expected unset value for {}",
registered.key
);
}
}
#[test]
fn decode_policy_from_global_settings_round_trip() {
let policy = openshell_core::proto::SandboxPolicy {
version: 7,
..Default::default()
};
let encoded = hex::encode(policy.encode_to_vec());
let global = StoredSettings {
revision: 1,
settings: std::iter::once(("policy".to_string(), StoredSettingValue::Bytes(encoded)))
.collect(),
..Default::default()
};
let decoded = decode_policy_from_global_settings(&global)
.unwrap()
.expect("policy present");
assert_eq!(decoded.version, 7);
}
#[test]
fn decode_policy_from_global_settings_validates_and_canonicalizes_stored_policy() {
let invalid = mcp_policy_with_versions(&["latest"]);
let invalid_global = StoredSettings {
revision: 1,
settings: std::iter::once((
POLICY_SETTING_KEY.to_string(),
StoredSettingValue::Bytes(hex::encode(invalid.encode_to_vec())),
))
.collect(),
..Default::default()
};
let error = decode_policy_from_global_settings(&invalid_global)
.expect_err("invalid global policy must fail closed");
assert_eq!(error.code(), Code::FailedPrecondition);
assert!(error.message().contains(STORED_POLICY_SOURCE_GLOBAL));
let reversed = mcp_policy_with_versions(&["2025-11-25", "2025-06-18", "2025-03-26"]);
let canonical = validate_and_canonicalize_policy(reversed.clone())
.expect("supported global policy must canonicalize");
let valid_global = StoredSettings {
revision: 1,
settings: std::iter::once((
POLICY_SETTING_KEY.to_string(),
StoredSettingValue::Bytes(hex::encode(reversed.encode_to_vec())),
))
.collect(),
..Default::default()
};
assert_eq!(
decode_policy_from_global_settings(&valid_global)
.expect("valid global policy")
.expect("global policy present"),
canonical
);
}
#[test]
fn config_revision_changes_when_effective_setting_changes() {
let policy = ProtoSandboxPolicy::default();
let mut settings = HashMap::new();
settings.insert(
"mode".to_string(),
EffectiveSetting {
value: Some(SettingValue {
value: Some(setting_value::Value::StringValue("strict".to_string())),
}),
scope: SettingScope::Sandbox.into(),
},
);
let rev_a = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]);
settings.insert(
"mode".to_string(),
EffectiveSetting {
value: Some(SettingValue {
value: Some(setting_value::Value::StringValue("relaxed".to_string())),
}),
scope: SettingScope::Sandbox.into(),
},
);
let rev_b = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]);
assert_ne!(rev_a, rev_b);
}
#[test]
fn proto_setting_to_stored_rejects_unknown_key() {
let value = SettingValue {
value: Some(setting_value::Value::StringValue("hello".to_string())),
};
let err = proto_setting_to_stored("unknown_key", &value).unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
assert!(err.message().contains("unknown setting key"));
}
#[test]
fn proto_setting_to_stored_rejects_type_mismatch() {
let value = SettingValue {
value: Some(setting_value::Value::StringValue("true".to_string())),
};
let err = proto_setting_to_stored("ocsf_json_enabled", &value).unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
assert!(err.message().contains("expects bool value"));
}
#[test]
fn proto_setting_to_stored_accepts_bool_for_registered_bool_key() {
let value = SettingValue {
value: Some(setting_value::Value::BoolValue(true)),
};
let stored = proto_setting_to_stored("ocsf_json_enabled", &value).unwrap();
assert_eq!(stored, StoredSettingValue::Bool(true));
}
#[test]
fn proto_setting_to_stored_accepts_allowed_proposal_approval_mode_values() {
for raw in ["manual", "auto"] {
let value = SettingValue {
value: Some(setting_value::Value::StringValue(raw.to_string())),
};
let stored = proto_setting_to_stored(settings::PROPOSAL_APPROVAL_MODE_KEY, &value)
.unwrap_or_else(|e| panic!("expected '{raw}' to be accepted, got: {e}"));
assert_eq!(stored, StoredSettingValue::String(raw.to_string()));
}
}
#[test]
fn proto_setting_to_stored_rejects_invalid_proposal_approval_mode_value() {
// Typos and future-reserved modes must be rejected at configure time
// — without this, the value silently resolves to manual at runtime
// (fail-closed) and the operator never finds out they fat-fingered
// the setting.
for raw in ["autom", "AUTO", "Manual", "auto_on_low_risk", "", " auto"] {
let value = SettingValue {
value: Some(setting_value::Value::StringValue(raw.to_string())),
};
let res = proto_setting_to_stored(settings::PROPOSAL_APPROVAL_MODE_KEY, &value);
assert!(
res.is_err(),
"expected '{raw}' to be rejected, got: {res:?}"
);
let err = res.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
}
}
#[test]
fn proto_setting_to_stored_rejection_message_lists_allowed_proposal_approval_mode_values() {
let value = SettingValue {
value: Some(setting_value::Value::StringValue("autom".to_string())),
};
let err =
proto_setting_to_stored(settings::PROPOSAL_APPROVAL_MODE_KEY, &value).unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
let msg = err.message();
assert!(msg.contains("manual"), "missing 'manual' in {msg}");
assert!(msg.contains("auto"), "missing 'auto' in {msg}");
assert!(msg.contains("autom"), "missing offending value in {msg}");
}
/// Locks in that invalid `proposal_approval_mode` is rejected at the
/// `UpdateConfig` RPC boundary — not just in the `proto_setting_to_stored`
/// helper. Prevents a future refactor from accidentally routing setting
/// writes around the validation chokepoint.
#[tokio::test]
async fn update_config_global_rejects_invalid_proposal_approval_mode() {
let state = test_server_state().await;
let req = with_user(Request::new(UpdateConfigRequest {
global: true,
setting_key: settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(),
setting_value: Some(SettingValue {
value: Some(setting_value::Value::StringValue("autom".to_string())),
}),
..Default::default()
}));
let err = handle_update_config(&state, req)
.await
.expect_err("invalid proposal_approval_mode must be rejected at UpdateConfig");
assert_eq!(err.code(), Code::InvalidArgument);
assert!(
err.message().contains("autom") && err.message().contains("manual"),
"expected rejection message to echo the bad value and list allowed values; got: {}",
err.message()
);
}
#[tokio::test]
async fn update_config_global_policy_rejects_reserved_provider_key() {
let state = test_server_state().await;
let err = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
global: true,
policy: Some(test_policy_with_rule(
"_provider_work_github",
"api.github.com",
)),
..Default::default()
})),
)
.await
.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
assert!(err.message().contains("_provider_work_github"));
assert!(err.message().contains("reserved '_provider_' prefix"));
}
#[tokio::test]
async fn update_config_global_policy_rejects_ambiguity_before_persisting() {
let state = test_server_state().await;
let error = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
global: true,
policy: Some(test_ambiguous_policy()),
..Default::default()
})),
)
.await
.expect_err("ambiguous global policy must fail before persistence");
assert_eq!(error.code(), Code::FailedPrecondition);
assert!(
state
.store
.get_latest_policy(GLOBAL_POLICY_SANDBOX_ID)
.await
.unwrap()
.is_none()
);
let settings = load_global_settings(state.store.as_ref()).await.unwrap();
assert!(!settings.settings.contains_key(POLICY_SETTING_KEY));
}
async fn install_ambiguous_provider_binding(state: &Arc<ServerState>, suffix: &str) {
use openshell_core::proto::{ProviderProfile, ProviderProfileCategory};
let profile_name = format!("ambiguous-{suffix}");
let provider_name = format!("provider-{suffix}");
state
.store
.put_message(&StoredProviderProfile {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: format!("profile-{suffix}"),
name: profile_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
profile: Some(ProviderProfile {
id: profile_name.clone(),
display_name: "Ambiguous".to_string(),
category: ProviderProfileCategory::Other as i32,
endpoints: vec![NetworkEndpoint {
host: "api.example.com".to_string(),
port: 443,
tls: openshell_core::proto::NetworkTlsMode::Skip as i32,
..Default::default()
}],
..Default::default()
}),
})
.await
.unwrap();
state
.store
.put_message(&test_provider(&provider_name, &profile_name))
.await
.unwrap();
state
.store
.put_message(&test_sandbox(
&format!("sandbox-{suffix}"),
&format!("sandbox-{suffix}"),
test_policy_with_rule("base", "api.example.com"),
vec![provider_name],
))
.await
.unwrap();
}
#[tokio::test]
async fn deleting_global_policy_rejects_reactivated_ambiguous_provider_binding() {
let state = test_server_state().await;
install_ambiguous_provider_binding(&state, "delete-policy").await;
handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
global: true,
policy: Some(test_policy_with_rule("global", "global.example.com")),
..Default::default()
})),
)
.await
.expect("global policy should suppress provider composition");
let error = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
global: true,
setting_key: POLICY_SETTING_KEY.to_string(),
delete_setting: true,
..Default::default()
})),
)
.await
.expect_err("global policy deletion must validate reactivated provider composition");
assert_eq!(error.code(), Code::FailedPrecondition);
assert!(error.message().contains("sandbox-delete-policy"));
let settings = load_global_settings(state.store.as_ref()).await.unwrap();
assert!(settings.settings.contains_key(POLICY_SETTING_KEY));
}
#[tokio::test]
async fn startup_preflight_rejects_persisted_ambiguous_provider_binding() {
let state = test_server_state().await;
install_ambiguous_provider_binding(&state, "upgrade").await;
let error = validate_provider_composition_startup_preflight(&state)
.await
.expect_err("startup must reject policy that unconditional composition would activate");
assert_eq!(error.code(), Code::FailedPrecondition);
assert!(error.message().contains("sandbox-upgrade"));
assert!(error.message().contains("invalid effective policy"));
}
#[test]
fn merge_effective_settings_global_overrides_sandbox_key() {
let global = StoredSettings {
revision: 2,
settings: std::iter::once((
settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY.to_string(),
StoredSettingValue::Bool(false),
))
.collect(),
..Default::default()
};
let sandbox = StoredSettings {
revision: 1,
settings: [
(
settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY.to_string(),
StoredSettingValue::Bool(true),
),
(
"ocsf_json_enabled".to_string(),
StoredSettingValue::Bool(true),
),
]
.into_iter()
.collect(),
..Default::default()
};
let merged = merge_effective_settings(&global, &sandbox).unwrap();
let ocsf_json = merged
.get("ocsf_json_enabled")
.expect("ocsf_json_enabled present");
assert_eq!(ocsf_json.scope, SettingScope::Sandbox as i32);
let proposals = merged
.get(settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY)
.expect("agent_policy_proposals_enabled present");
assert_eq!(proposals.scope, SettingScope::Global as i32);
}
#[test]
fn merge_effective_settings_sandbox_scoped_value_has_sandbox_scope() {
let global = StoredSettings::default();
let sandbox = StoredSettings {
revision: 1,
settings: std::iter::once((
"ocsf_json_enabled".to_string(),
StoredSettingValue::Bool(true),
))
.collect(),
..Default::default()
};
let merged = merge_effective_settings(&global, &sandbox).unwrap();
let ocsf_json = merged
.get("ocsf_json_enabled")
.expect("ocsf_json_enabled present");
assert_eq!(ocsf_json.scope, SettingScope::Sandbox as i32);
assert!(ocsf_json.value.is_some());
}
#[test]
fn merge_effective_settings_unset_key_has_unspecified_scope_and_no_value() {
let global = StoredSettings::default();
let sandbox = StoredSettings::default();
let merged = merge_effective_settings(&global, &sandbox).unwrap();
for registered in settings::REGISTERED_SETTINGS {
let setting = merged.get(registered.key).unwrap();
assert_eq!(setting.scope, SettingScope::Unspecified as i32);
assert!(setting.value.is_none());
}
}
#[test]
fn merge_effective_settings_policy_key_is_excluded() {
let global = StoredSettings {
revision: 1,
settings: std::iter::once((
"policy".to_string(),
StoredSettingValue::Bytes("deadbeef".to_string()),
))
.collect(),
..Default::default()
};
let sandbox = StoredSettings {
revision: 1,
settings: std::iter::once((
"policy".to_string(),
StoredSettingValue::Bytes("cafebabe".to_string()),
))
.collect(),
..Default::default()
};
let merged = merge_effective_settings(&global, &sandbox).unwrap();
assert!(!merged.contains_key("policy"));
}
#[test]
fn sandbox_settings_names_match_sandbox_names() {
let sandbox_name = "my-sandbox";
assert_eq!(sandbox_name, "my-sandbox");
}
// ---- compute_config_revision ----
#[test]
fn config_revision_stable_when_nothing_changes() {
let policy = ProtoSandboxPolicy::default();
let mut settings = HashMap::new();
settings.insert(
"log_level".to_string(),
EffectiveSetting {
value: Some(SettingValue {
value: Some(setting_value::Value::StringValue("info".to_string())),
}),
scope: SettingScope::Sandbox.into(),
},
);
let rev_a = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]);
let rev_b = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]);
assert_eq!(rev_a, rev_b);
}
#[test]
fn config_revision_changes_when_policy_changes() {
let policy_a = ProtoSandboxPolicy {
version: 1,
..Default::default()
};
let policy_b = ProtoSandboxPolicy {
version: 2,
..Default::default()
};
let settings = HashMap::new();
let rev_a = compute_config_revision(Some(&policy_a), &settings, PolicySource::Sandbox, &[]);
let rev_b = compute_config_revision(Some(&policy_b), &settings, PolicySource::Sandbox, &[]);
assert_ne!(rev_a, rev_b);
}
#[test]
fn policy_hash_changes_when_network_middlewares_change() {
let policy_a = ProtoSandboxPolicy::default();
let policy_b = ProtoSandboxPolicy {
network_middlewares: HashMap::from([(
"regex-redactor".into(),
openshell_core::proto::NetworkMiddlewareConfig {
middleware: "openshell/regex".into(),
on_error: "fail_closed".into(),
..Default::default()
},
)]),
..Default::default()
};
assert_ne!(
deterministic_policy_hash(&policy_a),
deterministic_policy_hash(&policy_b),
"middleware-only policy changes must produce a new policy hash"
);
}
#[test]
fn policy_hash_is_stable_across_middleware_config_field_insertion_order() {
use prost_types::{Struct, Value, value::Kind};
use std::collections::BTreeMap;
fn string_value(value: &str) -> Value {
Value {
kind: Some(Kind::StringValue(value.into())),
}
}
fn middleware_config(reverse: bool) -> Struct {
let mut nested = BTreeMap::new();
let mut fields = BTreeMap::new();
if reverse {
nested.insert("second".into(), string_value("two"));
nested.insert("first".into(), string_value("one"));
fields.insert(
"nested".into(),
Value {
kind: Some(Kind::StructValue(Struct { fields: nested })),
},
);
fields.insert("mode".into(), string_value("redact"));
} else {
nested.insert("first".into(), string_value("one"));
nested.insert("second".into(), string_value("two"));
fields.insert("mode".into(), string_value("redact"));
fields.insert(
"nested".into(),
Value {
kind: Some(Kind::StructValue(Struct { fields: nested })),
},
);
}
Struct { fields }
}
let policy = |reverse| ProtoSandboxPolicy {
network_middlewares: HashMap::from([(
"regex-redactor".into(),
openshell_core::proto::NetworkMiddlewareConfig {
middleware: "openshell/regex".into(),
config: Some(middleware_config(reverse)),
..Default::default()
},
)]),
..Default::default()
};
assert_eq!(
deterministic_policy_hash(&policy(false)),
deterministic_policy_hash(&policy(true)),
"equivalent middleware configs must hash identically regardless of field insertion order"
);
}
#[test]
fn review_token_and_policy_hash_are_stable_across_nested_proto_map_order() {
use openshell_core::proto::{GraphqlOperation, L7Allow, L7QueryMatcher};
fn matcher(value: &str) -> L7QueryMatcher {
L7QueryMatcher {
glob: value.to_string(),
any: Vec::new(),
}
}
fn rule(reverse: bool) -> NetworkPolicyRule {
let mut query = HashMap::new();
let mut params = HashMap::new();
let mut persisted = HashMap::new();
let entries = if reverse {
[("zeta", "two"), ("alpha", "one")]
} else {
[("alpha", "one"), ("zeta", "two")]
};
for (key, value) in entries {
query.insert(key.to_string(), matcher(value));
params.insert(key.to_string(), matcher(value));
persisted.insert(
key.to_string(),
GraphqlOperation {
operation_type: "query".to_string(),
operation_name: value.to_string(),
fields: vec![value.to_string()],
},
);
}
NetworkPolicyRule {
name: "mapped".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.example.com".to_string(),
port: 443,
ports: vec![443],
protocol: "graphql".to_string(),
rules: vec![L7Rule {
allow: Some(L7Allow {
method: "POST".to_string(),
path: "/graphql".to_string(),
query,
params,
operation_type: "query".to_string(),
..Default::default()
}),
}],
graphql_persisted_queries: persisted,
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_string(),
}],
}
}
let left_rule = rule(false);
let right_rule = rule(true);
assert_eq!(left_rule, right_rule);
let left_policy = ProtoSandboxPolicy {
network_policies: HashMap::from([("mapped".to_string(), left_rule.clone())]),
..Default::default()
};
let right_policy = ProtoSandboxPolicy {
network_policies: HashMap::from([("mapped".to_string(), right_rule.clone())]),
..Default::default()
};
assert_eq!(
deterministic_policy_hash(&left_policy),
deterministic_policy_hash(&right_policy)
);
assert_eq!(
compute_proposal_review_token(
"mapped",
&left_rule,
&left_policy,
&left_policy,
&CredentialSet::default(),
),
compute_proposal_review_token(
"mapped",
&right_rule,
&right_policy,
&right_policy,
&CredentialSet::default(),
)
);
assert_eq!(
compute_failed_proposal_evaluation_hash("mapped", &left_rule, &left_policy, "failed",),
compute_failed_proposal_evaluation_hash("mapped", &right_rule, &right_policy, "failed",)
);
let mut changed_rule = right_rule;
changed_rule.endpoints[0].rules[0]
.allow
.as_mut()
.unwrap()
.query
.get_mut("alpha")
.unwrap()
.glob = "changed".to_string();
let changed_policy = ProtoSandboxPolicy {
network_policies: HashMap::from([("mapped".to_string(), changed_rule.clone())]),
..Default::default()
};
assert_ne!(
deterministic_policy_hash(&left_policy),
deterministic_policy_hash(&changed_policy),
"map values remain decision-relevant after canonical ordering"
);
assert_ne!(
compute_proposal_review_token(
"mapped",
&left_rule,
&left_policy,
&left_policy,
&CredentialSet::default(),
),
compute_proposal_review_token(
"mapped",
&changed_rule,
&changed_policy,
&changed_policy,
&CredentialSet::default(),
)
);
}
#[test]
fn config_revision_changes_when_policy_source_changes() {
let policy = ProtoSandboxPolicy::default();
let settings = HashMap::new();
let rev_a = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]);
let rev_b = compute_config_revision(Some(&policy), &settings, PolicySource::Global, &[]);
assert_ne!(rev_a, rev_b);
}
#[test]
fn config_revision_changes_when_validation_failure_mode_changes() {
let policy = ProtoSandboxPolicy::default();
let settings = HashMap::new();
let fail_closed = compute_config_revision_with_validation_mode(
Some(&policy),
&settings,
PolicySource::Sandbox,
&[],
openshell_core::PolicyValidationFailureMode::FailClosed,
false,
);
let retain_last_valid = compute_config_revision_with_validation_mode(
Some(&policy),
&settings,
PolicySource::Sandbox,
&[],
openshell_core::PolicyValidationFailureMode::RetainLastValid,
false,
);
assert_ne!(fail_closed, retain_last_valid);
}
#[test]
fn config_revision_changes_when_extension_authentication_capability_changes() {
let policy = ProtoSandboxPolicy::default();
let settings = HashMap::new();
let disabled = compute_config_revision_with_validation_mode(
Some(&policy),
&settings,
PolicySource::Sandbox,
&[],
openshell_core::PolicyValidationFailureMode::FailClosed,
false,
);
let enabled = compute_config_revision_with_validation_mode(
Some(&policy),
&settings,
PolicySource::Sandbox,
&[],
openshell_core::PolicyValidationFailureMode::FailClosed,
true,
);
assert_ne!(disabled, enabled);
}
#[test]
fn config_revision_changes_when_supervisor_middleware_services_change() {
let policy = ProtoSandboxPolicy::default();
let settings = HashMap::new();
let service = openshell_core::proto::SupervisorMiddlewareService {
name: "local-guard".into(),
grpc_endpoint: "http://127.0.0.1:50051".into(),
max_payload_bytes: 1024,
..Default::default()
};
let without = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]);
let with =
compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[service]);
assert_ne!(without, with);
}
#[test]
fn config_revision_without_policy_still_hashes_settings() {
let mut settings = HashMap::new();
settings.insert(
"log_level".to_string(),
EffectiveSetting {
value: Some(SettingValue {
value: Some(setting_value::Value::StringValue("debug".to_string())),
}),
scope: SettingScope::Sandbox.into(),
},
);
let rev_a = compute_config_revision(None, &settings, PolicySource::Sandbox, &[]);
settings.insert(
"log_level".to_string(),
EffectiveSetting {
value: Some(SettingValue {
value: Some(setting_value::Value::StringValue("warn".to_string())),
}),
scope: SettingScope::Sandbox.into(),
},
);
let rev_b = compute_config_revision(None, &settings, PolicySource::Sandbox, &[]);
assert_ne!(rev_a, rev_b);
}
// ---- stored <-> proto round-trip ----
#[test]
fn stored_setting_to_proto_string_round_trip() {
let stored = StoredSettingValue::String("hello".to_string());
let proto = stored_setting_to_proto(&stored).unwrap();
assert_eq!(
proto.value,
Some(setting_value::Value::StringValue("hello".to_string()))
);
}
#[test]
fn stored_setting_to_proto_int_round_trip() {
let stored = StoredSettingValue::Int(42);
let proto = stored_setting_to_proto(&stored).unwrap();
assert_eq!(proto.value, Some(setting_value::Value::IntValue(42)));
}
#[test]
fn stored_setting_to_proto_bool_round_trip() {
let stored = StoredSettingValue::Bool(false);
let proto = stored_setting_to_proto(&stored).unwrap();
assert_eq!(proto.value, Some(setting_value::Value::BoolValue(false)));
}
// ---- upsert_setting_value ----
#[test]
fn upsert_setting_value_returns_true_on_insert() {
let mut map = BTreeMap::new();
let changed = upsert_setting_value(
&mut map,
"log_level",
StoredSettingValue::String("debug".to_string()),
);
assert!(changed);
assert_eq!(
map.get("log_level"),
Some(&StoredSettingValue::String("debug".to_string()))
);
}
#[test]
fn upsert_setting_value_returns_false_when_unchanged() {
let mut map = BTreeMap::new();
map.insert(
"log_level".to_string(),
StoredSettingValue::String("debug".to_string()),
);
let changed = upsert_setting_value(
&mut map,
"log_level",
StoredSettingValue::String("debug".to_string()),
);
assert!(!changed);
}
#[test]
fn upsert_setting_value_returns_true_on_update() {
let mut map = BTreeMap::new();
map.insert(
"log_level".to_string(),
StoredSettingValue::String("debug".to_string()),
);
let changed = upsert_setting_value(
&mut map,
"log_level",
StoredSettingValue::String("warn".to_string()),
);
assert!(changed);
}
// ---- Settings persistence ----
#[tokio::test]
async fn global_settings_load_returns_default_when_empty() {
let store = test_store().await;
let settings = load_global_settings(&store).await.unwrap();
assert!(settings.settings.is_empty());
assert_eq!(settings.revision, 0);
}
#[tokio::test]
async fn sandbox_settings_load_returns_default_when_empty() {
let store = test_store().await;
let settings = load_sandbox_settings(&store, "default", "nonexistent")
.await
.unwrap();
assert!(settings.settings.is_empty());
assert_eq!(settings.revision, 0);
}
#[tokio::test]
async fn global_settings_save_and_load_round_trip() {
let store = test_store().await;
let mut settings = StoredSettings::default();
settings.settings.insert(
"log_level".to_string(),
StoredSettingValue::String("error".to_string()),
);
settings.settings.insert(
"ocsf_json_enabled".to_string(),
StoredSettingValue::Bool(true),
);
settings.revision = 5;
save_global_settings(&store, &settings).await.unwrap();
let loaded = load_global_settings(&store).await.unwrap();
assert_eq!(loaded.revision, 5);
assert_eq!(
loaded.settings.get("log_level"),
Some(&StoredSettingValue::String("error".to_string()))
);
assert_eq!(
loaded.settings.get("ocsf_json_enabled"),
Some(&StoredSettingValue::Bool(true))
);
}
#[tokio::test]
async fn sandbox_settings_save_and_load_round_trip() {
let store = test_store().await;
let sandbox_name = "my-sandbox";
let mut settings = StoredSettings::default();
settings.settings.insert(
settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(),
StoredSettingValue::String("auto".to_string()),
);
settings.revision = 3;
save_sandbox_settings(&store, "default", sandbox_name, &settings)
.await
.unwrap();
let loaded = load_sandbox_settings(&store, "default", sandbox_name)
.await
.unwrap();
assert_eq!(loaded.revision, 3);
assert_eq!(
loaded.settings.get(settings::PROPOSAL_APPROVAL_MODE_KEY),
Some(&StoredSettingValue::String("auto".to_string()))
);
}
#[tokio::test]
async fn concurrent_global_setting_mutations_are_serialized() {
let store = Arc::new(test_store().await);
let mutex = Arc::new(tokio::sync::Mutex::new(()));
let n = 50;
let mut handles = Vec::with_capacity(n);
for i in 0..n {
let store = store.clone();
let mutex = mutex.clone();
handles.push(tokio::spawn(async move {
let _guard = mutex.lock().await;
let mut settings = load_global_settings(&store).await.unwrap();
settings
.settings
.insert(format!("key_{i}"), StoredSettingValue::Int(i as i64));
settings.revision = settings.revision.wrapping_add(1);
save_global_settings(&store, &settings).await.unwrap();
}));
}
for h in handles {
h.await.unwrap();
}
let final_settings = load_global_settings(&store).await.unwrap();
assert_eq!(final_settings.revision, n as u64);
assert_eq!(final_settings.settings.len(), n);
}
#[tokio::test]
async fn concurrent_global_setting_mutations_without_lock_can_lose_writes() {
let store = Arc::new(test_store().await);
let n = 50;
let mut handles = Vec::with_capacity(n);
for i in 0..n {
let store = store.clone();
handles.push(tokio::spawn(async move {
let mut settings = load_global_settings(&store).await.unwrap();
tokio::task::yield_now().await;
settings
.settings
.insert(format!("key_{i}"), StoredSettingValue::Int(i as i64));
settings.revision = settings.revision.wrapping_add(1);
save_global_settings(&store, &settings).await
}));
}
let mut succeeded = 0;
let mut cas_conflicts = 0;
for h in handles {
match h.await.unwrap() {
Ok(()) => succeeded += 1,
Err(e) if e.code() == Code::Aborted => cas_conflicts += 1,
Err(e) => panic!("unexpected error: {e}"),
}
}
let final_settings = load_global_settings(&store).await.unwrap();
// With single-attempt CAS (no retry), concurrent modifications are properly detected:
// - All tasks read initial state (revision=0, resource_version=0)
// - First write succeeds with resource_version=1
// - Subsequent writes fail with ABORTED (CAS conflict) because they all have stale resource_version=0
// - Only the first write succeeds; all others are rejected
//
// This demonstrates that single-attempt CAS prevents lost writes by rejecting stale updates.
// The caller must retry from a fresh read to incorporate concurrent changes.
assert!(
cas_conflicts > 0,
"most concurrent writes should fail with CAS conflict (succeeded={succeeded}, conflicts={cas_conflicts})"
);
assert!(
succeeded < n,
"not all writes should succeed due to conflicts (succeeded={succeeded}, total={n})"
);
assert_eq!(
final_settings.revision as usize, succeeded,
"final revision should match number of successful writes"
);
assert_eq!(
final_settings.settings.len(),
succeeded,
"final settings should contain exactly the keys from successful writes"
);
eprintln!(
"unlocked CAS test: {succeeded} succeeded, {cas_conflicts} CAS conflicts, \
final revision={} (matches succeeded count, demonstrating proper conflict detection)",
final_settings.revision
);
}
// ---- Conflict guard tests ----
#[tokio::test]
async fn conflict_guard_sandbox_set_blocked_when_global_exists() {
let store = test_store().await;
let mut global = StoredSettings::default();
global.settings.insert(
"log_level".to_string(),
StoredSettingValue::String("warn".to_string()),
);
global.revision = 1;
save_global_settings(&store, &global).await.unwrap();
let loaded_global = load_global_settings(&store).await.unwrap();
let globally_managed = loaded_global.settings.contains_key("log_level");
assert!(globally_managed);
}
#[tokio::test]
async fn conflict_guard_sandbox_delete_blocked_when_global_exists() {
let store = test_store().await;
let mut global = StoredSettings::default();
global.settings.insert(
"ocsf_json_enabled".to_string(),
StoredSettingValue::Bool(true),
);
global.revision = 1;
save_global_settings(&store, &global).await.unwrap();
let loaded_global = load_global_settings(&store).await.unwrap();
assert!(loaded_global.settings.contains_key("ocsf_json_enabled"));
}
#[tokio::test]
async fn delete_unlock_sandbox_set_succeeds_after_global_delete() {
let store = test_store().await;
// Create initial global settings
let mut global = StoredSettings::default();
global.settings.insert(
"log_level".to_string(),
StoredSettingValue::String("warn".to_string()),
);
global.revision = 1;
save_global_settings(&store, &global).await.unwrap();
let loaded = load_global_settings(&store).await.unwrap();
assert!(loaded.settings.contains_key("log_level"));
// Load fresh to get current resource_version before updating
let mut global = load_global_settings(&store).await.unwrap();
global.settings.remove("log_level");
global.revision = 2;
save_global_settings(&store, &global).await.unwrap();
let loaded = load_global_settings(&store).await.unwrap();
assert!(!loaded.settings.contains_key("log_level"));
let sandbox_name = "test-sandbox";
let mut sandbox_settings = load_sandbox_settings(&store, "default", sandbox_name)
.await
.unwrap();
let changed = upsert_setting_value(
&mut sandbox_settings.settings,
"log_level",
StoredSettingValue::String("debug".to_string()),
);
assert!(changed);
sandbox_settings.revision = sandbox_settings.revision.wrapping_add(1);
save_sandbox_settings(&store, "default", sandbox_name, &sandbox_settings)
.await
.unwrap();
let reloaded = load_sandbox_settings(&store, "default", sandbox_name)
.await
.unwrap();
assert_eq!(
reloaded.settings.get("log_level"),
Some(&StoredSettingValue::String("debug".to_string())),
);
}
#[tokio::test]
async fn sandbox_settings_are_workspace_isolated() {
let store = test_store().await;
let sandbox_name = "work";
let mut alpha_settings = StoredSettings::default();
alpha_settings.settings.insert(
settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(),
StoredSettingValue::String("auto".to_string()),
);
alpha_settings.revision = 1;
save_sandbox_settings(&store, "alpha", sandbox_name, &alpha_settings)
.await
.unwrap();
let mut beta_settings = StoredSettings::default();
beta_settings.settings.insert(
settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(),
StoredSettingValue::String("manual".to_string()),
);
beta_settings.revision = 1;
save_sandbox_settings(&store, "beta", sandbox_name, &beta_settings)
.await
.unwrap();
let alpha_loaded = load_sandbox_settings(&store, "alpha", sandbox_name)
.await
.unwrap();
let beta_loaded = load_sandbox_settings(&store, "beta", sandbox_name)
.await
.unwrap();
assert_eq!(
alpha_loaded
.settings
.get(settings::PROPOSAL_APPROVAL_MODE_KEY),
Some(&StoredSettingValue::String("auto".to_string())),
);
assert_eq!(
beta_loaded
.settings
.get(settings::PROPOSAL_APPROVAL_MODE_KEY),
Some(&StoredSettingValue::String("manual".to_string())),
);
}
#[tokio::test]
async fn get_sandbox_config_returns_workspace() {
use openshell_core::proto::{SandboxPhase, SandboxSpec};
let state = test_server_state().await;
for (ws, sb_id, sb_name) in [("alpha", "sb-alpha", "work"), ("beta", "sb-beta", "work")] {
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: sb_id.to_string(),
name: sb_name.to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
annotations: HashMap::new(),
resource_version: 0,
workspace: ws.to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Provisioning as i32);
state.store.put_message(&sandbox).await.unwrap();
}
let alpha_req = with_sandbox(
Request::new(GetSandboxConfigRequest {
name: "work".to_string(),
workspace_scope: None,
}),
"sb-alpha",
);
let alpha_resp = handle_get_sandbox_config(&state, alpha_req)
.await
.unwrap()
.into_inner();
assert_eq!(alpha_resp.workspace, "alpha");
let beta_req = with_sandbox(
Request::new(GetSandboxConfigRequest {
name: "work".to_string(),
workspace_scope: None,
}),
"sb-beta",
);
let beta_resp = handle_get_sandbox_config(&state, beta_req)
.await
.unwrap()
.into_inner();
assert_eq!(beta_resp.workspace, "beta");
}
#[test]
fn validate_registered_setting_key_rejects_policy() {
let err = validate_registered_setting_key("policy").unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
assert!(err.message().contains("unknown setting key"));
}
#[test]
fn proto_setting_to_stored_rejects_policy_key() {
let value = SettingValue {
value: Some(setting_value::Value::StringValue("anything".to_string())),
};
let err = proto_setting_to_stored("policy", &value).unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
assert!(err.message().contains("unknown setting key"));
}
#[tokio::test]
async fn save_settings_detects_concurrent_modification() {
let store = test_store().await;
// Create initial settings
let mut settings = StoredSettings {
revision: 1,
settings: std::iter::once((
"initial_key".to_string(),
StoredSettingValue::String("initial_value".to_string()),
))
.collect(),
..Default::default()
};
save_global_settings(&store, &settings).await.unwrap();
// Load settings (simulating first client read)
let loaded = load_global_settings(&store).await.unwrap();
assert_eq!(loaded.revision, 1);
// Simulate concurrent modification: another client updates the settings
let mut concurrent_update = loaded.clone();
concurrent_update.settings.insert(
"concurrent_key".to_string(),
StoredSettingValue::String("concurrent_value".to_string()),
);
concurrent_update.revision = 2;
save_global_settings(&store, &concurrent_update)
.await
.unwrap();
// Now attempt to save our original modification (which is based on stale revision 1)
settings.settings.insert(
"our_key".to_string(),
StoredSettingValue::String("our_value".to_string()),
);
settings.revision = 2; // We think we're updating to revision 2
let result = save_global_settings(&store, &settings).await;
// Should fail with ABORTED due to concurrent modification
assert!(result.is_err(), "save with stale revision should fail");
let err = result.unwrap_err();
assert_eq!(
err.code(),
Code::Aborted,
"should fail with ABORTED due to version mismatch"
);
assert!(
err.message().contains("concurrently"),
"error should mention concurrent modification: {}",
err.message()
);
// Verify the database contains the concurrent update, not our stale update
let final_settings = load_global_settings(&store).await.unwrap();
assert_eq!(final_settings.revision, 2);
assert!(
final_settings.settings.contains_key("concurrent_key"),
"concurrent update should be preserved"
);
assert!(
!final_settings.settings.contains_key("our_key"),
"stale update should NOT be in database"
);
}
// ---- CAS (Client-driven optimistic concurrency) tests for UpdateConfig ----
// These test the policy backfill path where spec.policy is None and UpdateConfig
// uses update_message_cas to atomically set it.
#[tokio::test]
async fn update_config_policy_backfill_cas_succeeds_with_correct_version() {
use openshell_core::proto::{SandboxPhase, SandboxSpec};
let state = test_server_state().await;
// Create a sandbox WITHOUT a policy (spec.policy = None)
// This simulates a sandbox before the supervisor has discovered and synced a policy
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-1".to_string(),
name: "test-sandbox".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None, // No policy yet - will be backfilled
providers: Vec::new(),
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Provisioning as i32);
state.store.put_message(&sandbox).await.unwrap();
// Fetch the sandbox to get its current resource_version
let current = state
.store
.get_message_by_name::<Sandbox>("default", "test-sandbox")
.await
.unwrap()
.unwrap();
let current_version = current.metadata.as_ref().unwrap().resource_version;
// Backfill the policy with correct expected_resource_version
let new_policy = ProtoSandboxPolicy {
process: Some(openshell_core::proto::ProcessPolicy {
run_as_user: "1234".to_string(),
run_as_group: String::new(),
}),
..Default::default()
};
let response = handle_update_config(
&state,
authed_request(UpdateConfigRequest {
request_id: String::new(),
sandbox: "test-sandbox".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(new_policy),
setting_key: String::new(),
setting_value: None,
delete_setting: false,
global: false,
merge_operations: vec![],
expected_resource_version: current_version,
annotations: HashMap::new(),
}),
)
.await
.unwrap()
.into_inner();
// UpdateConfigResponse contains the policy version
assert_eq!(response.version, 1);
// Verify the resource_version incremented and policy was backfilled
// without replacing an omitted process identity component.
let updated_sandbox = state
.store
.get_message_by_name::<Sandbox>("default", "test-sandbox")
.await
.unwrap()
.unwrap();
let process = updated_sandbox
.spec
.as_ref()
.and_then(|spec| spec.policy.as_ref())
.and_then(|policy| policy.process.as_ref())
.expect("partial process identity should be persisted");
assert_eq!(process.run_as_user, "1234");
assert!(process.run_as_group.is_empty());
assert_eq!(
updated_sandbox.metadata.as_ref().unwrap().resource_version,
current_version + 1,
"resource_version should increment during CAS backfill"
);
assert!(
updated_sandbox.spec.as_ref().unwrap().policy.is_some(),
"policy should be backfilled"
);
}
#[tokio::test]
async fn update_config_policy_backfill_persists_and_returns_annotations() {
use openshell_core::proto::{SandboxPhase, SandboxSpec};
let state = test_server_state().await;
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-annotated-backfill".to_string(),
name: "annotated-backfill".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::from([(
"openshell.nvidia.com/existing".to_string(),
"keep".to_string(),
)]),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
providers: Vec::new(),
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Provisioning as i32);
state.store.put_message(&sandbox).await.unwrap();
let current = state
.store
.get_message_by_name::<Sandbox>("default", "annotated-backfill")
.await
.unwrap()
.unwrap();
let current_version = current.metadata.as_ref().unwrap().resource_version;
let annotations = HashMap::from([
(
"openshell.nvidia.com/policy-signature".to_string(),
"signed-policy".to_string(),
),
(
"openshell.nvidia.com/policy-provenance".to_string(),
"governance-interceptor".to_string(),
),
]);
let response = handle_update_config(
&state,
authed_request(UpdateConfigRequest {
request_id: String::new(),
sandbox: "annotated-backfill".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(ProtoSandboxPolicy::default()),
setting_key: String::new(),
setting_value: None,
delete_setting: false,
global: false,
merge_operations: vec![],
expected_resource_version: current_version,
annotations: annotations.clone(),
}),
)
.await
.unwrap()
.into_inner();
assert_eq!(response.version, 1);
assert_eq!(
response.annotations.get("openshell.nvidia.com/existing"),
Some(&"keep".to_string())
);
for (key, value) in &annotations {
assert_eq!(response.annotations.get(key), Some(value));
}
let stored = state
.store
.get_message_by_name::<Sandbox>("default", "annotated-backfill")
.await
.unwrap()
.unwrap();
let stored_annotations = &stored.metadata.as_ref().unwrap().annotations;
assert_eq!(
stored_annotations.get("openshell.nvidia.com/existing"),
Some(&"keep".to_string())
);
for (key, value) in &annotations {
assert_eq!(stored_annotations.get(key), Some(value));
}
assert!(
stored.spec.as_ref().unwrap().policy.is_some(),
"policy should still be backfilled"
);
let revision = state
.store
.get_latest_policy("sb-annotated-backfill")
.await
.unwrap()
.unwrap();
assert_eq!(revision.provenance, annotations);
}
#[tokio::test]
async fn update_config_same_policy_hash_with_new_provenance_creates_revision() {
let state = test_server_state().await;
let policy = test_policy_with_rule("sandbox_only", "sandbox.example.com");
let hash = deterministic_policy_hash(&policy);
let sandbox = test_sandbox("sb-same-hash", "same-hash", policy.clone(), Vec::new());
state.store.put_message(&sandbox).await.unwrap();
state
.store
.put_policy_revision(
"policy-same-hash-v1",
"sb-same-hash",
"default",
1,
&policy.encode_to_vec(),
&hash,
)
.await
.unwrap();
let mut watch_rx = state.sandbox_watch_bus.subscribe("sb-same-hash");
let response = handle_update_config(
&state,
authed_request(UpdateConfigRequest {
sandbox: "same-hash".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(policy),
annotations: HashMap::from([(
"openshell.nvidia.com/policy-signature".to_string(),
"same-hash-signature".to_string(),
)]),
..Default::default()
}),
)
.await
.unwrap()
.into_inner();
assert_eq!(response.version, 2);
watch_rx
.try_recv()
.expect("new provenance revision must notify the sandbox watcher");
assert!(
matches!(
watch_rx.try_recv(),
Err(tokio::sync::broadcast::error::TryRecvError::Empty)
),
"one committed revision must wake the sandbox watcher exactly once"
);
assert_eq!(
response
.annotations
.get("openshell.nvidia.com/policy-signature")
.map(String::as_str),
Some("same-hash-signature")
);
let stored = state
.store
.get_message_by_name::<Sandbox>("default", "same-hash")
.await
.unwrap()
.unwrap();
assert_eq!(
stored
.metadata
.as_ref()
.unwrap()
.annotations
.get("openshell.nvidia.com/policy-signature")
.map(String::as_str),
Some("same-hash-signature")
);
let latest = state
.store
.get_latest_policy("sb-same-hash")
.await
.unwrap()
.unwrap();
assert_eq!(latest.version, 2);
assert_eq!(
latest.provenance,
HashMap::from([(
"openshell.nvidia.com/policy-signature".to_string(),
"same-hash-signature".to_string(),
)])
);
}
#[tokio::test]
async fn update_config_same_policy_and_provenance_is_idempotent() {
let state = test_server_state().await;
let policy = test_policy_with_rule("sandbox_only", "sandbox.example.com");
state
.store
.put_message(&test_sandbox(
"sb-idempotent-provenance",
"idempotent-provenance",
policy.clone(),
Vec::new(),
))
.await
.unwrap();
let annotations = HashMap::from([(
"openshell.nvidia.com/policy-signature".to_string(),
"same-signature".to_string(),
)]);
let first = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "idempotent-provenance".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(policy.clone()),
annotations: annotations.clone(),
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
let second = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "idempotent-provenance".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(policy),
annotations: annotations.clone(),
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(first.version, 1);
assert_eq!(second.version, 1);
assert_eq!(second.annotations, annotations);
let revisions = state
.store
.list_policies("sb-idempotent-provenance", 10, 0)
.await
.unwrap();
assert_eq!(revisions.len(), 1);
assert_eq!(revisions[0].provenance, annotations);
}
#[tokio::test]
async fn update_config_full_policy_empty_annotations_preserves_existing_annotations() {
let state = test_server_state().await;
let baseline = test_policy_with_rule("sandbox_only", "old.example.com");
let mut sandbox = test_sandbox(
"sb-preserve-full",
"preserve-full",
baseline.clone(),
Vec::new(),
);
sandbox.metadata.as_mut().unwrap().annotations.insert(
"openshell.nvidia.com/policy-signature".to_string(),
"keep".to_string(),
);
state.store.put_message(&sandbox).await.unwrap();
state
.store
.put_policy_revision(
"policy-preserve-full-v1",
"sb-preserve-full",
"default",
1,
&baseline.encode_to_vec(),
&deterministic_policy_hash(&baseline),
)
.await
.unwrap();
let updated = test_policy_with_rule("sandbox_only", "new.example.com");
let response = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "preserve-full".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(updated),
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(
response
.annotations
.get("openshell.nvidia.com/policy-signature")
.map(String::as_str),
Some("keep")
);
let stored = state
.store
.get_message_by_name::<Sandbox>("default", "preserve-full")
.await
.unwrap()
.unwrap();
assert_eq!(
stored
.metadata
.as_ref()
.unwrap()
.annotations
.get("openshell.nvidia.com/policy-signature")
.map(String::as_str),
Some("keep")
);
}
#[tokio::test]
async fn update_config_merge_empty_annotations_preserves_existing_annotations() {
let state = test_server_state().await;
let baseline = test_policy_with_rule("sandbox_only", "sandbox.example.com");
let mut sandbox = test_sandbox("sb-preserve-merge", "preserve-merge", baseline, Vec::new());
sandbox.metadata.as_mut().unwrap().annotations.insert(
"openshell.nvidia.com/policy-provenance".to_string(),
"keep".to_string(),
);
state.store.put_message(&sandbox).await.unwrap();
let response = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: ("preserve-merge").to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
merge_operations: vec![PolicyMergeOperation {
operation: Some(policy_merge_operation::Operation::AddRule(
openshell_core::proto::AddNetworkRule {
rule_name: "allow_api_example".to_string(),
rule: Some(NetworkPolicyRule {
name: "allow_api_example".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.example.com".to_string(),
port: 443,
..Default::default()
}],
..Default::default()
}),
},
)),
}],
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(
response
.annotations
.get("openshell.nvidia.com/policy-provenance")
.map(String::as_str),
Some("keep")
);
let stored = state
.store
.get_message_by_name::<Sandbox>("default", "preserve-merge")
.await
.unwrap()
.unwrap();
assert_eq!(
stored
.metadata
.as_ref()
.unwrap()
.annotations
.get("openshell.nvidia.com/policy-provenance")
.map(String::as_str),
Some("keep")
);
}
#[tokio::test]
async fn update_config_merge_stores_revision_provenance_atomically() {
let state = test_server_state().await;
let baseline = test_policy_with_rule("sandbox_only", "sandbox.example.com");
state
.store
.put_message(&test_sandbox(
"sb-merge-provenance",
"merge-provenance",
baseline,
Vec::new(),
))
.await
.unwrap();
let provenance = HashMap::from([(
"openshell.nvidia.com/policy-signature".to_string(),
"merge-signature".to_string(),
)]);
let response = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: ("merge-provenance").to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
merge_operations: vec![PolicyMergeOperation {
operation: Some(policy_merge_operation::Operation::AddRule(
openshell_core::proto::AddNetworkRule {
rule_name: "allow_api_example".to_string(),
rule: Some(NetworkPolicyRule {
name: "allow_api_example".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.example.com".to_string(),
port: 443,
..Default::default()
}],
..Default::default()
}),
},
)),
}],
annotations: provenance.clone(),
..Default::default()
})),
)
.await
.unwrap()
.into_inner();
assert_eq!(response.version, 1);
assert_eq!(response.annotations, provenance);
let revision = state
.store
.get_latest_policy("sb-merge-provenance")
.await
.unwrap()
.unwrap();
assert_eq!(revision.provenance, provenance);
}
#[tokio::test]
async fn update_config_backfill_empty_annotations_preserves_existing_annotations() {
use openshell_core::proto::{SandboxPhase, SandboxSpec};
let state = test_server_state().await;
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-preserve-backfill".to_string(),
name: "preserve-backfill".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::from([(
"openshell.nvidia.com/policy-signature".to_string(),
"keep".to_string(),
)]),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
providers: Vec::new(),
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Provisioning as i32);
state.store.put_message(&sandbox).await.unwrap();
let current = state
.store
.get_message_by_name::<Sandbox>("default", "preserve-backfill")
.await
.unwrap()
.unwrap();
let current_version = current.metadata.as_ref().unwrap().resource_version;
let response = handle_update_config(
&state,
authed_request(UpdateConfigRequest {
sandbox: "preserve-backfill".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(ProtoSandboxPolicy::default()),
expected_resource_version: current_version,
..Default::default()
}),
)
.await
.unwrap()
.into_inner();
assert_eq!(
response
.annotations
.get("openshell.nvidia.com/policy-signature")
.map(String::as_str),
Some("keep")
);
let stored = state
.store
.get_message_by_name::<Sandbox>("default", "preserve-backfill")
.await
.unwrap()
.unwrap();
assert_eq!(
stored
.metadata
.as_ref()
.unwrap()
.annotations
.get("openshell.nvidia.com/policy-signature")
.map(String::as_str),
Some("keep")
);
assert!(
stored.spec.as_ref().unwrap().policy.is_some(),
"policy should still be backfilled"
);
}
#[tokio::test]
async fn update_config_reports_immutable_removal_before_policy_safety_errors() {
let state = test_server_state().await;
let sandbox_id = "sb-static-error-priority";
let sandbox_name = "static-error-priority";
let baseline = openshell_policy::restrictive_default_policy();
state
.store
.put_message(&test_sandbox(
sandbox_id,
sandbox_name,
baseline.clone(),
Vec::new(),
))
.await
.unwrap();
let current = state
.store
.get_message_by_name::<Sandbox>("default", sandbox_name)
.await
.unwrap()
.unwrap();
let current_version = current.metadata.as_ref().unwrap().resource_version;
// The replacement removes baseline paths and adds an unsafe traversal.
// Live-policy immutability is the earlier contract, so it must remain
// the stable failure even when later whole-policy validation would fail.
let mut unsafe_replacement = baseline;
unsafe_replacement.filesystem.as_mut().unwrap().read_only =
vec!["/usr/../etc/shadow".to_string()];
let error = 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(unsafe_replacement),
expected_resource_version: current_version,
..Default::default()
})),
)
.await
.expect_err("immutable removal must fail before whole-policy validation");
assert_eq!(error.code(), Code::InvalidArgument);
assert!(error.message().contains("cannot be removed"));
let unchanged = state
.store
.get_message_by_name::<Sandbox>("default", sandbox_name)
.await
.unwrap()
.unwrap();
assert_eq!(
unchanged
.spec
.as_ref()
.and_then(|spec| spec.policy.as_ref()),
Some(&openshell_policy::restrictive_default_policy())
);
assert!(
state
.store
.get_latest_policy(sandbox_id)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn update_config_policy_backfill_validates_before_persistence() {
use openshell_core::proto::{SandboxPhase, SandboxSpec};
let state = test_server_state().await;
let sandbox_id = "sb-invalid-first-sync";
let sandbox_name = "invalid-first-sync";
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: sandbox_id.to_string(),
name: sandbox_name.to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
providers: Vec::new(),
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Provisioning as i32);
state.store.put_message(&sandbox).await.unwrap();
let current = state
.store
.get_message_by_name::<Sandbox>("default", sandbox_name)
.await
.unwrap()
.unwrap();
let current_version = current.metadata.as_ref().unwrap().resource_version;
let invalid_version_sets: &[&[&str]] = &[&["latest"], &["2025-11-25", "2025-11-25"]];
for versions in invalid_version_sets {
let error = 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(mcp_policy_with_versions(versions)),
expected_resource_version: current_version,
..Default::default()
})),
)
.await
.expect_err("invalid first-sync policy must fail before backfill");
assert_eq!(error.code(), Code::InvalidArgument);
let unchanged = state
.store
.get_message_by_name::<Sandbox>("default", sandbox_name)
.await
.unwrap()
.unwrap();
assert!(unchanged.spec.as_ref().unwrap().policy.is_none());
assert!(
state
.store
.get_latest_policy(sandbox_id)
.await
.unwrap()
.is_none()
);
}
}
#[tokio::test]
async fn update_config_policy_backfill_persists_defaulted_mcp_versions_identically() {
use openshell_core::proto::{SandboxPhase, SandboxSpec};
let state = test_server_state().await;
let canonical_policy = mcp_policy_with_versions(&["2025-11-25"]);
let canonical_policy = validate_and_canonicalize_policy(canonical_policy)
.expect("explicit default MCP policy must canonicalize");
let canonical_payload = canonical_policy.encode_to_vec();
let canonical_hash = deterministic_policy_hash(&canonical_policy);
let cases = [
("omitted-options", mcp_policy_without_options()),
("empty-versions", mcp_policy_with_versions(&[])),
(
"explicit-default",
mcp_policy_with_versions(&["2025-11-25"]),
),
];
for (case, policy) in cases {
let sandbox_id = format!("sb-default-first-sync-{case}");
let sandbox_name = format!("default-first-sync-{case}");
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: sandbox_id.clone(),
name: sandbox_name.clone(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
providers: Vec::new(),
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Provisioning as i32);
state.store.put_message(&sandbox).await.unwrap();
let current = state
.store
.get_message_by_name::<Sandbox>("default", &sandbox_name)
.await
.unwrap()
.unwrap();
let current_version = current.metadata.as_ref().unwrap().resource_version;
let response = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(policy),
expected_resource_version: current_version,
..Default::default()
})),
)
.await
.expect("defaulted first-sync policy must persist")
.into_inner();
assert_eq!(response.version, 1, "{case}");
assert_eq!(response.policy_hash, canonical_hash, "{case}");
let stored = state
.store
.get_message_by_name::<Sandbox>("default", &sandbox_name)
.await
.unwrap()
.unwrap();
let stored_policy = stored
.spec
.as_ref()
.and_then(|spec| spec.policy.as_ref())
.expect("backfilled sandbox policy");
assert_eq!(stored_policy, &canonical_policy, "{case}");
assert_eq!(
mcp_versions(stored_policy),
&["2025-11-25".to_string()],
"{case}"
);
let revision = state
.store
.get_latest_policy(&sandbox_id)
.await
.unwrap()
.expect("first-sync policy revision must exist");
assert_eq!(revision.policy_payload, canonical_payload, "{case}");
assert_eq!(revision.policy_hash, canonical_hash, "{case}");
}
}
#[tokio::test]
async fn update_config_policy_backfill_canonicalizes_mcp_versions_before_persistence() {
use openshell_core::proto::{SandboxPhase, SandboxSpec};
let state = test_server_state().await;
let sandbox_id = "sb-canonical-first-sync";
let sandbox_name = "canonical-first-sync";
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: sandbox_id.to_string(),
name: sandbox_name.to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
providers: Vec::new(),
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Provisioning as i32);
state.store.put_message(&sandbox).await.unwrap();
let current = state
.store
.get_message_by_name::<Sandbox>("default", sandbox_name)
.await
.unwrap()
.unwrap();
let current_version = current.metadata.as_ref().unwrap().resource_version;
let canonical_policy =
mcp_policy_with_versions(&["2025-03-26", "2025-06-18", "2025-11-25"]);
let canonical_policy = validate_and_canonicalize_policy(canonical_policy)
.expect("canonical MCP policy must validate");
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(mcp_policy_with_versions(&[
"2025-11-25",
"2025-06-18",
"2025-03-26",
])),
expected_resource_version: current_version,
..Default::default()
})),
)
.await
.expect("valid first-sync policy must persist");
let stored = state
.store
.get_message_by_name::<Sandbox>("default", sandbox_name)
.await
.unwrap()
.unwrap();
assert_eq!(
stored.spec.as_ref().and_then(|spec| spec.policy.as_ref()),
Some(&canonical_policy)
);
let revision = state
.store
.get_latest_policy(sandbox_id)
.await
.unwrap()
.expect("first-sync policy revision must exist");
assert_eq!(revision.policy_payload, canonical_policy.encode_to_vec());
assert_eq!(
revision.policy_hash,
deterministic_policy_hash(&canonical_policy)
);
}
#[tokio::test]
async fn update_config_global_rejects_annotations() {
let state = test_server_state().await;
let err = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
global: true,
setting_key: settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(),
setting_value: Some(SettingValue {
value: Some(setting_value::Value::StringValue("auto".to_string())),
}),
annotations: HashMap::from([(
"openshell.nvidia.com/policy-signature".to_string(),
"global".to_string(),
)]),
..Default::default()
})),
)
.await
.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
assert!(err.message().contains("sandbox-scoped"));
}
#[tokio::test]
async fn update_config_rejects_invalid_annotations() {
let state = test_server_state().await;
state
.store
.put_message(&test_sandbox(
"sb-invalid-annotation",
"invalid-annotation",
ProtoSandboxPolicy::default(),
Vec::new(),
))
.await
.unwrap();
let err = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "invalid-annotation".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(ProtoSandboxPolicy::default()),
annotations: HashMap::from([("bad key".to_string(), "value".to_string())]),
..Default::default()
})),
)
.await
.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
assert!(err.message().contains("label key"));
}
#[tokio::test]
async fn update_config_user_policy_rejects_reserved_provider_key() {
let state = test_server_state().await;
state
.store
.put_message(&test_sandbox(
"sb-user-reserved-key",
"user-reserved-key",
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
Vec::new(),
))
.await
.unwrap();
let err = handle_update_config(
&state,
with_user(Request::new(UpdateConfigRequest {
sandbox: "user-reserved-key".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(test_policy_with_rule(
"_provider_work_github",
"api.github.com",
)),
..Default::default()
})),
)
.await
.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
assert!(err.message().contains("_provider_work_github"));
assert!(err.message().contains("reserved '_provider_' prefix"));
}
#[tokio::test]
async fn update_config_sandbox_sync_strips_reserved_provider_keys_before_persisting() {
use openshell_core::proto::{SandboxPhase, SandboxSpec};
let state = test_server_state().await;
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-sync-strip".to_string(),
name: "sync-strip".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
providers: Vec::new(),
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Provisioning as i32);
state.store.put_message(&sandbox).await.unwrap();
let current = state
.store
.get_message_by_name::<Sandbox>("default", "sync-strip")
.await
.unwrap()
.unwrap();
let current_version = current.metadata.as_ref().unwrap().resource_version;
let mut synced_policy = test_policy_with_rule("sandbox_only", "sandbox.example.com");
synced_policy.network_policies.insert(
"_provider_work_github".to_string(),
NetworkPolicyRule {
name: "_provider_work_github".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.github.com".to_string(),
port: 443,
..Default::default()
}],
..Default::default()
},
);
let response = handle_update_config(
&state,
with_sandbox(
Request::new(UpdateConfigRequest {
sandbox: "sync-strip".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(synced_policy),
expected_resource_version: current_version,
..Default::default()
}),
"sb-sync-strip",
),
)
.await
.unwrap()
.into_inner();
assert_eq!(response.version, 1);
let updated_sandbox = state
.store
.get_message_by_name::<Sandbox>("default", "sync-strip")
.await
.unwrap()
.unwrap();
let spec_policy = updated_sandbox
.spec
.as_ref()
.and_then(|spec| spec.policy.as_ref())
.expect("spec.policy should be backfilled");
assert!(spec_policy.network_policies.contains_key("sandbox_only"));
assert!(
!spec_policy
.network_policies
.contains_key("_provider_work_github")
);
let persisted = state
.store
.get_latest_policy("sb-sync-strip")
.await
.unwrap()
.expect("policy revision should be persisted");
let persisted_policy = ProtoSandboxPolicy::decode(persisted.policy_payload.as_slice())
.expect("persisted policy should decode");
assert!(
persisted_policy
.network_policies
.contains_key("sandbox_only")
);
assert!(
!persisted_policy
.network_policies
.contains_key("_provider_work_github")
);
}
#[tokio::test]
async fn update_config_policy_backfill_cas_rejects_stale_version() {
use openshell_core::proto::{SandboxPhase, SandboxSpec};
let state = test_server_state().await;
// Create a sandbox WITHOUT a policy
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-1".to_string(),
name: "test-sandbox".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
providers: Vec::new(),
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Provisioning as i32);
state.store.put_message(&sandbox).await.unwrap();
// Get current version
let current = state
.store
.get_message_by_name::<Sandbox>("default", "test-sandbox")
.await
.unwrap()
.unwrap();
let current_version = current.metadata.as_ref().unwrap().resource_version;
// Try to backfill with a stale version
let new_policy = ProtoSandboxPolicy::default();
let err = handle_update_config(
&state,
authed_request(UpdateConfigRequest {
request_id: String::new(),
sandbox: "test-sandbox".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(new_policy),
setting_key: String::new(),
setting_value: None,
delete_setting: false,
global: false,
merge_operations: vec![],
expected_resource_version: 99, // stale version
annotations: HashMap::new(),
}),
)
.await
.unwrap_err();
// Should get ABORTED status for CAS conflict
assert_eq!(err.code(), Code::Aborted);
assert!(
err.message().contains("modified concurrently")
|| err.message().contains("resource_version"),
"error message should mention concurrency conflict: {}",
err.message()
);
// Verify the sandbox was not modified (policy still None)
let unchanged = state
.store
.get_message_by_name::<Sandbox>("default", "test-sandbox")
.await
.unwrap()
.unwrap();
assert_eq!(
unchanged.metadata.as_ref().unwrap().resource_version,
current_version,
"resource_version should not change when CAS fails"
);
assert!(
unchanged.spec.as_ref().unwrap().policy.is_none(),
"policy should still be None after failed backfill"
);
assert!(
state
.store
.get_latest_policy("sb-1")
.await
.unwrap()
.is_none(),
"failed backfill must not leave an orphan revision"
);
}
#[tokio::test]
async fn update_config_policy_backfill_concurrent_with_stale_versions() {
use openshell_core::proto::{SandboxPhase, SandboxSpec};
use std::sync::Arc;
let state = Arc::new(test_server_state().await);
// Create a sandbox WITHOUT a policy
let mut sandbox = Sandbox {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: "sb-1".to_string(),
name: "test-sandbox".to_string(),
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
labels: HashMap::new(),
resource_version: 0,
annotations: HashMap::new(),
workspace: "default".to_string(),
deletion_time: None,
}),
spec: Some(SandboxSpec {
policy: None,
providers: Vec::new(),
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Provisioning as i32);
state.store.put_message(&sandbox).await.unwrap();
// All three clients fetch the sandbox and see the same version
let initial = state
.store
.get_message_by_name::<Sandbox>("default", "test-sandbox")
.await
.unwrap()
.unwrap();
let initial_version = initial.metadata.as_ref().unwrap().resource_version;
// Launch 3 concurrent policy backfill attempts, all using the same initial version
let mut handles = vec![];
for _i in 0..3 {
let state_clone = Arc::clone(&state);
let new_policy = ProtoSandboxPolicy::default();
let handle = tokio::spawn(async move {
handle_update_config(
&state_clone,
authed_request(UpdateConfigRequest {
request_id: String::new(),
sandbox: "test-sandbox".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"default".to_string(),
)),
policy: Some(new_policy),
setting_key: String::new(),
setting_value: None,
delete_setting: false,
global: false,
merge_operations: vec![],
expected_resource_version: initial_version,
annotations: HashMap::new(),
}),
)
.await
});
handles.push(handle);
}
let results: Vec<_> = futures::future::join_all(handles)
.await
.into_iter()
.map(|r| r.unwrap())
.collect();
// Only one should succeed; others should get ABORTED
let successes = results.iter().filter(|r| r.is_ok()).count();
let aborted_conflicts = results
.iter()
.filter(|r| r.as_ref().err().is_some_and(|e| e.code() == Code::Aborted))
.count();
assert_eq!(
successes, 1,
"exactly one backfill should succeed with client-driven CAS"
);
assert_eq!(
aborted_conflicts, 2,
"two backfills should fail with ABORTED due to stale version"
);
// Final sandbox should have resource_version = initial_version + 1 and policy backfilled
let final_sandbox = state
.store
.get_message_by_name::<Sandbox>("default", "test-sandbox")
.await
.unwrap()
.unwrap();
assert_eq!(
final_sandbox.metadata.as_ref().unwrap().resource_version,
initial_version + 1
);
assert!(
final_sandbox.spec.as_ref().unwrap().policy.is_some(),
"policy should be backfilled after one success"
);
assert_eq!(
state
.store
.list_policies("sb-1", 10, 0)
.await
.unwrap()
.len(),
1,
"concurrent backfills must create exactly one revision"
);
}
/// Non-member callers must receive `PERMISSION_DENIED` — not `NOT_FOUND` —
/// when targeting a workspace that does not exist. Returning `NOT_FOUND`
/// would create a CWE-203 workspace-name oracle.
#[tokio::test]
async fn non_member_gets_gets_not_found_without_sandbox_oracle() {
let mut state = test_server_state().await;
Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".to_string();
fn non_member_request<T>(inner: T) -> Request<T> {
let mut req = Request::new(inner);
req.extensions_mut().insert(Principal::User(UserPrincipal {
identity: Identity {
subject: "non-member".to_string(),
display_name: None,
roles: vec![],
scopes: vec![],
provider: IdentityProvider::Oidc,
},
}));
req
}
let err = handle_get_sandbox_policy_status(
&state,
non_member_request(GetSandboxPolicyStatusRequest {
sandbox: ("any").to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"no-such-ws".to_string(),
)),
..Default::default()
}),
)
.await
.unwrap_err();
assert_eq!(
err.code(),
Code::NotFound,
"handle_get_sandbox_policy_status should return NotFound, got {:?}",
err.code()
);
let err = handle_list_sandbox_policies(
&state,
non_member_request(ListSandboxPoliciesRequest {
sandbox: ("any").to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"no-such-ws".to_string(),
)),
..Default::default()
}),
)
.await
.unwrap_err();
assert_eq!(
err.code(),
Code::NotFound,
"handle_list_sandbox_policies should return NotFound, got {:?}",
err.code()
);
let err = handle_update_config(
&state,
non_member_request(UpdateConfigRequest {
sandbox: ("any").to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"no-such-ws".to_string(),
)),
..Default::default()
}),
)
.await
.unwrap_err();
assert_eq!(
err.code(),
Code::NotFound,
"handle_update_config should return NotFound, got {:?}",
err.code()
);
let err = handle_get_draft_policy(
&state,
non_member_request(GetDraftPolicyRequest {
sandbox: ("any").to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"no-such-ws".to_string(),
)),
..Default::default()
}),
)
.await
.unwrap_err();
assert_eq!(
err.code(),
Code::NotFound,
"handle_get_draft_policy should return NotFound, got {:?}",
err.code()
);
let err = handle_approve_draft_chunk(
&state,
non_member_request(ApproveDraftChunkRequest {
sandbox: ("any").to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"no-such-ws".to_string(),
)),
..Default::default()
}),
)
.await
.unwrap_err();
assert_eq!(
err.code(),
Code::NotFound,
"handle_approve_draft_chunk should return NotFound, got {:?}",
err.code()
);
let err = handle_reject_draft_chunk(
&state,
non_member_request(RejectDraftChunkRequest {
sandbox: ("any").to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"no-such-ws".to_string(),
)),
..Default::default()
}),
)
.await
.unwrap_err();
assert_eq!(
err.code(),
Code::NotFound,
"handle_reject_draft_chunk should return NotFound, got {:?}",
err.code()
);
let err = handle_approve_all_draft_chunks(
&state,
non_member_request(ApproveAllDraftChunksRequest {
sandbox: ("any").to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"no-such-ws".to_string(),
)),
..Default::default()
}),
)
.await
.unwrap_err();
assert_eq!(
err.code(),
Code::NotFound,
"handle_approve_all_draft_chunks should return NotFound, got {:?}",
err.code()
);
let err = handle_edit_draft_chunk(
&state,
non_member_request(EditDraftChunkRequest {
sandbox: ("any").to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"no-such-ws".to_string(),
)),
..Default::default()
}),
)
.await
.unwrap_err();
assert_eq!(
err.code(),
Code::NotFound,
"handle_edit_draft_chunk should return NotFound, got {:?}",
err.code()
);
let err = handle_undo_draft_chunk(
&state,
non_member_request(UndoDraftChunkRequest {
sandbox: ("any").to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"no-such-ws".to_string(),
)),
..Default::default()
}),
)
.await
.unwrap_err();
assert_eq!(
err.code(),
Code::NotFound,
"handle_undo_draft_chunk should return NotFound, got {:?}",
err.code()
);
let err = handle_clear_draft_chunks(
&state,
non_member_request(ClearDraftChunksRequest {
request_id: String::new(),
sandbox: ("any").to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"no-such-ws".to_string(),
)),
}),
)
.await
.unwrap_err();
assert_eq!(
err.code(),
Code::NotFound,
"handle_clear_draft_chunks should return NotFound, got {:?}",
err.code()
);
let err = handle_get_draft_history(
&state,
non_member_request(GetDraftHistoryRequest {
sandbox: ("any").to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"no-such-ws".to_string(),
)),
}),
)
.await
.unwrap_err();
assert_eq!(
err.code(),
Code::NotFound,
"handle_get_draft_history should return NotFound, got {:?}",
err.code()
);
}
/// Name-based policy handlers hide cross-workspace resources after
/// authorizing the explicit workspace selector.
#[tokio::test]
async fn name_based_policy_handlers_hide_cross_workspace_sandboxes() {
let mut state = test_server_state().await;
Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".to_string();
fn non_member_request<T>(inner: T) -> Request<T> {
let mut req = Request::new(inner);
req.extensions_mut().insert(Principal::User(UserPrincipal {
identity: Identity {
subject: "non-member".to_string(),
display_name: None,
roles: vec![],
scopes: vec![],
provider: IdentityProvider::Oidc,
},
}));
req
}
let mut sandbox = test_sandbox(
"sandbox-other",
"other",
ProtoSandboxPolicy::default(),
Vec::new(),
);
sandbox.metadata.as_mut().unwrap().workspace = "other-workspace".to_string();
state.store.put_message(&sandbox).await.unwrap();
// --- handle_get_sandbox_config ---
let err = handle_get_sandbox_config(
&state,
non_member_request(GetSandboxConfigRequest {
name: "other".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"other-workspace".to_string(),
)),
}),
)
.await
.unwrap_err();
assert_eq!(
err.code(),
Code::NotFound,
"handle_get_sandbox_config must return NotFound, not PermissionDenied"
);
// --- handle_get_sandbox_logs ---
let err = handle_get_sandbox_logs(
&state,
non_member_request(GetSandboxLogsRequest {
sandbox: "other".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
"other-workspace".to_string(),
)),
..Default::default()
}),
)
.await
.unwrap_err();
assert_eq!(
err.code(),
Code::NotFound,
"handle_get_sandbox_logs must hide unauthorized sandbox existence"
);
}
#[tokio::test]
async fn get_gateway_config_accessible_without_platform_admin() {
let mut state = test_server_state().await;
Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".to_string();
let mut req = Request::new(GetGatewayConfigRequest {});
req.extensions_mut().insert(Principal::User(UserPrincipal {
identity: Identity {
subject: "workspace-user".to_string(),
display_name: None,
roles: vec![],
scopes: vec![],
provider: IdentityProvider::Oidc,
},
}));
let response = handle_get_gateway_config(&state, req).await;
assert!(
response.is_ok(),
"GetGatewayConfig must not require Platform Admin; got {:?}",
response.unwrap_err()
);
}
}