feat: dynamic-scopeの実装
This commit is contained in:
parent
6bf1f9a110
commit
189ee43a0c
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -82,6 +82,15 @@ version = "1.0.102"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arc-swap"
|
||||||
|
version = "1.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "assert-json-diff"
|
name = "assert-json-diff"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
|
|
@ -1696,6 +1705,7 @@ dependencies = [
|
||||||
name = "manifest"
|
name = "manifest"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"arc-swap",
|
||||||
"llm-worker",
|
"llm-worker",
|
||||||
"protocol",
|
"protocol",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
arc-swap = "1"
|
||||||
llm-worker = { workspace = true }
|
llm-worker = { workspace = true }
|
||||||
protocol = { workspace = true }
|
protocol = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ pub use model::{
|
||||||
};
|
};
|
||||||
pub use paths::user_manifest_path;
|
pub use paths::user_manifest_path;
|
||||||
pub use protocol::{Permission, ScopeRule};
|
pub use protocol::{Permission, ScopeRule};
|
||||||
pub use scope::{Scope, ScopeError};
|
pub use scope::{Scope, ScopeError, SharedScope};
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::num::NonZeroU32;
|
use std::num::NonZeroU32;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
|
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use arc_swap::{ArcSwap, Guard};
|
||||||
|
|
||||||
use crate::{Permission, ScopeConfig, ScopeRule};
|
use crate::{Permission, ScopeConfig, ScopeRule};
|
||||||
|
|
||||||
|
|
@ -182,6 +185,38 @@ impl Scope {
|
||||||
.map(|r| r.target.as_path())
|
.map(|r| r.target.as_path())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a new [`Scope`] equal to `self` with `extra_allow` appended
|
||||||
|
/// to the allow set. Used by dynamic-scope grow paths
|
||||||
|
/// (e.g. controller adding the bash-output Read rule, future
|
||||||
|
/// external `GrantScope`).
|
||||||
|
pub fn with_added_allow_rules(
|
||||||
|
&self,
|
||||||
|
extra_allow: impl IntoIterator<Item = ScopeRule>,
|
||||||
|
) -> Result<Self, ScopeError> {
|
||||||
|
let mut config = ScopeConfig {
|
||||||
|
allow: self.allow_rules(),
|
||||||
|
deny: self.deny_rules(),
|
||||||
|
};
|
||||||
|
config.allow.extend(extra_allow);
|
||||||
|
Self::from_config(&config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a new [`Scope`] equal to `self` with `extra_deny` appended
|
||||||
|
/// to the deny set. Used by dynamic-scope shrink paths
|
||||||
|
/// (e.g. SpawnPod-style delegation that strips Write from the
|
||||||
|
/// spawner without touching its allow rules).
|
||||||
|
pub fn with_added_deny_rules(
|
||||||
|
&self,
|
||||||
|
extra_deny: impl IntoIterator<Item = ScopeRule>,
|
||||||
|
) -> Result<Self, ScopeError> {
|
||||||
|
let mut config = ScopeConfig {
|
||||||
|
allow: self.allow_rules(),
|
||||||
|
deny: self.deny_rules(),
|
||||||
|
};
|
||||||
|
config.deny.extend(extra_deny);
|
||||||
|
Self::from_config(&config)
|
||||||
|
}
|
||||||
|
|
||||||
/// Human-readable grouping of allow rules, suitable for embedding in
|
/// Human-readable grouping of allow rules, suitable for embedding in
|
||||||
/// LLM system prompts. Deny rules are intentionally omitted — they
|
/// LLM system prompts. Deny rules are intentionally omitted — they
|
||||||
/// only cap effective permission and surface them would mislead the
|
/// only cap effective permission and surface them would mislead the
|
||||||
|
|
@ -230,6 +265,81 @@ impl Scope {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shared, atomically-swappable view of a [`Scope`].
|
||||||
|
///
|
||||||
|
/// Built around [`ArcSwap`] so the hot path (permission checks inside
|
||||||
|
/// `ScopedFs`) reads the current scope lock-free. Mutators are
|
||||||
|
/// serialised by an internal `Mutex` so concurrent `update` calls do
|
||||||
|
/// not lose each other's contributions.
|
||||||
|
///
|
||||||
|
/// All clones share the same underlying state — a `SharedScope` cloned
|
||||||
|
/// out to multiple consumers (Pod, ScopedFs, future grant/revoke
|
||||||
|
/// callers) sees every update.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SharedScope {
|
||||||
|
inner: Arc<SharedScopeInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct SharedScopeInner {
|
||||||
|
scope: ArcSwap<Scope>,
|
||||||
|
/// Serialises read-modify-write update transactions so a producer
|
||||||
|
/// can read the current scope, build a derived one, and store it
|
||||||
|
/// without losing concurrent updates.
|
||||||
|
write_lock: Mutex<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharedScope {
|
||||||
|
/// Wrap an owned [`Scope`] in a shared, atomically-swappable handle.
|
||||||
|
pub fn new(scope: Scope) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(SharedScopeInner {
|
||||||
|
scope: ArcSwap::from_pointee(scope),
|
||||||
|
write_lock: Mutex::new(()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot the current scope. Cheap and lock-free; the returned
|
||||||
|
/// guard borrows the live scope for as long as it is held.
|
||||||
|
pub fn load(&self) -> Guard<Arc<Scope>> {
|
||||||
|
self.inner.scope.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot the current scope into an owned `Arc<Scope>`. Useful
|
||||||
|
/// when the caller needs a value that outlives the load guard
|
||||||
|
/// (e.g. cloning into another struct).
|
||||||
|
pub fn snapshot(&self) -> Arc<Scope> {
|
||||||
|
self.inner.scope.load_full()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the current scope wholesale. Equivalent to building a
|
||||||
|
/// fresh [`Scope`] from a [`ScopeConfig`] and storing it. Concurrent
|
||||||
|
/// `update` callers in the middle of a read-modify-write will see
|
||||||
|
/// this store reflected on their next iteration if their derived
|
||||||
|
/// scope cannot be built from the now-stale snapshot.
|
||||||
|
pub fn store(&self, scope: Scope) {
|
||||||
|
let _guard = self.inner.write_lock.lock().expect("scope mutex poisoned");
|
||||||
|
self.inner.scope.store(Arc::new(scope));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-modify-write transaction. `f` is called with the current
|
||||||
|
/// scope and returns a derived one (or an error). The internal
|
||||||
|
/// write lock ensures that two concurrent `update` calls see each
|
||||||
|
/// other's results — the second observes the first's output as its
|
||||||
|
/// input.
|
||||||
|
pub fn update<F>(&self, f: F) -> Result<(), ScopeError>
|
||||||
|
where
|
||||||
|
F: FnOnce(&Scope) -> Result<Scope, ScopeError>,
|
||||||
|
{
|
||||||
|
let _guard = self.inner.write_lock.lock().expect("scope mutex poisoned");
|
||||||
|
let current = self.inner.scope.load();
|
||||||
|
let new = f(¤t)?;
|
||||||
|
self.inner.scope.store(Arc::new(new));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ResolvedRule {
|
impl ResolvedRule {
|
||||||
fn matches(&self, path: &Path) -> bool {
|
fn matches(&self, path: &Path) -> bool {
|
||||||
if self.recursive {
|
if self.recursive {
|
||||||
|
|
@ -545,4 +655,87 @@ mod tests {
|
||||||
let deep = dir.path().join("a/b/c/new.txt");
|
let deep = dir.path().join("a/b/c/new.txt");
|
||||||
assert!(scope.is_writable(&deep));
|
assert!(scope.is_writable(&deep));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_added_allow_rules_grows_readable_set() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let extra = TempDir::new().unwrap();
|
||||||
|
let base = Scope::writable(dir.path()).unwrap();
|
||||||
|
assert!(!base.is_readable(&extra.path().join("x")));
|
||||||
|
let extended = base
|
||||||
|
.with_added_allow_rules([ScopeRule {
|
||||||
|
target: extra.path().to_path_buf(),
|
||||||
|
permission: Permission::Read,
|
||||||
|
recursive: true,
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
assert!(extended.is_readable(&extra.path().join("x")));
|
||||||
|
assert!(extended.is_writable(&dir.path().join("y")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_added_deny_rules_demotes_write_to_read() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let sub = dir.path().join("sub");
|
||||||
|
std::fs::create_dir(&sub).unwrap();
|
||||||
|
let base = Scope::writable(dir.path()).unwrap();
|
||||||
|
let demoted = base
|
||||||
|
.with_added_deny_rules([ScopeRule {
|
||||||
|
target: sub.clone(),
|
||||||
|
permission: Permission::Write,
|
||||||
|
recursive: true,
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
let f = sub.join("a.txt");
|
||||||
|
assert_eq!(demoted.permission_at(&f), Some(Permission::Read));
|
||||||
|
assert_eq!(
|
||||||
|
demoted.permission_at(&dir.path().join("top.txt")),
|
||||||
|
Some(Permission::Write)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_scope_load_returns_current_value() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let shared = SharedScope::new(Scope::writable(dir.path()).unwrap());
|
||||||
|
assert!(shared.load().is_writable(&dir.path().join("a.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_scope_update_replaces_view_atomically() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let sub = dir.path().join("sub");
|
||||||
|
std::fs::create_dir(&sub).unwrap();
|
||||||
|
let shared = SharedScope::new(Scope::writable(dir.path()).unwrap());
|
||||||
|
let target = sub.join("a.txt");
|
||||||
|
assert_eq!(shared.load().permission_at(&target), Some(Permission::Write));
|
||||||
|
shared
|
||||||
|
.update(|cur| {
|
||||||
|
cur.with_added_deny_rules([ScopeRule {
|
||||||
|
target: sub.clone(),
|
||||||
|
permission: Permission::Write,
|
||||||
|
recursive: true,
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(shared.load().permission_at(&target), Some(Permission::Read));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_scope_clones_share_state() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let extra = TempDir::new().unwrap();
|
||||||
|
let a = SharedScope::new(Scope::writable(dir.path()).unwrap());
|
||||||
|
let b = a.clone();
|
||||||
|
assert!(!b.load().is_readable(&extra.path().join("x")));
|
||||||
|
a.update(|cur| {
|
||||||
|
cur.with_added_allow_rules([ScopeRule {
|
||||||
|
target: extra.path().to_path_buf(),
|
||||||
|
permission: Permission::Read,
|
||||||
|
recursive: true,
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(b.load().is_readable(&extra.path().join("x")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ impl PodController {
|
||||||
|
|
||||||
// Snapshot pod-immutable values needed for tool factories so the
|
// Snapshot pod-immutable values needed for tool factories so the
|
||||||
// mutable worker borrow below doesn't conflict with reads on `pod`.
|
// mutable worker borrow below doesn't conflict with reads on `pod`.
|
||||||
let scope_for_tools = pod.scope().clone();
|
let scope_handle = pod.scope().clone();
|
||||||
let pwd_for_tools = pod.pwd().to_path_buf();
|
let pwd_for_tools = pod.pwd().to_path_buf();
|
||||||
let spawner_name = pod.manifest().pod.name.clone();
|
let spawner_name = pod.manifest().pod.name.clone();
|
||||||
let spawner_model = pod.manifest().model.clone();
|
let spawner_model = pod.manifest().model.clone();
|
||||||
|
|
@ -230,11 +230,14 @@ impl PodController {
|
||||||
// touching.
|
// touching.
|
||||||
//
|
//
|
||||||
// Bash spills long outputs to a per-pod subdir under the
|
// Bash spills long outputs to a per-pod subdir under the
|
||||||
// runtime dir. We layer a recursive `allow(Read)` rule for
|
// runtime dir. Push a recursive `allow(Read)` for that path
|
||||||
// that path on top of the user-facing scope so the agent can
|
// into the Pod's runtime scope so the agent can `Read` the
|
||||||
// `Read` the saved files without polluting the workspace.
|
// saved files without polluting the workspace. The Pod's
|
||||||
// Same approach memory takes for its deny rules: round-trip
|
// SharedScope is the single source of truth — the same
|
||||||
// through `ScopeConfig` and rebuild via `from_config`.
|
// handle backs every ScopedFs (builtin tools, fs_view,
|
||||||
|
// compact worker), and any future scope mutation
|
||||||
|
// (SpawnPod-style revoke, future GrantScope) propagates
|
||||||
|
// through it.
|
||||||
let bash_output_dir = runtime_dir.path().join("bash-output");
|
let bash_output_dir = runtime_dir.path().join("bash-output");
|
||||||
std::fs::create_dir_all(&bash_output_dir).map_err(|e| {
|
std::fs::create_dir_all(&bash_output_dir).map_err(|e| {
|
||||||
std::io::Error::other(format!(
|
std::io::Error::other(format!(
|
||||||
|
|
@ -242,18 +245,16 @@ impl PodController {
|
||||||
bash_output_dir.display()
|
bash_output_dir.display()
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let mut scope_config = manifest::ScopeConfig {
|
scope_handle
|
||||||
allow: scope_for_tools.allow_rules(),
|
.update(|cur| {
|
||||||
deny: scope_for_tools.deny_rules(),
|
cur.with_added_allow_rules([manifest::ScopeRule {
|
||||||
};
|
target: bash_output_dir.clone(),
|
||||||
scope_config.allow.push(manifest::ScopeRule {
|
permission: manifest::Permission::Read,
|
||||||
target: bash_output_dir.clone(),
|
recursive: true,
|
||||||
permission: manifest::Permission::Read,
|
}])
|
||||||
recursive: true,
|
})
|
||||||
});
|
|
||||||
let scope_with_bash = manifest::Scope::from_config(&scope_config)
|
|
||||||
.map_err(std::io::Error::other)?;
|
.map_err(std::io::Error::other)?;
|
||||||
let fs = tools::ScopedFs::new(scope_with_bash, pwd_for_tools.clone());
|
let fs = tools::ScopedFs::with_shared_scope(scope_handle.clone(), pwd_for_tools.clone());
|
||||||
let tracker = tools::Tracker::new();
|
let tracker = tools::Tracker::new();
|
||||||
// The same ScopedFs also powers the IPC `ListCompletions`
|
// The same ScopedFs also powers the IPC `ListCompletions`
|
||||||
// query — keep a clone for the FS view we attach below,
|
// query — keep a clone for the FS view we attach below,
|
||||||
|
|
@ -292,6 +293,7 @@ impl PodController {
|
||||||
spawned_registry.clone(),
|
spawned_registry.clone(),
|
||||||
self_parent_socket.clone(),
|
self_parent_socket.clone(),
|
||||||
spawner_model.clone(),
|
spawner_model.clone(),
|
||||||
|
scope_handle.clone(),
|
||||||
));
|
));
|
||||||
worker.register_tool(send_to_pod_tool(spawned_registry.clone()));
|
worker.register_tool(send_to_pod_tool(spawned_registry.clone()));
|
||||||
worker.register_tool(read_pod_output_tool(spawned_registry.clone()));
|
worker.register_tool(read_pod_output_tool(spawned_registry.clone()));
|
||||||
|
|
@ -873,7 +875,7 @@ where
|
||||||
cwd: pod.pwd().display().to_string(),
|
cwd: pod.pwd().display().to_string(),
|
||||||
provider: provider_name,
|
provider: provider_name,
|
||||||
model: model_id,
|
model: model_id,
|
||||||
scope_summary: pod.scope().summary(),
|
scope_summary: pod.scope_snapshot().summary(),
|
||||||
tools: tool_names,
|
tools: tool_names,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,10 @@ use llm_worker::{ToolOutputLimits, UsageRecord, Worker, WorkerError, WorkerResul
|
||||||
use session_store::{EntryHash, SessionId, SessionStartState, Store, StoreError};
|
use session_store::{EntryHash, SessionId, SessionStartState, Store, StoreError};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use manifest::{PodManifest, PodManifestConfig, ResolveError, Scope, ScopeError, WorkerManifest};
|
use manifest::{
|
||||||
|
PodManifest, PodManifestConfig, ResolveError, Scope, ScopeError, ScopeRule, SharedScope,
|
||||||
|
WorkerManifest,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::compact::state::CompactState;
|
use crate::compact::state::CompactState;
|
||||||
use crate::compact::usage_tracker::UsageTracker;
|
use crate::compact::usage_tracker::UsageTracker;
|
||||||
|
|
@ -60,8 +63,11 @@ pub struct Pod<C: LlmClient, St: Store> {
|
||||||
head_hash: Option<EntryHash>,
|
head_hash: Option<EntryHash>,
|
||||||
/// Absolute working directory of the Pod.
|
/// Absolute working directory of the Pod.
|
||||||
pwd: PathBuf,
|
pwd: PathBuf,
|
||||||
/// Resolved scope — always present.
|
/// Shared, atomically-swappable view of the Pod's resolved scope.
|
||||||
scope: Scope,
|
/// Cloned out to `ScopedFs` instances (builtin tools, fs_view,
|
||||||
|
/// compact worker) so scope updates propagate to every consumer
|
||||||
|
/// at the next permission check.
|
||||||
|
scope: SharedScope,
|
||||||
hook_builder: HookRegistryBuilder,
|
hook_builder: HookRegistryBuilder,
|
||||||
interceptor_installed: bool,
|
interceptor_installed: bool,
|
||||||
/// Shared compaction state (present when compact_threshold is configured).
|
/// Shared compaction state (present when compact_threshold is configured).
|
||||||
|
|
@ -185,7 +191,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
session_id,
|
session_id,
|
||||||
head_hash: None,
|
head_hash: None,
|
||||||
pwd,
|
pwd,
|
||||||
scope,
|
scope: SharedScope::new(scope),
|
||||||
hook_builder: HookRegistryBuilder::new(),
|
hook_builder: HookRegistryBuilder::new(),
|
||||||
interceptor_installed: false,
|
interceptor_installed: false,
|
||||||
compact_state: None,
|
compact_state: None,
|
||||||
|
|
@ -252,11 +258,46 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
&self.pwd
|
&self.pwd
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Pod's directory scope.
|
/// The Pod's directory scope, as a shared atomically-swappable
|
||||||
pub fn scope(&self) -> &Scope {
|
/// handle. Clone it to share scope state with another consumer
|
||||||
|
/// (e.g. a tool that needs to mutate scope dynamically).
|
||||||
|
pub fn scope(&self) -> &SharedScope {
|
||||||
&self.scope
|
&self.scope
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Snapshot the current scope as an owned `Arc<Scope>`. Subsequent
|
||||||
|
/// scope mutations do not affect the returned snapshot.
|
||||||
|
pub fn scope_snapshot(&self) -> Arc<Scope> {
|
||||||
|
self.scope.snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply `extra_allow` to the Pod's runtime scope. Future tool
|
||||||
|
/// permission checks (read/write/glob/grep) reflect the broadened
|
||||||
|
/// scope; in-flight tool calls keep the snapshot they captured at
|
||||||
|
/// invocation time.
|
||||||
|
pub fn add_scope_rules(
|
||||||
|
&self,
|
||||||
|
extra_allow: impl IntoIterator<Item = ScopeRule>,
|
||||||
|
) -> Result<(), ScopeError> {
|
||||||
|
let extra: Vec<ScopeRule> = extra_allow.into_iter().collect();
|
||||||
|
self.scope
|
||||||
|
.update(|cur| cur.with_added_allow_rules(extra.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip `revoke` rules from the Pod's runtime scope by adding
|
||||||
|
/// matching deny rules. A `Permission::Write` revoke caps effective
|
||||||
|
/// access at `Read` (mirroring the pod-registry `effective_write`
|
||||||
|
/// semantics — Write is the only permission tracked across Pods).
|
||||||
|
/// A `Permission::Read` revoke removes access entirely.
|
||||||
|
pub fn revoke_scope_rules(
|
||||||
|
&self,
|
||||||
|
revoke: impl IntoIterator<Item = ScopeRule>,
|
||||||
|
) -> Result<(), ScopeError> {
|
||||||
|
let revoke: Vec<ScopeRule> = revoke.into_iter().collect();
|
||||||
|
self.scope
|
||||||
|
.update(|cur| cur.with_added_deny_rules(revoke.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Direct access to the underlying Worker.
|
/// Direct access to the underlying Worker.
|
||||||
pub fn worker(&self) -> &Worker<C, Mutable> {
|
pub fn worker(&self) -> &Worker<C, Mutable> {
|
||||||
self.worker.as_ref().expect("worker taken during run")
|
self.worker.as_ref().expect("worker taken during run")
|
||||||
|
|
@ -582,10 +623,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
let scope_snapshot = self.scope.snapshot();
|
||||||
let ctx = SystemPromptContext {
|
let ctx = SystemPromptContext {
|
||||||
now: chrono::Utc::now(),
|
now: chrono::Utc::now(),
|
||||||
cwd: &self.pwd,
|
cwd: &self.pwd,
|
||||||
scope: &self.scope,
|
scope: &scope_snapshot,
|
||||||
tool_names,
|
tool_names,
|
||||||
agents_md: agents_md_read.body,
|
agents_md: agents_md_read.body,
|
||||||
resident_knowledge: resident_slice,
|
resident_knowledge: resident_slice,
|
||||||
|
|
@ -667,7 +709,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
/// are skipped — the unresolved placeholder stays in the flattened
|
/// are skipped — the unresolved placeholder stays in the flattened
|
||||||
/// user message so the LLM still sees the intent.
|
/// user message so the LLM still sees the intent.
|
||||||
fn resolve_file_refs(&self, segments: &[Segment]) -> Vec<Item> {
|
fn resolve_file_refs(&self, segments: &[Segment]) -> Vec<Item> {
|
||||||
let view = crate::fs_view::PodFsView::new(tools::ScopedFs::new(
|
let view = crate::fs_view::PodFsView::new(tools::ScopedFs::with_shared_scope(
|
||||||
self.scope.clone(),
|
self.scope.clone(),
|
||||||
self.pwd.clone(),
|
self.pwd.clone(),
|
||||||
));
|
));
|
||||||
|
|
@ -1078,7 +1120,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
// with the main Pod (reads go through the same policy) but the
|
// with the main Pod (reads go through the same policy) but the
|
||||||
// Tracker is fresh — compact-time reads must not pollute the
|
// Tracker is fresh — compact-time reads must not pollute the
|
||||||
// main session's recency list, which feeds `default_refs` above.
|
// main session's recency list, which feeds `default_refs` above.
|
||||||
let scoped_fs = tools::ScopedFs::new(self.scope.clone(), self.pwd.clone());
|
let scoped_fs = tools::ScopedFs::with_shared_scope(self.scope.clone(), self.pwd.clone());
|
||||||
let summary_tracker = tools::Tracker::new();
|
let summary_tracker = tools::Tracker::new();
|
||||||
let summary_client: Box<dyn LlmClient> = self.build_compactor_client()?;
|
let summary_client: Box<dyn LlmClient> = self.build_compactor_client()?;
|
||||||
let summary_system_prompt = self
|
let summary_system_prompt = self
|
||||||
|
|
@ -1801,7 +1843,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
session_id,
|
session_id,
|
||||||
head_hash: None,
|
head_hash: None,
|
||||||
pwd: common.pwd,
|
pwd: common.pwd,
|
||||||
scope: common.scope,
|
scope: SharedScope::new(common.scope),
|
||||||
hook_builder: HookRegistryBuilder::new(),
|
hook_builder: HookRegistryBuilder::new(),
|
||||||
interceptor_installed: false,
|
interceptor_installed: false,
|
||||||
compact_state: None,
|
compact_state: None,
|
||||||
|
|
@ -1859,7 +1901,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
session_id,
|
session_id,
|
||||||
head_hash: None,
|
head_hash: None,
|
||||||
pwd: common.pwd,
|
pwd: common.pwd,
|
||||||
scope: common.scope,
|
scope: SharedScope::new(common.scope),
|
||||||
hook_builder: HookRegistryBuilder::new(),
|
hook_builder: HookRegistryBuilder::new(),
|
||||||
interceptor_installed: false,
|
interceptor_installed: false,
|
||||||
compact_state: None,
|
compact_state: None,
|
||||||
|
|
@ -1967,7 +2009,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
session_id,
|
session_id,
|
||||||
head_hash: state.head_hash,
|
head_hash: state.head_hash,
|
||||||
pwd: common.pwd,
|
pwd: common.pwd,
|
||||||
scope: common.scope,
|
scope: SharedScope::new(common.scope),
|
||||||
hook_builder: HookRegistryBuilder::new(),
|
hook_builder: HookRegistryBuilder::new(),
|
||||||
interceptor_installed: false,
|
interceptor_installed: false,
|
||||||
compact_state: None,
|
compact_state: None,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ use async_trait::async_trait;
|
||||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
use manifest::{
|
use manifest::{
|
||||||
ModelManifest, Permission, PodManifestConfig, PodMetaConfig, ScopeConfig, ScopeRule,
|
ModelManifest, Permission, PodManifestConfig, PodMetaConfig, ScopeConfig, ScopeRule,
|
||||||
WorkerManifestConfig,
|
SharedScope, WorkerManifestConfig,
|
||||||
};
|
};
|
||||||
use protocol::Method;
|
use protocol::Method;
|
||||||
use protocol::stream::JsonLineWriter;
|
use protocol::stream::JsonLineWriter;
|
||||||
|
|
@ -119,6 +119,14 @@ pub struct SpawnPodTool {
|
||||||
/// configuration in the manifest cascade. Per-spawn override is
|
/// configuration in the manifest cascade. Per-spawn override is
|
||||||
/// out of scope here (see `tickets/spawn-inherit-provider.md`).
|
/// out of scope here (see `tickets/spawn-inherit-provider.md`).
|
||||||
spawner_model: ModelManifest,
|
spawner_model: ModelManifest,
|
||||||
|
/// Spawner's runtime scope. After a successful spawn, the
|
||||||
|
/// `Permission::Write` rules in the delegated scope are revoked
|
||||||
|
/// from the spawner's in-memory view (a `deny(Write, target)` is
|
||||||
|
/// pushed on top, downgrading the spawner's effective access on
|
||||||
|
/// those paths to `Read`). Mirrors the pod-registry's
|
||||||
|
/// `effective_write` semantics: Write is the only permission
|
||||||
|
/// tracked across Pods, so revocation only touches Write.
|
||||||
|
spawner_scope: SharedScope,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpawnPodTool {
|
impl SpawnPodTool {
|
||||||
|
|
@ -130,6 +138,7 @@ impl SpawnPodTool {
|
||||||
registry: Arc<SpawnedPodRegistry>,
|
registry: Arc<SpawnedPodRegistry>,
|
||||||
parent_socket: Option<PathBuf>,
|
parent_socket: Option<PathBuf>,
|
||||||
spawner_model: ModelManifest,
|
spawner_model: ModelManifest,
|
||||||
|
spawner_scope: SharedScope,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
spawner_name,
|
spawner_name,
|
||||||
|
|
@ -139,6 +148,7 @@ impl SpawnPodTool {
|
||||||
registry,
|
registry,
|
||||||
parent_socket,
|
parent_socket,
|
||||||
spawner_model,
|
spawner_model,
|
||||||
|
spawner_scope,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -217,6 +227,27 @@ impl Tool for SpawnPodTool {
|
||||||
|
|
||||||
// Child is live. Post-start errors propagate but do not roll
|
// Child is live. Post-start errors propagate but do not roll
|
||||||
// back the scope allocation — the child already owns it.
|
// back the scope allocation — the child already owns it.
|
||||||
|
//
|
||||||
|
// Mirror that ownership transfer in the spawner's in-memory
|
||||||
|
// scope: every `Permission::Write` rule in the delegated scope
|
||||||
|
// is shadowed by a `deny(Write, target)` so subsequent tool
|
||||||
|
// calls (Edit/Write) on the delegated paths fail with
|
||||||
|
// `ReadOnly`. Read access is left intact — the registry only
|
||||||
|
// arbitrates Write, and keeping Read lets the spawner observe
|
||||||
|
// the child's intermediate output through Read/Glob/Grep.
|
||||||
|
let revoke_write: Vec<ScopeRule> = scope_allow
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.permission == Permission::Write)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
if !revoke_write.is_empty() {
|
||||||
|
self.spawner_scope
|
||||||
|
.update(|cur| cur.with_added_deny_rules(revoke_write.clone()))
|
||||||
|
.map_err(|e| {
|
||||||
|
ToolError::ExecutionFailed(format!("revoke spawner scope: {e}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
send_run(&predicted_socket, &input.task).await?;
|
send_run(&predicted_socket, &input.task).await?;
|
||||||
|
|
||||||
let record = SpawnedPodRecord {
|
let record = SpawnedPodRecord {
|
||||||
|
|
@ -456,6 +487,7 @@ pub fn spawn_pod_tool(
|
||||||
registry: Arc<SpawnedPodRegistry>,
|
registry: Arc<SpawnedPodRegistry>,
|
||||||
parent_socket: Option<PathBuf>,
|
parent_socket: Option<PathBuf>,
|
||||||
spawner_model: ModelManifest,
|
spawner_model: ModelManifest,
|
||||||
|
spawner_scope: SharedScope,
|
||||||
) -> ToolDefinition {
|
) -> ToolDefinition {
|
||||||
Arc::new(move || {
|
Arc::new(move || {
|
||||||
let schema = schemars::schema_for!(SpawnPodInput);
|
let schema = schemars::schema_for!(SpawnPodInput);
|
||||||
|
|
@ -471,6 +503,7 @@ pub fn spawn_pod_tool(
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
parent_socket.clone(),
|
parent_socket.clone(),
|
||||||
spawner_model.clone(),
|
spawner_model.clone(),
|
||||||
|
spawner_scope.clone(),
|
||||||
));
|
));
|
||||||
(meta, tool)
|
(meta, tool)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
|
||||||
use std::sync::{LazyLock, Mutex};
|
use std::sync::{LazyLock, Mutex};
|
||||||
|
|
||||||
use llm_worker::tool::{ToolError, ToolOutput};
|
use llm_worker::tool::{ToolError, ToolOutput};
|
||||||
use manifest::{AuthRef, ModelManifest, Permission, SchemeKind, ScopeRule};
|
use manifest::{AuthRef, ModelManifest, Permission, SchemeKind, Scope, ScopeRule, SharedScope};
|
||||||
use pod::runtime::dir::{RuntimeDir, SpawnedPodRecord};
|
use pod::runtime::dir::{RuntimeDir, SpawnedPodRecord};
|
||||||
use pod::runtime::pod_registry::{self, LockFileGuard};
|
use pod::runtime::pod_registry::{self, LockFileGuard};
|
||||||
use pod::spawn::registry::SpawnedPodRegistry;
|
use pod::spawn::registry::SpawnedPodRegistry;
|
||||||
|
|
@ -149,6 +149,14 @@ fn dummy_model() -> ModelManifest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawner-side `SharedScope` mirroring the `allow_root` granted by
|
||||||
|
/// `setup_spawner`. The tool revokes Write rules from this scope on
|
||||||
|
/// successful spawn — tests can `load()` it to assert the
|
||||||
|
/// revocation took effect.
|
||||||
|
fn shared_scope_for(allow_root: &Path) -> SharedScope {
|
||||||
|
SharedScope::new(Scope::writable(allow_root).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
fn clear_env() {
|
fn clear_env() {
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("INSOMNIA_RUNTIME_DIR");
|
std::env::remove_var("INSOMNIA_RUNTIME_DIR");
|
||||||
|
|
@ -169,6 +177,7 @@ async fn spawn_pod_delegates_scope_and_sends_run() {
|
||||||
let received = accept_one_method(listener);
|
let received = accept_one_method(listener);
|
||||||
|
|
||||||
let registry = SpawnedPodRegistry::new(spawner_rd.clone());
|
let registry = SpawnedPodRegistry::new(spawner_rd.clone());
|
||||||
|
let spawner_scope = shared_scope_for(allow_root.path());
|
||||||
let def = spawn_pod_tool(
|
let def = spawn_pod_tool(
|
||||||
"root".into(),
|
"root".into(),
|
||||||
spawner_socket.clone(),
|
spawner_socket.clone(),
|
||||||
|
|
@ -177,6 +186,7 @@ async fn spawn_pod_delegates_scope_and_sends_run() {
|
||||||
registry,
|
registry,
|
||||||
None,
|
None,
|
||||||
dummy_model(),
|
dummy_model(),
|
||||||
|
spawner_scope.clone(),
|
||||||
);
|
);
|
||||||
let (_meta, tool) = def();
|
let (_meta, tool) = def();
|
||||||
|
|
||||||
|
|
@ -190,6 +200,13 @@ async fn spawn_pod_delegates_scope_and_sends_run() {
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
|
// Pre-spawn: the spawner can write to the delegated path.
|
||||||
|
assert!(
|
||||||
|
spawner_scope
|
||||||
|
.load()
|
||||||
|
.is_writable(&allow_root.path().join("a.txt"))
|
||||||
|
);
|
||||||
|
|
||||||
let output: ToolOutput = tool.execute(&input).await.unwrap();
|
let output: ToolOutput = tool.execute(&input).await.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
output.summary.contains("child"),
|
output.summary.contains("child"),
|
||||||
|
|
@ -225,6 +242,15 @@ async fn spawn_pod_delegates_scope_and_sends_run() {
|
||||||
assert_eq!(records[0].pod_name, "child");
|
assert_eq!(records[0].pod_name, "child");
|
||||||
assert_eq!(records[0].callback_address, spawner_socket);
|
assert_eq!(records[0].callback_address, spawner_socket);
|
||||||
|
|
||||||
|
// Post-spawn: the spawner's runtime scope has been demoted on the
|
||||||
|
// delegated path. Write is gone, Read remains.
|
||||||
|
let post = spawner_scope.load();
|
||||||
|
assert_eq!(
|
||||||
|
post.permission_at(&allow_root.path().join("a.txt")),
|
||||||
|
Some(Permission::Read),
|
||||||
|
"spawner should still be able to read delegated path"
|
||||||
|
);
|
||||||
|
|
||||||
clear_env();
|
clear_env();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -239,6 +265,7 @@ async fn spawn_pod_rejects_scope_outside_spawner() {
|
||||||
point_pod_command_at_true();
|
point_pod_command_at_true();
|
||||||
|
|
||||||
let registry = SpawnedPodRegistry::new(spawner_rd);
|
let registry = SpawnedPodRegistry::new(spawner_rd);
|
||||||
|
let spawner_scope = shared_scope_for(allow_root.path());
|
||||||
let def = spawn_pod_tool(
|
let def = spawn_pod_tool(
|
||||||
"root".into(),
|
"root".into(),
|
||||||
spawner_socket,
|
spawner_socket,
|
||||||
|
|
@ -247,6 +274,7 @@ async fn spawn_pod_rejects_scope_outside_spawner() {
|
||||||
registry,
|
registry,
|
||||||
None,
|
None,
|
||||||
dummy_model(),
|
dummy_model(),
|
||||||
|
spawner_scope.clone(),
|
||||||
);
|
);
|
||||||
let (_meta, tool) = def();
|
let (_meta, tool) = def();
|
||||||
|
|
||||||
|
|
@ -277,6 +305,13 @@ async fn spawn_pod_rejects_scope_outside_spawner() {
|
||||||
let guard = LockFileGuard::open(&lock_path).unwrap();
|
let guard = LockFileGuard::open(&lock_path).unwrap();
|
||||||
assert!(guard.data().find("child").is_none());
|
assert!(guard.data().find("child").is_none());
|
||||||
|
|
||||||
|
// Failed spawn must not have demoted the spawner's scope either.
|
||||||
|
assert!(
|
||||||
|
spawner_scope
|
||||||
|
.load()
|
||||||
|
.is_writable(&allow_root.path().join("a.txt"))
|
||||||
|
);
|
||||||
|
|
||||||
clear_env();
|
clear_env();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -301,6 +336,7 @@ async fn spawn_pod_rolls_back_reservation_when_socket_never_appears() {
|
||||||
// by running this test alone when iterating.
|
// by running this test alone when iterating.
|
||||||
|
|
||||||
let registry = SpawnedPodRegistry::new(spawner_rd);
|
let registry = SpawnedPodRegistry::new(spawner_rd);
|
||||||
|
let spawner_scope = shared_scope_for(allow_root.path());
|
||||||
let def = spawn_pod_tool(
|
let def = spawn_pod_tool(
|
||||||
"root".into(),
|
"root".into(),
|
||||||
spawner_socket,
|
spawner_socket,
|
||||||
|
|
@ -309,6 +345,7 @@ async fn spawn_pod_rolls_back_reservation_when_socket_never_appears() {
|
||||||
registry,
|
registry,
|
||||||
None,
|
None,
|
||||||
dummy_model(),
|
dummy_model(),
|
||||||
|
spawner_scope.clone(),
|
||||||
);
|
);
|
||||||
let (_meta, tool) = def();
|
let (_meta, tool) = def();
|
||||||
|
|
||||||
|
|
@ -341,5 +378,13 @@ async fn spawn_pod_rolls_back_reservation_when_socket_never_appears() {
|
||||||
"allocation was not rolled back after socket wait timed out"
|
"allocation was not rolled back after socket wait timed out"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Spawner's runtime scope must also be untouched — revoke is
|
||||||
|
// performed only after exec_child succeeds.
|
||||||
|
assert!(
|
||||||
|
spawner_scope
|
||||||
|
.load()
|
||||||
|
.is_writable(&allow_root.path().join("a.txt"))
|
||||||
|
);
|
||||||
|
|
||||||
clear_env();
|
clear_env();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,17 +13,23 @@ use std::io::Write as _;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use manifest::Scope;
|
use manifest::{Scope, SharedScope};
|
||||||
|
|
||||||
use crate::error::ToolsError;
|
use crate::error::ToolsError;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ScopedFsInner {
|
struct ScopedFsInner {
|
||||||
scope: Scope,
|
scope: SharedScope,
|
||||||
pwd: PathBuf,
|
pwd: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scope-aware filesystem handle. Clone-cheap (`Arc` inside).
|
/// Scope-aware filesystem handle. Clone-cheap (`Arc` inside).
|
||||||
|
///
|
||||||
|
/// The wrapped [`SharedScope`] is shared with every clone of this
|
||||||
|
/// `ScopedFs` and with whoever else holds the same `SharedScope`
|
||||||
|
/// handle (typically the owning Pod). Mutations to that `SharedScope`
|
||||||
|
/// propagate atomically; the next permission check inside any
|
||||||
|
/// `ScopedFs` reads the new view.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ScopedFs {
|
pub struct ScopedFs {
|
||||||
inner: Arc<ScopedFsInner>,
|
inner: Arc<ScopedFsInner>,
|
||||||
|
|
@ -37,15 +43,34 @@ pub struct WriteOutcome {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScopedFs {
|
impl ScopedFs {
|
||||||
/// Create a new [`ScopedFs`] wrapping the given [`Scope`] and pwd.
|
/// Create a new [`ScopedFs`] wrapping `scope` and `pwd` in a fresh
|
||||||
|
/// [`SharedScope`]. Use [`ScopedFs::with_shared_scope`] when you
|
||||||
|
/// need the resulting `ScopedFs` to share scope state with another
|
||||||
|
/// holder of the `SharedScope` (typically the Pod).
|
||||||
pub fn new(scope: Scope, pwd: PathBuf) -> Self {
|
pub fn new(scope: Scope, pwd: PathBuf) -> Self {
|
||||||
|
Self::with_shared_scope(SharedScope::new(scope), pwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a [`ScopedFs`] over an existing [`SharedScope`]. The
|
||||||
|
/// resulting handle and any future updates the caller pushes to
|
||||||
|
/// `scope` are observed by every clone of this `ScopedFs`.
|
||||||
|
pub fn with_shared_scope(scope: SharedScope, pwd: PathBuf) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(ScopedFsInner { scope, pwd }),
|
inner: Arc::new(ScopedFsInner { scope, pwd }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The underlying [`Scope`].
|
/// Snapshot the current scope. Cheap; the returned `Arc<Scope>` is
|
||||||
pub fn scope(&self) -> &Scope {
|
/// a coherent point-in-time view that subsequent mutations do not
|
||||||
|
/// affect.
|
||||||
|
pub fn scope(&self) -> Arc<Scope> {
|
||||||
|
self.inner.scope.snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared scope handle backing this `ScopedFs`. Cloning it lets a
|
||||||
|
/// caller (usually the Pod) hold the same view and push updates
|
||||||
|
/// that are immediately reflected in subsequent permission checks.
|
||||||
|
pub fn shared_scope(&self) -> &SharedScope {
|
||||||
&self.inner.scope
|
&self.inner.scope
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,7 +92,7 @@ impl ScopedFs {
|
||||||
if !path.is_absolute() {
|
if !path.is_absolute() {
|
||||||
return Err(ToolsError::RelativePath(path.to_path_buf()));
|
return Err(ToolsError::RelativePath(path.to_path_buf()));
|
||||||
}
|
}
|
||||||
if !self.inner.scope.is_readable(path) {
|
if !self.inner.scope.load().is_readable(path) {
|
||||||
return Err(ToolsError::OutOfScope(path.to_path_buf()));
|
return Err(ToolsError::OutOfScope(path.to_path_buf()));
|
||||||
}
|
}
|
||||||
let meta = std::fs::metadata(path).map_err(|e| match e.kind() {
|
let meta = std::fs::metadata(path).map_err(|e| match e.kind() {
|
||||||
|
|
@ -100,13 +125,15 @@ impl ScopedFs {
|
||||||
if !path.is_absolute() {
|
if !path.is_absolute() {
|
||||||
return Err(ToolsError::RelativePath(path.to_path_buf()));
|
return Err(ToolsError::RelativePath(path.to_path_buf()));
|
||||||
}
|
}
|
||||||
if !self.inner.scope.is_writable(path) {
|
let scope = self.inner.scope.load();
|
||||||
return Err(if self.inner.scope.is_readable(path) {
|
if !scope.is_writable(path) {
|
||||||
|
return Err(if scope.is_readable(path) {
|
||||||
ToolsError::ReadOnly(path.to_path_buf())
|
ToolsError::ReadOnly(path.to_path_buf())
|
||||||
} else {
|
} else {
|
||||||
ToolsError::OutOfScope(path.to_path_buf())
|
ToolsError::OutOfScope(path.to_path_buf())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
drop(scope);
|
||||||
|
|
||||||
// Reject existing directory targets.
|
// Reject existing directory targets.
|
||||||
match std::fs::metadata(path) {
|
match std::fs::metadata(path) {
|
||||||
|
|
@ -299,4 +326,118 @@ mod tests {
|
||||||
let err = fs.write(dir.path(), b"x").unwrap_err();
|
let err = fs.write(dir.path(), b"x").unwrap_err();
|
||||||
assert!(matches!(err, ToolsError::IsDirectory(_)));
|
assert!(matches!(err, ToolsError::IsDirectory(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Dynamic scope: SharedScope mutations propagate into ScopedFs decisions
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_allow_rule_through_shared_scope_grows_readable_set() {
|
||||||
|
use manifest::SharedScope;
|
||||||
|
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let extra = TempDir::new().unwrap();
|
||||||
|
let extra_file = extra.path().join("x.txt");
|
||||||
|
fs::write(&extra_file, b"hi").unwrap();
|
||||||
|
|
||||||
|
let shared = SharedScope::new(Scope::writable(dir.path()).unwrap());
|
||||||
|
let fs = ScopedFs::with_shared_scope(shared.clone(), dir.path().to_path_buf());
|
||||||
|
|
||||||
|
// Before: extra is out of scope.
|
||||||
|
let err = fs.read_bytes(&extra_file).unwrap_err();
|
||||||
|
assert!(matches!(err, ToolsError::OutOfScope(_)));
|
||||||
|
|
||||||
|
// Push an allow(Read) rule.
|
||||||
|
shared
|
||||||
|
.update(|cur| {
|
||||||
|
cur.with_added_allow_rules([ScopeRule {
|
||||||
|
target: extra.path().to_path_buf(),
|
||||||
|
permission: Permission::Read,
|
||||||
|
recursive: true,
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// After: read goes through.
|
||||||
|
assert_eq!(fs.read_bytes(&extra_file).unwrap(), b"hi");
|
||||||
|
// But write still fails — allow only granted Read.
|
||||||
|
let err = fs.write(&extra.path().join("y.txt"), b"x").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, ToolsError::ReadOnly(_)),
|
||||||
|
"expected ReadOnly, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn revoke_write_through_shared_scope_blocks_subsequent_writes() {
|
||||||
|
use manifest::SharedScope;
|
||||||
|
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let sub = dir.path().join("sub");
|
||||||
|
fs::create_dir(&sub).unwrap();
|
||||||
|
let target = sub.join("a.txt");
|
||||||
|
|
||||||
|
let shared = SharedScope::new(Scope::writable(dir.path()).unwrap());
|
||||||
|
let fs = ScopedFs::with_shared_scope(shared.clone(), dir.path().to_path_buf());
|
||||||
|
|
||||||
|
// Write succeeds initially.
|
||||||
|
fs.write(&target, b"first").unwrap();
|
||||||
|
|
||||||
|
// Revoke Write on `sub` (push a deny(Write) rule).
|
||||||
|
shared
|
||||||
|
.update(|cur| {
|
||||||
|
cur.with_added_deny_rules([ScopeRule {
|
||||||
|
target: sub.clone(),
|
||||||
|
permission: Permission::Write,
|
||||||
|
recursive: true,
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Subsequent write fails with ReadOnly — Read is preserved.
|
||||||
|
let err = fs.write(&target, b"second").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, ToolsError::ReadOnly(_)),
|
||||||
|
"expected ReadOnly after revoke, got {err:?}"
|
||||||
|
);
|
||||||
|
// Read still works.
|
||||||
|
assert_eq!(fs.read_bytes(&target).unwrap(), b"first");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_scope_changes_propagate_across_clones() {
|
||||||
|
use manifest::SharedScope;
|
||||||
|
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let target = dir.path().join("a.txt");
|
||||||
|
|
||||||
|
let shared = SharedScope::new(Scope::writable(dir.path()).unwrap());
|
||||||
|
let fs1 = ScopedFs::with_shared_scope(shared.clone(), dir.path().to_path_buf());
|
||||||
|
let fs2 = fs1.clone();
|
||||||
|
|
||||||
|
// fs1 writes; both clones see the file.
|
||||||
|
fs1.write(&target, b"hi").unwrap();
|
||||||
|
assert_eq!(fs2.read_bytes(&target).unwrap(), b"hi");
|
||||||
|
|
||||||
|
// Revoke write through the original handle.
|
||||||
|
shared
|
||||||
|
.update(|cur| {
|
||||||
|
cur.with_added_deny_rules([ScopeRule {
|
||||||
|
target: dir.path().to_path_buf(),
|
||||||
|
permission: Permission::Write,
|
||||||
|
recursive: true,
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Both clones reject writes now — they share the same SharedScope.
|
||||||
|
assert!(matches!(
|
||||||
|
fs1.write(&target, b"x").unwrap_err(),
|
||||||
|
ToolsError::ReadOnly(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
fs2.write(&target, b"x").unwrap_err(),
|
||||||
|
ToolsError::ReadOnly(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user