ptuf 0.5.0

PreToolUseFilter: a generic guardrail layer for coding agents
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
//! `core.workspace` v1 — restrict tool I/O to a configured set of
//! workspace boundaries.
//!
//! `outside-access` denies any `Read` / `Edit` / `Write` / `apply_patch`
//! / MCP `path` / Bash redirect target whose canonical destination falls
//! outside the boundary set. The boundary set comes from the engine's
//! `repo_root` plus `packs.core.workspace.additionalWorkspaces` from the
//! merged config.
//!
//! Path resolution applies `canonicalize` to both candidate and
//! boundary so symlinks and `..` traversals are collapsed before the
//! prefix check; non-existent leaves fall back to climbing the ancestor
//! chain (see [`crate::facts::path::resolve_for_containment`]). Prefix
//! matching uses [`std::path::Path::starts_with`] (component-wise) so `/work-evil`
//! cannot impersonate `/work`.
//!
//! The pack ships disabled by default — Read inclusion would otherwise
//! block reads of external libraries (`~/.cargo/registry/...`,
//! `/usr/include`) for projects that have not opted in. Enable via
//! `packs.core.workspace.enabled: true` in `.ptuf.yaml`.

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 {
        // macOS `/var/folders/...` is a symlink to `/private/var/folders/...`.
        // `tempfile::TempDir::path()` returns the un-canonicalized form, but
        // the rule's `PathFact` canonicalizes its target — so the workspace
        // boundary check would compare `/private/var/folders/...` (resolved
        // input) against `/var/folders/...` (workspace) and report a false
        // Deny. Canonicalize each workspace path here so both sides share
        // the same resolved form. Falls back to the original path when the
        // directory does not exist (proptest passes synthetic paths).
        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() {
        // Regression: `1>` (and other numeric fd forms) must tokenize as a
        // redirect so its target is subject to the workspace boundary. If
        // the fd redirect were dropped by the lexer, the write would slip
        // past this guard.
        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() {
        // workspace ≠ workspace-evil as a path component prefix, even
        // though `<ws>-evil/x` byte-prefix-matches `<ws>`. Both dirs
        // sit inside the same parent TempDir so RAII cleans them up
        // even on panic.
        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");
        // /<ws>/foo/../../etc/passwd ⇒ /etc/passwd after normalization,
        // which lives outside the workspace.
        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() {
        // Use a self-contained outside tempdir as the symlink target rather
        // than an OS-specific path like /etc/hostname: macOS does not ship
        // /etc/hostname by default, which makes the symlink dangling and
        // lets climb-and-canonicalize re-resolve the path back inside the
        // workspace, producing a false Allow.
        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");
        // /<ws>/new/nested/dir/x.txt does not exist; the climb-and-canon
        // helper should resolve it via the workspace ancestor and judge
        // it inside.
        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);
            // facts.workspaces left empty — the engine never injected a boundary.
            prop_assert!(OUTSIDE_ACCESS_RULE.evaluate(&facts, &input).is_none());
        }

        #[test]
        fn pbt_outside_path_is_denied(
            // Pin to a known outside path; randomise the rest of the
            // payload so the rule's path-only check is still covered
            // for varied tool names.
            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");
            // Canonicalize for macOS /var/folders → /private/var/folders
            // parity with the rule's PathFact resolution.
            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);
        }
    }

    // Re-export PathFact / PathOrigin / PathTool so the proptest module
    // can build hand-crafted facts when needed without touching other
    // crates' visibility.
    #[allow(dead_code)]
    type _Reexport = (PathFact, PathOrigin, PathTool);
}