Skip to content
Merged
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 하나만 설치합니다.
Expand Down
14 changes: 13 additions & 1 deletion crates/xgeny-cli/src/composition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,16 @@ pub enum RejectionReason {
}

impl RejectionReason {
#[must_use]
pub const fn invocation_diagnostic(self) -> Option<xgeny_runtime::InvocationDiagnostic> {
match self {
Self::ProposalRejected(ProposalRejection::InvocationDiagnosed(diagnostic)) => {
Some(diagnostic)
}
_ => None,
}
}

#[must_use]
pub const fn code(self) -> &'static str {
match self {
Expand Down Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions crates/xgeny-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1361,6 +1361,13 @@ fn present(result: Result<LocalCommandResult, PublicRunError>) -> 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 }) => {
Expand Down
83 changes: 83 additions & 0 deletions crates/xgeny-cli/tests/workspace_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>>,
Expand Down
28 changes: 25 additions & 3 deletions crates/xgeny-runtime/src/agent_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2640,7 +2642,9 @@ fn prepare_plan<R: ResourceResolver>(
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);
}
Expand Down Expand Up @@ -2689,7 +2693,17 @@ fn prepare_plan<R: ResourceResolver>(
registry,
resolver,
)
.map_err(|error| map_invocation_rejection(&error))?;
.map_err(|error| {
map_invocation_rejection(
&error,
&registry
.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
Expand Down Expand Up @@ -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 { .. }
Expand Down
175 changes: 175 additions & 0 deletions crates/xgeny-runtime/src/invocation_diagnostic.rs
Original file line number Diff line number Diff line change
@@ -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<Self> {
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<InvocationDiagnostic> {
// 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
);
}
}
2 changes: 2 additions & 0 deletions crates/xgeny-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod admission;
mod agent_loop;
mod executor;
mod frontier;
mod invocation_diagnostic;
mod lease;
mod material;
mod registry;
Expand All @@ -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::*;
Expand Down
Loading