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
47/// Feature key for typed terminal sessions.
48pub const FEATURE_TERMINAL_SESSION: &str = "terminal:session";
49
50thread_local! {
51 static ENABLED: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
52}
53
54/// Mark `feature` as enabled on the current thread. Returns `true` if the
55/// feature was newly enabled, `false` if it was already on.
56pub fn enable(feature: &str) -> bool {
57 ENABLED.with(|cell| cell.borrow_mut().insert(feature.to_string()))
58}
59
60/// Mark `feature` as disabled on the current thread. Returns `true` if the
61/// feature was previously enabled. Mostly useful in tests that want to
62/// assert the gate works.
63pub fn disable(feature: &str) -> bool {
64 ENABLED.with(|cell| cell.borrow_mut().remove(feature))
65}
66
67/// Bulk-clear every enabled feature on the current thread. Tests use this
68/// to start from a known state.
69pub fn reset() {
70 ENABLED.with(|cell| cell.borrow_mut().clear());
71}
72
73/// Report whether `feature` is enabled on the current thread.
74pub fn is_enabled(feature: &str) -> bool {
75 ENABLED.with(|cell| cell.borrow().contains(feature))
76}
77
78/// Convenience wrapper for tests: enable the deterministic tools in the
79/// current thread without needing to reach for the builtin.
80pub fn enable_for_test() {
81 enable(FEATURE_TOOLS_DETERMINISTIC);
82}
83
84/// Wrap a builtin runner so it executes only when the deterministic-tools
85/// feature has been enabled on the current thread, returning a descriptive
86/// error otherwise.
87///
88/// This is the single gating policy shared by every hostlib builtin that
89/// reads or writes arbitrary host filesystem paths — the `tools::*` file
90/// I/O surface plus the `fs::*` and `ast::*` edit helpers — so a script
91/// denied `tools:deterministic` cannot mutate the working tree through any
92/// of them.
93pub fn gated_handler(
94 name: &'static str,
95 runner: fn(&[VmValue]) -> Result<VmValue, HostlibError>,
96) -> SyncHandler {
97 gated_handler_for(FEATURE_TOOLS_DETERMINISTIC, name, Arc::new(runner))
98}
99
100/// Wrap a stateful handler in an explicit per-session feature gate.
101pub(crate) fn gated_handler_for(
102 feature: &'static str,
103 name: &'static str,
104 runner: SyncHandler,
105) -> SyncHandler {
106 Arc::new(move |args: &[VmValue]| {
107 if !is_enabled(feature) {
108 return Err(HostlibError::Backend {
109 builtin: name,
110 message: format!(
111 "feature `{feature}` is not enabled in this \
112 session — call `hostlib_enable(\"{feature}\")` \
113 before invoking this capability"
114 ),
115 });
116 }
117 runner(args)
118 })
119}
120
121/// Reject `path` when it resolves outside the active execution policy's
122/// workspace roots, under a restricted sandbox profile.
123///
124/// This is the single path-scope policy shared by every hostlib builtin
125/// that resolves a host filesystem path — the `tools::*`, `fs::*`, and
126/// `ast::*` surfaces — so the granular workspace-root check stays
127/// consistent across all of them (the path-level complement to the coarse
128/// [`gated_handler`] feature gate). It delegates to
129/// [`harn_vm::process_sandbox::check_fs_path_scope`] so a path the
130/// `harness.fs.*` VM-native builtins would refuse is refused here too, with
131/// the same message. A no-op when no policy is active or the profile is
132/// unrestricted.
133pub fn enforce_path_scope(
134 builtin: &'static str,
135 path: &Path,
136 access: FsAccess,
137) -> Result<(), HostlibError> {
138 check_fs_path_scope(path, access).map_err(|violation| HostlibError::SandboxViolation {
139 builtin,
140 path: violation.attempted.display().to_string(),
141 message: violation.message(builtin),
142 })
143}