feat: dynamic-scopeの実装
This commit is contained in:
@@ -83,7 +83,7 @@ impl PodController {
|
||||
|
||||
// Snapshot pod-immutable values needed for tool factories so the
|
||||
// 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 spawner_name = pod.manifest().pod.name.clone();
|
||||
let spawner_model = pod.manifest().model.clone();
|
||||
@@ -230,11 +230,14 @@ impl PodController {
|
||||
// touching.
|
||||
//
|
||||
// Bash spills long outputs to a per-pod subdir under the
|
||||
// runtime dir. We layer a recursive `allow(Read)` rule for
|
||||
// that path on top of the user-facing scope so the agent can
|
||||
// `Read` the saved files without polluting the workspace.
|
||||
// Same approach memory takes for its deny rules: round-trip
|
||||
// through `ScopeConfig` and rebuild via `from_config`.
|
||||
// runtime dir. Push a recursive `allow(Read)` for that path
|
||||
// into the Pod's runtime scope so the agent can `Read` the
|
||||
// saved files without polluting the workspace. The Pod's
|
||||
// SharedScope is the single source of truth — the same
|
||||
// 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");
|
||||
std::fs::create_dir_all(&bash_output_dir).map_err(|e| {
|
||||
std::io::Error::other(format!(
|
||||
@@ -242,18 +245,16 @@ impl PodController {
|
||||
bash_output_dir.display()
|
||||
))
|
||||
})?;
|
||||
let mut scope_config = manifest::ScopeConfig {
|
||||
allow: scope_for_tools.allow_rules(),
|
||||
deny: scope_for_tools.deny_rules(),
|
||||
};
|
||||
scope_config.allow.push(manifest::ScopeRule {
|
||||
target: bash_output_dir.clone(),
|
||||
permission: manifest::Permission::Read,
|
||||
recursive: true,
|
||||
});
|
||||
let scope_with_bash = manifest::Scope::from_config(&scope_config)
|
||||
scope_handle
|
||||
.update(|cur| {
|
||||
cur.with_added_allow_rules([manifest::ScopeRule {
|
||||
target: bash_output_dir.clone(),
|
||||
permission: manifest::Permission::Read,
|
||||
recursive: true,
|
||||
}])
|
||||
})
|
||||
.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();
|
||||
// The same ScopedFs also powers the IPC `ListCompletions`
|
||||
// query — keep a clone for the FS view we attach below,
|
||||
@@ -292,6 +293,7 @@ impl PodController {
|
||||
spawned_registry.clone(),
|
||||
self_parent_socket.clone(),
|
||||
spawner_model.clone(),
|
||||
scope_handle.clone(),
|
||||
));
|
||||
worker.register_tool(send_to_pod_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(),
|
||||
provider: provider_name,
|
||||
model: model_id,
|
||||
scope_summary: pod.scope().summary(),
|
||||
scope_summary: pod.scope_snapshot().summary(),
|
||||
tools: tool_names,
|
||||
}
|
||||
}
|
||||
|
||||
+54
-12
@@ -10,7 +10,10 @@ use llm_worker::{ToolOutputLimits, UsageRecord, Worker, WorkerError, WorkerResul
|
||||
use session_store::{EntryHash, SessionId, SessionStartState, Store, StoreError};
|
||||
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::usage_tracker::UsageTracker;
|
||||
@@ -60,8 +63,11 @@ pub struct Pod<C: LlmClient, St: Store> {
|
||||
head_hash: Option<EntryHash>,
|
||||
/// Absolute working directory of the Pod.
|
||||
pwd: PathBuf,
|
||||
/// Resolved scope — always present.
|
||||
scope: Scope,
|
||||
/// Shared, atomically-swappable view of the Pod's resolved 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,
|
||||
interceptor_installed: bool,
|
||||
/// Shared compaction state (present when compact_threshold is configured).
|
||||
@@ -185,7 +191,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
session_id,
|
||||
head_hash: None,
|
||||
pwd,
|
||||
scope,
|
||||
scope: SharedScope::new(scope),
|
||||
hook_builder: HookRegistryBuilder::new(),
|
||||
interceptor_installed: false,
|
||||
compact_state: None,
|
||||
@@ -252,11 +258,46 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
&self.pwd
|
||||
}
|
||||
|
||||
/// The Pod's directory scope.
|
||||
pub fn scope(&self) -> &Scope {
|
||||
/// The Pod's directory scope, as a shared atomically-swappable
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn worker(&self) -> &Worker<C, Mutable> {
|
||||
self.worker.as_ref().expect("worker taken during run")
|
||||
@@ -582,10 +623,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let scope_snapshot = self.scope.snapshot();
|
||||
let ctx = SystemPromptContext {
|
||||
now: chrono::Utc::now(),
|
||||
cwd: &self.pwd,
|
||||
scope: &self.scope,
|
||||
scope: &scope_snapshot,
|
||||
tool_names,
|
||||
agents_md: agents_md_read.body,
|
||||
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
|
||||
/// user message so the LLM still sees the intent.
|
||||
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.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
|
||||
// Tracker is fresh — compact-time reads must not pollute the
|
||||
// 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_client: Box<dyn LlmClient> = self.build_compactor_client()?;
|
||||
let summary_system_prompt = self
|
||||
@@ -1801,7 +1843,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
session_id,
|
||||
head_hash: None,
|
||||
pwd: common.pwd,
|
||||
scope: common.scope,
|
||||
scope: SharedScope::new(common.scope),
|
||||
hook_builder: HookRegistryBuilder::new(),
|
||||
interceptor_installed: false,
|
||||
compact_state: None,
|
||||
@@ -1859,7 +1901,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
session_id,
|
||||
head_hash: None,
|
||||
pwd: common.pwd,
|
||||
scope: common.scope,
|
||||
scope: SharedScope::new(common.scope),
|
||||
hook_builder: HookRegistryBuilder::new(),
|
||||
interceptor_installed: false,
|
||||
compact_state: None,
|
||||
@@ -1967,7 +2009,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
session_id,
|
||||
head_hash: state.head_hash,
|
||||
pwd: common.pwd,
|
||||
scope: common.scope,
|
||||
scope: SharedScope::new(common.scope),
|
||||
hook_builder: HookRegistryBuilder::new(),
|
||||
interceptor_installed: false,
|
||||
compact_state: None,
|
||||
|
||||
@@ -15,7 +15,7 @@ use async_trait::async_trait;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use manifest::{
|
||||
ModelManifest, Permission, PodManifestConfig, PodMetaConfig, ScopeConfig, ScopeRule,
|
||||
WorkerManifestConfig,
|
||||
SharedScope, WorkerManifestConfig,
|
||||
};
|
||||
use protocol::Method;
|
||||
use protocol::stream::JsonLineWriter;
|
||||
@@ -119,6 +119,14 @@ pub struct SpawnPodTool {
|
||||
/// configuration in the manifest cascade. Per-spawn override is
|
||||
/// out of scope here (see `tickets/spawn-inherit-provider.md`).
|
||||
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 {
|
||||
@@ -130,6 +138,7 @@ impl SpawnPodTool {
|
||||
registry: Arc<SpawnedPodRegistry>,
|
||||
parent_socket: Option<PathBuf>,
|
||||
spawner_model: ModelManifest,
|
||||
spawner_scope: SharedScope,
|
||||
) -> Self {
|
||||
Self {
|
||||
spawner_name,
|
||||
@@ -139,6 +148,7 @@ impl SpawnPodTool {
|
||||
registry,
|
||||
parent_socket,
|
||||
spawner_model,
|
||||
spawner_scope,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,6 +227,27 @@ impl Tool for SpawnPodTool {
|
||||
|
||||
// Child is live. Post-start errors propagate but do not roll
|
||||
// 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?;
|
||||
|
||||
let record = SpawnedPodRecord {
|
||||
@@ -456,6 +487,7 @@ pub fn spawn_pod_tool(
|
||||
registry: Arc<SpawnedPodRegistry>,
|
||||
parent_socket: Option<PathBuf>,
|
||||
spawner_model: ModelManifest,
|
||||
spawner_scope: SharedScope,
|
||||
) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(SpawnPodInput);
|
||||
@@ -471,6 +503,7 @@ pub fn spawn_pod_tool(
|
||||
registry.clone(),
|
||||
parent_socket.clone(),
|
||||
spawner_model.clone(),
|
||||
spawner_scope.clone(),
|
||||
));
|
||||
(meta, tool)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user