use super::{CompiledPolicy, EvalContext, UntrustedKind, 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> {
let ctx = EvalContext {
cwd: cwd.to_owned(),
..self.ctx.clone()
};
let (atoms, _) = checked_atoms(action, &ctx, self.opens_as(), paths);
if let Some(exposed) = atoms.iter().find(|atom| UntrustedKind::of(atom).is_some()) {
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.describe(),
};
return Some(cut(source));
}
let Action::Shell { command } = action.shown() else {
return None;
};
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<String> = source_atoms
.iter()
.filter_map(host_of)
.map(crate::host::canonical)
.collect();
let host_token = |kind: TokenKind| {
let own_hosts = &own_hosts;
move |host: String| {
let text = crate::host::canonical(&host);
let foreign = !own_hosts.contains(&text) && !crate::host::is_loopback(&host);
foreign.then_some(Token { kind, text })
}
};
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,
});
let found = atoms
.iter()
.filter(|atom| reaches_outside || !matches!(atom, AtomicAction::Shell { .. }))
.filter_map(|atom| self.injected(atom, seen))
.collect();
planted(found).into_iter().fold(decision, |mut acc, next| {
acc.merge(next);
acc
})
}
fn injected(&self, atom: &AtomicAction, seen: &dyn InjectedLookup) -> Option<Found> {
let (token, source) = match atom {
AtomicAction::Net { host } | AtomicAction::Fetch { host } => {
let kinds = [TokenKind::UrlHost, TokenKind::WordHost];
probe(seen, host_tokens(host, &kinds))
.or_else(|| self.parent_source(atom, host, seen))
}
AtomicAction::NetWord { host } => probe(seen, host_tokens(host, &[TokenKind::UrlHost])),
AtomicAction::FsWrite { path } => probe(
seen,
[Token {
kind: TokenKind::Path,
text: self.path_key(path),
}],
),
AtomicAction::Shell { argv } => probe(
seen,
command_text(argv).map(|text| Token {
kind: TokenKind::Command,
text,
}),
),
_ => None,
}?;
if self.allows(atom) {
return None;
}
let what = match token.kind {
TokenKind::UrlHost | TokenKind::WordHost => "host",
TokenKind::Path => "path",
TokenKind::Command => "command",
};
Some(Found {
part: format!("the {what} `{}`", token.text),
source,
})
}
fn parent_source(
&self,
atom: &AtomicAction,
host: &str,
seen: &dyn InjectedLookup,
) -> Option<(Token, String)> {
let host = crate::host::canonical(host);
crate::host::parents(&host).find_map(|parent| {
let token = Token {
kind: TokenKind::UrlHost,
text: parent.to_owned(),
};
let source = seen.source_of(&token)?;
(!self.allows(&with_host(atom, parent))).then_some((token, source))
})
}
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)
}
struct Found {
part: String,
source: String,
}
fn planted(found: Vec<Found>) -> Vec<Decision> {
let mut sources: Vec<(String, Vec<String>)> = Vec::new();
for Found { part, source } in found {
match sources.iter_mut().find(|(known, _)| *known == source) {
Some((_, parts)) if !parts.contains(&part) => parts.push(part),
Some(_) => {}
None => sources.push((source, vec![part])),
}
}
sources
.into_iter()
.map(|(source, parts)| {
Decision::single(
Verdict::Review,
injection::INJECTED_RULE,
format!(
"{} came from {source}, untrusted content this session read; the call may \
follow an instruction planted there",
listed(&parts)
),
)
})
.collect()
}
fn listed(parts: &[String]) -> String {
match parts {
[] => String::new(),
[one] => one.clone(),
[init @ .., last] => format!("{} and {last}", init.join(", ")),
}
}
fn probe(
seen: &dyn InjectedLookup,
tokens: impl IntoIterator<Item = Token>,
) -> Option<(Token, String)> {
tokens
.into_iter()
.find_map(|token| seen.source_of(&token).map(|source| (token, source)))
}
fn host_tokens(host: &str, kinds: &[TokenKind]) -> Vec<Token> {
let canonical = crate::host::canonical(host);
let written = host.trim_end_matches('.').to_ascii_lowercase();
let spellings = if written == canonical {
vec![canonical]
} else {
vec![canonical, written]
};
spellings
.iter()
.flat_map(|text| {
kinds.iter().map(|kind| Token {
kind: *kind,
text: text.clone(),
})
})
.collect()
}
fn with_host(atom: &AtomicAction, host: &str) -> AtomicAction {
let host = host.to_owned();
if matches!(atom, AtomicAction::Fetch { .. }) {
AtomicAction::Fetch { host }
} else {
AtomicAction::Net { host }
}
}
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;