use crate::decision::{Decision, DecisionKind, Severity};
use crate::facts::Facts;
use crate::facts::path::{self, PathFact};
use crate::hook_input::HookInput;
use crate::reason;
use super::ConfigRule;
const RULE_ID: &str = "core.workspace.outside-access";
pub struct OutsideAccessRule;
pub static OUTSIDE_ACCESS_RULE: OutsideAccessRule = OutsideAccessRule;
impl ConfigRule for OutsideAccessRule {
fn id(&self) -> &str {
RULE_ID
}
fn severity(&self) -> Severity {
Severity::Medium
}
fn default_decision(&self) -> DecisionKind {
DecisionKind::Deny
}
fn evaluate(&self, facts: &Facts, _input: &HookInput) -> Option<Decision> {
if facts.workspaces.is_empty() {
return None;
}
for fact in facts.paths.iter().chain(facts.bash_redirects.iter()) {
let resolved = path::resolve_for_containment(fact);
if !path::is_within_workspace(&resolved, &facts.workspaces) {
return Some(Decision::Deny {
rule_id: RULE_ID.into(),
reason: build_reason(fact, &resolved, &facts.workspaces),
});
}
}
None
}
}
fn build_reason(
fact: &PathFact,
resolved: &std::path::Path,
workspaces: &[std::path::PathBuf],
) -> String {
let workspace_list = workspaces
.iter()
.map(|w| w.display().to_string())
.collect::<Vec<_>>()
.join(", ");
let problem = format!(
"Path {raw:?} (resolved {resolved}) falls outside the configured workspace \
boundaries [{workspace_list}]. core.workspace.outside-access blocks reads \
and writes that escape the project root.",
raw = fact.raw,
resolved = resolved.display(),
);
reason::build(
RULE_ID,
&problem,
&[
"Move the file under the project root or a directory listed in \
packs.core.workspace.additionalWorkspaces.",
"Add packs.core.workspace.additionalWorkspaces: [<path>] to .ptuf.yaml \
if this destination is intentionally shared.",
"Disable the rule for this repo with packs.core.workspace.enabled: false \
when external access is the norm.",
],
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::facts::path::{PathFact, PathOrigin, PathTool};
use std::path::PathBuf;
fn read_input(p: &str) -> HookInput {
HookInput {
tool_name: "Read".into(),
tool_input: serde_json::json!({ "file_path": p }),
}
}
fn write_input(p: &str) -> HookInput {
HookInput {
tool_name: "Write".into(),
tool_input: serde_json::json!({ "file_path": p, "content": "x" }),
}
}
fn facts_with_workspaces(input: &HookInput, workspaces: Vec<PathBuf>) -> Facts {
let mut f = crate::facts::extract(input);
f.workspaces = workspaces
.into_iter()
.map(|w| w.canonicalize().unwrap_or(w))
.collect();
f
}
#[test]
fn skips_when_no_workspaces_configured() {
let f = facts_with_workspaces(&read_input("/etc/passwd"), Vec::new());
let d = OUTSIDE_ACCESS_RULE.evaluate(&f, &read_input("/etc/passwd"));
assert!(d.is_none(), "no workspace ⇒ skip; got {d:?}");
}
#[test]
fn allows_write_inside_workspace() {
let dir = tempfile::TempDir::new().expect("tempdir");
let inside = dir.path().join("note.txt");
let input = write_input(inside.to_str().expect("utf-8"));
let f = facts_with_workspaces(&input, vec![dir.path().to_path_buf()]);
let d = OUTSIDE_ACCESS_RULE.evaluate(&f, &input);
assert!(d.is_none(), "inside ⇒ allow; got {d:?}");
}
#[test]
fn denies_write_outside_workspace() {
let dir = tempfile::TempDir::new().expect("tempdir");
let input = write_input("/etc/ptuf-must-not-write");
let f = facts_with_workspaces(&input, vec![dir.path().to_path_buf()]);
match OUTSIDE_ACCESS_RULE.evaluate(&f, &input) {
Some(Decision::Deny { rule_id, .. }) => assert_eq!(rule_id, RULE_ID),
other => panic!("expected Deny, got {other:?}"),
}
}
#[test]
fn allows_read_inside_workspace() {
let dir = tempfile::TempDir::new().expect("tempdir");
let inside = dir.path().join("subdir/file.rs");
let input = read_input(inside.to_str().expect("utf-8"));
let f = facts_with_workspaces(&input, vec![dir.path().to_path_buf()]);
assert!(OUTSIDE_ACCESS_RULE.evaluate(&f, &input).is_none());
}
#[test]
fn denies_read_outside_workspace() {
let dir = tempfile::TempDir::new().expect("tempdir");
let input = read_input("/etc/passwd");
let f = facts_with_workspaces(&input, vec![dir.path().to_path_buf()]);
assert!(matches!(
OUTSIDE_ACCESS_RULE.evaluate(&f, &input),
Some(Decision::Deny { .. })
));
}
#[test]
fn denies_bash_redirect_outside_workspace() {
let dir = tempfile::TempDir::new().expect("tempdir");
let input = HookInput {
tool_name: "Bash".into(),
tool_input: serde_json::json!({ "command": "echo x > /etc/ptuf-redirect-x" }),
};
let f = facts_with_workspaces(&input, vec![dir.path().to_path_buf()]);
assert!(matches!(
OUTSIDE_ACCESS_RULE.evaluate(&f, &input),
Some(Decision::Deny { .. })
));
}
#[test]
fn denies_numeric_fd_redirect_outside_workspace() {
let dir = tempfile::TempDir::new().expect("tempdir");
let input = HookInput {
tool_name: "Bash".into(),
tool_input: serde_json::json!({ "command": "echo x 1> /etc/ptuf-redirect-x" }),
};
let f = facts_with_workspaces(&input, vec![dir.path().to_path_buf()]);
assert!(matches!(
OUTSIDE_ACCESS_RULE.evaluate(&f, &input),
Some(Decision::Deny { .. })
));
}
#[test]
fn lookalike_prefix_does_not_satisfy_boundary() {
let parent = tempfile::TempDir::new().expect("tempdir");
let workspace = parent.path().join("work");
let evil_root = parent.path().join("work-evil");
std::fs::create_dir_all(&workspace).expect("mkdir workspace");
std::fs::create_dir_all(&evil_root).expect("mkdir evil");
let evil_target = evil_root.join("payload.txt");
let input = write_input(evil_target.to_str().expect("utf-8"));
let f = facts_with_workspaces(&input, vec![workspace]);
assert!(matches!(
OUTSIDE_ACCESS_RULE.evaluate(&f, &input),
Some(Decision::Deny { .. })
));
}
#[test]
fn dotdot_traversal_resolved_before_check() {
let dir = tempfile::TempDir::new().expect("tempdir");
let workspace = dir.path().canonicalize().expect("canonicalize");
let traversal = workspace.join("foo/../../etc/passwd");
let traversal_str = traversal.to_str().expect("utf-8");
let input = read_input(traversal_str);
let f = facts_with_workspaces(&input, vec![workspace]);
assert!(matches!(
OUTSIDE_ACCESS_RULE.evaluate(&f, &input),
Some(Decision::Deny { .. })
));
}
#[test]
fn symlink_inside_workspace_pointing_outside_is_denied() {
let outside_dir = tempfile::TempDir::new().expect("outside tempdir");
let outside_target = outside_dir.path().join("target");
std::fs::write(&outside_target, b"x").expect("write outside target");
let outside_canonical = outside_target
.canonicalize()
.expect("canonicalize outside target");
let dir = tempfile::TempDir::new().expect("tempdir");
let workspace = dir.path().canonicalize().expect("canonicalize");
let link = workspace.join("escape");
std::os::unix::fs::symlink(&outside_canonical, &link).expect("symlink");
let input = read_input(link.to_str().expect("utf-8"));
let f = facts_with_workspaces(&input, vec![workspace]);
assert!(matches!(
OUTSIDE_ACCESS_RULE.evaluate(&f, &input),
Some(Decision::Deny { .. })
));
}
#[test]
fn workspace_root_itself_being_a_symlink_is_followed_for_internal_writes() {
let dir = tempfile::TempDir::new().expect("tempdir");
let real = dir.path().join("real-root");
std::fs::create_dir_all(&real).expect("mkdir");
let alias = dir.path().join("alias-root");
std::os::unix::fs::symlink(&real, &alias).expect("symlink");
let canonical_workspace = alias.canonicalize().expect("canonicalize");
let inside = real.join("note.txt");
let input = write_input(inside.to_str().expect("utf-8"));
let f = facts_with_workspaces(&input, vec![canonical_workspace]);
assert!(OUTSIDE_ACCESS_RULE.evaluate(&f, &input).is_none());
}
#[test]
fn nonexistent_descendant_is_classified_via_existing_ancestor() {
let dir = tempfile::TempDir::new().expect("tempdir");
let workspace = dir.path().canonicalize().expect("canonicalize");
let target = workspace.join("new/nested/dir/x.txt");
let input = write_input(target.to_str().expect("utf-8"));
let f = facts_with_workspaces(&input, vec![workspace]);
assert!(OUTSIDE_ACCESS_RULE.evaluate(&f, &input).is_none());
}
#[test]
fn matches_any_of_multiple_workspaces() {
let a = tempfile::TempDir::new().expect("a");
let b = tempfile::TempDir::new().expect("b");
let inside_b = b.path().join("file.txt");
let input = write_input(inside_b.to_str().expect("utf-8"));
let f = facts_with_workspaces(&input, vec![a.path().to_path_buf(), b.path().to_path_buf()]);
assert!(OUTSIDE_ACCESS_RULE.evaluate(&f, &input).is_none());
}
#[test]
fn deny_reason_carries_resolved_path_and_workspace_list() {
let rule: &dyn ConfigRule = &OUTSIDE_ACCESS_RULE;
assert_eq!(rule.severity(), Severity::Medium);
assert_eq!(rule.default_decision(), DecisionKind::Deny);
let dir = tempfile::TempDir::new().expect("tempdir");
let workspace = dir.path().canonicalize().expect("canonicalize");
let input = write_input("/etc/ptuf-test-outside-write");
let f = facts_with_workspaces(&input, vec![workspace.clone()]);
let Some(Decision::Deny { reason, .. }) = OUTSIDE_ACCESS_RULE.evaluate(&f, &input) else {
panic!("expected Deny");
};
assert!(reason.contains("/etc/ptuf-test-outside-write"));
assert!(reason.contains(&workspace.display().to_string()));
assert!(reason.contains(RULE_ID));
}
use proptest::prelude::*;
proptest! {
#[test]
fn pbt_no_panic_with_arbitrary_inputs(
input in crate::testing::proptest::richer_hook_input(),
ws_count in 0usize..=3usize,
) {
let workspaces: Vec<PathBuf> = (0..ws_count)
.map(|i| PathBuf::from(format!("/tmp/ptuf-pbt-ws-{i}")))
.collect();
let mut facts = crate::facts::extract(&input);
facts.workspaces = workspaces;
let _ = OUTSIDE_ACCESS_RULE.evaluate(&facts, &input);
}
#[test]
fn pbt_empty_workspaces_always_skip(
input in crate::testing::proptest::richer_hook_input(),
) {
let facts = crate::facts::extract(&input);
prop_assert!(OUTSIDE_ACCESS_RULE.evaluate(&facts, &input).is_none());
}
#[test]
fn pbt_outside_path_is_denied(
tool in prop::sample::select(vec!["Read", "Write", "Edit"]),
) {
let dir = tempfile::TempDir::new().expect("tempdir");
let input = HookInput {
tool_name: tool.into(),
tool_input: serde_json::json!({
"file_path": "/etc/ptuf-pbt-outside-path",
"content": "x",
}),
};
let mut facts = crate::facts::extract(&input);
facts.workspaces = vec![dir.path().to_path_buf()];
let d = OUTSIDE_ACCESS_RULE.evaluate(&facts, &input);
let is_deny = matches!(d, Some(Decision::Deny { .. }));
prop_assert!(is_deny);
}
#[test]
fn pbt_inside_path_is_allowed(
tool in prop::sample::select(vec!["Read", "Write", "Edit"]),
tail in "[a-z][a-z0-9_]{0,16}",
) {
let dir = tempfile::TempDir::new().expect("tempdir");
let workspace = dir.path().canonicalize().expect("canonicalize");
let inside = workspace.join(&tail);
let input = HookInput {
tool_name: tool.into(),
tool_input: serde_json::json!({
"file_path": inside.to_str().expect("utf-8"),
"content": "x",
}),
};
let mut facts = crate::facts::extract(&input);
facts.workspaces = vec![workspace];
let allowed = OUTSIDE_ACCESS_RULE.evaluate(&facts, &input).is_none();
prop_assert!(allowed);
}
}
#[allow(dead_code)]
type _Reexport = (PathFact, PathOrigin, PathTool);
}