feat(core): persist network approvals in execpolicy (#12357)
## Summary Persist network approval allow/deny decisions as `network_rule(...)` entries in execpolicy (not proxy config) It adds `network_rule` parsing + append support in `codex-execpolicy`, including `decision="prompt"` (parse-only; not compiled into proxy allow/deny lists) - compile execpolicy network rules into proxy allow/deny lists and update the live proxy state on approval - preserve requirements execpolicy `network_rule(...)` entries when merging with file-based execpolicy - reject broad wildcard hosts (for example `*`) for persisted `network_rule(...)`
This commit is contained in:
parent
af215eb390
commit
c3048ff90a
31 changed files with 1617 additions and 13 deletions
|
|
@ -1,6 +1,28 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"NetworkPolicyAmendment": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"$ref": "#/definitions/NetworkPolicyRuleAction"
|
||||
},
|
||||
"host": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action",
|
||||
"host"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkPolicyRuleAction": {
|
||||
"enum": [
|
||||
"allow",
|
||||
"deny"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ReviewDecision": {
|
||||
"description": "User's decision in response to an ExecApprovalRequest.",
|
||||
"oneOf": [
|
||||
|
|
@ -43,6 +65,28 @@
|
|||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.",
|
||||
"properties": {
|
||||
"network_policy_amendment": {
|
||||
"properties": {
|
||||
"network_policy_amendment": {
|
||||
"$ref": "#/definitions/NetworkPolicyAmendment"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"network_policy_amendment"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"network_policy_amendment"
|
||||
],
|
||||
"title": "NetworkPolicyAmendmentReviewDecision",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "User has denied this command and the agent should not execute it, but it should continue the session and try something else.",
|
||||
"enum": [
|
||||
|
|
|
|||
|
|
@ -1662,6 +1662,16 @@
|
|||
"null"
|
||||
]
|
||||
},
|
||||
"proposed_network_policy_amendments": {
|
||||
"description": "Proposed network policy amendments (for example allow/deny this host in future).",
|
||||
"items": {
|
||||
"$ref": "#/definitions/NetworkPolicyAmendment"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"reason": {
|
||||
"description": "Optional human-readable reason for the approval (e.g. retry without sandbox).",
|
||||
"type": [
|
||||
|
|
@ -3637,6 +3647,28 @@
|
|||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NetworkPolicyAmendment": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"$ref": "#/definitions/NetworkPolicyRuleAction"
|
||||
},
|
||||
"host": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action",
|
||||
"host"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkPolicyRuleAction": {
|
||||
"enum": [
|
||||
"allow",
|
||||
"deny"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ParsedCommand": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
|
@ -6907,6 +6939,16 @@
|
|||
"null"
|
||||
]
|
||||
},
|
||||
"proposed_network_policy_amendments": {
|
||||
"description": "Proposed network policy amendments (for example allow/deny this host in future).",
|
||||
"items": {
|
||||
"$ref": "#/definitions/NetworkPolicyAmendment"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"reason": {
|
||||
"description": "Optional human-readable reason for the approval (e.g. retry without sandbox).",
|
||||
"type": [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,28 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"NetworkPolicyAmendment": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"$ref": "#/definitions/NetworkPolicyRuleAction"
|
||||
},
|
||||
"host": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action",
|
||||
"host"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkPolicyRuleAction": {
|
||||
"enum": [
|
||||
"allow",
|
||||
"deny"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ReviewDecision": {
|
||||
"description": "User's decision in response to an ExecApprovalRequest.",
|
||||
"oneOf": [
|
||||
|
|
@ -43,6 +65,28 @@
|
|||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.",
|
||||
"properties": {
|
||||
"network_policy_amendment": {
|
||||
"properties": {
|
||||
"network_policy_amendment": {
|
||||
"$ref": "#/definitions/NetworkPolicyAmendment"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"network_policy_amendment"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"network_policy_amendment"
|
||||
],
|
||||
"title": "NetworkPolicyAmendmentReviewDecision",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "User has denied this command and the agent should not execute it, but it should continue the session and try something else.",
|
||||
"enum": [
|
||||
|
|
|
|||
|
|
@ -2723,6 +2723,16 @@
|
|||
"null"
|
||||
]
|
||||
},
|
||||
"proposed_network_policy_amendments": {
|
||||
"description": "Proposed network policy amendments (for example allow/deny this host in future).",
|
||||
"items": {
|
||||
"$ref": "#/definitions/NetworkPolicyAmendment"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"reason": {
|
||||
"description": "Optional human-readable reason for the approval (e.g. retry without sandbox).",
|
||||
"type": [
|
||||
|
|
@ -4898,6 +4908,28 @@
|
|||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NetworkPolicyAmendment": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"$ref": "#/definitions/NetworkPolicyRuleAction"
|
||||
},
|
||||
"host": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action",
|
||||
"host"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkPolicyRuleAction": {
|
||||
"enum": [
|
||||
"allow",
|
||||
"deny"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ParsedCommand": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
|
@ -5310,6 +5342,28 @@
|
|||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.",
|
||||
"properties": {
|
||||
"network_policy_amendment": {
|
||||
"properties": {
|
||||
"network_policy_amendment": {
|
||||
"$ref": "#/definitions/NetworkPolicyAmendment"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"network_policy_amendment"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"network_policy_amendment"
|
||||
],
|
||||
"title": "NetworkPolicyAmendmentReviewDecision",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "User has denied this command and the agent should not execute it, but it should continue the session and try something else.",
|
||||
"enum": [
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ExecPolicyAmendment } from "./ExecPolicyAmendment";
|
||||
import type { NetworkApprovalContext } from "./NetworkApprovalContext";
|
||||
import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
|
||||
import type { ParsedCommand } from "./ParsedCommand";
|
||||
|
||||
export type ExecApprovalRequestEvent = {
|
||||
|
|
@ -41,4 +42,8 @@ network_approval_context?: NetworkApprovalContext,
|
|||
/**
|
||||
* Proposed execpolicy amendment that can be applied to allow future runs.
|
||||
*/
|
||||
proposed_execpolicy_amendment?: ExecPolicyAmendment, parsed_cmd: Array<ParsedCommand>, };
|
||||
proposed_execpolicy_amendment?: ExecPolicyAmendment,
|
||||
/**
|
||||
* Proposed network policy amendments (for example allow/deny this host in future).
|
||||
*/
|
||||
proposed_network_policy_amendments?: Array<NetworkPolicyAmendment>, parsed_cmd: Array<ParsedCommand>, };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction";
|
||||
|
||||
export type NetworkPolicyAmendment = { host: string, action: NetworkPolicyRuleAction, };
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type NetworkPolicyRuleAction = "allow" | "deny";
|
||||
|
|
@ -2,8 +2,9 @@
|
|||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ExecPolicyAmendment } from "./ExecPolicyAmendment";
|
||||
import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
|
||||
|
||||
/**
|
||||
* User's decision in response to an ExecApprovalRequest.
|
||||
*/
|
||||
export type ReviewDecision = "approved" | { "approved_execpolicy_amendment": { proposed_execpolicy_amendment: ExecPolicyAmendment, } } | "approved_for_session" | "denied" | "abort";
|
||||
export type ReviewDecision = "approved" | { "approved_execpolicy_amendment": { proposed_execpolicy_amendment: ExecPolicyAmendment, } } | "approved_for_session" | { "network_policy_amendment": { network_policy_amendment: NetworkPolicyAmendment, } } | "denied" | "abort";
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@ export type { ModelRerouteReason } from "./ModelRerouteReason";
|
|||
export type { NetworkAccess } from "./NetworkAccess";
|
||||
export type { NetworkApprovalContext } from "./NetworkApprovalContext";
|
||||
export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol";
|
||||
export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
|
||||
export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction";
|
||||
export type { NewConversationParams } from "./NewConversationParams";
|
||||
export type { NewConversationResponse } from "./NewConversationResponse";
|
||||
export type { ParsedCommand } from "./ParsedCommand";
|
||||
|
|
|
|||
|
|
@ -55,8 +55,11 @@ use codex_hooks::HookResult;
|
|||
use codex_hooks::Hooks;
|
||||
use codex_hooks::HooksConfig;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_network_proxy::normalize_host;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
|
|
@ -165,6 +168,7 @@ use crate::mentions::build_connector_slug_counts;
|
|||
use crate::mentions::build_skill_name_counts;
|
||||
use crate::mentions::collect_explicit_app_ids;
|
||||
use crate::mentions::collect_tool_mentions_from_messages;
|
||||
use crate::network_policy_decision::execpolicy_network_rule_amendment;
|
||||
use crate::project_doc::get_user_instructions;
|
||||
use crate::proposed_plan_parser::ProposedPlanParser;
|
||||
use crate::proposed_plan_parser::ProposedPlanSegment;
|
||||
|
|
@ -2377,6 +2381,103 @@ impl Session {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn persist_network_policy_amendment(
|
||||
&self,
|
||||
amendment: &NetworkPolicyAmendment,
|
||||
network_approval_context: &NetworkApprovalContext,
|
||||
) -> anyhow::Result<()> {
|
||||
let host =
|
||||
Self::validated_network_policy_amendment_host(amendment, network_approval_context)?;
|
||||
let codex_home = self
|
||||
.state
|
||||
.lock()
|
||||
.await
|
||||
.session_configuration
|
||||
.codex_home()
|
||||
.clone();
|
||||
let execpolicy_amendment =
|
||||
execpolicy_network_rule_amendment(amendment, network_approval_context, &host);
|
||||
|
||||
if let Some(started_network_proxy) = self.services.network_proxy.as_ref() {
|
||||
let proxy = started_network_proxy.proxy();
|
||||
match amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => proxy
|
||||
.add_allowed_domain(&host)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("failed to update runtime allowlist: {err}"))?,
|
||||
NetworkPolicyRuleAction::Deny => proxy
|
||||
.add_denied_domain(&host)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("failed to update runtime denylist: {err}"))?,
|
||||
}
|
||||
}
|
||||
|
||||
self.services
|
||||
.exec_policy
|
||||
.append_network_rule_and_update(
|
||||
&codex_home,
|
||||
&host,
|
||||
execpolicy_amendment.protocol,
|
||||
execpolicy_amendment.decision,
|
||||
Some(execpolicy_amendment.justification),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
anyhow::anyhow!("failed to persist network policy amendment to execpolicy: {err}")
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validated_network_policy_amendment_host(
|
||||
amendment: &NetworkPolicyAmendment,
|
||||
network_approval_context: &NetworkApprovalContext,
|
||||
) -> anyhow::Result<String> {
|
||||
let approved_host = normalize_host(&network_approval_context.host);
|
||||
let amendment_host = normalize_host(&amendment.host);
|
||||
if amendment_host != approved_host {
|
||||
return Err(anyhow::anyhow!(
|
||||
"network policy amendment host '{}' does not match approved host '{}'",
|
||||
amendment.host,
|
||||
network_approval_context.host
|
||||
));
|
||||
}
|
||||
Ok(approved_host)
|
||||
}
|
||||
|
||||
pub(crate) async fn record_network_policy_amendment_message(
|
||||
&self,
|
||||
sub_id: &str,
|
||||
amendment: &NetworkPolicyAmendment,
|
||||
) {
|
||||
let (action, list_name) = match amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => ("Allowed", "allowlist"),
|
||||
NetworkPolicyRuleAction::Deny => ("Denied", "denylist"),
|
||||
};
|
||||
let text = format!(
|
||||
"{action} network rule saved in execpolicy ({list_name}): {}",
|
||||
amendment.host
|
||||
);
|
||||
let message: ResponseItem = DeveloperInstructions::new(text.clone()).into();
|
||||
|
||||
if let Some(turn_context) = self.turn_context_for_sub_id(sub_id).await {
|
||||
self.record_conversation_items(&turn_context, std::slice::from_ref(&message))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
if self
|
||||
.inject_response_items(vec![ResponseInputItem::Message {
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText { text }],
|
||||
}])
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!("no active turn found to record network policy amendment message for {sub_id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit an exec approval request event and await the user's decision.
|
||||
///
|
||||
/// The request is keyed by `call_id` + `approval_id` so matching responses are delivered
|
||||
|
|
@ -2414,6 +2515,18 @@ impl Session {
|
|||
}
|
||||
|
||||
let parsed_cmd = parse_command(&command);
|
||||
let proposed_network_policy_amendments = network_approval_context.as_ref().map(|context| {
|
||||
vec![
|
||||
NetworkPolicyAmendment {
|
||||
host: context.host.clone(),
|
||||
action: NetworkPolicyRuleAction::Allow,
|
||||
},
|
||||
NetworkPolicyAmendment {
|
||||
host: context.host.clone(),
|
||||
action: NetworkPolicyRuleAction::Deny,
|
||||
},
|
||||
]
|
||||
});
|
||||
let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
call_id,
|
||||
approval_id,
|
||||
|
|
@ -2423,6 +2536,7 @@ impl Session {
|
|||
reason,
|
||||
network_approval_context,
|
||||
proposed_execpolicy_amendment,
|
||||
proposed_network_policy_amendments,
|
||||
parsed_cmd,
|
||||
});
|
||||
self.send_event(turn_context, event).await;
|
||||
|
|
@ -6120,6 +6234,7 @@ mod tests {
|
|||
use crate::protocol::CompactedItem;
|
||||
use crate::protocol::CreditsSnapshot;
|
||||
use crate::protocol::InitialHistory;
|
||||
use crate::protocol::NetworkApprovalProtocol;
|
||||
use crate::protocol::RateLimitSnapshot;
|
||||
use crate::protocol::RateLimitWindow;
|
||||
use crate::protocol::ResumedHistory;
|
||||
|
|
@ -6246,6 +6361,41 @@ mod tests {
|
|||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validated_network_policy_amendment_host_allows_normalized_match() {
|
||||
let amendment = NetworkPolicyAmendment {
|
||||
host: "ExAmPlE.Com.:443".to_string(),
|
||||
action: NetworkPolicyRuleAction::Allow,
|
||||
};
|
||||
let context = NetworkApprovalContext {
|
||||
host: "example.com".to_string(),
|
||||
protocol: NetworkApprovalProtocol::Https,
|
||||
};
|
||||
|
||||
let host = Session::validated_network_policy_amendment_host(&amendment, &context)
|
||||
.expect("normalized hosts should match");
|
||||
|
||||
assert_eq!(host, "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validated_network_policy_amendment_host_rejects_mismatch() {
|
||||
let amendment = NetworkPolicyAmendment {
|
||||
host: "evil.example.com".to_string(),
|
||||
action: NetworkPolicyRuleAction::Deny,
|
||||
};
|
||||
let context = NetworkApprovalContext {
|
||||
host: "api.example.com".to_string(),
|
||||
protocol: NetworkApprovalProtocol::Https,
|
||||
};
|
||||
|
||||
let err = Session::validated_network_policy_amendment_host(&amendment, &context)
|
||||
.expect_err("mismatched hosts should be rejected");
|
||||
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("does not match approved host"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_base_instructions_no_user_content() {
|
||||
let prompt_with_apply_patch_instructions =
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@ use codex_execpolicy::AmendError;
|
|||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Error as ExecPolicyRuleError;
|
||||
use codex_execpolicy::Evaluation;
|
||||
use codex_execpolicy::NetworkRuleProtocol;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_execpolicy::PolicyParser;
|
||||
use codex_execpolicy::RuleMatch;
|
||||
use codex_execpolicy::blocking_append_allow_prefix_rule;
|
||||
use codex_execpolicy::blocking_append_network_rule;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
|
|
@ -293,6 +295,43 @@ impl ExecPolicyManager {
|
|||
self.policy.store(Arc::new(updated_policy));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn append_network_rule_and_update(
|
||||
&self,
|
||||
codex_home: &Path,
|
||||
host: &str,
|
||||
protocol: NetworkRuleProtocol,
|
||||
decision: Decision,
|
||||
justification: Option<String>,
|
||||
) -> Result<(), ExecPolicyUpdateError> {
|
||||
let policy_path = default_policy_path(codex_home);
|
||||
let host = host.to_string();
|
||||
spawn_blocking({
|
||||
let policy_path = policy_path.clone();
|
||||
let host = host.clone();
|
||||
let justification = justification.clone();
|
||||
move || {
|
||||
blocking_append_network_rule(
|
||||
&policy_path,
|
||||
&host,
|
||||
protocol,
|
||||
decision,
|
||||
justification.as_deref(),
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|source| ExecPolicyUpdateError::JoinBlockingTask { source })?
|
||||
.map_err(|source| ExecPolicyUpdateError::AppendRule {
|
||||
path: policy_path,
|
||||
source,
|
||||
})?;
|
||||
|
||||
let mut updated_policy = self.current().as_ref().clone();
|
||||
updated_policy.add_network_rule(&host, protocol, decision, justification)?;
|
||||
self.policy.store(Arc::new(updated_policy));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ExecPolicyManager {
|
||||
|
|
@ -440,7 +479,10 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy,
|
|||
}
|
||||
}
|
||||
|
||||
Ok(Policy::new(combined_rules))
|
||||
let mut combined_network_rules = policy.network_rules().to_vec();
|
||||
combined_network_rules.extend(requirements_policy.as_ref().network_rules().iter().cloned());
|
||||
|
||||
Ok(Policy::from_parts(combined_rules, combined_network_rules))
|
||||
}
|
||||
|
||||
/// If a command is not matched by any execpolicy rule, derive a [`Decision`].
|
||||
|
|
@ -914,6 +956,41 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merges_requirements_exec_policy_network_rules() -> anyhow::Result<()> {
|
||||
let temp_dir = tempdir()?;
|
||||
|
||||
let mut requirements_exec_policy = Policy::empty();
|
||||
requirements_exec_policy.add_network_rule(
|
||||
"blocked.example.com",
|
||||
codex_execpolicy::NetworkRuleProtocol::Https,
|
||||
Decision::Forbidden,
|
||||
None,
|
||||
)?;
|
||||
|
||||
let requirements = ConfigRequirements {
|
||||
exec_policy: Some(codex_config::Sourced::new(
|
||||
codex_config::RequirementsExecPolicy::new(requirements_exec_policy),
|
||||
codex_config::RequirementSource::Unknown,
|
||||
)),
|
||||
..ConfigRequirements::default()
|
||||
};
|
||||
let dot_codex_folder = AbsolutePathBuf::from_absolute_path(temp_dir.path())?;
|
||||
let layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::Project { dot_codex_folder },
|
||||
TomlValue::Table(Default::default()),
|
||||
);
|
||||
let config_stack =
|
||||
ConfigLayerStack::new(vec![layer], requirements, ConfigRequirementsToml::default())?;
|
||||
|
||||
let policy = load_exec_policy(&config_stack).await?;
|
||||
let (allowed, denied) = policy.compiled_network_domains();
|
||||
|
||||
assert!(allowed.is_empty());
|
||||
assert_eq!(denied, vec!["blocked.example.com".to_string()]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ignores_policies_outside_policy_dir() {
|
||||
let temp_dir = tempdir().expect("create temp dir");
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
use codex_execpolicy::Decision as ExecPolicyDecision;
|
||||
use codex_execpolicy::NetworkRuleProtocol as ExecPolicyNetworkRuleProtocol;
|
||||
use codex_network_proxy::BlockedRequest;
|
||||
use codex_network_proxy::NetworkDecisionSource;
|
||||
use codex_network_proxy::NetworkPolicyDecision;
|
||||
use codex_protocol::approvals::NetworkApprovalContext;
|
||||
use codex_protocol::approvals::NetworkApprovalProtocol;
|
||||
use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
|
|
@ -17,6 +21,13 @@ pub struct NetworkPolicyDecisionPayload {
|
|||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ExecPolicyNetworkRuleAmendment {
|
||||
pub protocol: ExecPolicyNetworkRuleProtocol,
|
||||
pub decision: ExecPolicyDecision,
|
||||
pub justification: String,
|
||||
}
|
||||
|
||||
impl NetworkPolicyDecisionPayload {
|
||||
pub(crate) fn is_ask_from_decider(&self) -> bool {
|
||||
self.decision == NetworkPolicyDecision::Ask && self.source == NetworkDecisionSource::Decider
|
||||
|
|
@ -79,10 +90,42 @@ pub(crate) fn denied_network_policy_message(blocked: &BlockedRequest) -> Option<
|
|||
))
|
||||
}
|
||||
|
||||
pub(crate) fn execpolicy_network_rule_amendment(
|
||||
amendment: &NetworkPolicyAmendment,
|
||||
network_approval_context: &NetworkApprovalContext,
|
||||
host: &str,
|
||||
) -> ExecPolicyNetworkRuleAmendment {
|
||||
let protocol = match network_approval_context.protocol {
|
||||
NetworkApprovalProtocol::Http => ExecPolicyNetworkRuleProtocol::Http,
|
||||
NetworkApprovalProtocol::Https => ExecPolicyNetworkRuleProtocol::Https,
|
||||
NetworkApprovalProtocol::Socks5Tcp => ExecPolicyNetworkRuleProtocol::Socks5Tcp,
|
||||
NetworkApprovalProtocol::Socks5Udp => ExecPolicyNetworkRuleProtocol::Socks5Udp,
|
||||
};
|
||||
let (decision, action_verb) = match amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => (ExecPolicyDecision::Allow, "Allow"),
|
||||
NetworkPolicyRuleAction::Deny => (ExecPolicyDecision::Forbidden, "Deny"),
|
||||
};
|
||||
let protocol_label = match network_approval_context.protocol {
|
||||
NetworkApprovalProtocol::Http => "http",
|
||||
NetworkApprovalProtocol::Https => "https_connect",
|
||||
NetworkApprovalProtocol::Socks5Tcp => "socks5_tcp",
|
||||
NetworkApprovalProtocol::Socks5Udp => "socks5_udp",
|
||||
};
|
||||
let justification = format!("{action_verb} {protocol_label} access to {host}");
|
||||
|
||||
ExecPolicyNetworkRuleAmendment {
|
||||
protocol,
|
||||
decision,
|
||||
justification,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_network_proxy::BlockedRequest;
|
||||
use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
|
|
@ -211,6 +254,27 @@ mod tests {
|
|||
assert_eq!(payload.protocol, Some(NetworkApprovalProtocol::Https));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execpolicy_network_rule_amendment_maps_protocol_action_and_justification() {
|
||||
let amendment = NetworkPolicyAmendment {
|
||||
action: NetworkPolicyRuleAction::Deny,
|
||||
host: "example.com".to_string(),
|
||||
};
|
||||
let context = NetworkApprovalContext {
|
||||
host: "example.com".to_string(),
|
||||
protocol: NetworkApprovalProtocol::Socks5Udp,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
execpolicy_network_rule_amendment(&amendment, &context, "example.com"),
|
||||
ExecPolicyNetworkRuleAmendment {
|
||||
protocol: ExecPolicyNetworkRuleProtocol::Socks5Udp,
|
||||
decision: ExecPolicyDecision::Forbidden,
|
||||
justification: "Deny socks5_udp access to example.com".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denied_network_policy_message_requires_deny_decision() {
|
||||
let blocked = BlockedRequest {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ use crate::config_loader::ConfigLayerStack;
|
|||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::config_loader::LoaderOverrides;
|
||||
use crate::config_loader::load_config_layers_state;
|
||||
use crate::exec_policy::ExecPolicyError;
|
||||
use crate::exec_policy::format_exec_policy_error_with_source;
|
||||
use crate::exec_policy::load_exec_policy;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
|
@ -18,6 +21,7 @@ use codex_network_proxy::NetworkProxyConstraintError;
|
|||
use codex_network_proxy::NetworkProxyConstraints;
|
||||
use codex_network_proxy::NetworkProxyState;
|
||||
use codex_network_proxy::build_config_state;
|
||||
use codex_network_proxy::normalize_host;
|
||||
use codex_network_proxy::validate_policy_against_constraints;
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -49,7 +53,21 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec<LayerMtime
|
|||
.await
|
||||
.context("failed to load Codex config")?;
|
||||
|
||||
let config = config_from_layers(&config_layer_stack)?;
|
||||
let (exec_policy, warning) = match load_exec_policy(&config_layer_stack).await {
|
||||
Ok(policy) => (policy, None),
|
||||
Err(err @ ExecPolicyError::ParsePolicy { .. }) => {
|
||||
(codex_execpolicy::Policy::empty(), Some(err))
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
if let Some(err) = warning.as_ref() {
|
||||
tracing::warn!(
|
||||
"failed to parse execpolicy while building network proxy state: {}",
|
||||
format_exec_policy_error_with_source(err)
|
||||
);
|
||||
}
|
||||
|
||||
let config = config_from_layers(&config_layer_stack, &exec_policy)?;
|
||||
|
||||
let constraints = enforce_trusted_constraints(&config_layer_stack, &config)?;
|
||||
let layer_mtimes = collect_layer_mtimes(&config_layer_stack);
|
||||
|
|
@ -175,15 +193,46 @@ fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesTo
|
|||
}
|
||||
}
|
||||
|
||||
fn config_from_layers(layers: &ConfigLayerStack) -> Result<NetworkProxyConfig> {
|
||||
fn config_from_layers(
|
||||
layers: &ConfigLayerStack,
|
||||
exec_policy: &codex_execpolicy::Policy,
|
||||
) -> Result<NetworkProxyConfig> {
|
||||
let mut config = NetworkProxyConfig::default();
|
||||
for layer in layers.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) {
|
||||
let parsed = network_tables_from_toml(&layer.config)?;
|
||||
apply_network_tables(&mut config, parsed);
|
||||
}
|
||||
apply_exec_policy_network_rules(&mut config, exec_policy);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn apply_exec_policy_network_rules(
|
||||
config: &mut NetworkProxyConfig,
|
||||
exec_policy: &codex_execpolicy::Policy,
|
||||
) {
|
||||
let (allowed_domains, denied_domains) = exec_policy.compiled_network_domains();
|
||||
for host in allowed_domains {
|
||||
upsert_network_domain(
|
||||
&mut config.network.allowed_domains,
|
||||
&mut config.network.denied_domains,
|
||||
host,
|
||||
);
|
||||
}
|
||||
for host in denied_domains {
|
||||
upsert_network_domain(
|
||||
&mut config.network.denied_domains,
|
||||
&mut config.network.allowed_domains,
|
||||
host,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_network_domain(target: &mut Vec<String>, opposite: &mut Vec<String>, host: String) {
|
||||
opposite.retain(|entry| normalize_host(entry) != host);
|
||||
target.retain(|entry| normalize_host(entry) != host);
|
||||
target.push(host);
|
||||
}
|
||||
|
||||
fn is_user_controlled_layer(layer: &ConfigLayerSource) -> bool {
|
||||
matches!(
|
||||
layer,
|
||||
|
|
@ -260,6 +309,9 @@ impl ConfigReloader for MtimeConfigReloader {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::NetworkRuleProtocol;
|
||||
use codex_execpolicy::Policy;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
|
|
@ -292,6 +344,45 @@ allowed_domains = ["higher.example.com"]
|
|||
assert_eq!(config.network.allowed_domains, vec!["higher.example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execpolicy_network_rules_overlay_network_lists() {
|
||||
let mut config = NetworkProxyConfig::default();
|
||||
config.network.allowed_domains = vec!["config.example.com".to_string()];
|
||||
config.network.denied_domains = vec!["blocked.example.com".to_string()];
|
||||
|
||||
let mut exec_policy = Policy::empty();
|
||||
exec_policy
|
||||
.add_network_rule(
|
||||
"blocked.example.com",
|
||||
NetworkRuleProtocol::Https,
|
||||
Decision::Allow,
|
||||
None,
|
||||
)
|
||||
.expect("allow rule should be valid");
|
||||
exec_policy
|
||||
.add_network_rule(
|
||||
"api.example.com",
|
||||
NetworkRuleProtocol::Http,
|
||||
Decision::Forbidden,
|
||||
None,
|
||||
)
|
||||
.expect("deny rule should be valid");
|
||||
|
||||
apply_exec_policy_network_rules(&mut config, &exec_policy);
|
||||
|
||||
assert_eq!(
|
||||
config.network.allowed_domains,
|
||||
vec![
|
||||
"config.example.com".to_string(),
|
||||
"blocked.example.com".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
config.network.denied_domains,
|
||||
vec!["api.example.com".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_network_constraints_includes_allow_all_unix_sockets_flag() {
|
||||
let config: toml::Value = toml::from_str(
|
||||
|
|
|
|||
|
|
@ -10,8 +10,12 @@ use codex_network_proxy::NetworkProtocol;
|
|||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::approvals::NetworkApprovalContext;
|
||||
use codex_protocol::approvals::NetworkApprovalProtocol;
|
||||
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use indexmap::IndexMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
|
@ -19,6 +23,7 @@ use std::sync::Arc;
|
|||
use tokio::sync::Mutex;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
|
|
@ -158,6 +163,7 @@ pub(crate) struct NetworkApprovalService {
|
|||
call_outcomes: Mutex<HashMap<String, NetworkApprovalOutcome>>,
|
||||
pending_host_approvals: Mutex<HashMap<HostApprovalKey, Arc<PendingHostApproval>>>,
|
||||
session_approved_hosts: Mutex<HashSet<HostApprovalKey>>,
|
||||
session_denied_hosts: Mutex<HashSet<HostApprovalKey>>,
|
||||
}
|
||||
|
||||
impl Default for NetworkApprovalService {
|
||||
|
|
@ -167,6 +173,7 @@ impl Default for NetworkApprovalService {
|
|||
call_outcomes: Mutex::new(HashMap::new()),
|
||||
pending_host_approvals: Mutex::new(HashMap::new()),
|
||||
session_approved_hosts: Mutex::new(HashSet::new()),
|
||||
session_denied_hosts: Mutex::new(HashSet::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -272,6 +279,13 @@ impl NetworkApprovalService {
|
|||
};
|
||||
let key = HostApprovalKey::from_request(&request, protocol);
|
||||
|
||||
{
|
||||
let denied_hosts = self.session_denied_hosts.lock().await;
|
||||
if denied_hosts.contains(&key) {
|
||||
return NetworkDecision::deny(REASON_NOT_ALLOWED);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let approved_hosts = self.session_approved_hosts.lock().await;
|
||||
if approved_hosts.contains(&key) {
|
||||
|
|
@ -312,6 +326,10 @@ impl NetworkApprovalService {
|
|||
|
||||
let approval_id = Self::approval_id_for_key(&key);
|
||||
let prompt_command = vec!["network-access".to_string(), target.clone()];
|
||||
let network_approval_context = NetworkApprovalContext {
|
||||
host: request.host.clone(),
|
||||
protocol,
|
||||
};
|
||||
|
||||
let approval_decision = session
|
||||
.request_command_approval(
|
||||
|
|
@ -321,19 +339,86 @@ impl NetworkApprovalService {
|
|||
prompt_command,
|
||||
turn_context.cwd.clone(),
|
||||
Some(prompt_reason),
|
||||
Some(NetworkApprovalContext {
|
||||
host: request.host.clone(),
|
||||
protocol,
|
||||
}),
|
||||
Some(network_approval_context.clone()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut cache_session_deny = false;
|
||||
let resolved = match approval_decision {
|
||||
ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
|
||||
PendingApprovalDecision::AllowOnce
|
||||
}
|
||||
ReviewDecision::ApprovedForSession => PendingApprovalDecision::AllowForSession,
|
||||
ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment,
|
||||
} => match network_policy_amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => {
|
||||
match session
|
||||
.persist_network_policy_amendment(
|
||||
&network_policy_amendment,
|
||||
&network_approval_context,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
session
|
||||
.record_network_policy_amendment_message(
|
||||
&turn_context.sub_id,
|
||||
&network_policy_amendment,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
let message =
|
||||
format!("Failed to apply network policy amendment: {err}");
|
||||
warn!("{message}");
|
||||
session
|
||||
.send_event_raw(Event {
|
||||
id: turn_context.sub_id.clone(),
|
||||
msg: EventMsg::Warning(WarningEvent { message }),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
PendingApprovalDecision::AllowForSession
|
||||
}
|
||||
NetworkPolicyRuleAction::Deny => {
|
||||
match session
|
||||
.persist_network_policy_amendment(
|
||||
&network_policy_amendment,
|
||||
&network_approval_context,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
session
|
||||
.record_network_policy_amendment_message(
|
||||
&turn_context.sub_id,
|
||||
&network_policy_amendment,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
let message =
|
||||
format!("Failed to apply network policy amendment: {err}");
|
||||
warn!("{message}");
|
||||
session
|
||||
.send_event_raw(Event {
|
||||
id: turn_context.sub_id.clone(),
|
||||
msg: EventMsg::Warning(WarningEvent { message }),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
self.record_outcome_for_single_active_call(
|
||||
NetworkApprovalOutcome::DeniedByUser,
|
||||
)
|
||||
.await;
|
||||
cache_session_deny = true;
|
||||
PendingApprovalDecision::Deny
|
||||
}
|
||||
},
|
||||
ReviewDecision::Denied | ReviewDecision::Abort => {
|
||||
self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByUser)
|
||||
.await;
|
||||
|
|
@ -342,10 +427,23 @@ impl NetworkApprovalService {
|
|||
};
|
||||
|
||||
if matches!(resolved, PendingApprovalDecision::AllowForSession) {
|
||||
{
|
||||
let mut denied_hosts = self.session_denied_hosts.lock().await;
|
||||
denied_hosts.remove(&key);
|
||||
}
|
||||
let mut approved_hosts = self.session_approved_hosts.lock().await;
|
||||
approved_hosts.insert(key.clone());
|
||||
}
|
||||
|
||||
if cache_session_deny {
|
||||
{
|
||||
let mut approved_hosts = self.session_approved_hosts.lock().await;
|
||||
approved_hosts.remove(&key);
|
||||
}
|
||||
let mut denied_hosts = self.session_denied_hosts.lock().await;
|
||||
denied_hosts.insert(key.clone());
|
||||
}
|
||||
|
||||
pending.set_decision(resolved).await;
|
||||
let mut pending_approvals = self.pending_host_approvals.lock().await;
|
||||
pending_approvals.remove(&key);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ use crate::tools::sandboxing::ToolRuntime;
|
|||
use crate::tools::sandboxing::default_exec_approval_requirement;
|
||||
use codex_otel::ToolDecisionSource;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::NetworkPolicyRuleAction;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
|
||||
pub(crate) struct ToolOrchestrator {
|
||||
|
|
@ -145,6 +146,14 @@ impl ToolOrchestrator {
|
|||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
||||
| ReviewDecision::ApprovedForSession => {}
|
||||
ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment,
|
||||
} => match network_policy_amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => {}
|
||||
NetworkPolicyRuleAction::Deny => {
|
||||
return Err(ToolError::Rejected("rejected by user".to_string()));
|
||||
}
|
||||
},
|
||||
}
|
||||
already_approved = true;
|
||||
}
|
||||
|
|
@ -273,6 +282,14 @@ impl ToolOrchestrator {
|
|||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
||||
| ReviewDecision::ApprovedForSession => {}
|
||||
ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment,
|
||||
} => match network_policy_amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => {}
|
||||
NetworkPolicyRuleAction::Deny => {
|
||||
return Err(ToolError::Rejected("rejected by user".to_string()));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ use crate::protocol::ExecCommandOutputDeltaEvent;
|
|||
#[cfg(unix)]
|
||||
use crate::protocol::ExecOutputStream;
|
||||
#[cfg(unix)]
|
||||
use crate::protocol::NetworkPolicyRuleAction;
|
||||
#[cfg(unix)]
|
||||
use crate::protocol::ReviewDecision;
|
||||
#[cfg(unix)]
|
||||
use anyhow::Context as _;
|
||||
|
|
@ -373,6 +375,16 @@ impl ZshExecBridge {
|
|||
| ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
|
||||
(WrapperExecAction::Run, None, false)
|
||||
}
|
||||
ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment,
|
||||
} => match network_policy_amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => (WrapperExecAction::Run, None, false),
|
||||
NetworkPolicyRuleAction::Deny => (
|
||||
WrapperExecAction::Deny,
|
||||
Some("command denied by host approval policy".to_string()),
|
||||
true,
|
||||
),
|
||||
},
|
||||
ReviewDecision::Denied => (
|
||||
WrapperExecAction::Deny,
|
||||
Some("command denied by host approval policy".to_string()),
|
||||
|
|
|
|||
|
|
@ -2,8 +2,17 @@
|
|||
|
||||
use anyhow::Result;
|
||||
use codex_core::config::Constrained;
|
||||
use codex_core::config_loader::ConfigLayerStack;
|
||||
use codex_core::config_loader::ConfigLayerStackOrdering;
|
||||
use codex_core::config_loader::NetworkConstraints;
|
||||
use codex_core::config_loader::NetworkRequirementsToml;
|
||||
use codex_core::config_loader::RequirementSource;
|
||||
use codex_core::config_loader::Sourced;
|
||||
use codex_core::features::Feature;
|
||||
use codex_core::sandboxing::SandboxPermissions;
|
||||
use codex_protocol::approvals::NetworkApprovalProtocol;
|
||||
use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
|
|
@ -26,6 +35,7 @@ use core_test_support::skip_if_no_network;
|
|||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_with_timeout;
|
||||
use pretty_assertions::assert_eq;
|
||||
use regex_lite::Regex;
|
||||
use serde_json::Value;
|
||||
|
|
@ -33,6 +43,8 @@ use serde_json::json;
|
|||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
|
|
@ -2106,6 +2118,305 @@ async fn approving_fallback_rule_for_compound_command_works() -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn denying_network_policy_amendment_persists_policy_and_skips_future_network_prompt()
|
||||
-> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let home = Arc::new(TempDir::new()?);
|
||||
fs::write(
|
||||
home.path().join("config.toml"),
|
||||
r#"[permissions.network]
|
||||
enabled = true
|
||||
mode = "limited"
|
||||
allow_local_binding = true
|
||||
"#,
|
||||
)?;
|
||||
let approval_policy = AskForApproval::OnFailure;
|
||||
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
read_only_access: Default::default(),
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
};
|
||||
let sandbox_policy_for_config = sandbox_policy.clone();
|
||||
let mut builder = test_codex().with_home(home).with_config(move |config| {
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config);
|
||||
let layers = config
|
||||
.config_layer_stack
|
||||
.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true)
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut requirements = config.config_layer_stack.requirements().clone();
|
||||
requirements.network = Some(Sourced::new(
|
||||
NetworkConstraints {
|
||||
enabled: Some(true),
|
||||
allow_local_binding: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
RequirementSource::CloudRequirements,
|
||||
));
|
||||
let mut requirements_toml = config.config_layer_stack.requirements_toml().clone();
|
||||
requirements_toml.network = Some(NetworkRequirementsToml {
|
||||
enabled: Some(true),
|
||||
allow_local_binding: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
config.config_layer_stack = ConfigLayerStack::new(layers, requirements, requirements_toml)
|
||||
.expect("rebuild config layer stack with network requirements");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
assert!(
|
||||
test.config.managed_network_requirements_enabled(),
|
||||
"expected managed network requirements to be enabled"
|
||||
);
|
||||
assert!(
|
||||
test.config.permissions.network.is_some(),
|
||||
"expected managed network proxy config to be present"
|
||||
);
|
||||
let runtime_proxy = test
|
||||
.session_configured
|
||||
.network_proxy
|
||||
.as_ref()
|
||||
.expect("expected runtime managed network proxy addresses");
|
||||
let proxy_addr = runtime_proxy.http_addr.as_str();
|
||||
|
||||
let call_id_first = "allow-network-first";
|
||||
// Use the same urllib-based pattern as the other network integration tests,
|
||||
// but point it at the runtime proxy directly so the blocked host reliably
|
||||
// produces a network approval request without relying on curl.
|
||||
let fetch_command = format!(
|
||||
"python3 -c \"import urllib.request; proxy = urllib.request.ProxyHandler({{'http': 'http://{proxy_addr}'}}); opener = urllib.request.build_opener(proxy); print('OK:' + opener.open('http://codex-network-test.invalid', timeout=30).read().decode(errors='replace'))\""
|
||||
);
|
||||
let first_event = shell_event(
|
||||
call_id_first,
|
||||
&fetch_command,
|
||||
30_000,
|
||||
SandboxPermissions::UseDefault,
|
||||
)?;
|
||||
|
||||
let _ = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-allow-network-1"),
|
||||
first_event,
|
||||
ev_completed("resp-allow-network-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let first_results = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-allow-network-1", "done"),
|
||||
ev_completed("resp-allow-network-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_turn(
|
||||
&test,
|
||||
"allow-network-first",
|
||||
approval_policy,
|
||||
sandbox_policy.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
let approval = loop {
|
||||
let remaining = deadline
|
||||
.checked_duration_since(std::time::Instant::now())
|
||||
.expect("timed out waiting for network approval request");
|
||||
let event = wait_for_event_with_timeout(
|
||||
&test.codex,
|
||||
|event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
||||
)
|
||||
},
|
||||
remaining,
|
||||
)
|
||||
.await;
|
||||
match event {
|
||||
EventMsg::ExecApprovalRequest(approval) => {
|
||||
if approval.command.first().map(std::string::String::as_str)
|
||||
== Some("network-access")
|
||||
{
|
||||
break approval;
|
||||
}
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval.effective_approval_id(),
|
||||
turn_id: None,
|
||||
decision: ReviewDecision::Approved,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
EventMsg::TurnComplete(_) => {
|
||||
panic!("expected network approval request before completion");
|
||||
}
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
};
|
||||
let network_context = approval
|
||||
.network_approval_context
|
||||
.clone()
|
||||
.expect("expected network approval context");
|
||||
assert_eq!(network_context.protocol, NetworkApprovalProtocol::Http);
|
||||
let expected_network_amendments = vec![
|
||||
NetworkPolicyAmendment {
|
||||
host: network_context.host.clone(),
|
||||
action: NetworkPolicyRuleAction::Allow,
|
||||
},
|
||||
NetworkPolicyAmendment {
|
||||
host: network_context.host.clone(),
|
||||
action: NetworkPolicyRuleAction::Deny,
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
approval.proposed_network_policy_amendments,
|
||||
Some(expected_network_amendments.clone())
|
||||
);
|
||||
let deny_network_amendment = expected_network_amendments
|
||||
.into_iter()
|
||||
.find(|amendment| amendment.action == NetworkPolicyRuleAction::Deny)
|
||||
.expect("expected deny network policy amendment");
|
||||
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval.effective_approval_id(),
|
||||
turn_id: None,
|
||||
decision: ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment: deny_network_amendment.clone(),
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
wait_for_completion(&test).await;
|
||||
|
||||
let policy_path = test.home.path().join("rules").join("default.rules");
|
||||
let policy_contents = fs::read_to_string(&policy_path)?;
|
||||
let expected_rule = format!(
|
||||
r#"network_rule(host="{}", protocol="{}", decision="deny", justification="Deny {} access to {}")"#,
|
||||
deny_network_amendment.host,
|
||||
match network_context.protocol {
|
||||
NetworkApprovalProtocol::Http => "http",
|
||||
NetworkApprovalProtocol::Https => "https_connect",
|
||||
NetworkApprovalProtocol::Socks5Tcp => "socks5_tcp",
|
||||
NetworkApprovalProtocol::Socks5Udp => "socks5_udp",
|
||||
},
|
||||
match network_context.protocol {
|
||||
NetworkApprovalProtocol::Http => "http",
|
||||
NetworkApprovalProtocol::Https => "https_connect",
|
||||
NetworkApprovalProtocol::Socks5Tcp => "socks5_tcp",
|
||||
NetworkApprovalProtocol::Socks5Udp => "socks5_udp",
|
||||
},
|
||||
deny_network_amendment.host
|
||||
);
|
||||
assert!(
|
||||
policy_contents.contains(&expected_rule),
|
||||
"unexpected policy contents: {policy_contents}"
|
||||
);
|
||||
|
||||
let first_output = parse_result(
|
||||
&first_results
|
||||
.single_request()
|
||||
.function_call_output(call_id_first),
|
||||
);
|
||||
Expectation::CommandFailure {
|
||||
output_contains: "",
|
||||
}
|
||||
.verify(&test, &first_output)?;
|
||||
|
||||
let call_id_second = "allow-network-second";
|
||||
let second_event = shell_event(
|
||||
call_id_second,
|
||||
&fetch_command,
|
||||
30_000,
|
||||
SandboxPermissions::UseDefault,
|
||||
)?;
|
||||
|
||||
let _ = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-allow-network-3"),
|
||||
second_event,
|
||||
ev_completed("resp-allow-network-3"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second_results = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-allow-network-2", "done"),
|
||||
ev_completed("resp-allow-network-4"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_turn(
|
||||
&test,
|
||||
"allow-network-second",
|
||||
approval_policy,
|
||||
sandbox_policy.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
loop {
|
||||
let remaining = deadline
|
||||
.checked_duration_since(std::time::Instant::now())
|
||||
.expect("timed out waiting for second turn completion");
|
||||
let event = wait_for_event_with_timeout(
|
||||
&test.codex,
|
||||
|event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
||||
)
|
||||
},
|
||||
remaining,
|
||||
)
|
||||
.await;
|
||||
match event {
|
||||
EventMsg::ExecApprovalRequest(approval) => {
|
||||
if approval.command.first().map(std::string::String::as_str)
|
||||
== Some("network-access")
|
||||
{
|
||||
panic!(
|
||||
"unexpected network approval request: {:?}",
|
||||
approval.command
|
||||
);
|
||||
}
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval.effective_approval_id(),
|
||||
turn_id: None,
|
||||
decision: ReviewDecision::Approved,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
EventMsg::TurnComplete(_) => break,
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
let second_output = parse_result(
|
||||
&second_results
|
||||
.single_request()
|
||||
.function_call_output(call_id_second),
|
||||
);
|
||||
Expectation::CommandFailure {
|
||||
output_contains: "",
|
||||
}
|
||||
.verify(&test, &second_output)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// todo(dylan) add ScenarioSpec support for rules
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[cfg(unix)]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ use std::io::Write;
|
|||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::decision::Decision;
|
||||
use crate::rule::NetworkRuleProtocol;
|
||||
use crate::rule::normalize_network_rule_host;
|
||||
use serde_json;
|
||||
use thiserror::Error;
|
||||
|
||||
|
|
@ -13,6 +16,8 @@ use thiserror::Error;
|
|||
pub enum AmendError {
|
||||
#[error("prefix rule requires at least one token")]
|
||||
EmptyPrefix,
|
||||
#[error("invalid network rule: {0}")]
|
||||
InvalidNetworkRule(String),
|
||||
#[error("policy path has no parent: {path}")]
|
||||
MissingParent { path: PathBuf },
|
||||
#[error("failed to create policy directory {dir}: {source}")]
|
||||
|
|
@ -22,6 +27,8 @@ pub enum AmendError {
|
|||
},
|
||||
#[error("failed to format prefix tokens: {source}")]
|
||||
SerializePrefix { source: serde_json::Error },
|
||||
#[error("failed to serialize network rule field: {source}")]
|
||||
SerializeNetworkRule { source: serde_json::Error },
|
||||
#[error("failed to open policy file {path}: {source}")]
|
||||
OpenPolicyFile {
|
||||
path: PathBuf,
|
||||
|
|
@ -71,7 +78,54 @@ pub fn blocking_append_allow_prefix_rule(
|
|||
.map_err(|source| AmendError::SerializePrefix { source })?;
|
||||
let pattern = format!("[{}]", tokens.join(", "));
|
||||
let rule = format!(r#"prefix_rule(pattern={pattern}, decision="allow")"#);
|
||||
append_rule_line(policy_path, &rule)
|
||||
}
|
||||
|
||||
/// Note this function uses advisory file locking and performs blocking I/O, so it should be used
|
||||
/// with [`tokio::task::spawn_blocking`] when called from an async context.
|
||||
pub fn blocking_append_network_rule(
|
||||
policy_path: &Path,
|
||||
host: &str,
|
||||
protocol: NetworkRuleProtocol,
|
||||
decision: Decision,
|
||||
justification: Option<&str>,
|
||||
) -> Result<(), AmendError> {
|
||||
let host = normalize_network_rule_host(host)
|
||||
.map_err(|err| AmendError::InvalidNetworkRule(err.to_string()))?;
|
||||
if let Some(raw) = justification
|
||||
&& raw.trim().is_empty()
|
||||
{
|
||||
return Err(AmendError::InvalidNetworkRule(
|
||||
"justification cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let host = serde_json::to_string(&host)
|
||||
.map_err(|source| AmendError::SerializeNetworkRule { source })?;
|
||||
let protocol = serde_json::to_string(protocol.as_policy_string())
|
||||
.map_err(|source| AmendError::SerializeNetworkRule { source })?;
|
||||
let decision = serde_json::to_string(match decision {
|
||||
Decision::Allow => "allow",
|
||||
Decision::Prompt => "prompt",
|
||||
Decision::Forbidden => "deny",
|
||||
})
|
||||
.map_err(|source| AmendError::SerializeNetworkRule { source })?;
|
||||
|
||||
let mut args = vec![
|
||||
format!("host={host}"),
|
||||
format!("protocol={protocol}"),
|
||||
format!("decision={decision}"),
|
||||
];
|
||||
if let Some(justification) = justification {
|
||||
let justification = serde_json::to_string(justification)
|
||||
.map_err(|source| AmendError::SerializeNetworkRule { source })?;
|
||||
args.push(format!("justification={justification}"));
|
||||
}
|
||||
let rule = format!("network_rule({})", args.join(", "));
|
||||
append_rule_line(policy_path, &rule)
|
||||
}
|
||||
|
||||
fn append_rule_line(policy_path: &Path, rule: &str) -> Result<(), AmendError> {
|
||||
let dir = policy_path
|
||||
.parent()
|
||||
.ok_or_else(|| AmendError::MissingParent {
|
||||
|
|
@ -87,7 +141,8 @@ pub fn blocking_append_allow_prefix_rule(
|
|||
});
|
||||
}
|
||||
}
|
||||
append_locked_line(policy_path, &rule)
|
||||
|
||||
append_locked_line(policy_path, rule)
|
||||
}
|
||||
|
||||
fn append_locked_line(policy_path: &Path, line: &str) -> Result<(), AmendError> {
|
||||
|
|
@ -215,4 +270,69 @@ prefix_rule(pattern=["echo", "Hello, world!"], decision="allow")
|
|||
"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appends_network_rule() {
|
||||
let tmp = tempdir().expect("create temp dir");
|
||||
let policy_path = tmp.path().join("rules").join("default.rules");
|
||||
|
||||
blocking_append_network_rule(
|
||||
&policy_path,
|
||||
"Api.GitHub.com",
|
||||
NetworkRuleProtocol::Https,
|
||||
Decision::Allow,
|
||||
Some("Allow https_connect access to api.github.com"),
|
||||
)
|
||||
.expect("append network rule");
|
||||
|
||||
let contents = std::fs::read_to_string(&policy_path).expect("read policy");
|
||||
assert_eq!(
|
||||
contents,
|
||||
r#"network_rule(host="api.github.com", protocol="https", decision="allow", justification="Allow https_connect access to api.github.com")
|
||||
"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appends_prefix_and_network_rules() {
|
||||
let tmp = tempdir().expect("create temp dir");
|
||||
let policy_path = tmp.path().join("rules").join("default.rules");
|
||||
|
||||
blocking_append_allow_prefix_rule(&policy_path, &[String::from("curl")])
|
||||
.expect("append prefix rule");
|
||||
blocking_append_network_rule(
|
||||
&policy_path,
|
||||
"api.github.com",
|
||||
NetworkRuleProtocol::Https,
|
||||
Decision::Allow,
|
||||
Some("Allow https_connect access to api.github.com"),
|
||||
)
|
||||
.expect("append network rule");
|
||||
|
||||
let contents = std::fs::read_to_string(&policy_path).expect("read policy");
|
||||
assert_eq!(
|
||||
contents,
|
||||
r#"prefix_rule(pattern=["curl"], decision="allow")
|
||||
network_rule(host="api.github.com", protocol="https", decision="allow", justification="Allow https_connect access to api.github.com")
|
||||
"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wildcard_network_rule_host() {
|
||||
let tmp = tempdir().expect("create temp dir");
|
||||
let policy_path = tmp.path().join("rules").join("default.rules");
|
||||
let err = blocking_append_network_rule(
|
||||
&policy_path,
|
||||
"*.example.com",
|
||||
NetworkRuleProtocol::Https,
|
||||
Decision::Allow,
|
||||
None,
|
||||
)
|
||||
.expect_err("wildcards should be rejected");
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"invalid network rule: invalid rule: network_rule host must be a specific host; wildcards are not allowed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ pub mod rule;
|
|||
|
||||
pub use amend::AmendError;
|
||||
pub use amend::blocking_append_allow_prefix_rule;
|
||||
pub use amend::blocking_append_network_rule;
|
||||
pub use decision::Decision;
|
||||
pub use error::Error;
|
||||
pub use error::ErrorLocation;
|
||||
|
|
@ -18,6 +19,7 @@ pub use execpolicycheck::ExecPolicyCheckCommand;
|
|||
pub use parser::PolicyParser;
|
||||
pub use policy::Evaluation;
|
||||
pub use policy::Policy;
|
||||
pub use rule::NetworkRuleProtocol;
|
||||
pub use rule::Rule;
|
||||
pub use rule::RuleMatch;
|
||||
pub use rule::RuleRef;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ use std::sync::Arc;
|
|||
use crate::decision::Decision;
|
||||
use crate::error::Error;
|
||||
use crate::error::Result;
|
||||
use crate::rule::NetworkRule;
|
||||
use crate::rule::NetworkRuleProtocol;
|
||||
use crate::rule::PatternToken;
|
||||
use crate::rule::PrefixPattern;
|
||||
use crate::rule::PrefixRule;
|
||||
|
|
@ -71,12 +73,14 @@ impl PolicyParser {
|
|||
#[derive(Debug, ProvidesStaticType)]
|
||||
struct PolicyBuilder {
|
||||
rules_by_program: MultiMap<String, RuleRef>,
|
||||
network_rules: Vec<NetworkRule>,
|
||||
}
|
||||
|
||||
impl PolicyBuilder {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
rules_by_program: MultiMap::new(),
|
||||
network_rules: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -85,8 +89,12 @@ impl PolicyBuilder {
|
|||
.insert(rule.program().to_string(), rule);
|
||||
}
|
||||
|
||||
fn add_network_rule(&mut self, rule: NetworkRule) {
|
||||
self.network_rules.push(rule);
|
||||
}
|
||||
|
||||
fn build(self) -> crate::policy::Policy {
|
||||
crate::policy::Policy::new(self.rules_by_program)
|
||||
crate::policy::Policy::from_parts(self.rules_by_program, self.network_rules)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,6 +150,13 @@ fn parse_examples<'v>(examples: UnpackList<Value<'v>>) -> Result<Vec<Vec<String>
|
|||
examples.items.into_iter().map(parse_example).collect()
|
||||
}
|
||||
|
||||
fn parse_network_rule_decision(raw: &str) -> Result<Decision> {
|
||||
match raw {
|
||||
"deny" => Ok(Decision::Forbidden),
|
||||
other => Decision::parse(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_example<'v>(value: Value<'v>) -> Result<Vec<String>> {
|
||||
if let Some(raw) = value.unpack_str() {
|
||||
parse_string_example(raw)
|
||||
|
|
@ -266,4 +281,31 @@ fn policy_builtins(builder: &mut GlobalsBuilder) {
|
|||
rules.into_iter().for_each(|rule| builder.add_rule(rule));
|
||||
Ok(NoneType)
|
||||
}
|
||||
|
||||
fn network_rule<'v>(
|
||||
host: &'v str,
|
||||
protocol: &'v str,
|
||||
decision: &'v str,
|
||||
justification: Option<&'v str>,
|
||||
eval: &mut Evaluator<'v, '_, '_>,
|
||||
) -> anyhow::Result<NoneType> {
|
||||
let protocol = NetworkRuleProtocol::parse(protocol)?;
|
||||
let decision = parse_network_rule_decision(decision)?;
|
||||
let justification = match justification {
|
||||
Some(raw) if raw.trim().is_empty() => {
|
||||
return Err(Error::InvalidRule("justification cannot be empty".to_string()).into());
|
||||
}
|
||||
Some(raw) => Some(raw.to_string()),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut builder = policy_builder(eval);
|
||||
builder.add_network_rule(NetworkRule {
|
||||
host: crate::rule::normalize_network_rule_host(host)?,
|
||||
protocol,
|
||||
decision,
|
||||
justification,
|
||||
});
|
||||
Ok(NoneType)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
use crate::decision::Decision;
|
||||
use crate::error::Error;
|
||||
use crate::error::Result;
|
||||
use crate::rule::NetworkRule;
|
||||
use crate::rule::NetworkRuleProtocol;
|
||||
use crate::rule::PatternToken;
|
||||
use crate::rule::PrefixPattern;
|
||||
use crate::rule::PrefixRule;
|
||||
use crate::rule::RuleMatch;
|
||||
use crate::rule::RuleRef;
|
||||
use crate::rule::normalize_network_rule_host;
|
||||
use multimap::MultiMap;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
|
@ -16,11 +19,22 @@ type HeuristicsFallback<'a> = Option<&'a dyn Fn(&[String]) -> Decision>;
|
|||
#[derive(Clone, Debug)]
|
||||
pub struct Policy {
|
||||
rules_by_program: MultiMap<String, RuleRef>,
|
||||
network_rules: Vec<NetworkRule>,
|
||||
}
|
||||
|
||||
impl Policy {
|
||||
pub fn new(rules_by_program: MultiMap<String, RuleRef>) -> Self {
|
||||
Self { rules_by_program }
|
||||
Self::from_parts(rules_by_program, Vec::new())
|
||||
}
|
||||
|
||||
pub fn from_parts(
|
||||
rules_by_program: MultiMap<String, RuleRef>,
|
||||
network_rules: Vec<NetworkRule>,
|
||||
) -> Self {
|
||||
Self {
|
||||
rules_by_program,
|
||||
network_rules,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
|
|
@ -31,6 +45,10 @@ impl Policy {
|
|||
&self.rules_by_program
|
||||
}
|
||||
|
||||
pub fn network_rules(&self) -> &[NetworkRule] {
|
||||
&self.network_rules
|
||||
}
|
||||
|
||||
pub fn get_allowed_prefixes(&self) -> Vec<Vec<String>> {
|
||||
let mut prefixes = Vec::new();
|
||||
|
||||
|
|
@ -77,6 +95,51 @@ impl Policy {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_network_rule(
|
||||
&mut self,
|
||||
host: &str,
|
||||
protocol: NetworkRuleProtocol,
|
||||
decision: Decision,
|
||||
justification: Option<String>,
|
||||
) -> Result<()> {
|
||||
let host = normalize_network_rule_host(host)?;
|
||||
if let Some(raw) = justification.as_deref()
|
||||
&& raw.trim().is_empty()
|
||||
{
|
||||
return Err(Error::InvalidRule(
|
||||
"justification cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
self.network_rules.push(NetworkRule {
|
||||
host,
|
||||
protocol,
|
||||
decision,
|
||||
justification,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn compiled_network_domains(&self) -> (Vec<String>, Vec<String>) {
|
||||
let mut allowed = Vec::new();
|
||||
let mut denied = Vec::new();
|
||||
|
||||
for rule in &self.network_rules {
|
||||
match rule.decision {
|
||||
Decision::Allow => {
|
||||
denied.retain(|entry| entry != &rule.host);
|
||||
upsert_domain(&mut allowed, &rule.host);
|
||||
}
|
||||
Decision::Forbidden => {
|
||||
allowed.retain(|entry| entry != &rule.host);
|
||||
upsert_domain(&mut denied, &rule.host);
|
||||
}
|
||||
Decision::Prompt => {}
|
||||
}
|
||||
}
|
||||
|
||||
(allowed, denied)
|
||||
}
|
||||
|
||||
pub fn check<F>(&self, cmd: &[String], heuristics_fallback: &F) -> Evaluation
|
||||
where
|
||||
F: Fn(&[String]) -> Decision,
|
||||
|
|
@ -140,6 +203,11 @@ impl Policy {
|
|||
}
|
||||
}
|
||||
|
||||
fn upsert_domain(entries: &mut Vec<String>, host: &str) {
|
||||
entries.retain(|entry| entry != host);
|
||||
entries.push(host.to_string());
|
||||
}
|
||||
|
||||
fn render_pattern_token(token: &PatternToken) -> String {
|
||||
match token {
|
||||
PatternToken::Single(value) => value.clone(),
|
||||
|
|
|
|||
|
|
@ -92,6 +92,103 @@ pub struct PrefixRule {
|
|||
pub justification: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum NetworkRuleProtocol {
|
||||
Http,
|
||||
Https,
|
||||
Socks5Tcp,
|
||||
Socks5Udp,
|
||||
}
|
||||
|
||||
impl NetworkRuleProtocol {
|
||||
pub fn parse(raw: &str) -> Result<Self> {
|
||||
match raw {
|
||||
"http" => Ok(Self::Http),
|
||||
"https" | "https_connect" | "http-connect" => Ok(Self::Https),
|
||||
"socks5_tcp" => Ok(Self::Socks5Tcp),
|
||||
"socks5_udp" => Ok(Self::Socks5Udp),
|
||||
other => Err(Error::InvalidRule(format!(
|
||||
"network_rule protocol must be one of http, https, socks5_tcp, socks5_udp (got {other})"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_policy_string(self) -> &'static str {
|
||||
match self {
|
||||
Self::Http => "http",
|
||||
Self::Https => "https",
|
||||
Self::Socks5Tcp => "socks5_tcp",
|
||||
Self::Socks5Udp => "socks5_udp",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct NetworkRule {
|
||||
pub host: String,
|
||||
pub protocol: NetworkRuleProtocol,
|
||||
pub decision: Decision,
|
||||
pub justification: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_network_rule_host(raw: &str) -> Result<String> {
|
||||
let mut host = raw.trim();
|
||||
if host.is_empty() {
|
||||
return Err(Error::InvalidRule(
|
||||
"network_rule host cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if host.contains("://") || host.contains('/') || host.contains('?') || host.contains('#') {
|
||||
return Err(Error::InvalidRule(
|
||||
"network_rule host must be a hostname or IP literal (without scheme or path)"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(stripped) = host.strip_prefix('[') {
|
||||
let Some((inside, rest)) = stripped.split_once(']') else {
|
||||
return Err(Error::InvalidRule(
|
||||
"network_rule host has an invalid bracketed IPv6 literal".to_string(),
|
||||
));
|
||||
};
|
||||
let port_ok = rest
|
||||
.strip_prefix(':')
|
||||
.is_some_and(|port| !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()));
|
||||
if !rest.is_empty() && !port_ok {
|
||||
return Err(Error::InvalidRule(format!(
|
||||
"network_rule host contains an unsupported suffix: {raw}"
|
||||
)));
|
||||
}
|
||||
host = inside;
|
||||
} else if host.matches(':').count() == 1
|
||||
&& let Some((candidate, port)) = host.rsplit_once(':')
|
||||
&& !candidate.is_empty()
|
||||
&& !port.is_empty()
|
||||
&& port.chars().all(|c| c.is_ascii_digit())
|
||||
{
|
||||
host = candidate;
|
||||
}
|
||||
|
||||
let normalized = host.trim_end_matches('.').trim().to_ascii_lowercase();
|
||||
if normalized.is_empty() {
|
||||
return Err(Error::InvalidRule(
|
||||
"network_rule host cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if normalized.contains('*') {
|
||||
return Err(Error::InvalidRule(
|
||||
"network_rule host must be a specific host; wildcards are not allowed".to_string(),
|
||||
));
|
||||
}
|
||||
if normalized.chars().any(char::is_whitespace) {
|
||||
return Err(Error::InvalidRule(
|
||||
"network_rule host cannot contain whitespace".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub trait Rule: Any + Debug + Send + Sync {
|
||||
fn program(&self) -> &str;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use anyhow::Result;
|
|||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Error;
|
||||
use codex_execpolicy::Evaluation;
|
||||
use codex_execpolicy::NetworkRuleProtocol;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_execpolicy::PolicyParser;
|
||||
use codex_execpolicy::RuleMatch;
|
||||
|
|
@ -67,6 +68,45 @@ fn append_allow_prefix_rule_dedupes_existing_rule() -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_rules_compile_into_domain_lists() -> Result<()> {
|
||||
let policy_src = r#"
|
||||
network_rule(host = "google.com", protocol = "http", decision = "allow")
|
||||
network_rule(host = "api.github.com", protocol = "https", decision = "allow")
|
||||
network_rule(host = "blocked.example.com", protocol = "https", decision = "deny")
|
||||
network_rule(host = "prompt-only.example.com", protocol = "https", decision = "prompt")
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser.parse("network.rules", policy_src)?;
|
||||
let policy = parser.build();
|
||||
|
||||
assert_eq!(policy.network_rules().len(), 4);
|
||||
assert_eq!(
|
||||
policy.network_rules()[1].protocol,
|
||||
NetworkRuleProtocol::Https
|
||||
);
|
||||
|
||||
let (allowed, denied) = policy.compiled_network_domains();
|
||||
assert_eq!(
|
||||
allowed,
|
||||
vec!["google.com".to_string(), "api.github.com".to_string()]
|
||||
);
|
||||
assert_eq!(denied, vec!["blocked.example.com".to_string()]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_rule_rejects_wildcard_hosts() {
|
||||
let mut parser = PolicyParser::new();
|
||||
let err = parser
|
||||
.parse(
|
||||
"network.rules",
|
||||
r#"network_rule(host="*", protocol="http", decision="allow")"#,
|
||||
)
|
||||
.expect_err("wildcard network_rule host should fail");
|
||||
assert!(err.to_string().contains("wildcards are not allowed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_match() -> Result<()> {
|
||||
let policy_src = r#"
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ async fn run_codex_tool_session_inner(
|
|||
approval_id: _,
|
||||
reason: _,
|
||||
proposed_execpolicy_amendment: _,
|
||||
proposed_network_policy_amendments: _,
|
||||
parsed_cmd,
|
||||
network_approval_context: _,
|
||||
} = ev;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ pub use network_policy::NetworkPolicyDecision;
|
|||
pub use network_policy::NetworkPolicyRequest;
|
||||
pub use network_policy::NetworkPolicyRequestArgs;
|
||||
pub use network_policy::NetworkProtocol;
|
||||
pub use policy::normalize_host;
|
||||
pub use proxy::ALL_PROXY_ENV_KEYS;
|
||||
pub use proxy::ALLOW_LOCAL_BINDING_ENV_KEY;
|
||||
pub use proxy::Args;
|
||||
|
|
|
|||
|
|
@ -425,6 +425,14 @@ impl NetworkProxy {
|
|||
self.admin_addr
|
||||
}
|
||||
|
||||
pub async fn add_allowed_domain(&self, host: &str) -> Result<()> {
|
||||
self.state.add_allowed_domain(host).await
|
||||
}
|
||||
|
||||
pub async fn add_denied_domain(&self, host: &str) -> Result<()> {
|
||||
self.state.add_denied_domain(host).await
|
||||
}
|
||||
|
||||
pub fn allow_local_binding(&self) -> bool {
|
||||
self.allow_local_binding
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ use crate::reasons::REASON_NOT_ALLOWED;
|
|||
use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
|
||||
use crate::state::NetworkProxyConstraintError;
|
||||
use crate::state::NetworkProxyConstraints;
|
||||
#[cfg(test)]
|
||||
use crate::state::build_config_state;
|
||||
use crate::state::validate_policy_against_constraints;
|
||||
use anyhow::Context;
|
||||
|
|
@ -500,6 +499,70 @@ impl NetworkProxyState {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn add_allowed_domain(&self, host: &str) -> Result<()> {
|
||||
self.update_domain_list(host, DomainListKind::Allow).await
|
||||
}
|
||||
|
||||
pub async fn add_denied_domain(&self, host: &str) -> Result<()> {
|
||||
self.update_domain_list(host, DomainListKind::Deny).await
|
||||
}
|
||||
|
||||
async fn update_domain_list(&self, host: &str, target: DomainListKind) -> Result<()> {
|
||||
let host = Host::parse(host).context("invalid network host")?;
|
||||
let normalized_host = host.as_str().to_string();
|
||||
let list_name = target.list_name();
|
||||
let constraint_field = target.constraint_field();
|
||||
|
||||
loop {
|
||||
self.reload_if_needed().await?;
|
||||
let (previous_cfg, constraints, blocked, blocked_total) = {
|
||||
let guard = self.state.read().await;
|
||||
(
|
||||
guard.config.clone(),
|
||||
guard.constraints.clone(),
|
||||
guard.blocked.clone(),
|
||||
guard.blocked_total,
|
||||
)
|
||||
};
|
||||
|
||||
let mut candidate = previous_cfg.clone();
|
||||
let (target_entries, opposite_entries) = candidate.split_domain_lists_mut(target);
|
||||
let target_contains = target_entries
|
||||
.iter()
|
||||
.any(|entry| normalize_host(entry) == normalized_host);
|
||||
let opposite_contains = opposite_entries
|
||||
.iter()
|
||||
.any(|entry| normalize_host(entry) == normalized_host);
|
||||
if target_contains && !opposite_contains {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
target_entries.retain(|entry| normalize_host(entry) != normalized_host);
|
||||
target_entries.push(normalized_host.clone());
|
||||
opposite_entries.retain(|entry| normalize_host(entry) != normalized_host);
|
||||
|
||||
validate_policy_against_constraints(&candidate, &constraints)
|
||||
.map_err(NetworkProxyConstraintError::into_anyhow)
|
||||
.with_context(|| format!("{constraint_field} constrained by managed config"))?;
|
||||
|
||||
let mut new_state = build_config_state(candidate.clone(), constraints.clone())
|
||||
.with_context(|| format!("failed to compile updated network {list_name}"))?;
|
||||
new_state.blocked = blocked;
|
||||
new_state.blocked_total = blocked_total;
|
||||
|
||||
let mut guard = self.state.write().await;
|
||||
if guard.constraints != constraints || guard.config != previous_cfg {
|
||||
drop(guard);
|
||||
continue;
|
||||
}
|
||||
|
||||
log_policy_changes(&guard.config, &candidate);
|
||||
*guard = new_state;
|
||||
info!("updated network {list_name} with {normalized_host}");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
async fn reload_if_needed(&self) -> Result<()> {
|
||||
match self.reloader.maybe_reload().await? {
|
||||
None => Ok(()),
|
||||
|
|
@ -527,6 +590,46 @@ impl NetworkProxyState {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum DomainListKind {
|
||||
Allow,
|
||||
Deny,
|
||||
}
|
||||
|
||||
impl DomainListKind {
|
||||
fn list_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Allow => "allowlist",
|
||||
Self::Deny => "denylist",
|
||||
}
|
||||
}
|
||||
|
||||
fn constraint_field(self) -> &'static str {
|
||||
match self {
|
||||
Self::Allow => "network.allowed_domains",
|
||||
Self::Deny => "network.denied_domains",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkProxyConfig {
|
||||
fn split_domain_lists_mut(
|
||||
&mut self,
|
||||
target: DomainListKind,
|
||||
) -> (&mut Vec<String>, &mut Vec<String>) {
|
||||
match target {
|
||||
DomainListKind::Allow => (
|
||||
&mut self.network.allowed_domains,
|
||||
&mut self.network.denied_domains,
|
||||
),
|
||||
DomainListKind::Deny => (
|
||||
&mut self.network.denied_domains,
|
||||
&mut self.network.allowed_domains,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn unix_socket_permissions_supported() -> bool {
|
||||
cfg!(target_os = "macos")
|
||||
}
|
||||
|
|
@ -695,6 +798,42 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_allowed_domain_removes_matching_deny_entry() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
denied_domains: vec!["example.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
|
||||
state.add_allowed_domain("ExAmPlE.CoM").await.unwrap();
|
||||
|
||||
let (allowed, denied) = state.current_patterns().await.unwrap();
|
||||
assert_eq!(allowed, vec!["example.com".to_string()]);
|
||||
assert!(denied.is_empty());
|
||||
assert_eq!(
|
||||
state.host_blocked("example.com", 80).await.unwrap(),
|
||||
HostBlockDecision::Allowed
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_denied_domain_removes_matching_allow_entry() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
|
||||
state.add_denied_domain("EXAMPLE.COM").await.unwrap();
|
||||
|
||||
let (allowed, denied) = state.current_patterns().await.unwrap();
|
||||
assert!(allowed.is_empty());
|
||||
assert_eq!(denied, vec!["example.com".to_string()]);
|
||||
assert_eq!(
|
||||
state.host_blocked("example.com", 80).await.unwrap(),
|
||||
HostBlockDecision::Blocked(HostBlockReason::Denied)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocked_snapshot_does_not_consume_entries() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
|
||||
|
|
|
|||
|
|
@ -55,6 +55,19 @@ pub struct NetworkApprovalContext {
|
|||
pub protocol: NetworkApprovalProtocol,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NetworkPolicyRuleAction {
|
||||
Allow,
|
||||
Deny,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
pub struct NetworkPolicyAmendment {
|
||||
pub host: String,
|
||||
pub action: NetworkPolicyRuleAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct ExecApprovalRequestEvent {
|
||||
/// Identifier for the associated command execution item.
|
||||
|
|
@ -85,6 +98,10 @@ pub struct ExecApprovalRequestEvent {
|
|||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
||||
/// Proposed network policy amendments (for example allow/deny this host in future).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub proposed_network_policy_amendments: Option<Vec<NetworkPolicyAmendment>>,
|
||||
pub parsed_cmd: Vec<ParsedCommand>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ pub use crate::approvals::ExecApprovalRequestEvent;
|
|||
pub use crate::approvals::ExecPolicyAmendment;
|
||||
pub use crate::approvals::NetworkApprovalContext;
|
||||
pub use crate::approvals::NetworkApprovalProtocol;
|
||||
pub use crate::approvals::NetworkPolicyAmendment;
|
||||
pub use crate::approvals::NetworkPolicyRuleAction;
|
||||
pub use crate::request_user_input::RequestUserInputEvent;
|
||||
|
||||
/// Open/close tags for special user-input blocks. Used across crates to avoid
|
||||
|
|
@ -2756,6 +2758,12 @@ pub enum ReviewDecision {
|
|||
/// remainder of the session.
|
||||
ApprovedForSession,
|
||||
|
||||
/// User chose to persist a network policy rule (allow/deny) for future
|
||||
/// requests to the same host.
|
||||
NetworkPolicyAmendment {
|
||||
network_policy_amendment: NetworkPolicyAmendment,
|
||||
},
|
||||
|
||||
/// User has denied this command and the agent should not execute it, but
|
||||
/// it should continue the session and try something else.
|
||||
#[default]
|
||||
|
|
@ -2774,6 +2782,12 @@ impl ReviewDecision {
|
|||
ReviewDecision::Approved => "approved",
|
||||
ReviewDecision::ApprovedExecpolicyAmendment { .. } => "approved_with_amendment",
|
||||
ReviewDecision::ApprovedForSession => "approved_for_session",
|
||||
ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment,
|
||||
} => match network_policy_amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => "approved_with_network_policy_allow",
|
||||
NetworkPolicyRuleAction::Deny => "denied_with_network_policy_deny",
|
||||
},
|
||||
ReviewDecision::Denied => "denied",
|
||||
ReviewDecision::Abort => "abort",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2775,6 +2775,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() {
|
|||
),
|
||||
network_approval_context: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_network_policy_amendments: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
|
|
@ -2821,6 +2822,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
|
|||
),
|
||||
network_approval_context: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_network_policy_amendments: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
|
|
@ -2873,6 +2875,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
|
|||
reason: None,
|
||||
network_approval_context: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_network_policy_amendments: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
|
|
@ -6470,6 +6473,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> {
|
|||
"hello".into(),
|
||||
"world".into(),
|
||||
])),
|
||||
proposed_network_policy_amendments: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
|
|
@ -6528,6 +6532,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> {
|
|||
"hello".into(),
|
||||
"world".into(),
|
||||
])),
|
||||
proposed_network_policy_amendments: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
|
|
@ -6573,6 +6578,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot()
|
|||
reason: None,
|
||||
network_approval_context: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
|
||||
proposed_network_policy_amendments: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
|
|
@ -6937,6 +6943,7 @@ async fn status_widget_and_approval_modal_snapshot() {
|
|||
"echo".into(),
|
||||
"hello world".into(),
|
||||
])),
|
||||
proposed_network_policy_amendments: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
|
|
|
|||
|
|
@ -828,6 +828,31 @@ pub fn new_approval_decision_cell(
|
|||
],
|
||||
)
|
||||
}
|
||||
NetworkPolicyAmendment {
|
||||
network_policy_amendment,
|
||||
} => {
|
||||
let host = Span::from(network_policy_amendment.host).dim();
|
||||
match network_policy_amendment.action {
|
||||
codex_protocol::protocol::NetworkPolicyRuleAction::Allow => (
|
||||
"✔ ".green(),
|
||||
vec![
|
||||
"You ".into(),
|
||||
"approved".bold(),
|
||||
" future network access to ".into(),
|
||||
host,
|
||||
],
|
||||
),
|
||||
codex_protocol::protocol::NetworkPolicyRuleAction::Deny => (
|
||||
"✗ ".red(),
|
||||
vec![
|
||||
"You ".into(),
|
||||
"blocked".bold(),
|
||||
" future network access to ".into(),
|
||||
host,
|
||||
],
|
||||
),
|
||||
}
|
||||
}
|
||||
Denied => {
|
||||
let snippet = Span::from(exec_snippet(&command)).dim();
|
||||
(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue