fix: record native prior Read only after result validation

This commit is contained in:
2026-10-20 03:21:14 +09:00
parent 47b32ef6ab
commit f38b2a52b1
4 changed files with 131 additions and 10 deletions
+8 -8
View File
@@ -404,8 +404,8 @@ fn check_pattern(pattern: &str) -> Result<(), ToolError> {
#[path = "checkout_tests.rs"]
mod tests;
/// Read a bounded native text window through the same checked target and attachment tracker.
/// Search callers do not establish the prior-Read precondition for mutations.
/// Read a bounded native text window through the same checked target.
/// The returned one-shot action records prior-Read only after the caller's presentation succeeds.
#[allow(clippy::too_many_arguments)]
pub async fn read_checkout_text(
router: Arc<WorkdirSessionRouter>,
@@ -417,8 +417,7 @@ pub async fn read_checkout_text(
offset: usize,
limit: usize,
max_bytes: usize,
record_read: bool,
) -> Result<(workdir::ReadResult, Option<Vec<u8>>), ToolError> {
) -> Result<(workdir::ReadResult, Option<Vec<u8>>, impl FnOnce()), ToolError> {
let selected =
crate::routing::resolve_session(&router, Some(alias), WorkdirSessionCapability::Read)?;
if selected.generation != generation {
@@ -431,10 +430,11 @@ pub async fn read_checkout_text(
validator: Some(validator),
};
let (result, validator) = target.read(offset, limit, max_bytes).await?;
if record_read {
tracker.record_workdir_observation(&path, result.content_hash.clone(), result.total_lines);
}
Ok((result, validator))
let hash = result.content_hash.clone();
let total_lines = result.total_lines;
Ok((result, validator, move || {
tracker.record_workdir_observation(&path, hash, total_lines);
}))
}
/// Binary-safe checked byte window. This does not establish a text prior-Read observation.
+6 -2
View File
@@ -625,7 +625,6 @@ impl WipOperationHandler for FileHandler {
offset,
limit,
crate::file_operation::MAX_BYTES,
operation == "read_lines",
),
)
.await
@@ -636,7 +635,7 @@ impl WipOperationHandler for FileHandler {
)
})?
.map_err(map_tool_error)?;
let (read, validator) = result;
let (read, validator, record_read) = result;
self.check_connection()?;
let text = String::from_utf8_lossy(&read.bytes);
let value = crate::file_operation::page_result(
@@ -646,6 +645,11 @@ impl WipOperationHandler for FileHandler {
offset,
read.truncated,
)?;
// Only a successfully rendered Read can establish mutation authority.
// Search and failed result budgets must leave prior-Read history unchanged.
if operation == "read_lines" {
record_read();
}
return Ok(match validator {
Some(raw) => WipOperationOutput::native_with_validator(
value,
+38
View File
@@ -841,3 +841,41 @@ async fn remote_checked_byte_read_uses_common_contract_and_keeps_binary_boundari
source.bytes
);
}
#[tokio::test]
async fn http_failed_text_read_does_not_authorize_mutations() {
for operation in ["write", "edit"] {
for oversized in ["\"".repeat(50_000), "x".repeat(65_530)] {
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await;
let remote = fixture.open(TOKEN).await.unwrap();
checkout_wip_tests::failed_text_read_cannot_authorize_mutation(
Arc::new(remote),
&fixture.dir.path().join("text"),
&oversized,
operation,
)
.await;
let mutations = fixture
.state
.operations
.lock()
.unwrap()
.iter()
.filter(|op| {
matches!(
op,
WorkdirSessionOperation::CheckoutExecute(CheckoutRequest {
operation: CheckoutOperation::Write { .. }
| CheckoutOperation::Edit { .. },
..
})
)
})
.count();
assert_eq!(
mutations, 1,
"only the mutation after a successful Read may reach the provider"
);
}
}
}
+79
View File
@@ -1536,3 +1536,82 @@ fn common_contract_registration_rejects_descriptor_redefinition_and_domain_opera
Err(WipMountError::InterfaceCollision { .. })
));
}
pub(super) async fn failed_text_read_cannot_authorize_mutation(
source: WorkdirSessionHandle,
disk_file: &std::path::Path,
oversized: &str,
operation: &str,
) {
let original = format!("{oversized}\nknown\n");
std::fs::write(disk_file, &original).unwrap();
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(WorkdirAttachmentAlias::new("main").unwrap(), source)
.unwrap();
let r = Arc::new(runtime(router, None));
let inspected = model_inspect(r.clone(), "/checkouts/main/text").await;
let arguments = if operation == "write" {
json!({"content":"changed"})
} else {
json!({"old_string":"known","new_string":"changed"})
};
let failure = model_invoke(r.clone(), &inspected, "read", json!({"limit":1}))
.await
.unwrap_err();
assert!(
failure.to_string().contains("ResourceLimitExceeded"),
"{failure}"
);
let denied = model_invoke(r.clone(), &inspected, operation, arguments.clone()).await;
assert!(denied.is_err(), "failed read authorized {operation}");
let denied = denied.unwrap_err();
assert!(denied.to_string().contains("InvalidArguments"), "{denied}");
assert_eq!(std::fs::read_to_string(disk_file).unwrap(), original);
model_invoke(r.clone(), &inspected, "read", json!({"offset":1,"limit":1}))
.await
.unwrap();
// A failed read must neither grant new authority nor erase an earlier successful Read.
assert!(
model_invoke(r.clone(), &inspected, "read", json!({"limit":1}))
.await
.is_err()
);
model_invoke(r, &inspected, operation, arguments)
.await
.unwrap();
let expected = if operation == "write" {
"changed".to_string()
} else {
original.replace("known", "changed")
};
assert_eq!(std::fs::read_to_string(disk_file).unwrap(), expected);
}
#[tokio::test]
async fn failed_text_read_does_not_authorize_write() {
for oversized in ["\"".repeat(50_000), "x".repeat(65_530)] {
let dir = TempDir::new().unwrap();
failed_text_read_cannot_authorize_mutation(
session(&dir, false),
&dir.path().join("text"),
&oversized,
"write",
)
.await;
}
}
#[tokio::test]
async fn failed_text_read_does_not_authorize_edit() {
for oversized in ["\"".repeat(50_000), "x".repeat(65_530)] {
let dir = TempDir::new().unwrap();
failed_text_read_cannot_authorize_mutation(
session(&dir, false),
&dir.path().join("text"),
&oversized,
"edit",
)
.await;
}
}