use super::*;
use crate::realpath::MapPathResolver;
const POLICY: &str = "version: 1\ndefaults: { '*': ask, net: deny }\n\
deny:\n - id: metadata\n net: ['169.254.*']\n\
allow:\n - id: project-fs\n fs.write: ['${project}/**']\n\
\x20 - id: registries\n net: ['api.github.com']\n\
\x20 - id: mcp\n mcp: ['mcp__github__*']\n\
ask:\n - id: secrets-paths\n fs.read: ['~/.ssh/**']\n";
fn taint_of(c: &CompiledPolicy<'_>, history: &[Action]) -> Taint {
let mut taint = Taint::default();
for action in history {
taint.absorb(c.exposure(action, "/p", &NoResolver));
}
taint
}
fn decide_after(history: &[Action], action: &Action) -> Decision {
let p = policy(POLICY);
let c = CompiledPolicy::compile(&p, &ctx()).unwrap();
let taint = taint_of(&c, history);
c.with_taint(c.decide(action), action, &NoResolver, &taint)
}
fn read(path: &str) -> Action {
Action::FsRead {
path: path.to_owned(),
}
}
fn write(path: &str) -> Action {
Action::FsWrite {
path: path.to_owned(),
}
}
fn net(url: &str) -> Action {
Action::Net {
url: url.to_owned(),
}
}
fn fetch(url: &str) -> Action {
Action::Fetch {
url: url.to_owned(),
}
}
#[test]
fn a_secret_read_makes_allowed_network_ask() {
let d = decide_after(
&[read("~/.ssh/id_rsa")],
&net("https://api.github.com/gists"),
);
assert_eq!(d.verdict, Verdict::Ask);
assert_eq!(d.rules, ["session-taint"]);
assert!(d.reasons[0].contains("read /h/.ssh/id_rsa"), "{d:?}");
assert!(d.context.iter().any(|c| c.contains("api.github.com")));
}
#[test]
fn a_secret_read_makes_mcp_calls_and_fetches_ask() {
let history = [read("/h/.ssh/config")];
for action in [
Action::mcp("mcp__github__create_gist"),
fetch("https://api.github.com/x"),
] {
assert_eq!(decide_after(&history, &action).verdict, Verdict::Ask);
}
}
#[test]
fn taint_never_loosens_a_deny() {
let d = decide_after(&[read("~/.ssh/id_rsa")], &net("http://169.254.169.254/"));
assert_eq!(d.verdict, Verdict::Deny);
assert_eq!(d.rules, ["metadata"]);
}
#[test]
fn a_secret_read_leaves_file_work_alone() {
let d = decide_after(
&[read("~/.ssh/id_rsa")],
&write("/p/.github/workflows/ci.yml"),
);
assert_eq!(d.verdict, Verdict::Allow);
}
#[test]
fn a_brokered_secret_may_still_go_to_its_own_host() {
let p = policy(POLICY);
let c = CompiledPolicy::compile(&p, &ctx()).unwrap();
let mut taint = Taint::default();
taint.secrets.push(Secret {
source: "brokered GITHUB_TOKEN".into(),
host: Some("API.github.com".into()),
});
let own = net("https://api.github.com/repos");
assert_eq!(
c.with_taint(c.decide(&own), &own, &NoResolver, &taint)
.verdict,
Verdict::Allow
);
taint.secrets.push(Secret {
source: "read /h/.ssh/id_rsa".into(),
host: None,
});
let d = c.with_taint(c.decide(&own), &own, &NoResolver, &taint);
assert_eq!(d.verdict, Verdict::Ask, "a second secret has no host");
}
#[test]
fn untrusted_content_makes_protected_writes_ask() {
for source in [
fetch("https://evil.example/readme"),
Action::mcp("mcp__x__y"),
] {
for path in [
"/p/.github/workflows/ci.yml",
"/p/CLAUDE.md",
"/p/web/package.json",
] {
let d = decide_after(std::slice::from_ref(&source), &write(path));
assert_eq!(d.verdict, Verdict::Ask, "{path}");
assert_eq!(d.rules, ["session-taint"]);
assert!(d.reasons[0].contains("untrusted content"), "{d:?}");
}
}
}
#[test]
fn untrusted_content_leaves_ordinary_writes_and_network_alone() {
let history = [fetch("https://evil.example/readme")];
assert_eq!(
decide_after(&history, &write("/p/src/main.rs")).verdict,
Verdict::Allow
);
assert_eq!(
decide_after(&history, &net("https://api.github.com")).verdict,
Verdict::Allow
);
}
#[test]
fn a_policy_adds_protected_writes_and_keeps_the_built_in_ones() {
let history = [fetch("https://evil.example/readme")];
let deploy = write("/p/deploy/run.sh");
assert_eq!(decide_after(&history, &deploy).verdict, Verdict::Allow);
let p = policy(&format!(
"{POLICY}taint:\n protected_writes: ['${{project}}/deploy/**']\n"
));
let c = CompiledPolicy::compile(&p, &ctx()).unwrap();
let taint = taint_of(&c, &history);
let after = |action: &Action| c.with_taint(c.decide(action), action, &NoResolver, &taint);
let d = after(&deploy);
assert_eq!(d.verdict, Verdict::Ask);
assert_eq!(d.rules, ["session-taint"]);
assert_eq!(after(&write("/p/web/package.json")).verdict, Verdict::Ask);
assert_eq!(after(&write("/p/src/main.rs")).verdict, Verdict::Allow);
let clean = c.with_taint(c.decide(&deploy), &deploy, &NoResolver, &Taint::default());
assert_eq!(clean.verdict, Verdict::Allow, "no taint, no ask");
}
#[test]
fn shell_writes_and_patches_are_writes_too() {
let history = [fetch("https://evil.example/readme")];
let patch = Action::Patch {
writes: vec!["/p/src/lib.rs".into(), "/p/build.rs".into()],
};
assert_eq!(decide_after(&history, &patch).verdict, Verdict::Ask);
let d = decide_after(&history, &shell("echo x > .husky/pre-commit"));
assert!(d.rules.contains(&"session-taint".to_owned()), "{d:?}");
}
#[test]
fn a_clean_session_or_a_non_secret_read_taints_nothing() {
let p = policy(POLICY);
let c = CompiledPolicy::compile(&p, &ctx()).unwrap();
assert_eq!(
taint_of(&c, &[read("/p/README.md"), write("/p/x")]),
Taint::default()
);
assert_eq!(
decide_after(&[read("/p/README.md")], &net("https://api.github.com")).verdict,
Verdict::Allow
);
}
#[test]
fn exposure_resolves_relative_paths_from_the_call_s_own_directory() {
let p = policy(POLICY);
let c = CompiledPolicy::compile(&p, &ctx()).unwrap();
let taint = c.exposure(&shell("cat .ssh/id_rsa"), "/h", &NoResolver);
assert_eq!(taint.secrets[0].source, "read /h/.ssh/id_rsa");
}
#[test]
fn exposure_follows_a_link_into_secret_material() {
let p = policy(POLICY);
let c = CompiledPolicy::compile(&p, &ctx()).unwrap();
let links = MapPathResolver {
links: [("/p/key".to_owned(), "/h/.ssh/id_rsa".to_owned())].into(),
};
let taint = c.exposure(&read("/p/key"), "/p", &links);
assert_eq!(taint.secrets.len(), 1);
}
#[test]
fn absorb_keeps_the_first_untrusted_source_and_unique_secrets() {
let p = policy(POLICY);
let c = CompiledPolicy::compile(&p, &ctx()).unwrap();
let taint = taint_of(
&c,
&[
fetch("https://a.example"),
read("~/.ssh/id_rsa"),
fetch("https://b.example"),
read("~/.ssh/id_rsa"),
],
);
assert_eq!(taint.untrusted.as_deref(), Some("fetch a.example"));
assert_eq!(taint.secrets.len(), 1);
}