Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@

## 책임과 개발 범위

2026-10-31 구현: [Run별 capability 범위](docs/development/capability-scope.md).
`run --capability ID`는 선택한 정의와 instance만 planner/admission에 등록하고 manifest와
실행 profile에 결속해. 기본 전체 catalog·기존 승인·Receipt는 유지하며 resume은 저장한
범위를 복원해. library109+workspace22검사 및 clippy 통과. 별도 호스트의 도구 설명 크기
문제에서 시작했지만 업무 규칙은 엔진에 넣지 않았어. 실제 모델 품질·운영 채택은 미검증이야.

| 구성 요소 | 책임 | 별도로 추적할 개선 |
| --- | --- | --- |
| xgen CLI | 연결·인증 관리, 도구 호출 계약, 권한·승인, 실행, Receipt, 최종 응답, 사용자 경험 | 연결 흐름, 어댑터, 결과 검증, 배포·자동 실행 |
Expand Down
100 changes: 100 additions & 0 deletions crates/xgen-cli/src/capability_scope.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//! Optional host-selected capability subset, committed before planning and admission.
use std::collections::BTreeSet;

use xgen_runtime::CapabilityRegistry;

pub(crate) fn normalize(ids: Vec<String>) -> Result<Option<Vec<String>>, ()> {
if ids.is_empty() {
return Ok(None);
}
let mut sorted = ids;
sorted.sort();
if !valid(&sorted) {
return Err(());
}
Ok(Some(sorted))
}

pub(crate) fn valid(ids: &[String]) -> bool {
!ids.is_empty()
&& ids.len() <= 128
&& ids.windows(2).all(|p| p[0] < p[1])
&& ids.iter().all(|id| {
!id.is_empty()
&& id.len() <= 256
&& id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"._/-:".contains(&c))
})
}

pub(crate) fn validate_available<'a>(
ids: Option<&[String]>,
available: impl Iterator<Item = &'a str>,
) -> Result<(), ()> {
if let Some(ids) = ids {
let available = available.collect::<BTreeSet<_>>();
if !valid(ids) || ids.iter().any(|id| !available.contains(id.as_str())) {
return Err(());
}
}
Ok(())
}

pub(crate) fn apply(
registry: CapabilityRegistry,
ids: Option<&[String]>,
) -> Result<CapabilityRegistry, ()> {
let Some(ids) = ids else { return Ok(registry) };
validate_available(
Some(ids),
registry.definitions().map(|d| d.metadata.id.as_str()),
)?;
let mut scoped = CapabilityRegistry::new();
for definition in registry
.definitions()
.filter(|d| ids.contains(&d.metadata.id))
{
scoped
.register_schema_validated_definition(definition.clone())
.map_err(|_| ())?;
}
for instance in registry
.instances()
.filter(|i| ids.contains(&i.definition.capability_id))
{
scoped
.register_schema_validated_instance(instance.clone())
.map_err(|_| ())?;
}
Ok(scoped)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn default_and_canonical_scope() {
assert_eq!(normalize(vec![]), Ok(None));
assert_eq!(
normalize(vec!["b/read".into(), "a/read".into()]),
Ok(Some(vec!["a/read".into(), "b/read".into()]))
);
for ids in [
vec!["a/read".into(), "a/read".into()],
vec!["".into()],
vec!["a read".into()],
] {
assert!(normalize(ids).is_err());
}
}

#[test]
fn unknown_scope_never_falls_back_to_full_catalog() {
for id in ["missing/read", "missing/write"] {
assert!(validate_available(Some(&[id.into()]), ["a/read"].into_iter()).is_err());
assert!(apply(CapabilityRegistry::new(), Some(&[id.into()])).is_err());
}
}
}
75 changes: 67 additions & 8 deletions crates/xgen-cli/src/composition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ pub struct LocalRunRequest {
/// one. The REPL has always turned this on; `run` could not, so the same
/// question answered interactively failed as a single invocation.
pub conversation_responses: bool,
/// Optional capability IDs; empty retains the full catalog. Saved for resume.
pub capabilities: Vec<String>,
}

impl LocalRunRequest {
Expand Down Expand Up @@ -189,6 +191,7 @@ impl LocalRunRequest {
final_response_schema: None,
completion_checks: None,
conversation_responses: false,
capabilities: Vec::new(),
}
}
}
Expand Down Expand Up @@ -1061,6 +1064,8 @@ where
});
}
validate_goal(&request.goal)?;
let capability_scope = crate::capability_scope::normalize(request.capabilities.clone())
.map_err(|()| PublicRunError::Configuration)?;

let workspace_id = WorkspaceId::new(WORKSPACE_ID).map_err(|_| PublicRunError::Internal)?;
let workspace = WorkspaceRoot::open_ambient(&request.workspace, workspace_id.clone())
Expand Down Expand Up @@ -1118,6 +1123,7 @@ where
&discovery_snapshots,
&http_connections,
&request.bearer_env_for,
capability_scope.as_deref(),
)?;
let run_id = generate_run_id().map_err(|_| PublicRunError::Internal)?;
let manifest = RunManifest::new(
Expand All @@ -1135,6 +1141,8 @@ where
)
.map_err(|_| PublicRunError::Configuration)?;
let manifest = manifest
.with_capability_scope(capability_scope)
.map_err(|_| PublicRunError::Configuration)?
.with_tool_discovery_snapshots(discovery_snapshots)
.map_err(|_| PublicRunError::Configuration)?
.with_http_read_connections(http_connections)
Expand Down Expand Up @@ -1381,6 +1389,7 @@ where
manifest.tool_discovery_snapshots(),
manifest.http_read_connections(),
manifest.http_bearer_overrides(),
manifest.capability_scope(),
)?
{
let current = process
Expand Down Expand Up @@ -1683,6 +1692,8 @@ fn continue_incomplete(
&mut verifiers,
&mut route,
)?;
capabilities = crate::capability_scope::apply(capabilities, manifest.capability_scope())
.map_err(|()| PublicRunError::Configuration)?;
let mut planning_constraints = catalog
.workspace_authorization()
.map(|authorization| {
Expand All @@ -1698,8 +1709,23 @@ fn continue_incomplete(
.map_err(|_| PublicRunError::Internal)?,
);
}
if discovery.has_connections() {
planning_constraints.push(PlanningConstraint::new("http.read-connections", "Host-approved read connections are available through xgen.http/read. First describe the tool, check http_read.supported, use its exact contract_digest as contractDigest, and pass parameters.path and parameters.query. For explicitly host-approved POST reads, also pass body according to http_read.request_body. A successful HTTP Receipt and schema-valid body are required before claiming API results. Credentials are resolved only by the host.").map_err(|_| PublicRunError::Internal)?);
if discovery.has_connections()
&& manifest
.capability_scope()
.is_none_or(|ids| ids.iter().any(|id| id == crate::http_read::READ))
{
let hint = if manifest
.capability_scope()
.is_some_and(|ids| !ids.iter().any(|id| id == graph_discovery::DESCRIBE))
{
"Host-approved read connections are available through xgen.http/read. This Run does not offer describe: use an exact host-supplied tool contract and contractDigest, or report the missing prerequisite; never guess. Pass parameters.path and parameters.query and, for host-approved POST reads, the specified body. A successful HTTP Receipt and schema-valid body are required before claiming API results. Credentials are resolved only by the host."
} else {
"Host-approved read connections are available through xgen.http/read. First describe the tool, check http_read.supported, use its exact contract_digest as contractDigest, and pass parameters.path and parameters.query. For explicitly host-approved POST reads, also pass body according to http_read.request_body. A successful HTTP Receipt and schema-valid body are required before claiming API results. Credentials are resolved only by the host."
};
planning_constraints.push(
PlanningConstraint::new("http.read-connections", hint)
.map_err(|_| PublicRunError::Internal)?,
);
}
if let Some(process) = process {
planning_constraints.push(
Expand Down Expand Up @@ -3152,15 +3178,30 @@ fn execution_profile_with_discovery(
snapshots: &graph_discovery::Snapshots,
connections: &crate::http_read::Connections,
overrides: &crate::http_read::BearerOverrides,
scope: Option<&[String]>,
) -> Result<String, PublicRunError> {
let local_digest = local_execution_profile_digest(workspace, catalog, process)?;
if snapshots.is_empty() {
return Ok(local_digest);
}

let discovery = DiscoveryCatalog::saved(snapshots.clone())
.and_then(|catalog| catalog.with_connections(connections.clone()))
.and_then(|catalog| catalog.with_bearer_overrides(overrides.clone()))
.map_err(|()| PublicRunError::Configuration)?;
if scope.is_some() {
let product = local_tool_product(workspace, catalog, process)?;
let specs = discovery_specs(&discovery)?;
crate::capability_scope::validate_available(
scope,
product
.capabilities
.definitions()
.map(|d| d.metadata.id.as_str())
.chain(specs.iter().map(|(d, _)| d.metadata.id.as_str())),
)
.map_err(|()| PublicRunError::Configuration)?;
}
if snapshots.is_empty() {
return bind_capability_scope_digest(local_digest, scope);
}
let mut profile = serde_json::json!({
"domain":"xgen.cli.local-and-tool-discovery-execution-profile/v1",
"local_profile_digest":local_digest,
Expand All @@ -3178,6 +3219,18 @@ fn execution_profile_with_discovery(
.ok_or(PublicRunError::Internal)?
.remove("http_read_profile");
}
bind_capability_scope_digest(
sha256_digest(&serde_jcs::to_vec(&profile).map_err(|_| PublicRunError::Internal)?),
scope,
)
}

fn bind_capability_scope_digest(
base: String,
scope: Option<&[String]>,
) -> Result<String, PublicRunError> {
let Some(scope) = scope else { return Ok(base) };
let profile = serde_json::json!({"domain":"xgen.cli.capability-scope/v1","base":base,"capabilities":scope});
Ok(sha256_digest(
&serde_jcs::to_vec(&profile).map_err(|_| PublicRunError::Internal)?,
))
Expand Down Expand Up @@ -3670,7 +3723,8 @@ mod tests {
None,
&graph_discovery::Snapshots::new(),
&crate::http_read::Connections::new(),
&crate::http_read::BearerOverrides::new()
&crate::http_read::BearerOverrides::new(),
None,
)
.unwrap(),
local_execution_profile_digest(&workspace, &catalog, None).unwrap()
Expand All @@ -3684,7 +3738,8 @@ mod tests {
None,
&first,
&crate::http_read::Connections::new(),
&crate::http_read::BearerOverrides::new()
&crate::http_read::BearerOverrides::new(),
None,
)
.unwrap(),
execution_profile_with_discovery(
Expand All @@ -3693,7 +3748,8 @@ mod tests {
None,
&second,
&crate::http_read::Connections::new(),
&crate::http_read::BearerOverrides::new()
&crate::http_read::BearerOverrides::new(),
None,
)
.unwrap()
);
Expand Down Expand Up @@ -3721,6 +3777,7 @@ mod tests {
&snapshots,
&connections,
overrides,
None,
)
.unwrap()
};
Expand Down Expand Up @@ -4124,6 +4181,7 @@ mod tests {
final_response_schema: None,
completion_checks: None,
conversation_responses: false,
capabilities: Vec::new(),
};
assert_eq!(
run_local(request).unwrap(),
Expand Down Expand Up @@ -4184,6 +4242,7 @@ mod tests {
final_response_schema: None,
completion_checks: None,
conversation_responses: false,
capabilities: Vec::new(),
};
assert!(matches!(
run_local_with_process_session_progress(
Expand Down
1 change: 1 addition & 0 deletions crates/xgen-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
mod allow_file;
mod allow_path;
mod allow_process;
mod capability_scope;
mod change_report;
mod composition;
mod driver;
Expand Down
5 changes: 5 additions & 0 deletions crates/xgen-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,9 @@ struct RunArgs {
/// Explicitly allow goal/context/tool output transfer to the remote model boundary.
#[arg(long)]
allow_remote_model_egress: bool,
/// Restrict this Run to these capability IDs; repeat for more. Resume keeps the saved subset.
#[arg(long = "capability", value_name = "ID")]
capabilities: Vec<String>,
/// Approve each one-shot read-only action selected within the declared file/directory scope.
#[arg(long)]
allow_read: bool,
Expand Down Expand Up @@ -757,6 +760,7 @@ impl repl::ReplHost for InteractiveHost {
// The interactive driver admits this contract regardless; said
// here so the request describes the Run it actually starts.
conversation_responses: true,
capabilities: Vec::new(),
},
&process_session,
|run_id| {
Expand Down Expand Up @@ -1282,6 +1286,7 @@ fn run_command(args: RunArgs, debug: bool) -> ExitCode {
final_response_schema,
completion_checks,
conversation_responses: args.conversation_responses,
capabilities: args.capabilities,
};
let started = |run_id: &str| eprintln!("XGEN_STARTED run_id={run_id}");
present(run_local_with_progress(
Expand Down
Loading
Loading