diff --git a/MEMORY.md b/MEMORY.md index 047e41c..cde3c12 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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, 최종 응답, 사용자 경험 | 연결 흐름, 어댑터, 결과 검증, 배포·자동 실행 | diff --git a/crates/xgen-cli/src/capability_scope.rs b/crates/xgen-cli/src/capability_scope.rs new file mode 100644 index 0000000..0be300c --- /dev/null +++ b/crates/xgen-cli/src/capability_scope.rs @@ -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) -> Result>, ()> { + 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, +) -> Result<(), ()> { + if let Some(ids) = ids { + let available = available.collect::>(); + 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 { + 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()); + } + } +} diff --git a/crates/xgen-cli/src/composition.rs b/crates/xgen-cli/src/composition.rs index 324eaa7..0e83350 100644 --- a/crates/xgen-cli/src/composition.rs +++ b/crates/xgen-cli/src/composition.rs @@ -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, } impl LocalRunRequest { @@ -189,6 +191,7 @@ impl LocalRunRequest { final_response_schema: None, completion_checks: None, conversation_responses: false, + capabilities: Vec::new(), } } } @@ -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()) @@ -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( @@ -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) @@ -1381,6 +1389,7 @@ where manifest.tool_discovery_snapshots(), manifest.http_read_connections(), manifest.http_bearer_overrides(), + manifest.capability_scope(), )? { let current = process @@ -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| { @@ -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( @@ -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 { 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, @@ -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 { + 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)?, )) @@ -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() @@ -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( @@ -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() ); @@ -3721,6 +3777,7 @@ mod tests { &snapshots, &connections, overrides, + None, ) .unwrap() }; @@ -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(), @@ -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( diff --git a/crates/xgen-cli/src/lib.rs b/crates/xgen-cli/src/lib.rs index 7fc8008..ab8bef5 100644 --- a/crates/xgen-cli/src/lib.rs +++ b/crates/xgen-cli/src/lib.rs @@ -3,6 +3,7 @@ mod allow_file; mod allow_path; mod allow_process; +mod capability_scope; mod change_report; mod composition; mod driver; diff --git a/crates/xgen-cli/src/main.rs b/crates/xgen-cli/src/main.rs index 1f50340..4240ede 100644 --- a/crates/xgen-cli/src/main.rs +++ b/crates/xgen-cli/src/main.rs @@ -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, /// Approve each one-shot read-only action selected within the declared file/directory scope. #[arg(long)] allow_read: bool, @@ -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| { @@ -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( diff --git a/crates/xgen-cli/src/manifest.rs b/crates/xgen-cli/src/manifest.rs index f8ec069..b6d003c 100644 --- a/crates/xgen-cli/src/manifest.rs +++ b/crates/xgen-cli/src/manifest.rs @@ -39,6 +39,8 @@ struct RunManifestRecord { http_read_connections: crate::http_read::Connections, #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] http_bearer_overrides: crate::http_read::BearerOverrides, + #[serde(default, skip_serializing_if = "Option::is_none")] + capability_scope: Option>, budget: ManifestBudget, #[serde(default, skip_serializing_if = "std::ops::Not::not")] conversation_responses: bool, @@ -167,6 +169,7 @@ impl RunManifest { tool_discovery_snapshots: crate::graph_discovery::Snapshots::new(), http_read_connections: crate::http_read::Connections::new(), http_bearer_overrides: crate::http_read::BearerOverrides::new(), + capability_scope: None, budget, conversation_responses: false, final_response_schema: None, @@ -180,6 +183,20 @@ impl RunManifest { }) } + pub(crate) fn with_capability_scope( + mut self, + scope: Option>, + ) -> Result { + self.record.capability_scope = scope; + validate_record(&self.record)?; + self.record_digest = digest_record(&self.record)?; + Ok(self) + } + + pub(crate) fn capability_scope(&self) -> Option<&[String]> { + self.record.capability_scope.as_deref() + } + pub(crate) fn with_http_bearer_overrides( mut self, overrides: crate::http_read::BearerOverrides, @@ -396,6 +413,13 @@ fn validate_record(record: &RunManifestRecord) -> Result<(), ManifestError> { }) { return Err(ManifestError::Invalid); } + if record + .capability_scope + .as_ref() + .is_some_and(|ids| !crate::capability_scope::valid(ids)) + { + return Err(ManifestError::Invalid); + } if !crate::http_read::valid_bearer_overrides( &record.http_bearer_overrides, &record.http_read_connections, @@ -545,6 +569,38 @@ mod tests { } } + #[test] + fn capability_scope_is_optional_bound_and_validated() { + let legacy = fixture(); + assert!(legacy.capability_scope().is_none()); + assert!( + !String::from_utf8(legacy.to_bytes().unwrap()) + .unwrap() + .contains("capabilityScope") + ); + for scope in [ + vec!["generic/read".into()], + vec!["generic/describe".into(), "generic/search".into()], + ] { + let bound = legacy.clone().with_capability_scope(Some(scope)).unwrap(); + assert_ne!(legacy.authority(), bound.authority()); + assert_eq!( + RunManifest::from_bytes(&bound.to_bytes().unwrap()).unwrap(), + bound + ); + let mut tampered: Value = serde_json::from_slice(&bound.to_bytes().unwrap()).unwrap(); + tampered["record"]["capabilityScope"] = serde_json::json!(["generic/write"]); + assert!(RunManifest::from_bytes(&serde_json::to_vec(&tampered).unwrap()).is_err()); + } + for ids in [ + vec![], + vec!["b".into(), "a".into()], + vec!["a".into(), "a".into()], + ] { + assert!(legacy.clone().with_capability_scope(Some(ids)).is_err()); + } + } + #[test] fn http_connections_are_optional_pinned_and_not_credentials() { let legacy = fixture(); diff --git a/crates/xgen-cli/tests/workspace_discovery.rs b/crates/xgen-cli/tests/workspace_discovery.rs index d53c9f6..ae5366c 100644 --- a/crates/xgen-cli/tests/workspace_discovery.rs +++ b/crates/xgen-cli/tests/workspace_discovery.rs @@ -20,6 +20,139 @@ const COMPLETION: &str = "workspace discovery completed"; const TEST_TIMEOUT: Duration = Duration::from_secs(60); const PROCESS_TIMEOUT: Duration = Duration::from_secs(180); +#[test] +fn capability_scope_persists_across_approval_and_resume_without_changing_receipts() { + for capability in ["xgeny.fs/read-text", "xgeny.fs/stat"] { + let fixture = tempdir().unwrap(); + let state = fixture.path().join("state"); + let workspace = fixture.path().join("workspace"); + fs::create_dir(&workspace).unwrap(); + fs::write(workspace.join("sample.txt"), "scope validation content").unwrap(); + let server = SequentialServer::spawn_responses(vec![ + plan_response( + "inspect", + "Inspect a file", + capability, + &json!({"path":"sample.txt"}), + ), + completion_response(), + ]); + let first = bounded_output(xgen(&state).args([ + "run", + "--workspace", + path_text(&workspace), + "--base-url", + &server.base_url, + "--model", + MODEL, + "--tokenizer", + TOKENIZER, + "--allow-dir", + ".", + "--capability", + capability, + "--allow-remote-model-egress", + "Inspect the specified file.", + ])) + .unwrap(); + assert_eq!(first.status.code(), Some(10), "{}", stderr(&first)); + let run_id = extract_run_id(&stderr(&first)); + let initial = planning_context(&server.requests.recv_timeout(TEST_TIMEOUT).unwrap()); + assert_eq!(capability_ids(&initial), vec![capability]); + let resumed = bounded_output(xgen(&state).args([ + "resume", + &run_id, + "--workspace", + path_text(&workspace), + "--base-url", + &server.base_url, + "--allow-dir", + ".", + "--allow-read", + "--allow-remote-model-egress", + ])) + .unwrap(); + assert!(resumed.status.success(), "{}", stderr(&resumed)); + let completed = planning_context(&server.requests.recv_timeout(TEST_TIMEOUT).unwrap()); + assert_eq!(capability_ids(&completed), vec![capability]); + assert_eq!(completed["catalogDigest"], initial["catalogDigest"]); + assert_eq!(completed["toolOutputs"].as_array().unwrap().len(), 1); + assert_eq!( + completed["toolOutputs"][0]["capability"]["capabilityId"], + capability + ); + let replay = bounded_output(xgen(&state).args(["resume", &run_id])).unwrap(); + assert!(replay.status.success()); + server.handle.join().unwrap(); + } +} + +#[test] +fn capability_scope_rejects_unoffered_action_even_with_write_permission() { + let fixture = tempdir().unwrap(); + let state = fixture.path().join("state"); + let workspace = fixture.path().join("workspace"); + fs::create_dir(&workspace).unwrap(); + let server = SequentialServer::spawn_responses((0..=xgen_cli::MAX_REJECTED_RESPONSE_RETRIES) + .map(|_| plan_response("write", "Unselected action", "xgeny.fs/write-atomic", + &json!({"path":"unwanted.txt","content":"must not execute","expectedDigest":null}))).collect()); + let output = bounded_output(xgen(&state).args([ + "run", + "--workspace", + path_text(&workspace), + "--base-url", + &server.base_url, + "--model", + MODEL, + "--tokenizer", + TOKENIZER, + "--allow-dir", + ".", + "--allow-write", + "--capability", + "xgeny.fs/read-text", + "--allow-remote-model-egress", + "Inspect only.", + ])) + .unwrap(); + assert_eq!(output.status.code(), Some(20), "{}", stderr(&output)); + assert!(!workspace.join("unwanted.txt").exists()); + for _ in 0..=xgen_cli::MAX_REJECTED_RESPONSE_RETRIES { + let request = server.requests.recv_timeout(TEST_TIMEOUT).unwrap(); + assert_eq!( + capability_ids(&planning_context(&request)), + vec!["xgeny.fs/read-text"] + ); + } + server.handle.join().unwrap(); +} + +#[test] +fn capability_scope_unknown_or_duplicate_ids_fail_before_provider_access() { + for scope in [vec!["absent/read"], vec!["xgeny.fs/stat", "xgeny.fs/stat"]] { + let fixture = tempdir().unwrap(); + let mut command = xgen(&fixture.path().join("state")); + command.args([ + "run", + "--workspace", + path_text(fixture.path()), + "--base-url", + "http://127.0.0.1:1/v1", + "--model", + MODEL, + "--allow-dir", + ".", + "--allow-remote-model-egress", + ]); + for id in scope { + command.args(["--capability", id]); + } + let output = bounded_output(command.arg("No provider access")).unwrap(); + assert_eq!(output.status.code(), Some(64), "{}", stderr(&output)); + assert!(!stderr(&output).contains("XGEN_STARTED")); + } +} + #[test] fn invocation_diagnostics_distinguish_schema_and_resource_failures_without_values() { let cases = [ diff --git a/docs/development/capability-scope.md b/docs/development/capability-scope.md new file mode 100644 index 0000000..8e43a0d --- /dev/null +++ b/docs/development/capability-scope.md @@ -0,0 +1,25 @@ +# Run별 capability 범위 + +`run --capability ID`로 계획과 실행에 등록할 capability ID를 제한해. 여러 번 지정할 수 있어. +옵션을 생략하면 기존 전체 catalog를 유지해. 특정 업무나 모델에 종속되지 않는 일반 기능이야. + +```sh +xgen run --workspace . --allow-dir . --allow-read --allow-remote-model-egress \ + --capability xgeny.fs/read-text 'README.md를 읽고 요약해' +``` + +선택은 원래 등록 가능한 정의와 instance의 부분집합이야. 모르는 ID·중복 ID는 Run을 만들거나 +모델에 요청하기 전에 거절해. capability를 선택해도 파일·HTTP·실행 승인 조건은 그대로 필요해. +모델이 목록 밖의 작업을 제안하면 admission에서 거절해. + +정렬된 목록을 manifest, authority, 실행 profile digest에 결속해. `resume`은 저장된 범위를 +복원하고 새 범위 옵션을 받지 않아. 기본값은 manifest에 새 필드를 저장하지 않아 이전 Run과 +전체 catalog 경로를 유지해. 도구 반환값·Receipt·원문은 수정하거나 요약하지 않아. + +HTTP read만 선택한 호스트는 `tools describe`로 확인한 정확한 요청·contractDigest를 goal에 +전달할 수 있어. describe가 범위 밖이고 계약도 없다면 에이전트는 선행조건 부족을 보고해야 해. +범위 선택은 부족한 계약을 추측하거나 HTTP 권한을 부여하는 기능이 아니야. + +검증: CLI library109개·workspace integration22개 통과, clippy 경고0. 새 검사에서 읽기/상태 +조회 각각의 승인 전 정지·재개·완료 재열기, write 승인이 있어도 범위 밖 쓰기 거절, +잘못된 ID·중복·manifest 변경 차단을 확인했어. 실제 모델의 판단 품질 향상은 검증하지 않았어.