use super::{CompiledPolicy, EvalContext, checked_atoms};
use crate::action::{Action, AtomicAction};
use crate::injection::{
self, InjectedLookup, MAX_COMMAND_LEN, MAX_TOKENS_PER_OUTPUT, MIN_COMMAND_LEN, Token, TokenKind,
};
use crate::programs::NoResolver;
use crate::realpath::PathResolver;
use crate::verdict::{Decision, Verdict};
const REMOTE_TEXT_PROGRAMS: [&str; 3] = ["gh", "glab", "tea"];
const MAX_SOURCE_LEN: usize = 200;
impl CompiledPolicy<'_> {
#[must_use]
pub fn output_source(
&self,
action: &Action,
cwd: &str,
paths: &dyn PathResolver,
) -> Option<String> {
if let Some(exposed) = self.exposure(action, cwd, paths).untrusted {
let source = match action.shown() {
Action::Fetch { url } => format!("the page fetched from {url}"),
Action::WebSearch { query } => format!("the web search results for \"{query}\""),
Action::McpTool { name, .. } => format!("the result of MCP tool {name}"),
Action::UnknownTool { tool, .. } => format!("the output of tool {tool}"),
Action::Shell { command } => format!("the output of `{command}`"),
_ => exposed,
};
return Some(cut(source));
}
let Action::Shell { command } = action.shown() else {
return None;
};
let ctx = EvalContext {
cwd: cwd.to_owned(),
..self.ctx.clone()
};
let (atoms, _) = checked_atoms(action, &ctx, self.opens_as(), paths);
let reads_remote = atoms.iter().any(|atom| match atom {
AtomicAction::Net { host } | AtomicAction::Fetch { host } => {
!crate::host::is_loopback(host) && !self.allows(atom)
}
AtomicAction::Shell { argv } => argv
.first()
.is_some_and(|p| REMOTE_TEXT_PROGRAMS.contains(&program_name(p))),
_ => false,
});
reads_remote.then(|| cut(format!("the output of `{command}`")))
}
#[must_use]
pub fn injected_tokens(&self, source: &Action, cwd: &str, output: &str) -> Vec<Token> {
let found = injection::candidates(output);
let ctx = EvalContext {
cwd: cwd.to_owned(),
..self.ctx.clone()
};
let (source_atoms, _) = checked_atoms(source, &ctx, self.opens_as(), &NoResolver);
let own_hosts: Vec<&str> = source_atoms.iter().filter_map(host_of).collect();
let host_token = |kind: TokenKind| {
let own_hosts = &own_hosts;
move |host: String| {
let foreign = !own_hosts.iter().any(|h| h.eq_ignore_ascii_case(&host))
&& !crate::host::is_loopback(&host);
foreign.then_some(Token { kind, text: host })
}
};
let commands = found
.commands
.iter()
.flat_map(|line| self.commands_of(line, &ctx));
let paths = found.paths.iter().filter_map(|raw| {
let path = crate::paths::resolve(raw, &ctx.home, ctx.project.as_deref(), None)?;
(path.len() > 1 && !self.in_project(&path)).then(|| Token {
kind: TokenKind::Path,
text: self.path_key(&path),
})
});
let mut tokens: Vec<Token> = Vec::new();
let all = found
.url_hosts
.into_iter()
.filter_map(host_token(TokenKind::UrlHost))
.chain(commands)
.chain(paths)
.chain(
found
.word_hosts
.into_iter()
.filter_map(host_token(TokenKind::WordHost)),
);
for token in all {
if tokens.len() >= MAX_TOKENS_PER_OUTPUT {
break;
}
if !tokens.contains(&token) {
tokens.push(token);
}
}
tokens
}
#[must_use]
pub fn with_injected(
&self,
decision: Decision,
action: &Action,
paths: &dyn PathResolver,
seen: &dyn InjectedLookup,
) -> Decision {
let checked = matches!(
action.shown(),
Action::Shell { .. } | Action::FsWrite { .. } | Action::Patch { .. }
);
if !checked || decision.verdict == Verdict::Deny {
return decision;
}
let (atoms, _) = self.atoms(action, paths);
let reaches_outside = atoms.iter().any(|atom| match atom {
AtomicAction::Net { .. } | AtomicAction::Fetch { .. } => !self.allows(atom),
AtomicAction::FsRead { path } | AtomicAction::FsWrite { path } => {
!self.in_project(path) && !self.allows(atom)
}
_ => false,
});
atoms
.iter()
.filter(|atom| reaches_outside || !matches!(atom, AtomicAction::Shell { .. }))
.filter_map(|atom| self.injected(atom, seen))
.fold(decision, |mut acc, next| {
acc.merge(next);
acc
})
}
fn injected(&self, atom: &AtomicAction, seen: &dyn InjectedLookup) -> Option<Decision> {
let candidates: Vec<Token> = match atom {
AtomicAction::Net { host } | AtomicAction::Fetch { host } => {
[TokenKind::UrlHost, TokenKind::WordHost]
.map(|kind| Token {
kind,
text: host.to_ascii_lowercase(),
})
.to_vec()
}
AtomicAction::NetWord { host } => vec![Token {
kind: TokenKind::UrlHost,
text: host.to_ascii_lowercase(),
}],
AtomicAction::FsWrite { path } => vec![Token {
kind: TokenKind::Path,
text: self.path_key(path),
}],
AtomicAction::Shell { argv } => command_text(argv)
.map(|text| Token {
kind: TokenKind::Command,
text,
})
.into_iter()
.collect(),
_ => return None,
};
let (token, source) = candidates
.into_iter()
.find_map(|token| seen.source_of(&token).map(|source| (token, source)))?;
if self.allows(atom) {
return None;
}
let what = match token.kind {
TokenKind::UrlHost | TokenKind::WordHost => "host",
TokenKind::Path => "path",
TokenKind::Command => "command",
};
Some(Decision::single(
Verdict::Review,
injection::INJECTED_RULE,
format!(
"the {what} `{}` came from {source}, untrusted content this session read; {} may \
follow an instruction planted there",
token.text,
atom.describe()
),
))
}
fn allows(&self, atom: &AtomicAction) -> bool {
self.evaluate_atomic(atom)
.is_some_and(|d| d.verdict == Verdict::Allow)
}
fn commands_of(&self, line: &str, ctx: &EvalContext) -> Vec<Token> {
let action = Action::Shell {
command: line.to_owned(),
};
let (atoms, _) = checked_atoms(&action, ctx, self.opens_as(), &NoResolver);
atoms
.iter()
.filter_map(|atom| match atom {
AtomicAction::Shell { argv } => command_text(argv),
_ => None,
})
.map(|text| Token {
kind: TokenKind::Command,
text,
})
.collect()
}
fn in_project(&self, path: &str) -> bool {
let key = self.path_key(path);
[&self.ctx.project, &self.ctx.real_project]
.into_iter()
.flatten()
.any(|root| {
let root = self.path_key(root);
let root = root.trim_end_matches('/');
key == root
|| key
.strip_prefix(root)
.is_some_and(|rest| rest.starts_with('/'))
})
}
fn path_key(&self, path: &str) -> String {
if self.ctx.case_insensitive_paths {
path.to_lowercase()
} else {
path.to_owned()
}
}
}
fn command_text(argv: &[String]) -> Option<String> {
let text = argv.join(" ");
(argv.len() >= 2 && (MIN_COMMAND_LEN..=MAX_COMMAND_LEN).contains(&text.len())).then_some(text)
}
fn host_of(atom: &AtomicAction) -> Option<&str> {
match atom {
AtomicAction::Net { host }
| AtomicAction::Fetch { host }
| AtomicAction::NetWord { host } => Some(host),
_ => None,
}
}
fn program_name(word: &str) -> &str {
word.rsplit(['/', '\\']).next().unwrap_or(word)
}
fn cut(text: String) -> String {
if text.chars().count() <= MAX_SOURCE_LEN {
return text;
}
let mut short: String = text.chars().take(MAX_SOURCE_LEN).collect();
short.push('…');
short
}
#[cfg(test)]
mod tests;