harn_hostlib/tools/permissions.rs
1//! Permission enforcement shared by the path-touching hostlib builtins.
2//!
3//! Two complementary layers live here:
4//!
5//! * The per-thread "enabled features" registry plus [`gated_handler`] —
6//! the coarse "is this session allowed to touch the filesystem at all?"
7//! gate (see the module body below).
8//! * [`enforce_path_scope`] — the granular "is this *specific path* inside
9//! the session's workspace roots?" check, delegating to the VM-native
10//! sandbox so the hostlib surface and `harness.fs.*` agree.
11//!
12//! `harn-hostlib` exposes the deterministic tool builtins on every VM that
13//! `install_default` runs against, but pipelines must explicitly opt in to
14//! their use by calling the `hostlib_enable("tools:deterministic")` builtin
15//! before any of the tool methods will execute. This keeps the surface
16//! sandbox-friendly: a script that doesn't ask for the tools cannot poke
17//! the host filesystem or shell out to `git` even though the contract is
18//! registered.
19//!
20//! State is held in a thread-local so that:
21//!
22//! * Independent VM runs stay isolated when the embedder executes them on
23//! separate threads.
24//! * Cargo test isolation works without extra ceremony.
25//!
26//! Embedders can also call [`enable_for_test`] / [`reset`] from Rust if
27//! they need to bypass the builtin (for example, tests that don't drive
28//! a live VM).
29
30use std::cell::RefCell;
31use std::collections::BTreeSet;
32use std::path::Path;
33use std::sync::Arc;
34
35use harn_vm::process_sandbox::{check_fs_path_scope, FsAccess};
36use harn_vm::VmValue;
37
38use crate::error::HostlibError;
39use crate::registry::SyncHandler;
40
41/// Feature key for the deterministic-tools surface.
42///
43/// Kept here as a constant so [`tools::register_builtins`](super::register_builtins)
44/// and the integration tests share the exact same string.
45pub const FEATURE_TOOLS_DETERMINISTIC: &str = "tools:deterministic";
46
47thread_local! {
48 static ENABLED: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
49}
50
51/// Mark `feature` as enabled on the current thread. Returns `true` if the
52/// feature was newly enabled, `false` if it was already on.
53pub fn enable(feature: &str) -> bool {
54 ENABLED.with(|cell| cell.borrow_mut().insert(feature.to_string()))
55}
56
57/// Mark `feature` as disabled on the current thread. Returns `true` if the
58/// feature was previously enabled. Mostly useful in tests that want to
59/// assert the gate works.
60pub fn disable(feature: &str) -> bool {
61 ENABLED.with(|cell| cell.borrow_mut().remove(feature))
62}
63
64/// Bulk-clear every enabled feature on the current thread. Tests use this
65/// to start from a known state.
66pub fn reset() {
67 ENABLED.with(|cell| cell.borrow_mut().clear());
68}
69
70/// Report whether `feature` is enabled on the current thread.
71pub fn is_enabled(feature: &str) -> bool {
72 ENABLED.with(|cell| cell.borrow().contains(feature))
73}
74
75/// Convenience wrapper for tests: enable the deterministic tools in the
76/// current thread without needing to reach for the builtin.
77pub fn enable_for_test() {
78 enable(FEATURE_TOOLS_DETERMINISTIC);
79}
80
81/// Wrap a builtin runner so it executes only when the deterministic-tools
82/// feature has been enabled on the current thread, returning a descriptive
83/// error otherwise.
84///
85/// This is the single gating policy shared by every hostlib builtin that
86/// reads or writes arbitrary host filesystem paths — the `tools::*` file
87/// I/O surface plus the `fs::*` and `ast::*` edit helpers — so a script
88/// denied `tools:deterministic` cannot mutate the working tree through any
89/// of them.
90pub fn gated_handler(
91 name: &'static str,
92 runner: fn(&[VmValue]) -> Result<VmValue, HostlibError>,
93) -> SyncHandler {
94 Arc::new(move |args: &[VmValue]| {
95 if !is_enabled(FEATURE_TOOLS_DETERMINISTIC) {
96 return Err(HostlibError::Backend {
97 builtin: name,
98 message: format!(
99 "feature `{FEATURE_TOOLS_DETERMINISTIC}` is not enabled in this \
100 session — call `hostlib_enable(\"{FEATURE_TOOLS_DETERMINISTIC}\")` \
101 before invoking deterministic tools"
102 ),
103 });
104 }
105 runner(args)
106 })
107}
108
109/// Reject `path` when it resolves outside the active execution policy's
110/// workspace roots, under a restricted sandbox profile.
111///
112/// This is the single path-scope policy shared by every hostlib builtin
113/// that resolves a host filesystem path — the `tools::*`, `fs::*`, and
114/// `ast::*` surfaces — so the granular workspace-root check stays
115/// consistent across all of them (the path-level complement to the coarse
116/// [`gated_handler`] feature gate). It delegates to
117/// [`harn_vm::process_sandbox::check_fs_path_scope`] so a path the
118/// `harness.fs.*` VM-native builtins would refuse is refused here too, with
119/// the same message. A no-op when no policy is active or the profile is
120/// unrestricted.
121pub fn enforce_path_scope(
122 builtin: &'static str,
123 path: &Path,
124 access: FsAccess,
125) -> Result<(), HostlibError> {
126 check_fs_path_scope(path, access).map_err(|violation| HostlibError::SandboxViolation {
127 builtin,
128 path: violation.attempted.display().to_string(),
129 message: violation.message(builtin),
130 })
131}