From 08b7e6fedaa11856192c32d7a22a739f2fe14f4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=84=B1=EC=A4=80?= Date: Sun, 13 Sep 2026 18:30:13 +0900 Subject: [PATCH] feat: expose safe invocation rejection diagnostics --- README.md | 5 + crates/xgeny-cli/src/composition.rs | 14 +- crates/xgeny-cli/src/main.rs | 7 + crates/xgeny-cli/tests/workspace_discovery.rs | 83 +++++++++ crates/xgeny-runtime/src/agent_loop.rs | 28 ++- .../src/invocation_diagnostic.rs | 175 ++++++++++++++++++ crates/xgeny-runtime/src/lib.rs | 2 + docs/adr/0038-safe-invocation-diagnostics.md | 45 +++++ 8 files changed, 355 insertions(+), 4 deletions(-) create mode 100644 crates/xgeny-runtime/src/invocation_diagnostic.rs create mode 100644 docs/adr/0038-safe-invocation-diagnostics.md diff --git a/README.md b/README.md index c285b8c..b507ee9 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,11 @@ strict JSON Schema response와 응답의 exact model ID를 지원하는 서버 Project, Cargo dependency, Rust standard library, Linux musl과 LLVM libunwind의 배포 고지는 binary에 포함되어 있어 network나 별도 파일 없이 `xgeny licenses`로 확인할 수 있다. +Headless `run`/`resume`에서 도구 인자 검증이 거절되면 기존 verdict와 exit code를 유지하면서 +허용된 오류 범주·필드 이름만 담은 `XGENY_INVOCATION_DIAGNOSTIC` 한 줄을 추가할 수 있다. +값·실제 경로·원문 오류는 출력하지 않으며, 이 정보는 자동 재시도 권한이나 durable 증빙이 아니다. +호스트 연동 계약과 제한은 [ADR-0038](docs/adr/0038-safe-invocation-diagnostics.md)을 따른다. + ## 제품 원칙 - 사용자는 `xgeny` 하나만 설치합니다. diff --git a/crates/xgeny-cli/src/composition.rs b/crates/xgeny-cli/src/composition.rs index 3170494..9af10c9 100644 --- a/crates/xgeny-cli/src/composition.rs +++ b/crates/xgeny-cli/src/composition.rs @@ -444,6 +444,16 @@ pub enum RejectionReason { } impl RejectionReason { + #[must_use] + pub const fn invocation_diagnostic(self) -> Option { + match self { + Self::ProposalRejected(ProposalRejection::InvocationDiagnosed(diagnostic)) => { + Some(diagnostic) + } + _ => None, + } + } + #[must_use] pub const fn code(self) -> &'static str { match self { @@ -489,7 +499,9 @@ const fn proposal_rejection_code(rejection: ProposalRejection) -> &'static str { ProposalRejection::DependencyCycle => "proposal_rejected.dependency_cycle", ProposalRejection::CapabilityUnavailable => "proposal_rejected.capability_unavailable", ProposalRejection::CapabilityUnsupported => "proposal_rejected.capability_unsupported", - ProposalRejection::InvocationInvalid => "proposal_rejected.invocation_invalid", + ProposalRejection::InvocationInvalid | ProposalRejection::InvocationDiagnosed(_) => { + "proposal_rejected.invocation_invalid" + } ProposalRejection::DuplicateSemanticAction => "proposal_rejected.duplicate_semantic_action", ProposalRejection::PlannedStepBudgetExceeded => { "proposal_rejected.planned_step_budget_exceeded" diff --git a/crates/xgeny-cli/src/main.rs b/crates/xgeny-cli/src/main.rs index 20549b0..51d7a7b 100644 --- a/crates/xgeny-cli/src/main.rs +++ b/crates/xgeny-cli/src/main.rs @@ -1361,6 +1361,13 @@ fn present(result: Result) -> ExitCode { } Ok(LocalCommandResult::Rejected { run_id, reason }) => { eprintln!("XGENY_REJECTED run_id={run_id} reason={}", reason.code()); + if let Some(diagnostic) = reason.invocation_diagnostic() { + eprintln!( + "XGENY_INVOCATION_DIAGNOSTIC run_id={run_id} version=1 category={} field={}", + diagnostic.category(), + diagnostic.field() + ); + } ExitCode::from(20) } Ok(LocalCommandResult::RecoveryRequired { run_id, reason }) => { diff --git a/crates/xgeny-cli/tests/workspace_discovery.rs b/crates/xgeny-cli/tests/workspace_discovery.rs index 367e900..c6cf56e 100644 --- a/crates/xgeny-cli/tests/workspace_discovery.rs +++ b/crates/xgeny-cli/tests/workspace_discovery.rs @@ -20,6 +20,89 @@ const COMPLETION: &str = "workspace discovery completed"; const TEST_TIMEOUT: Duration = Duration::from_secs(60); const PROCESS_TIMEOUT: Duration = Duration::from_secs(180); +#[test] +fn invocation_diagnostics_distinguish_schema_and_resource_failures_without_values() { + let cases = [ + ( + json!({"path":"DECISION.json","content":"PRIVATE"}), + "schema_required", + "expectedDigest", + ), + ( + json!({"path":"DECISION.json","content":{"SECRET":"PRIVATE"},"expectedDigest":null}), + "schema_type", + "content", + ), + ( + json!({"path":"DECISION.json","content":"PRIVATE","expectedDigest":null,"SECRET":"PRIVATE"}), + "schema_additional_property", + "other", + ), + ( + json!({"path":"","content":"PRIVATE","expectedDigest":null}), + "schema_min_length", + "path", + ), + ( + json!({"path":"../SECRET","content":"PRIVATE","expectedDigest":null}), + "resource_resolution", + "other", + ), + ( + json!({"path":"DECISION.json","content":"PRIVATE","expectedDigest":"SECRET"}), + "schema_one_of", + "expectedDigest", + ), + ]; + for (arguments, category, field) in cases { + let fixture = tempdir().unwrap(); + let state_root = fixture.path().join("state"); + let workspace = fixture.path().join("workspace"); + fs::create_dir(&workspace).unwrap(); + let server = SequentialServer::spawn_responses(vec![plan_response( + "write", + "Write fixture", + "xgeny.fs/write-atomic", + &arguments, + )]); + let output = bounded_output(xgeny(&state_root).args([ + "run", + "--workspace", + path_text(&workspace), + "--base-url", + &server.base_url, + "--model", + MODEL, + "--tokenizer", + TOKENIZER, + "--allow-dir", + ".", + "--allow-write", + "--allow-remote-model-egress", + "Write a fixture.", + ])) + .unwrap(); + let text = stderr(&output); + assert_eq!(output.status.code(), Some(20), "{text}"); + let run_id = extract_run_id(&text); + assert!(text.contains(&format!( + "XGENY_REJECTED run_id={run_id} reason=proposal_rejected.invocation_invalid" + ))); + assert!(text.contains(&format!("XGENY_INVOCATION_DIAGNOSTIC run_id={run_id} version=1 category={category} field={field}")), "{text}"); + for secret in ["SECRET", "PRIVATE", path_text(&workspace)] { + assert!(!text.contains(secret)); + } + assert_eq!(fs::read_dir(&workspace).unwrap().count(), 0); + assert!(!fixture.path().join("SECRET").exists()); + let db = state_root.join("runs").join(run_id).join("run.sqlite3"); + let store = SqliteRunStore::open_existing(db).unwrap(); + assert!(store.load_execution_receipts().unwrap().is_empty()); + server.requests.recv_timeout(TEST_TIMEOUT).unwrap(); + server.handle.join().unwrap(); + assert!(server.requests.try_recv().is_err()); + } +} + struct SequentialServer { base_url: String, requests: Receiver>, diff --git a/crates/xgeny-runtime/src/agent_loop.rs b/crates/xgeny-runtime/src/agent_loop.rs index 104fcc1..2aa791f 100644 --- a/crates/xgeny-runtime/src/agent_loop.rs +++ b/crates/xgeny-runtime/src/agent_loop.rs @@ -872,6 +872,8 @@ pub enum ProposalRejection { CapabilityUnavailable, CapabilityUnsupported, InvocationInvalid, + /// Same coarse rejection with optional fixed, value-free terminal metadata. + InvocationDiagnosed(crate::InvocationDiagnostic), /// Two Steps in the same proposal resolve to one canonical semantic action. DuplicateSemanticAction, PlannedStepBudgetExceeded, @@ -2640,7 +2642,9 @@ fn prepare_plan( registry, resolver, ) - .map_err(|error| map_invocation_rejection(&error))?; + .map_err(|error| { + map_invocation_rejection(&error, &definition.spec.input_schema, &step.arguments) + })?; if !proposed_semantic_action_digests.insert(facts.semantic_action_digest.clone()) { return Err(ProposalRejection::DuplicateSemanticAction); } @@ -2689,7 +2693,17 @@ fn prepare_plan( registry, resolver, ) - .map_err(|error| map_invocation_rejection(&error))?; + .map_err(|error| { + map_invocation_rejection( + &error, + ®istry + .definition(&step.capability) + .expect("validated definition") + .spec + .input_schema, + &step.normalized_arguments, + ) + })?; if final_facts.normalized_arguments != step.normalized_arguments || final_facts.definition_digest != step.definition_digest || final_facts.semantic_action_digest != step.semantic_action_digest @@ -2847,7 +2861,15 @@ fn validate_proposal_structure( Ok(()) } -fn map_invocation_rejection(error: &AdmissionError) -> ProposalRejection { +fn map_invocation_rejection( + error: &AdmissionError, + schema: &Value, + arguments: &Value, +) -> ProposalRejection { + if let Some(diagnostic) = crate::InvocationDiagnostic::from_admission(error, schema, arguments) + { + return ProposalRejection::InvocationDiagnosed(diagnostic); + } match error { AdmissionError::DefinitionNotFound { .. } => ProposalRejection::CapabilityUnavailable, AdmissionError::UnsupportedEffectClass { .. } diff --git a/crates/xgeny-runtime/src/invocation_diagnostic.rs b/crates/xgeny-runtime/src/invocation_diagnostic.rs new file mode 100644 index 0000000..9746b10 --- /dev/null +++ b/crates/xgeny-runtime/src/invocation_diagnostic.rs @@ -0,0 +1,175 @@ +//! Fixed, value-free diagnostics for rejected untrusted invocation arguments. +use jsonschema::{Draft, error::ValidationErrorKind}; +use serde_json::Value; + +use crate::AdmissionError; + +/// Optional terminal metadata, not durable evidence or recovery authority. +/// Fields are private so only this allowlist projection can construct a value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InvocationDiagnostic { + category: &'static str, + field: &'static str, +} + +impl InvocationDiagnostic { + #[must_use] + pub const fn category(self) -> &'static str { + self.category + } + + #[must_use] + pub const fn field(self) -> &'static str { + self.field + } + + pub(crate) fn from_admission( + error: &AdmissionError, + schema: &Value, + arguments: &Value, + ) -> Option { + match error { + AdmissionError::ArgumentsDoNotConform => schema_diagnostic(schema, arguments), + AdmissionError::Resolution(_) => Some(Self { + category: "resource_resolution", + field: "other", + }), + _ => None, + } + } +} + +fn known_field(name: &str) -> &'static str { + match name { + "path" => "path", + "content" => "content", + "expectedDigest" => "expectedDigest", + _ => "other", + } +} + +fn schema_diagnostic(schema: &Value, arguments: &Value) -> Option { + // Same offline validation policy as admission. Never format the error itself. + let validator = jsonschema::options() + .with_draft(Draft::Draft202012) + .offline() + .should_validate_formats(true) + .build(schema) + .ok()?; + let error = validator.iter_errors(arguments).next()?; + let path = error.instance_path().as_str(); + let field = match path { + "/path" => "path", + "/content" => "content", + "/expectedDigest" => "expectedDigest", + _ => "other", + }; + let (category, field) = match error.kind() { + ValidationErrorKind::FalseSchema + if error + .schema_path() + .as_str() + .ends_with("/additionalProperties") => + { + ("schema_additional_property", "other") + } + ValidationErrorKind::Required { property } => ( + "schema_required", + if path.is_empty() { + known_field(property.as_str().unwrap_or("")) + } else { + "other" + }, + ), + ValidationErrorKind::Type { .. } => ("schema_type", field), + ValidationErrorKind::AdditionalProperties { .. } => ("schema_additional_property", "other"), + ValidationErrorKind::MinLength { .. } => ("schema_min_length", field), + ValidationErrorKind::Pattern { .. } => ("schema_pattern", field), + ValidationErrorKind::OneOfNotValid { .. } + | ValidationErrorKind::OneOfMultipleValid { .. } => ("schema_one_of", field), + _ => ("schema_other", field), + }; + Some(InvocationDiagnostic { category, field }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn known_top_level_fields_have_fixed_categories() { + let cases = [ + ( + json!({"required":["expectedDigest"]}), + json!({}), + "schema_required", + "expectedDigest", + ), + ( + json!({"properties":{"content":{"type":"string"}}}), + json!({"content":{"SECRET":"PRIVATE"}}), + "schema_type", + "content", + ), + ( + json!({"additionalProperties":false}), + json!({"SECRET":"PRIVATE"}), + "schema_additional_property", + "other", + ), + ( + json!({"properties":{"path":{"minLength":1}}}), + json!({"path":""}), + "schema_min_length", + "path", + ), + ( + json!({"properties":{"expectedDigest":{"pattern":"^sha256:"}}}), + json!({"expectedDigest":"SECRET"}), + "schema_pattern", + "expectedDigest", + ), + ( + json!({"properties":{"expectedDigest":{"oneOf":[{"type":"null"},{"pattern":"^sha256:","type":"string"}]}}}), + json!({"expectedDigest":"SECRET"}), + "schema_one_of", + "expectedDigest", + ), + ]; + for (schema, args, category, field) in cases { + let diagnostic = schema_diagnostic(&schema, &args).unwrap(); + assert_eq!(diagnostic.category(), category); + assert_eq!(diagnostic.field(), field); + assert!(!format!("{diagnostic:?}").contains("SECRET")); + assert!(!format!("{diagnostic:?}").contains("PRIVATE")); + } + } + + #[test] + fn nested_and_dynamic_names_never_escape() { + for (schema, args) in [ + (json!({"required":["SECRET"]}), json!({})), + ( + json!({"properties":{"SECRET":{"required":["path"]}}}), + json!({"SECRET":{}}), + ), + ( + json!({"properties":{"SECRET":{"type":"string"}}}), + json!({"SECRET":123}), + ), + ] { + let diagnostic = schema_diagnostic(&schema, &args).unwrap(); + assert_eq!(diagnostic.field(), "other"); + assert!(!format!("{diagnostic:?}").contains("SECRET")); + } + assert_eq!( + schema_diagnostic(&json!({"type":"object"}), &json!({})), + None + ); + assert_eq!( + schema_diagnostic(&json!({"type":"not-a-type"}), &json!({})), + None + ); + } +} diff --git a/crates/xgeny-runtime/src/lib.rs b/crates/xgeny-runtime/src/lib.rs index ebf8b1b..645b52c 100644 --- a/crates/xgeny-runtime/src/lib.rs +++ b/crates/xgeny-runtime/src/lib.rs @@ -4,6 +4,7 @@ mod admission; mod agent_loop; mod executor; mod frontier; +mod invocation_diagnostic; mod lease; mod material; mod registry; @@ -18,6 +19,7 @@ pub use admission::*; pub use agent_loop::*; pub use executor::*; pub use frontier::*; +pub use invocation_diagnostic::*; pub use lease::*; pub use material::*; pub use registry::*; diff --git a/docs/adr/0038-safe-invocation-diagnostics.md b/docs/adr/0038-safe-invocation-diagnostics.md new file mode 100644 index 0000000..57a4aa4 --- /dev/null +++ b/docs/adr/0038-safe-invocation-diagnostics.md @@ -0,0 +1,45 @@ +# ADR-0038: Safe invocation rejection diagnostics + +Status: Accepted + +## Problem + +Distinct argument-schema and resource-resolution failures currently collapse into +`proposal_rejected.invocation_invalid`. Hosts cannot safely infer which field the +model should correct. Raw validation errors may contain input values, paths, +schema content and dynamically supplied property names, so printing them is unsafe. + +## Decision + +Retain the coarse verdict, exit code, journal settlement and no-retry behavior. +For a newly rejected proposal in headless `run`/`resume`, optionally emit one +additional bounded stderr line (the REPL display is unchanged): + +```text +XGENY_INVOCATION_DIAGNOSTIC run_id=RUN version=1 category=CATEGORY field=FIELD +``` + +Both CATEGORY and FIELD are fixed allowlisted constants, never error formatting. +Categories: `schema_required`, `schema_type`, `schema_additional_property`, +`schema_min_length`, `schema_pattern`, `schema_one_of`, `schema_other`, +`resource_resolution`. Fields: `path`, `content`, `expectedDigest`, `other`. +Only exact top-level pointers and root-level required properties can disclose the +three known field names; nested/dynamic names become `other`. Return one finding, +not the raw validator error, input, schema, actual path, exception or model text. + +This diagnostic is ephemeral terminal metadata, not new durable evidence or +execution authority. Resume cannot reconstruct missing old diagnostics. Hosts +must bind the line to the same native terminal run, preserve their request/binary +identity, and independently verify the journal before considering any recovery. +Unknown categories/versions must fail closed. No automatic retry or instruction +to the model is added by this change. Journal schema 8/protocol v0.1 is unchanged. +The first schema finding only is projected; this is not a complete error list. +Normalization-instability rejections retain the generic verdict without a diagnostic. +The provider's nested planner-arguments grammar is not changed by this decision. + +## Verification + +Runtime tests cover redaction and category/field selection. Public CLI loopback +tests must distinguish malformed arguments from resource failures while retaining +the original coarse verdict and zero effects. Host integration checks are separate +from live-model competence and ML training; no live provider is needed.