use serde::{Deserialize, Serialize};
use super::{CompiledPolicy, EvalContext, checked_atoms};
use crate::action::{Action, AtomicAction};
use crate::pattern::{GlobPattern, any_match};
use crate::policy::{Policy, PolicyError};
use crate::realpath::PathResolver;
use crate::repo::REPO_RULE_PREFIX;
use crate::verdict::{Decision, Verdict};
const SECRET_RULE: &str = "secrets-paths";
const TAINT_RULE: &str = "session-taint";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Secret {
pub source: String,
pub host: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Taint {
pub secrets: Vec<Secret>,
pub untrusted: Option<String>,
}
impl Taint {
pub fn absorb(&mut self, other: Taint) {
for secret in other.secrets {
if !self.secrets.contains(&secret) {
self.secrets.push(secret);
}
}
if self.untrusted.is_none() {
self.untrusted = other.untrusted;
}
}
}
pub(super) fn compile_protected(
policy: &Policy,
ctx: &EvalContext,
) -> Result<Vec<GlobPattern>, PolicyError> {
crate::taint::protected_writes(policy.taint.as_ref())
.flat_map(|p| ctx.spellings(p))
.map(|p| GlobPattern::compile(&p, ctx.case_insensitive_paths))
.collect()
}
impl CompiledPolicy<'_> {
#[must_use]
pub fn exposure(&self, action: &Action, cwd: &str, paths: &dyn PathResolver) -> Taint {
let ctx = EvalContext {
cwd: cwd.to_owned(),
..self.ctx.clone()
};
let (atoms, _) = checked_atoms(action, &ctx, paths);
let mut taint = Taint::default();
for atom in atoms {
match atom {
AtomicAction::FsRead { .. } if self.names_secret(&atom) => {
taint.absorb(Taint {
secrets: vec![Secret {
source: atom.describe(),
host: None,
}],
untrusted: None,
});
}
AtomicAction::Fetch { .. }
| AtomicAction::WebSearch { .. }
| AtomicAction::UnknownTool { .. }
| AtomicAction::McpTool { .. } => {
taint.untrusted.get_or_insert_with(|| atom.describe());
}
_ => {}
}
}
taint
}
#[must_use]
pub fn with_taint(
&self,
decision: Decision,
action: &Action,
paths: &dyn PathResolver,
taint: &Taint,
) -> Decision {
if *taint == Taint::default() {
return decision;
}
self.atoms(action, paths)
.0
.iter()
.filter_map(|atom| self.tainted(atom, taint))
.fold(decision, |mut acc, next| {
acc.merge(next);
acc
})
}
fn tainted(&self, atom: &AtomicAction, taint: &Taint) -> Option<Decision> {
let reason = match atom {
AtomicAction::Net { host }
| AtomicAction::NetWord { host }
| AtomicAction::Fetch { host } => {
let secret = taint.secrets.iter().find(|s| {
!s.host
.as_deref()
.is_some_and(|own| own.eq_ignore_ascii_case(host))
})?;
secret_reason(secret, atom)
}
AtomicAction::McpTool { .. }
| AtomicAction::WebSearch { .. }
| AtomicAction::UnknownTool { .. } => secret_reason(taint.secrets.first()?, atom),
AtomicAction::FsWrite { path } if any_match(&self.protected, path.as_str()) => {
format!(
"the session read untrusted content ({}); {} changes what runs or what the agent is told later",
taint.untrusted.as_ref()?,
atom.describe()
)
}
_ => return None,
};
Some(Decision::single(Verdict::Ask, TAINT_RULE, reason))
}
fn names_secret(&self, atom: &AtomicAction) -> bool {
[&self.deny, &self.repo_ask, &self.allow, &self.ask]
.into_iter()
.flatten()
.any(|g| {
let id = g.group.id.as_str();
(id == SECRET_RULE
|| id
.strip_prefix(REPO_RULE_PREFIX)
.is_some_and(|rest| rest == SECRET_RULE))
&& g.matches(atom)
})
}
}
fn secret_reason(secret: &Secret, atom: &AtomicAction) -> String {
format!(
"the session read secret material ({}); {} could carry it out",
secret.source,
atom.describe()
)
}