mirror of
https://github.com/openai/codex.git
synced 2026-05-03 10:56:37 +00:00
Compare commits
6 Commits
dev/servic
...
codex/wind
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c61c48eec | ||
|
|
c23edf2838 | ||
|
|
0c30ead917 | ||
|
|
57a1d792af | ||
|
|
cd822ab0e1 | ||
|
|
4a93d01fa3 |
@@ -37,6 +37,10 @@ use crate::sandboxing::SandboxPermissions;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
use codex_shell_command::bash::parse_shell_lc_plain_commands;
|
||||
use codex_shell_command::bash::parse_shell_lc_single_command_prefix;
|
||||
#[cfg(windows)]
|
||||
use codex_shell_command::powershell::PowershellCommandSequenceParseMode;
|
||||
#[cfg(windows)]
|
||||
use codex_shell_command::powershell::try_parse_powershell_command_sequence;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use shlex::try_join as shlex_try_join;
|
||||
|
||||
@@ -237,7 +241,7 @@ impl ExecPolicyManager {
|
||||
req: ExecApprovalRequest<'_>,
|
||||
) -> ExecApprovalRequirement {
|
||||
let ExecApprovalRequest {
|
||||
command,
|
||||
command: original_command,
|
||||
approval_policy,
|
||||
permission_profile,
|
||||
file_system_sandbox_policy,
|
||||
@@ -246,43 +250,100 @@ impl ExecPolicyManager {
|
||||
prefix_rule,
|
||||
} = req;
|
||||
let exec_policy = self.current();
|
||||
let (commands, used_complex_parsing) = commands_for_exec_policy(command);
|
||||
#[cfg(windows)]
|
||||
let powershell_commands = try_parse_powershell_command_sequence(
|
||||
original_command,
|
||||
PowershellCommandSequenceParseMode::ExecPolicy,
|
||||
)
|
||||
.filter(|commands| !commands.is_empty());
|
||||
#[cfg(not(windows))]
|
||||
let powershell_commands: Option<Vec<Vec<String>>> = None;
|
||||
let match_options = MatchOptions {
|
||||
resolve_host_executables: true,
|
||||
};
|
||||
let (evaluation, requested_amendment, used_complex_parsing) =
|
||||
if let Some(powershell_commands) = powershell_commands.as_ref() {
|
||||
// PowerShell wrappers have two useful views: the outer argv for
|
||||
// heuristics and wrapper-targeted rules, and the recovered
|
||||
// inner commands for explicit prefix matching.
|
||||
let exec_policy_fallback = |_: &[String]| {
|
||||
render_decision_for_unmatched_command(
|
||||
approval_policy,
|
||||
&permission_profile,
|
||||
file_system_sandbox_policy,
|
||||
sandbox_cwd,
|
||||
original_command,
|
||||
sandbox_permissions,
|
||||
/*used_complex_parsing*/ false,
|
||||
)
|
||||
};
|
||||
let mut evaluation = exec_policy.check_multiple_with_options(
|
||||
powershell_commands.iter(),
|
||||
&exec_policy_fallback,
|
||||
&match_options,
|
||||
);
|
||||
evaluation
|
||||
.matched_rules
|
||||
.extend(exec_policy.matches_for_command_with_options(
|
||||
original_command,
|
||||
/*heuristics_fallback*/ None,
|
||||
&match_options,
|
||||
));
|
||||
evaluation.decision = evaluation
|
||||
.matched_rules
|
||||
.iter()
|
||||
.map(RuleMatch::decision)
|
||||
.max()
|
||||
.expect("invariant failed: matched_rules must be non-empty");
|
||||
|
||||
let requested_amendment =
|
||||
derive_requested_execpolicy_amendment_from_prefix_rule_for_powershell(
|
||||
prefix_rule.as_ref(),
|
||||
&evaluation.matched_rules,
|
||||
exec_policy.as_ref(),
|
||||
powershell_commands,
|
||||
&match_options,
|
||||
);
|
||||
(evaluation, requested_amendment, false)
|
||||
} else {
|
||||
let (commands, used_complex_parsing) = commands_for_exec_policy(original_command);
|
||||
let exec_policy_fallback = |parsed_command: &[String]| {
|
||||
render_decision_for_unmatched_command(
|
||||
approval_policy,
|
||||
&permission_profile,
|
||||
file_system_sandbox_policy,
|
||||
sandbox_cwd,
|
||||
parsed_command,
|
||||
sandbox_permissions,
|
||||
used_complex_parsing,
|
||||
)
|
||||
};
|
||||
let evaluation = exec_policy.check_multiple_with_options(
|
||||
commands.iter(),
|
||||
&exec_policy_fallback,
|
||||
&match_options,
|
||||
);
|
||||
let requested_amendment = derive_requested_execpolicy_amendment_from_prefix_rule(
|
||||
prefix_rule.as_ref(),
|
||||
&evaluation.matched_rules,
|
||||
exec_policy.as_ref(),
|
||||
&commands,
|
||||
&exec_policy_fallback,
|
||||
&match_options,
|
||||
);
|
||||
(evaluation, requested_amendment, used_complex_parsing)
|
||||
};
|
||||
// Keep heredoc prefix parsing for rule evaluation so existing
|
||||
// allow/prompt/forbidden rules still apply, but avoid auto-derived
|
||||
// amendments when only the heredoc fallback parser matched.
|
||||
let auto_amendment_allowed = !used_complex_parsing;
|
||||
let exec_policy_fallback = |cmd: &[String]| {
|
||||
render_decision_for_unmatched_command(
|
||||
approval_policy,
|
||||
&permission_profile,
|
||||
file_system_sandbox_policy,
|
||||
sandbox_cwd,
|
||||
cmd,
|
||||
sandbox_permissions,
|
||||
used_complex_parsing,
|
||||
)
|
||||
};
|
||||
let match_options = MatchOptions {
|
||||
resolve_host_executables: true,
|
||||
};
|
||||
let evaluation = exec_policy.check_multiple_with_options(
|
||||
commands.iter(),
|
||||
&exec_policy_fallback,
|
||||
&match_options,
|
||||
);
|
||||
|
||||
let requested_amendment = derive_requested_execpolicy_amendment_from_prefix_rule(
|
||||
prefix_rule.as_ref(),
|
||||
&evaluation.matched_rules,
|
||||
exec_policy.as_ref(),
|
||||
&commands,
|
||||
&exec_policy_fallback,
|
||||
&match_options,
|
||||
);
|
||||
let auto_prompt_amendment_allowed = auto_amendment_allowed
|
||||
&& !(powershell_commands.is_some()
|
||||
&& evaluation.matched_rules.iter().any(is_policy_match));
|
||||
|
||||
match evaluation.decision {
|
||||
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
|
||||
reason: derive_forbidden_reason(command, &evaluation),
|
||||
reason: derive_forbidden_reason(original_command, &evaluation),
|
||||
},
|
||||
Decision::Prompt => {
|
||||
let prompt_is_rule = evaluation.matched_rules.iter().any(|rule_match| {
|
||||
@@ -293,9 +354,9 @@ impl ExecPolicyManager {
|
||||
reason: reason.to_string(),
|
||||
},
|
||||
None => ExecApprovalRequirement::NeedsApproval {
|
||||
reason: derive_prompt_reason(command, &evaluation),
|
||||
reason: derive_prompt_reason(original_command, &evaluation),
|
||||
proposed_execpolicy_amendment: requested_amendment.or_else(|| {
|
||||
if auto_amendment_allowed {
|
||||
if auto_prompt_amendment_allowed {
|
||||
try_derive_execpolicy_amendment_for_prompt_rules(
|
||||
&evaluation.matched_rules,
|
||||
)
|
||||
@@ -307,19 +368,10 @@ impl ExecPolicyManager {
|
||||
}
|
||||
}
|
||||
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
|
||||
})
|
||||
// Bypass sandbox only when the allow decision came entirely
|
||||
// from explicit execpolicy allow rules.
|
||||
bypass_sandbox: evaluation.matched_rules.iter().all(|rule_match| {
|
||||
is_policy_match(rule_match) && rule_match.decision() == Decision::Allow
|
||||
}),
|
||||
proposed_execpolicy_amendment: if auto_amendment_allowed {
|
||||
try_derive_execpolicy_amendment_for_allow_rules(&evaluation.matched_rules)
|
||||
@@ -749,7 +801,10 @@ fn try_derive_execpolicy_amendment_for_prompt_rules(
|
||||
fn try_derive_execpolicy_amendment_for_allow_rules(
|
||||
matched_rules: &[RuleMatch],
|
||||
) -> Option<ExecPolicyAmendment> {
|
||||
if matched_rules.iter().any(is_policy_match) {
|
||||
if matched_rules
|
||||
.iter()
|
||||
.any(|rule_match| is_policy_match(rule_match) && rule_match.decision() != Decision::Allow)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -764,6 +819,16 @@ fn try_derive_execpolicy_amendment_for_allow_rules(
|
||||
})
|
||||
}
|
||||
|
||||
fn prefix_rule_is_banned(prefix_rule: &[String]) -> bool {
|
||||
BANNED_PREFIX_SUGGESTIONS.iter().any(|banned| {
|
||||
prefix_rule.len() == banned.len()
|
||||
&& prefix_rule
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.eq(banned.iter().copied())
|
||||
})
|
||||
}
|
||||
|
||||
fn derive_requested_execpolicy_amendment_from_prefix_rule(
|
||||
prefix_rule: Option<&Vec<String>>,
|
||||
matched_rules: &[RuleMatch],
|
||||
@@ -776,18 +841,19 @@ fn derive_requested_execpolicy_amendment_from_prefix_rule(
|
||||
if prefix_rule.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if BANNED_PREFIX_SUGGESTIONS.iter().any(|banned| {
|
||||
prefix_rule.len() == banned.len()
|
||||
&& prefix_rule
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.eq(banned.iter().copied())
|
||||
}) {
|
||||
if prefix_rule_is_banned(prefix_rule) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// if any policy rule already matches, don't suggest an additional rule that might conflict or not apply
|
||||
if matched_rules.iter().any(is_policy_match) {
|
||||
// PowerShell evaluation can combine outer wrapper matches with unmatched
|
||||
// inner commands. Existing explicit allow rules should not suppress a
|
||||
// requested inner-command prefix rule that would resolve the remaining
|
||||
// prompt, but explicit prompt/forbidden matches still should.
|
||||
if matched_rules
|
||||
.iter()
|
||||
.any(|rule_match| is_policy_match(rule_match) && rule_match.decision() != Decision::Allow)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -805,6 +871,55 @@ fn derive_requested_execpolicy_amendment_from_prefix_rule(
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_requested_execpolicy_amendment_from_prefix_rule_for_powershell(
|
||||
prefix_rule: Option<&Vec<String>>,
|
||||
matched_rules: &[RuleMatch],
|
||||
exec_policy: &Policy,
|
||||
powershell_commands: &[Vec<String>],
|
||||
match_options: &MatchOptions,
|
||||
) -> Option<ExecPolicyAmendment> {
|
||||
let prefix_rule = prefix_rule?;
|
||||
if prefix_rule.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if prefix_rule_is_banned(prefix_rule) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// PowerShell evaluation can combine outer wrapper matches with unmatched
|
||||
// inner commands. Existing explicit allow rules should not suppress a
|
||||
// requested inner-command prefix rule that would resolve the remaining
|
||||
// prompt, but explicit prompt/forbidden matches still should.
|
||||
if matched_rules
|
||||
.iter()
|
||||
.any(|rule_match| is_policy_match(rule_match) && rule_match.decision() != Decision::Allow)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let amendment = ExecPolicyAmendment::new(prefix_rule.clone());
|
||||
let mut policy_with_prefix_rule = exec_policy.clone();
|
||||
if policy_with_prefix_rule
|
||||
.add_prefix_rule(&amendment.command, Decision::Allow)
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
powershell_commands
|
||||
.iter()
|
||||
.all(|command| {
|
||||
let matched_rules = policy_with_prefix_rule.matches_for_command_with_options(
|
||||
command,
|
||||
/*heuristics_fallback*/ None,
|
||||
match_options,
|
||||
);
|
||||
!matched_rules.is_empty()
|
||||
&& matched_rules.iter().map(RuleMatch::decision).max() == Some(Decision::Allow)
|
||||
})
|
||||
.then_some(amendment)
|
||||
}
|
||||
|
||||
fn prefix_rule_would_approve_all_commands(
|
||||
exec_policy: &Policy,
|
||||
prefix_rule: &[String],
|
||||
|
||||
@@ -656,6 +656,247 @@ async fn evaluates_bash_lc_inner_commands() {
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn evaluates_powershell_wrapped_inner_commands_against_prefix_rules() {
|
||||
let cases = vec![
|
||||
(
|
||||
r#"prefix_rule(pattern=["git", "push"], decision="forbidden")"#.to_string(),
|
||||
vec_str(&["powershell.exe", "-Command", "git push origin main"]),
|
||||
vec_str(&["git", "push"]),
|
||||
),
|
||||
(
|
||||
r#"prefix_rule(pattern=["git", "fetch"], decision="forbidden")"#.to_string(),
|
||||
vec_str(&[
|
||||
"powershell.exe",
|
||||
"-WindowStyle",
|
||||
"Hidden",
|
||||
"-Command",
|
||||
"git fetch origin main",
|
||||
]),
|
||||
vec_str(&["git", "fetch"]),
|
||||
),
|
||||
(
|
||||
r#"prefix_rule(pattern=["Invoke-WebRequest"], decision="forbidden")"#.to_string(),
|
||||
vec_str(&[
|
||||
"powershell.exe",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
"Invoke-WebRequest https://example.com",
|
||||
]),
|
||||
vec_str(&["Invoke-WebRequest"]),
|
||||
),
|
||||
(
|
||||
r#"prefix_rule(pattern=["Remove-Item"], decision="forbidden")"#.to_string(),
|
||||
vec_str(&[
|
||||
"powershell.exe",
|
||||
"-WorkingDirectory",
|
||||
"C:\\repo",
|
||||
"-Command",
|
||||
"Remove-Item foo.txt",
|
||||
]),
|
||||
vec_str(&["Remove-Item"]),
|
||||
),
|
||||
(
|
||||
r#"prefix_rule(pattern=["git", "push"], decision="forbidden")"#.to_string(),
|
||||
vec_str(&[
|
||||
"powershell.exe",
|
||||
"-Version",
|
||||
"5.1",
|
||||
"-NoExit",
|
||||
"-Command",
|
||||
"git push origin main",
|
||||
]),
|
||||
vec_str(&["git", "push"]),
|
||||
),
|
||||
];
|
||||
|
||||
for (policy_src, command, matched_prefix) in cases {
|
||||
let expected_reason = format!(
|
||||
"`{}` rejected: policy forbids commands starting with `{}`",
|
||||
render_shlex_command(&command),
|
||||
render_shlex_command(&matched_prefix),
|
||||
);
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(policy_src),
|
||||
command,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
},
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: expected_reason,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn powershell_wrapper_rules_still_apply_when_inner_commands_are_parsed() {
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(
|
||||
concat!(
|
||||
r#"prefix_rule(pattern=["powershell.exe"], decision="forbidden")"#,
|
||||
"\n",
|
||||
r#"prefix_rule(pattern=["Get-Content"], decision="allow")"#,
|
||||
)
|
||||
.to_string(),
|
||||
),
|
||||
command: vec_str(&["powershell.exe", "-Command", "Get-Content Cargo.toml"]),
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
},
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: "`powershell.exe -Command 'Get-Content Cargo.toml'` rejected: policy forbids commands starting with `powershell.exe`".to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn unmatched_powershell_wrappers_keep_outer_command_heuristics() {
|
||||
for (command, expected) in [
|
||||
(
|
||||
vec_str(&["powershell.exe", "-Command", "Get-Content Cargo.toml"]),
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec_str(&[
|
||||
"powershell.exe",
|
||||
"-Command",
|
||||
"Get-Content Cargo.toml",
|
||||
]))),
|
||||
},
|
||||
),
|
||||
(
|
||||
vec_str(&[
|
||||
"powershell.exe",
|
||||
"-WindowStyle",
|
||||
"Hidden",
|
||||
"-Command",
|
||||
"Get-Content Cargo.toml",
|
||||
]),
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec_str(&[
|
||||
"powershell.exe",
|
||||
"-WindowStyle",
|
||||
"Hidden",
|
||||
"-Command",
|
||||
"Get-Content Cargo.toml",
|
||||
]))),
|
||||
},
|
||||
),
|
||||
] {
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: None,
|
||||
command,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
},
|
||||
expected,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn allow_rule_matches_powershell_wrapped_inner_commands() {
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(
|
||||
r#"prefix_rule(pattern=["Get-Content"], decision="allow")"#.to_string(),
|
||||
),
|
||||
command: vec_str(&["powershell.exe", "-Command", "Get-Content Cargo.toml"]),
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
},
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: true,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn powershell_outer_allow_does_not_override_unmatched_wrapper_prompt() {
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(
|
||||
r#"prefix_rule(pattern=["powershell.exe"], decision="allow")"#.to_string(),
|
||||
),
|
||||
command: vec_str(&[
|
||||
"powershell.exe",
|
||||
"-WindowStyle",
|
||||
"Hidden",
|
||||
"-Command",
|
||||
"Get-Content Cargo.toml",
|
||||
]),
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn powershell_outer_allow_still_allows_inner_prefix_rule_suggestion() {
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(
|
||||
r#"prefix_rule(pattern=["powershell.exe"], decision="allow")"#.to_string(),
|
||||
),
|
||||
command: vec_str(&[
|
||||
"powershell.exe",
|
||||
"-WindowStyle",
|
||||
"Hidden",
|
||||
"-Command",
|
||||
"Get-Content Cargo.toml",
|
||||
]),
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: Some(vec_str(&["Get-Content"])),
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec_str(&[
|
||||
"Get-Content",
|
||||
]))),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_for_exec_policy_falls_back_for_empty_shell_script() {
|
||||
let command = vec!["bash".to_string(), "-lc".to_string(), "".to_string()];
|
||||
@@ -1739,6 +1980,53 @@ fn derive_requested_execpolicy_amendment_allows_non_exact_banned_prefix_rule_mat
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_requested_execpolicy_amendment_for_powershell_uses_inner_commands() {
|
||||
let prefix_rule = vec!["git".to_string(), "push".to_string()];
|
||||
let powershell_commands = vec![vec![
|
||||
"git".to_string(),
|
||||
"push".to_string(),
|
||||
"origin".to_string(),
|
||||
"main".to_string(),
|
||||
]];
|
||||
|
||||
assert_eq!(
|
||||
Some(ExecPolicyAmendment::new(prefix_rule.clone())),
|
||||
derive_requested_execpolicy_amendment_from_prefix_rule_for_powershell(
|
||||
Some(&prefix_rule),
|
||||
&[],
|
||||
&Policy::empty(),
|
||||
&powershell_commands,
|
||||
&MatchOptions::default(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_requested_execpolicy_amendment_for_powershell_requires_all_inner_commands_to_match() {
|
||||
let prefix_rule = vec!["git".to_string(), "push".to_string()];
|
||||
let powershell_commands = vec![
|
||||
vec![
|
||||
"git".to_string(),
|
||||
"push".to_string(),
|
||||
"origin".to_string(),
|
||||
"main".to_string(),
|
||||
],
|
||||
vec!["whoami".to_string()],
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
None,
|
||||
derive_requested_execpolicy_amendment_from_prefix_rule_for_powershell(
|
||||
Some(&prefix_rule),
|
||||
&[],
|
||||
&Policy::empty(),
|
||||
&powershell_commands,
|
||||
&MatchOptions::default(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_requested_execpolicy_amendment_returns_none_when_policy_matches() {
|
||||
let prefix_rule = vec!["cargo".to_string(), "build".to_string()];
|
||||
|
||||
@@ -81,11 +81,6 @@ fn assert_no_matched_rules_invariant(output_item: &Value) {
|
||||
|
||||
#[tokio::test]
|
||||
async fn execpolicy_blocks_shell_invocation() -> Result<()> {
|
||||
// TODO execpolicy doesn't parse powershell commands yet
|
||||
if cfg!(windows) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
let policy_path = config.codex_home.join("rules").join("policy.rules");
|
||||
fs::create_dir_all(
|
||||
|
||||
@@ -2,4 +2,5 @@ mod powershell_parser;
|
||||
|
||||
pub mod is_dangerous_command;
|
||||
pub mod is_safe_command;
|
||||
pub(crate) use powershell_parser::try_parse_powershell_ast_commands;
|
||||
pub(crate) mod windows_safe_commands;
|
||||
|
||||
@@ -34,6 +34,16 @@ pub(super) fn parse_with_powershell_ast(executable: &str, script: &str) -> Power
|
||||
parse_with_cached_process(&mut parser_processes, executable, script)
|
||||
}
|
||||
|
||||
pub(crate) fn try_parse_powershell_ast_commands(
|
||||
executable: &str,
|
||||
script: &str,
|
||||
) -> Option<Vec<Vec<String>>> {
|
||||
match parse_with_powershell_ast(executable, script) {
|
||||
PowershellParseOutcome::Commands(commands) => Some(commands),
|
||||
PowershellParseOutcome::Unsupported | PowershellParseOutcome::Failed => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum PowershellParseOutcome {
|
||||
Commands(Vec<Vec<String>>),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use crate::command_safety::is_dangerous_command::git_global_option_requires_prompt;
|
||||
use crate::command_safety::powershell_parser::PowershellParseOutcome;
|
||||
use crate::command_safety::powershell_parser::parse_with_powershell_ast;
|
||||
use std::path::Path;
|
||||
use crate::powershell::PowershellCommandSequenceParseMode;
|
||||
use crate::powershell::try_parse_powershell_command_sequence;
|
||||
|
||||
/// On Windows, we conservatively allow only clearly read-only PowerShell invocations
|
||||
/// that match a small safelist. Anything else (including direct CMD commands) is unsafe.
|
||||
pub fn is_safe_command_windows(command: &[String]) -> bool {
|
||||
if let Some(commands) = try_parse_powershell_command_sequence(command) {
|
||||
if let Some(commands) = try_parse_powershell_command_sequence(
|
||||
command,
|
||||
PowershellCommandSequenceParseMode::SafeCommand,
|
||||
) {
|
||||
commands
|
||||
.iter()
|
||||
.all(|cmd| is_safe_powershell_command(cmd.as_slice()))
|
||||
@@ -16,130 +18,6 @@ pub fn is_safe_command_windows(command: &[String]) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns each command sequence if the invocation starts with a PowerShell binary.
|
||||
/// For example, the tokens from `pwsh Get-ChildItem | Measure-Object` become two sequences.
|
||||
fn try_parse_powershell_command_sequence(command: &[String]) -> Option<Vec<Vec<String>>> {
|
||||
let (exe, rest) = command.split_first()?;
|
||||
if is_powershell_executable(exe) {
|
||||
parse_powershell_invocation(exe, rest)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a PowerShell invocation into discrete command vectors, rejecting unsafe patterns.
|
||||
fn parse_powershell_invocation(executable: &str, args: &[String]) -> Option<Vec<Vec<String>>> {
|
||||
if args.is_empty() {
|
||||
// Examples rejected here: "pwsh" and "powershell.exe" with no additional arguments.
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut idx = 0;
|
||||
while idx < args.len() {
|
||||
let arg = &args[idx];
|
||||
let lower = arg.to_ascii_lowercase();
|
||||
match lower.as_str() {
|
||||
"-command" | "/command" | "-c" => {
|
||||
let script = args.get(idx + 1)?;
|
||||
if idx + 2 != args.len() {
|
||||
// Reject if there is more than one token representing the actual command.
|
||||
// Examples rejected here: "pwsh -Command foo bar" and "powershell -c ls extra".
|
||||
return None;
|
||||
}
|
||||
return parse_powershell_script(executable, script);
|
||||
}
|
||||
_ if lower.starts_with("-command:") || lower.starts_with("/command:") => {
|
||||
if idx + 1 != args.len() {
|
||||
// Reject if there are more tokens after the command itself.
|
||||
// Examples rejected here: "pwsh -Command:dir C:\\" and "powershell /Command:dir C:\\" with trailing args.
|
||||
return None;
|
||||
}
|
||||
let script = arg.split_once(':')?.1;
|
||||
return parse_powershell_script(executable, script);
|
||||
}
|
||||
|
||||
// Benign, no-arg flags we tolerate.
|
||||
"-nologo" | "-noprofile" | "-noninteractive" | "-mta" | "-sta" => {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Explicitly forbidden/opaque or unnecessary for read-only operations.
|
||||
"-encodedcommand" | "-ec" | "-file" | "/file" | "-windowstyle" | "-executionpolicy"
|
||||
| "-workingdirectory" => {
|
||||
// Examples rejected here: "pwsh -EncodedCommand ..." and "powershell -File script.ps1".
|
||||
return None;
|
||||
}
|
||||
|
||||
// Unknown switch → bail conservatively.
|
||||
_ if lower.starts_with('-') => {
|
||||
// Examples rejected here: "pwsh -UnknownFlag" and "powershell -foo bar".
|
||||
return None;
|
||||
}
|
||||
|
||||
// If we hit non-flag tokens, treat the remainder as a command sequence.
|
||||
// This happens if powershell is invoked without -Command, e.g.
|
||||
// ["pwsh", "-NoLogo", "git", "-c", "core.pager=cat", "status"]
|
||||
_ => {
|
||||
let script = join_arguments_as_script(&args[idx..]);
|
||||
return parse_powershell_script(executable, &script);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Examples rejected here: "pwsh" and "powershell.exe -NoLogo" without a script.
|
||||
None
|
||||
}
|
||||
|
||||
/// Tokenizes an inline PowerShell script and delegates to the command splitter.
|
||||
/// Examples of when this is called: pwsh.exe -Command '<script>' or pwsh.exe -Command:<script>
|
||||
fn parse_powershell_script(executable: &str, script: &str) -> Option<Vec<Vec<String>>> {
|
||||
if let PowershellParseOutcome::Commands(commands) =
|
||||
parse_with_powershell_ast(executable, script)
|
||||
{
|
||||
Some(commands)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when the executable name is one of the supported PowerShell binaries.
|
||||
fn is_powershell_executable(exe: &str) -> bool {
|
||||
let executable_name = Path::new(exe)
|
||||
.file_name()
|
||||
.and_then(|osstr| osstr.to_str())
|
||||
.unwrap_or(exe)
|
||||
.to_ascii_lowercase();
|
||||
|
||||
matches!(
|
||||
executable_name.as_str(),
|
||||
"powershell" | "powershell.exe" | "pwsh" | "pwsh.exe"
|
||||
)
|
||||
}
|
||||
|
||||
fn join_arguments_as_script(args: &[String]) -> String {
|
||||
let mut words = Vec::with_capacity(args.len());
|
||||
if let Some((first, rest)) = args.split_first() {
|
||||
words.push(first.clone());
|
||||
for arg in rest {
|
||||
words.push(quote_argument(arg));
|
||||
}
|
||||
}
|
||||
words.join(" ")
|
||||
}
|
||||
|
||||
fn quote_argument(arg: &str) -> String {
|
||||
if arg.is_empty() {
|
||||
return "''".to_string();
|
||||
}
|
||||
|
||||
if arg.chars().all(|ch| !ch.is_whitespace()) {
|
||||
return arg.to_string();
|
||||
}
|
||||
|
||||
format!("'{}'", arg.replace('\'', "''"))
|
||||
}
|
||||
|
||||
/// Validates that a parsed PowerShell command stays within our read-only safelist.
|
||||
/// Everything before this is parsing, and rejecting things that make us feel uncomfortable.
|
||||
fn is_safe_powershell_command(words: &[String]) -> bool {
|
||||
@@ -490,6 +368,15 @@ mod tests {
|
||||
"-Command",
|
||||
"''"
|
||||
])));
|
||||
|
||||
// Wrapper flags that exec policy may still parse stay unsafe here.
|
||||
assert!(!is_safe_command_windows(&vec_str(&[
|
||||
"powershell.exe",
|
||||
"-WindowStyle",
|
||||
"Hidden",
|
||||
"-Command",
|
||||
"Get-Content Cargo.toml",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,10 +2,21 @@ use std::path::PathBuf;
|
||||
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use crate::command_safety::try_parse_powershell_ast_commands;
|
||||
use crate::shell_detect::ShellType;
|
||||
use crate::shell_detect::detect_shell_type;
|
||||
|
||||
const POWERSHELL_FLAGS: &[&str] = &["-nologo", "-noprofile", "-command", "-c"];
|
||||
const POWERSHELL_NO_ARG_PARSE_FLAGS: &[&str] =
|
||||
&["-nologo", "-noprofile", "-noninteractive", "-mta", "-sta"];
|
||||
const POWERSHELL_VALUE_PARSE_FLAGS: &[&str] =
|
||||
&["-windowstyle", "-executionpolicy", "-workingdirectory"];
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PowershellCommandSequenceParseMode {
|
||||
ExecPolicy,
|
||||
SafeCommand,
|
||||
}
|
||||
|
||||
/// Prefixed command for powershell shell calls to force UTF-8 console output.
|
||||
pub const UTF8_OUTPUT_PREFIX: &str = "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;\n";
|
||||
@@ -68,6 +79,199 @@ pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Recover discrete inner command vectors from an explicit one-layer PowerShell wrapper.
|
||||
///
|
||||
/// This recognizes top-level `powershell` / `powershell.exe` / `pwsh` / `pwsh.exe`
|
||||
/// invocations with an explicit `-Command` / `/Command` / `-c` body and parses that
|
||||
/// script with the PowerShell AST parser. Unsupported or opaque forms such as
|
||||
/// `-EncodedCommand` return `None`.
|
||||
pub fn try_parse_powershell_command_sequence(
|
||||
command: &[String],
|
||||
mode: PowershellCommandSequenceParseMode,
|
||||
) -> Option<Vec<Vec<String>>> {
|
||||
let (executable, args) = command.split_first()?;
|
||||
if is_powershell_executable(executable) {
|
||||
parse_powershell_invocation(executable, args, mode)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_powershell_invocation(
|
||||
executable: &str,
|
||||
args: &[String],
|
||||
mode: PowershellCommandSequenceParseMode,
|
||||
) -> Option<Vec<Vec<String>>> {
|
||||
if args.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut idx = 0;
|
||||
while idx < args.len() {
|
||||
let arg = &args[idx];
|
||||
let lower = arg.to_ascii_lowercase();
|
||||
match lower.as_str() {
|
||||
"-command" | "/command" | "-c" => {
|
||||
let script = args.get(idx + 1)?;
|
||||
if idx + 2 != args.len() {
|
||||
return None;
|
||||
}
|
||||
return parse_powershell_script_to_commands(executable, script);
|
||||
}
|
||||
_ if lower.starts_with("-command:") || lower.starts_with("/command:") => {
|
||||
if idx + 1 != args.len() {
|
||||
return None;
|
||||
}
|
||||
let script = arg.split_once(':')?.1;
|
||||
return parse_powershell_script_to_commands(executable, script);
|
||||
}
|
||||
_ if is_powershell_no_arg_parse_flag(&lower) => {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
_ if is_powershell_value_parse_flag(&lower) => {
|
||||
if mode == PowershellCommandSequenceParseMode::SafeCommand {
|
||||
return None;
|
||||
}
|
||||
args.get(idx + 1)?;
|
||||
idx += 2;
|
||||
continue;
|
||||
}
|
||||
_ if is_powershell_value_parse_flag_with_inline_value(&lower) => {
|
||||
if mode == PowershellCommandSequenceParseMode::SafeCommand {
|
||||
return None;
|
||||
}
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
_ if is_unsupported_powershell_parse_flag(&lower)
|
||||
|| has_unsupported_powershell_parse_flag_inline_value(&lower) =>
|
||||
{
|
||||
return None;
|
||||
}
|
||||
_ if looks_like_powershell_flag(&lower) => {
|
||||
if mode == PowershellCommandSequenceParseMode::SafeCommand {
|
||||
return None;
|
||||
}
|
||||
|
||||
idx += powershell_wrapper_flag_length(args, idx);
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
if mode == PowershellCommandSequenceParseMode::ExecPolicy {
|
||||
return None;
|
||||
}
|
||||
|
||||
let script = join_arguments_as_script(&args[idx..]);
|
||||
return parse_powershell_script_to_commands(executable, &script);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn parse_powershell_script_to_commands(
|
||||
executable: &str,
|
||||
script: &str,
|
||||
) -> Option<Vec<Vec<String>>> {
|
||||
try_parse_powershell_ast_commands(executable, script)
|
||||
}
|
||||
|
||||
pub(crate) fn is_powershell_executable(exe: &str) -> bool {
|
||||
let executable_name = std::path::Path::new(exe)
|
||||
.file_name()
|
||||
.and_then(|osstr| osstr.to_str())
|
||||
.unwrap_or(exe)
|
||||
.to_ascii_lowercase();
|
||||
|
||||
matches!(
|
||||
executable_name.as_str(),
|
||||
"powershell" | "powershell.exe" | "pwsh" | "pwsh.exe"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn join_arguments_as_script(args: &[String]) -> String {
|
||||
let mut words = Vec::with_capacity(args.len());
|
||||
if let Some((first, rest)) = args.split_first() {
|
||||
words.push(first.clone());
|
||||
for arg in rest {
|
||||
words.push(quote_argument(arg));
|
||||
}
|
||||
}
|
||||
words.join(" ")
|
||||
}
|
||||
|
||||
fn quote_argument(arg: &str) -> String {
|
||||
if arg.is_empty() {
|
||||
return "''".to_string();
|
||||
}
|
||||
|
||||
if arg.chars().all(|ch| !ch.is_whitespace()) {
|
||||
return arg.to_string();
|
||||
}
|
||||
|
||||
format!("'{}'", arg.replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn looks_like_powershell_flag(lower: &str) -> bool {
|
||||
lower.starts_with('-') || lower.starts_with('/')
|
||||
}
|
||||
|
||||
fn is_powershell_no_arg_parse_flag(lower: &str) -> bool {
|
||||
POWERSHELL_NO_ARG_PARSE_FLAGS.contains(&lower)
|
||||
}
|
||||
|
||||
fn is_powershell_value_parse_flag(lower: &str) -> bool {
|
||||
POWERSHELL_VALUE_PARSE_FLAGS.contains(&lower)
|
||||
|| matches!(
|
||||
lower,
|
||||
"/windowstyle" | "/executionpolicy" | "/workingdirectory"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_powershell_value_parse_flag_with_inline_value(lower: &str) -> bool {
|
||||
matches!(
|
||||
split_flag_inline_value(lower),
|
||||
Some((
|
||||
"-windowstyle"
|
||||
| "/windowstyle"
|
||||
| "-executionpolicy"
|
||||
| "/executionpolicy"
|
||||
| "-workingdirectory"
|
||||
| "/workingdirectory",
|
||||
_
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
fn is_unsupported_powershell_parse_flag(lower: &str) -> bool {
|
||||
matches!(lower, "-encodedcommand" | "-ec" | "-file" | "/file")
|
||||
}
|
||||
|
||||
fn has_unsupported_powershell_parse_flag_inline_value(lower: &str) -> bool {
|
||||
matches!(
|
||||
split_flag_inline_value(lower),
|
||||
Some(("-encodedcommand" | "-ec" | "-file" | "/file", _))
|
||||
)
|
||||
}
|
||||
|
||||
fn split_flag_inline_value(lower: &str) -> Option<(&str, &str)> {
|
||||
lower.split_once(':')
|
||||
}
|
||||
|
||||
fn powershell_wrapper_flag_length(args: &[String], idx: usize) -> usize {
|
||||
let Some(next_arg) = args.get(idx + 1) else {
|
||||
return 1;
|
||||
};
|
||||
|
||||
if looks_like_powershell_flag(&next_arg.to_ascii_lowercase()) {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
/// This function attempts to find a powershell.exe executable on the system.
|
||||
pub fn try_find_powershell_executable_blocking() -> Option<AbsolutePathBuf> {
|
||||
try_find_powershellish_executable_in_path(&["powershell.exe"])
|
||||
@@ -138,7 +342,10 @@ fn is_powershellish_executable_available(powershell_or_pwsh_exe: &std::path::Pat
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PowershellCommandSequenceParseMode;
|
||||
use super::extract_powershell_command;
|
||||
use super::try_parse_powershell_command_sequence;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn extracts_basic_powershell_command() {
|
||||
@@ -186,4 +393,27 @@ mod tests {
|
||||
let (_shell, script) = extract_powershell_command(&cmd).expect("extract");
|
||||
assert_eq!(script, "Get-ChildItem | Select-String foo");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn exec_policy_parsing_ignores_ordinary_wrapper_flags() {
|
||||
let command = vec![
|
||||
"powershell.exe".to_string(),
|
||||
"-Version".to_string(),
|
||||
"5.1".to_string(),
|
||||
"-NoExit".to_string(),
|
||||
"-Command".to_string(),
|
||||
"Get-Content 'foo bar'".to_string(),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
try_parse_powershell_command_sequence(
|
||||
&command,
|
||||
PowershellCommandSequenceParseMode::ExecPolicy,
|
||||
),
|
||||
Some(vec![
|
||||
vec!["Get-Content".to_string(), "foo bar".to_string(),]
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user