feat: dynamic-scopeの実装

This commit is contained in:
2026-05-02 01:26:17 +09:00
parent 5479b14411
commit d33b1c111e
9 changed files with 508 additions and 41 deletions
+20 -18
View File
@@ -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
View File
@@ -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,
+34 -1
View File
@@ -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)
})