mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
Merge commit 'ee10594f6cea193c6eb3530b80454a6f298c44f5' into bookholt/psec-4922-repeated-approval-client-integrity
This commit is contained in:
@@ -35,6 +35,7 @@ use tracing::instrument;
|
||||
use crate::config::Config;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
use crate::tools::sandboxing::unsandboxed_execution_allowed;
|
||||
use codex_shell_command::bash::parse_shell_lc_plain_commands;
|
||||
use codex_shell_command::bash::parse_shell_lc_single_command_prefix;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -284,13 +285,15 @@ impl ExecPolicyManager {
|
||||
} = req;
|
||||
let exec_policy = self.current();
|
||||
#[cfg(windows)]
|
||||
let parsed_powershell = match powershell_policy::prepare(command) {
|
||||
Some(powershell_policy::PreparedPowerShell::Terminal(requirement)) => {
|
||||
return requirement;
|
||||
}
|
||||
Some(powershell_policy::PreparedPowerShell::Parsed(parsed)) => Some(parsed),
|
||||
None => None,
|
||||
};
|
||||
let (parsed_powershell, powershell_outer_authority) =
|
||||
match powershell_policy::prepare(command) {
|
||||
Some(powershell_policy::PreparedPowerShell::Terminal(requirement)) => {
|
||||
return requirement;
|
||||
}
|
||||
Some(powershell_policy::PreparedPowerShell::Parsed(parsed)) => (Some(parsed), true),
|
||||
Some(powershell_policy::PreparedPowerShell::Unsupported) => (None, true),
|
||||
None => (None, false),
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let exec_policy_commands = if let Some(parsed) = parsed_powershell.as_ref() {
|
||||
ExecPolicyCommands {
|
||||
@@ -303,6 +306,8 @@ impl ExecPolicyManager {
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let exec_policy_commands = commands_for_exec_policy(command);
|
||||
#[cfg(not(windows))]
|
||||
let powershell_outer_authority = false;
|
||||
let ExecPolicyCommands {
|
||||
commands,
|
||||
used_complex_parsing,
|
||||
@@ -328,13 +333,82 @@ impl ExecPolicyManager {
|
||||
let match_options = MatchOptions {
|
||||
resolve_host_executables: true,
|
||||
};
|
||||
let evaluation = exec_policy.check_multiple_with_options(
|
||||
let parsed_powershell_outer = powershell_outer_authority.then_some(command);
|
||||
let mut evaluation = exec_policy.check_multiple_with_options(
|
||||
commands.iter(),
|
||||
&exec_policy_fallback,
|
||||
&match_options,
|
||||
);
|
||||
let outer_matches = parsed_powershell_outer
|
||||
.map(|outer| {
|
||||
exec_policy.matches_for_command_with_options(
|
||||
outer,
|
||||
/*heuristics_fallback*/ None,
|
||||
&match_options,
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let outer_allow = outer_matches.iter().any(|rule_match| {
|
||||
matches!(
|
||||
rule_match,
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
decision: Decision::Allow,
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
let exact_outer_allow = parsed_powershell_outer.is_some_and(|outer| {
|
||||
outer_matches.iter().any(|rule_match| {
|
||||
matches!(
|
||||
rule_match,
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix,
|
||||
decision: Decision::Allow,
|
||||
..
|
||||
} if matched_prefix.len() == outer.len()
|
||||
)
|
||||
})
|
||||
});
|
||||
evaluation.matched_rules.extend(outer_matches);
|
||||
evaluation.decision = evaluation
|
||||
.matched_rules
|
||||
.iter()
|
||||
.filter(|rule_match| !outer_allow || is_policy_match(rule_match))
|
||||
.map(RuleMatch::decision)
|
||||
.max()
|
||||
.unwrap_or(Decision::Forbidden);
|
||||
|
||||
let requested_amendment = if auto_amendment_allowed {
|
||||
let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy();
|
||||
let managed_filesystem_restrictions =
|
||||
profile_has_managed_filesystem_restrictions(&permission_profile);
|
||||
let permission_delta_requires_outer = match (&permission_profile, sandbox_permissions) {
|
||||
(PermissionProfile::Disabled, _) => false,
|
||||
(_, SandboxPermissions::WithAdditionalPermissions) => true,
|
||||
(PermissionProfile::External { .. }, SandboxPermissions::RequireEscalated) => true,
|
||||
(PermissionProfile::Managed { .. }, SandboxPermissions::RequireEscalated) => {
|
||||
unsandboxed_execution_allowed(&file_system_sandbox_policy)
|
||||
}
|
||||
(_, SandboxPermissions::UseDefault) => false,
|
||||
};
|
||||
let parsed_powershell_needs_outer_approval = parsed_powershell_outer.is_some()
|
||||
&& !exact_outer_allow
|
||||
&& (permission_delta_requires_outer
|
||||
|| (cfg!(windows)
|
||||
&& windows_sandbox_level == WindowsSandboxLevel::Disabled
|
||||
&& managed_filesystem_restrictions));
|
||||
if evaluation.decision == Decision::Allow && parsed_powershell_needs_outer_approval {
|
||||
evaluation.decision = Decision::Prompt;
|
||||
evaluation
|
||||
.matched_rules
|
||||
.push(RuleMatch::HeuristicsRuleMatch {
|
||||
command: command.to_vec(),
|
||||
decision: Decision::Prompt,
|
||||
});
|
||||
}
|
||||
|
||||
let requested_amendment = if parsed_powershell_outer.is_some() {
|
||||
None
|
||||
} else if auto_amendment_allowed {
|
||||
derive_requested_execpolicy_amendment_from_prefix_rule(
|
||||
prefix_rule.as_ref(),
|
||||
&evaluation.matched_rules,
|
||||
@@ -362,33 +436,48 @@ impl ExecPolicyManager {
|
||||
None => ExecApprovalRequirement::NeedsApproval {
|
||||
reason: derive_prompt_reason(command, &evaluation),
|
||||
proposed_execpolicy_amendment: requested_amendment.or_else(|| {
|
||||
if auto_amendment_allowed {
|
||||
try_derive_execpolicy_amendment_for_prompt_rules(
|
||||
&evaluation.matched_rules,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
match (
|
||||
parsed_powershell_outer,
|
||||
prompt_is_rule,
|
||||
auto_amendment_allowed,
|
||||
) {
|
||||
(Some(outer), false, _) => {
|
||||
Some(ExecPolicyAmendment::new(outer.to_vec()))
|
||||
}
|
||||
(None, _, true) => {
|
||||
try_derive_execpolicy_amendment_for_prompt_rules(
|
||||
&evaluation.matched_rules,
|
||||
)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
Decision::Allow => ExecApprovalRequirement::Skip {
|
||||
// Bypass sandbox only when every parsed command segment is
|
||||
// explicitly allowed by execpolicy.
|
||||
bypass_sandbox: commands.iter().all(|command| {
|
||||
exec_policy
|
||||
.matches_for_command_with_options(
|
||||
command,
|
||||
/*heuristics_fallback*/ None,
|
||||
&match_options,
|
||||
)
|
||||
.iter()
|
||||
.any(|rule_match| {
|
||||
is_policy_match(rule_match) && rule_match.decision() == Decision::Allow
|
||||
})
|
||||
}),
|
||||
proposed_execpolicy_amendment: if auto_amendment_allowed {
|
||||
// Lowered PowerShell command names do not bind runtime module, profile, or PATH
|
||||
// resolution. Only an exact full-wrapper Allow may authorize sandbox bypass.
|
||||
bypass_sandbox: if parsed_powershell_outer.is_some() {
|
||||
exact_outer_allow
|
||||
} else {
|
||||
commands.iter().all(|command| {
|
||||
exec_policy
|
||||
.matches_for_command_with_options(
|
||||
command,
|
||||
/*heuristics_fallback*/ None,
|
||||
&match_options,
|
||||
)
|
||||
.iter()
|
||||
.any(|rule_match| {
|
||||
is_policy_match(rule_match)
|
||||
&& rule_match.decision() == Decision::Allow
|
||||
})
|
||||
})
|
||||
},
|
||||
proposed_execpolicy_amendment: if let Some(outer) = parsed_powershell_outer {
|
||||
(!exact_outer_allow).then(|| ExecPolicyAmendment::new(outer.to_vec()))
|
||||
} else if auto_amendment_allowed {
|
||||
try_derive_execpolicy_amendment_for_allow_rules(&evaluation.matched_rules)
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use codex_shell_command::powershell::PowerShellExecPolicyParse;
|
||||
use codex_shell_command::powershell::PowerShellExecPolicyParseOutcome;
|
||||
|
||||
use super::render_shlex_command;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
@@ -6,6 +7,7 @@ use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
pub(super) enum PreparedPowerShell {
|
||||
Terminal(ExecApprovalRequirement),
|
||||
Parsed(ParsedPowerShell),
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
pub(super) struct ParsedPowerShell {
|
||||
@@ -13,26 +15,43 @@ pub(super) struct ParsedPowerShell {
|
||||
}
|
||||
|
||||
pub(super) fn prepare(command: &[String]) -> Option<PreparedPowerShell> {
|
||||
match codex_shell_command::powershell::parse_powershell_command_for_exec_policy(command)? {
|
||||
let parsed =
|
||||
codex_shell_command::powershell::parse_powershell_command_for_exec_policy(command)?;
|
||||
prepare_classified(command, parsed)
|
||||
}
|
||||
|
||||
pub(super) fn prepare_classified(
|
||||
command: &[String],
|
||||
parsed: PowerShellExecPolicyParse,
|
||||
) -> Option<PreparedPowerShell> {
|
||||
match parsed {
|
||||
PowerShellExecPolicyParse::TrustedRuntime {
|
||||
commands: Some(commands),
|
||||
} if !commands.is_empty() => {
|
||||
Some(PreparedPowerShell::Parsed(ParsedPowerShell { commands }))
|
||||
}
|
||||
PowerShellExecPolicyParse::TrustedRuntime { .. } => Some(forbidden(
|
||||
command,
|
||||
"the PowerShell script could not be inspected",
|
||||
)),
|
||||
outcome: PowerShellExecPolicyParseOutcome::Commands(commands),
|
||||
} => Some(PreparedPowerShell::Parsed(ParsedPowerShell { commands })),
|
||||
PowerShellExecPolicyParse::TrustedRuntime {
|
||||
outcome: PowerShellExecPolicyParseOutcome::Unsupported,
|
||||
} => Some(PreparedPowerShell::Unsupported),
|
||||
PowerShellExecPolicyParse::TrustedRuntime {
|
||||
outcome: PowerShellExecPolicyParseOutcome::Failed,
|
||||
} => Some(forbidden(command, "the protected PowerShell parser failed")),
|
||||
PowerShellExecPolicyParse::UntrustedRuntime {
|
||||
commands: Some(commands),
|
||||
} if !commands.is_empty() => Some(forbidden(
|
||||
outcome: PowerShellExecPolicyParseOutcome::Commands(_),
|
||||
} => Some(forbidden(
|
||||
command,
|
||||
"the PowerShell runtime is not a protected system executable",
|
||||
)),
|
||||
PowerShellExecPolicyParse::UntrustedRuntime { .. } => Some(forbidden(
|
||||
PowerShellExecPolicyParse::UntrustedRuntime {
|
||||
outcome: PowerShellExecPolicyParseOutcome::Unsupported,
|
||||
} => Some(forbidden(
|
||||
command,
|
||||
"an untrusted PowerShell wrapper could not be inspected with the protected system parser",
|
||||
)),
|
||||
PowerShellExecPolicyParse::UntrustedRuntime {
|
||||
outcome: PowerShellExecPolicyParseOutcome::Failed,
|
||||
} => Some(forbidden(
|
||||
command,
|
||||
"the protected system parser failed while inspecting an untrusted PowerShell wrapper",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
use codex_shell_command::powershell::PowerShellExecPolicyParse;
|
||||
use codex_shell_command::powershell::PowerShellExecPolicyParseOutcome;
|
||||
|
||||
fn trusted_windows_powershell() -> String {
|
||||
codex_shell_command::powershell::try_find_powershell_executable_blocking()
|
||||
@@ -9,6 +11,29 @@ fn trusted_windows_powershell() -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn powershell_command(script: &str) -> Vec<String> {
|
||||
vec![
|
||||
trusted_windows_powershell(),
|
||||
"-Command".to_string(),
|
||||
script.to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn outer_result(command: &[String], prompt: bool) -> ExecApprovalRequirement {
|
||||
let amendment = Some(ExecPolicyAmendment::new(command.to_vec()));
|
||||
if prompt {
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: amendment,
|
||||
}
|
||||
} else {
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: amendment,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn granular(rules: bool, sandbox_approval: bool) -> AskForApproval {
|
||||
AskForApproval::Granular(GranularApprovalConfig {
|
||||
rules,
|
||||
@@ -19,8 +44,39 @@ fn granular(rules: bool, sandbox_approval: bool) -> AskForApproval {
|
||||
})
|
||||
}
|
||||
|
||||
fn approval_policies() -> [AskForApproval; 7] {
|
||||
[
|
||||
AskForApproval::Never,
|
||||
AskForApproval::OnRequest,
|
||||
AskForApproval::UnlessTrusted,
|
||||
granular(/*rules*/ false, /*sandbox_approval*/ false),
|
||||
granular(/*rules*/ false, /*sandbox_approval*/ true),
|
||||
granular(/*rules*/ true, /*sandbox_approval*/ false),
|
||||
granular(/*rules*/ true, /*sandbox_approval*/ true),
|
||||
]
|
||||
}
|
||||
|
||||
async fn requirement(
|
||||
policy_src: Option<&str>,
|
||||
command: &[String],
|
||||
approval_policy: AskForApproval,
|
||||
permission_profile: PermissionProfile,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
) -> ExecApprovalRequirement {
|
||||
exec_approval_requirement_for_command(ExecApprovalRequirementScenario {
|
||||
policy_src: policy_src.map(str::to_owned),
|
||||
command: command.to_vec(),
|
||||
approval_policy,
|
||||
permission_profile,
|
||||
sandbox_permissions,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_every_noninspectable_or_untrusted_powershell_state() {
|
||||
async fn rejects_untrusted_powershell_across_approval_and_sandbox_modes() {
|
||||
let trusted = trusted_windows_powershell();
|
||||
let cases = [
|
||||
(
|
||||
"untrusted parsed runtime",
|
||||
@@ -28,16 +84,6 @@ async fn rejects_every_noninspectable_or_untrusted_powershell_state() {
|
||||
Some(r#"prefix_rule(pattern=["echo"], decision="allow")"#),
|
||||
"the PowerShell runtime is not a protected system executable",
|
||||
),
|
||||
(
|
||||
"trusted empty body",
|
||||
vec![
|
||||
trusted_windows_powershell(),
|
||||
"-Command".into(),
|
||||
String::new(),
|
||||
],
|
||||
None,
|
||||
"the PowerShell script could not be inspected",
|
||||
),
|
||||
(
|
||||
"untrusted opaque body",
|
||||
vec_str(&[
|
||||
@@ -49,6 +95,34 @@ async fn rejects_every_noninspectable_or_untrusted_powershell_state() {
|
||||
None,
|
||||
"an untrusted PowerShell wrapper could not be inspected with the protected system parser",
|
||||
),
|
||||
(
|
||||
"full outer rule for bare runtime",
|
||||
vec_str(&["powershell.exe", "-Command", "echo allowed"]),
|
||||
Some(
|
||||
r#"prefix_rule(pattern=["powershell.exe", "-Command", "echo allowed"], decision="allow")"#,
|
||||
),
|
||||
"the PowerShell runtime is not a protected system executable",
|
||||
),
|
||||
(
|
||||
"verbatim path alias",
|
||||
vec![
|
||||
format!(r"\\?\{trusted}."),
|
||||
"-Command".to_string(),
|
||||
"echo allowed".to_string(),
|
||||
],
|
||||
Some(r#"prefix_rule(pattern=["echo"], decision="allow")"#),
|
||||
"the PowerShell runtime is not a protected system executable",
|
||||
),
|
||||
(
|
||||
"device path alias",
|
||||
vec![
|
||||
format!(r"\\.\{trusted} "),
|
||||
"-Command".to_string(),
|
||||
"echo allowed".to_string(),
|
||||
],
|
||||
Some(r#"prefix_rule(pattern=["echo"], decision="allow")"#),
|
||||
"the PowerShell runtime is not a protected system executable",
|
||||
),
|
||||
];
|
||||
let policies = [
|
||||
AskForApproval::Never,
|
||||
@@ -95,3 +169,189 @@ async fn rejects_every_noninspectable_or_untrusted_powershell_state() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_failures_are_terminal_for_trusted_and_untrusted_runtimes() {
|
||||
let command = powershell_command("Get-Content Cargo.toml");
|
||||
let rendered = render_shlex_command(&command);
|
||||
let cases = [
|
||||
(
|
||||
PowerShellExecPolicyParse::TrustedRuntime {
|
||||
outcome: PowerShellExecPolicyParseOutcome::Failed,
|
||||
},
|
||||
"the protected PowerShell parser failed",
|
||||
),
|
||||
(
|
||||
PowerShellExecPolicyParse::UntrustedRuntime {
|
||||
outcome: PowerShellExecPolicyParseOutcome::Failed,
|
||||
},
|
||||
"the protected system parser failed while inspecting an untrusted PowerShell wrapper",
|
||||
),
|
||||
];
|
||||
|
||||
for (parsed, reason) in cases {
|
||||
let Some(powershell_policy::PreparedPowerShell::Terminal(requirement)) =
|
||||
powershell_policy::prepare_classified(&command, parsed)
|
||||
else {
|
||||
panic!("parser failure must produce a terminal policy result");
|
||||
};
|
||||
pretty_assertions::assert_eq!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: format!("`{rendered}` rejected: {reason}"),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trusted_unsupported_scripts_use_the_generic_outer_policy() {
|
||||
let scripts = [
|
||||
"",
|
||||
"param([string]$path) Get-Content Cargo.toml",
|
||||
"#Requires -Modules C:\\workspace\\CodexProbe.psm1\nGet-Content Cargo.toml",
|
||||
"UsInG MoDuLe '\\\\attacker\\share\\Evil.psd1'\nGet-Content Cargo.toml",
|
||||
"configuration CodexProbe { Import-DscResource -ModuleName '\\\\attacker\\share\\Evil.psd1' }",
|
||||
"[Codex.DoesNotExist, C:/workspace/Codex.AttackerAssembly]",
|
||||
// The raw pre-parser gate intentionally accepts false positives in exchange for
|
||||
// never invoking SMA on a possible parse-time construct.
|
||||
"Write-Output 'confusing but inert'",
|
||||
];
|
||||
let profiles = [PermissionProfile::Disabled, PermissionProfile::read_only()];
|
||||
|
||||
for script in scripts {
|
||||
let command = powershell_command(script);
|
||||
for approval_policy in approval_policies() {
|
||||
for permission_profile in &profiles {
|
||||
let requirement = requirement(
|
||||
None,
|
||||
&command,
|
||||
approval_policy,
|
||||
permission_profile.clone(),
|
||||
SandboxPermissions::UseDefault,
|
||||
)
|
||||
.await;
|
||||
let expected =
|
||||
outer_result(&command, approval_policy == AskForApproval::UnlessTrusted);
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
requirement,
|
||||
expected,
|
||||
"script {script:?} with {approval_policy:?} and {permission_profile:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dangerous_trusted_unsupported_scripts_keep_generic_policy_protections() {
|
||||
let command = powershell_command("Write-Output 'using'; Remove-Item target -Force");
|
||||
let rendered = render_shlex_command(&command);
|
||||
let profiles = [PermissionProfile::Disabled, PermissionProfile::read_only()];
|
||||
|
||||
for approval_policy in approval_policies() {
|
||||
for permission_profile in &profiles {
|
||||
let requirement = requirement(
|
||||
None,
|
||||
&command,
|
||||
approval_policy,
|
||||
permission_profile.clone(),
|
||||
SandboxPermissions::UseDefault,
|
||||
)
|
||||
.await;
|
||||
let expected = match approval_policy {
|
||||
AskForApproval::Never
|
||||
if matches!(permission_profile, PermissionProfile::Disabled) =>
|
||||
{
|
||||
outer_result(&command, false)
|
||||
}
|
||||
AskForApproval::Never => ExecApprovalRequirement::Forbidden {
|
||||
reason: format!("`{rendered}` rejected: blocked by policy"),
|
||||
},
|
||||
AskForApproval::Granular(config) if !config.allows_sandbox_approval() => {
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: REJECT_SANDBOX_APPROVAL_REASON.to_string(),
|
||||
}
|
||||
}
|
||||
_ => outer_result(&command, true),
|
||||
};
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
requirement,
|
||||
expected,
|
||||
"{approval_policy:?} with {permission_profile:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sandbox_override_on_trusted_unsupported_script_uses_outer_argv() {
|
||||
let command = powershell_command("Write-Output 'confusing but inert'");
|
||||
|
||||
for (approval_policy, permits_prompt) in [
|
||||
(AskForApproval::OnRequest, true),
|
||||
(granular(/*rules*/ true, /*sandbox_approval*/ true), true),
|
||||
(granular(/*rules*/ true, /*sandbox_approval*/ false), false),
|
||||
] {
|
||||
pretty_assertions::assert_eq!(
|
||||
requirement(
|
||||
None,
|
||||
&command,
|
||||
approval_policy,
|
||||
PermissionProfile::read_only(),
|
||||
SandboxPermissions::RequireEscalated,
|
||||
)
|
||||
.await,
|
||||
if permits_prompt {
|
||||
outer_result(&command, true)
|
||||
} else {
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: REJECT_SANDBOX_APPROVAL_REASON.to_string(),
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trusted_unsupported_scripts_only_match_outer_rules() {
|
||||
let inner_rule_command = powershell_command(
|
||||
"#Requires -Modules C:\\workspace\\CodexProbe.psm1\nGet-Content Cargo.toml",
|
||||
);
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
requirement(
|
||||
Some(r#"prefix_rule(pattern=["Get-Content"], decision="allow")"#),
|
||||
&inner_rule_command,
|
||||
AskForApproval::UnlessTrusted,
|
||||
PermissionProfile::read_only(),
|
||||
SandboxPermissions::UseDefault,
|
||||
)
|
||||
.await,
|
||||
outer_result(&inner_rule_command, true),
|
||||
);
|
||||
|
||||
let command = powershell_command("Write-Output 'confusing but inert'");
|
||||
let outer_pattern = command
|
||||
.iter()
|
||||
.map(|word| format!(r#""{}""#, starlark_string(word)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let policy_src = format!(r#"prefix_rule(pattern=[{outer_pattern}], decision="allow")"#);
|
||||
pretty_assertions::assert_eq!(
|
||||
requirement(
|
||||
Some(&policy_src),
|
||||
&command,
|
||||
AskForApproval::UnlessTrusted,
|
||||
PermissionProfile::read_only(),
|
||||
SandboxPermissions::UseDefault,
|
||||
)
|
||||
.await,
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: true,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,80 @@ static TRUSTED_WINDOWS_POWERSHELL_EXE: LazyLock<String> = LazyLock::new(|| {
|
||||
.to_string()
|
||||
});
|
||||
|
||||
fn powershell_command(script: &str) -> Vec<String> {
|
||||
vec![
|
||||
TRUSTED_WINDOWS_POWERSHELL_EXE.to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
"-Command".to_string(),
|
||||
script.to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn prefix_rule_for(command: &[String], decision: &str) -> String {
|
||||
let pattern = command
|
||||
.iter()
|
||||
.map(|word| format!(r#""{}""#, starlark_string(word)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!(r#"prefix_rule(pattern=[{pattern}], decision="{decision}")"#)
|
||||
}
|
||||
|
||||
fn skip_outer(command: &[String], bypass_sandbox: bool) -> ExecApprovalRequirement {
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox,
|
||||
proposed_execpolicy_amendment: (!bypass_sandbox)
|
||||
.then(|| ExecPolicyAmendment::new(command.to_vec())),
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_outer(command: &[String]) -> ExecApprovalRequirement {
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command.to_vec())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn windows_requirement(
|
||||
policy_src: String,
|
||||
command: &[String],
|
||||
approval_policy: AskForApproval,
|
||||
permission_profile: PermissionProfile,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
) -> ExecApprovalRequirement {
|
||||
ExecPolicyManager::new(policy_from_src(Some(&policy_src)))
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command,
|
||||
approval_policy,
|
||||
permission_profile,
|
||||
windows_sandbox_level,
|
||||
sandbox_permissions,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn default_windows_requirement(
|
||||
policy_src: String,
|
||||
command: &[String],
|
||||
) -> ExecApprovalRequirement {
|
||||
windows_requirement(
|
||||
policy_src,
|
||||
command,
|
||||
AskForApproval::UnlessTrusted,
|
||||
PermissionProfile::read_only(),
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
SandboxPermissions::UseDefault,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn external_profile() -> PermissionProfile {
|
||||
PermissionProfile::External {
|
||||
network: NetworkSandboxPolicy::Restricted,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evaluates_powershell_inner_commands_against_prompt_rules() {
|
||||
assert_exec_approval_requirement_for_command(
|
||||
@@ -35,27 +109,271 @@ async fn evaluates_powershell_inner_commands_against_prompt_rules() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evaluates_powershell_inner_commands_against_allow_rules() {
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(r#"prefix_rule(pattern=["echo"], decision="allow")"#.to_string()),
|
||||
command: vec![
|
||||
TRUSTED_WINDOWS_POWERSHELL_EXE.to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
"-Command".to_string(),
|
||||
"echo blocked".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
},
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: true,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
async fn inner_allows_for_runtime_resolved_names_remain_sandboxed() {
|
||||
for (script, inner) in [
|
||||
("echo blocked", "echo"),
|
||||
("Get-Content Cargo.toml", "Get-Content"),
|
||||
("Invoke-ProfileHook test", "Invoke-ProfileHook"),
|
||||
("codex-path-helper --version", "codex-path-helper"),
|
||||
] {
|
||||
let command = powershell_command(script);
|
||||
assert_eq!(
|
||||
default_windows_requirement(
|
||||
format!(r#"prefix_rule(pattern=["{inner}"], decision="allow")"#),
|
||||
&command,
|
||||
)
|
||||
.await,
|
||||
skip_outer(&command, false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_exact_outer_rules_can_bypass_runtime_resolution() {
|
||||
let command = powershell_command("Remove-Item target -Force");
|
||||
let executable = starlark_string(&command[0]);
|
||||
let rest = command[1..]
|
||||
.iter()
|
||||
.map(|word| format!(r#""{}""#, starlark_string(word)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let mismatched = powershell_command("Remove-Item other -Force");
|
||||
let cases = [
|
||||
(prefix_rule_for(&command[..1], "allow"), Some(false)),
|
||||
(prefix_rule_for(&command, "allow"), Some(true)),
|
||||
(
|
||||
format!(
|
||||
"host_executable(name = \"powershell\", paths = [\"{executable}\"])\n\
|
||||
prefix_rule(pattern=[\"powershell\", {rest}], decision=\"allow\")"
|
||||
),
|
||||
Some(true),
|
||||
),
|
||||
(
|
||||
format!(
|
||||
"prefix_rule(pattern=[[\"{executable}\", \"C:\\\\other\\\\powershell.exe\"], \
|
||||
[\"-NoProfile\", \"-noprofile\"], \"-Command\", \
|
||||
\"Remove-Item target -Force\"], decision=\"allow\")"
|
||||
),
|
||||
Some(true),
|
||||
),
|
||||
(prefix_rule_for(&mismatched, "allow"), None),
|
||||
];
|
||||
|
||||
for (policy_src, bypass_sandbox) in cases {
|
||||
assert_eq!(
|
||||
default_windows_requirement(policy_src, &command).await,
|
||||
bypass_sandbox.map_or_else(
|
||||
|| prompt_outer(&command),
|
||||
|bypass| skip_outer(&command, bypass)
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_outer_allow_does_not_bypass_extended_unsupported_wrapper() {
|
||||
use SandboxPermissions::*;
|
||||
use WindowsSandboxLevel::*;
|
||||
|
||||
let command = powershell_command("Get-Content Cargo.toml");
|
||||
let policy_src = prefix_rule_for(&command, "allow");
|
||||
let mut extended = command.clone();
|
||||
extended.push("trailing-runtime-argument".to_string());
|
||||
|
||||
for (profile, level, permissions, prompts) in [
|
||||
(
|
||||
PermissionProfile::read_only(),
|
||||
RestrictedToken,
|
||||
UseDefault,
|
||||
false,
|
||||
),
|
||||
(
|
||||
PermissionProfile::read_only(),
|
||||
RestrictedToken,
|
||||
RequireEscalated,
|
||||
true,
|
||||
),
|
||||
(
|
||||
PermissionProfile::read_only(),
|
||||
RestrictedToken,
|
||||
WithAdditionalPermissions,
|
||||
true,
|
||||
),
|
||||
(PermissionProfile::read_only(), Disabled, UseDefault, true),
|
||||
(
|
||||
PermissionProfile::Disabled,
|
||||
RestrictedToken,
|
||||
UseDefault,
|
||||
false,
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
windows_requirement(
|
||||
policy_src.clone(),
|
||||
&extended,
|
||||
AskForApproval::UnlessTrusted,
|
||||
profile,
|
||||
level,
|
||||
permissions,
|
||||
)
|
||||
.await,
|
||||
if prompts {
|
||||
prompt_outer(&extended)
|
||||
} else {
|
||||
skip_outer(&extended, false)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_inner_and_outer_restrictions_remain_strictest() {
|
||||
let command = powershell_command("Get-Content Cargo.toml");
|
||||
let outer_allow = prefix_rule_for(&command, "allow");
|
||||
let inner_allow = r#"prefix_rule(pattern=["Get-Content"], decision="allow")"#;
|
||||
let rendered = render_shlex_command(&command);
|
||||
|
||||
for (inner, decision) in [
|
||||
(true, "prompt"),
|
||||
(true, "forbidden"),
|
||||
(false, "prompt"),
|
||||
(false, "forbidden"),
|
||||
] {
|
||||
let (policy_src, forbidden_prefix) = if inner {
|
||||
(
|
||||
format!(
|
||||
"{outer_allow}\nprefix_rule(pattern=[\"Get-Content\"], decision=\"{decision}\")"
|
||||
),
|
||||
"Get-Content".to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!("{inner_allow}\n{}", prefix_rule_for(&command, decision)),
|
||||
rendered.clone(),
|
||||
)
|
||||
};
|
||||
let expected = if decision == "prompt" {
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: Some(format!("`{rendered}` requires approval by policy")),
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
} else {
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: format!(
|
||||
"`{rendered}` rejected: policy forbids commands starting with `{forbidden_prefix}`"
|
||||
),
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
windows_requirement(
|
||||
policy_src,
|
||||
&command,
|
||||
AskForApproval::OnRequest,
|
||||
PermissionProfile::read_only(),
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
SandboxPermissions::UseDefault,
|
||||
)
|
||||
.await,
|
||||
expected,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn outer_authority_tracks_permission_deltas_and_missing_managed_sandbox() {
|
||||
use SandboxPermissions::*;
|
||||
use WindowsSandboxLevel::*;
|
||||
|
||||
let command = powershell_command("Get-Content Cargo.toml");
|
||||
let inner_allow = r#"prefix_rule(pattern=["Get-Content"], decision="allow")"#.to_string();
|
||||
let denied_read_profile = PermissionProfile::from_runtime_permissions(
|
||||
&FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
||||
path: FileSystemPath::GlobPattern {
|
||||
pattern: "**/*.env".to_string(),
|
||||
},
|
||||
access: FileSystemAccessMode::Deny,
|
||||
}]),
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
);
|
||||
|
||||
for (profile, level, permissions, prompts) in [
|
||||
(
|
||||
PermissionProfile::read_only(),
|
||||
RestrictedToken,
|
||||
RequireEscalated,
|
||||
true,
|
||||
),
|
||||
(PermissionProfile::read_only(), Disabled, UseDefault, true),
|
||||
(
|
||||
denied_read_profile,
|
||||
RestrictedToken,
|
||||
RequireEscalated,
|
||||
false,
|
||||
),
|
||||
(
|
||||
PermissionProfile::read_only(),
|
||||
RestrictedToken,
|
||||
WithAdditionalPermissions,
|
||||
true,
|
||||
),
|
||||
(
|
||||
PermissionProfile::Disabled,
|
||||
RestrictedToken,
|
||||
RequireEscalated,
|
||||
false,
|
||||
),
|
||||
(
|
||||
PermissionProfile::Disabled,
|
||||
RestrictedToken,
|
||||
WithAdditionalPermissions,
|
||||
false,
|
||||
),
|
||||
(external_profile(), RestrictedToken, UseDefault, false),
|
||||
(external_profile(), RestrictedToken, RequireEscalated, true),
|
||||
(
|
||||
external_profile(),
|
||||
RestrictedToken,
|
||||
WithAdditionalPermissions,
|
||||
true,
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
windows_requirement(
|
||||
inner_allow.clone(),
|
||||
&command,
|
||||
AskForApproval::OnRequest,
|
||||
profile,
|
||||
level,
|
||||
permissions,
|
||||
)
|
||||
.await,
|
||||
if prompts {
|
||||
prompt_outer(&command)
|
||||
} else {
|
||||
skip_outer(&command, false)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let exact_allow = format!("{inner_allow}\n{}", prefix_rule_for(&command, "allow"));
|
||||
for (level, permissions) in [
|
||||
(RestrictedToken, RequireEscalated),
|
||||
(RestrictedToken, WithAdditionalPermissions),
|
||||
(Disabled, UseDefault),
|
||||
] {
|
||||
assert_eq!(
|
||||
windows_requirement(
|
||||
exact_allow.clone(),
|
||||
&command,
|
||||
AskForApproval::OnRequest,
|
||||
PermissionProfile::read_only(),
|
||||
level,
|
||||
permissions,
|
||||
)
|
||||
.await,
|
||||
skip_outer(&command, true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -183,21 +501,12 @@ fn writable_windows_policy_without_sandbox_backend_still_requires_approval() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn unmatched_dangerous_powershell_inner_commands_require_approval() {
|
||||
let inner_command = vec![
|
||||
"Remove-Item".to_string(),
|
||||
"test".to_string(),
|
||||
"-Force".to_string(),
|
||||
];
|
||||
let command = powershell_command("Remove-Item test -Force");
|
||||
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: None,
|
||||
command: vec![
|
||||
TRUSTED_WINDOWS_POWERSHELL_EXE.to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
"-Command".to_string(),
|
||||
"Remove-Item test -Force".to_string(),
|
||||
],
|
||||
command: command.clone(),
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
permission_profile: PermissionProfile::Disabled,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
@@ -205,7 +514,28 @@ async fn unmatched_dangerous_powershell_inner_commands_require_approval() {
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(inner_command)),
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mixed_powershell_inner_commands_use_the_strictest_decision() {
|
||||
let command = powershell_command("echo safe; Remove-Item target -Force");
|
||||
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(r#"prefix_rule(pattern=["echo"], decision="allow")"#.to_string()),
|
||||
command: command.clone(),
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
permission_profile: PermissionProfile::Disabled,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
Reference in New Issue
Block a user