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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,16 @@ For independent patch attempts, host-recorded tests and a separate read-only jud

Experimental [native computer use](docs/computer-use.md) adds macOS 14+ application/display screenshots and input with expiring owner grants, CLI/browser owner controls, private evidence and emergency Stop. Off by default; controlled macOS capture, input and Stop acceptance passed. Other layouts and signed distribution remain release checks.

The [binary workspace](docs/binary-editor.md) provides read-only, paged hex/ASCII viewing, literal hex/UTF-8 search, exact goto, bookmarks, byte copying and an LE/BE inspector. Open it with `cyber hex <path>`, `/hex <path>`, or `x` on an explorer file, without starting inference. Session read permissions apply. The owner API/SDK also supports private staged edits, draft undo/redo, byte review and recovery on admitted Linux/macOS workspaces. Terminal editing, production saves, model binary operations and snapshot/hooks integration remain tracked in [#94](https://github.com/CyberdyneCorp/cybercode-cli/issues/94).
The [binary workspace](docs/binary-editor.md) provides paged hex/ASCII viewing, literal hex/UTF-8 search, exact goto, bookmarks, byte copying and an LE/BE inspector. Open it with `cyber hex <path>`, `/hex <path>`, or `x` on an explorer file, without starting inference. Session read permissions apply. On admitted Linux/macOS workspaces, F2 prepares private staged edits with undo/redo; Ctrl+S reviews the exact byte changes, then explicitly saves. Reviewed Restore Last Save and retained draft recovery are also available through the owner API/SDK. Authorized Sessions can review shared saved history, and Session diffs show saved binary references independently of Git. Model binary operations, checkpoint/retention/rewind adapters and remaining acceptance work are tracked in [#94](https://github.com/CyberdyneCorp/cybercode-cli/issues/94).

![Binary hex workspace with a shared hex/ASCII cursor and little-endian byte inspector](docs/screenshots/binary-hex.png)

*Actual TUI capture of a synthetic 512-byte fixture, opened with `cyber hex fixtures/firmware.bin --offset 0x20 --read-only`.*

![Reviewed binary edit with exact before/after bytes and SHA-256 fingerprints](docs/screenshots/binary-hex-edit.png)

*Actual TUI review of a staged one-byte overwrite. Opening the review leaves the source unchanged; a second Ctrl+S applies it.*

## Sessions and people working together

Cyber supports communication between Sessions and between different people's agents working on the same Git project.
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ The [current inventory](docs/implementation/spec-doc-reconciliation.md) contains

**Usage limit waits (`session-runtime`, `configuration`, `exec-mode`, `tui`, `web-client`, P1; `goals`, `loops-scheduling`, `workflows`, P2): Status: implemented** (change [`add-usage-limit-wait`](openspec/changes/add-usage-limit-wait/proposal.md)). An unattended run (`cyber exec`, a Goal, a loop iteration, a workflow agent, a teammate, or their subagents) that hits a subscription usage limit with a known reset waits for it and retries the Turn instead of failing: within `usage_limit.max_wait` (default 6 h), 10 s after the reset plus per-Session jitter, as one new attempt from the same history. The wait is durable (`session.usage_limit.waiting|resumed|abandoned.1`), cancelled by the normal interrupt, resumed after a restart for top-level Sessions and ended cleanly for owned ones; wall deadlines (Session, run, Goal and loop `max_wall_seconds`, `exec --timeout`) win over waiting. `exec` reports `usage_limit` stream-json lines, the TUI offers `/resume-at-reset` and a footer badge, and the dashboard shows the wait. The model fallback chain, once implemented, runs first.

**Binary hex editor (P1), in progress:** [`add-binary-hex-editor`](openspec/changes/add-binary-hex-editor/proposal.md) now provides the [live binary workspace](docs/binary-editor.md): paged hex/ASCII inspection, literal search, exact goto, bookmarks, LE/BE interpretation and scoped owner API/SDK access. The user prioritized issue #94 on 2026-11-01 ahead of the unfinished plugins/hooks and daily-coding work. The native core and owner API/SDK now provide durable staged patches, draft undo/redo, byte review and explicit recovery on admitted Linux/macOS workspaces. Terminal editing, production save/restore, model operations, shared snapshot/hook integration and remaining acceptance remain open; this is not full editor acceptance.
**Binary hex editor (P1), in progress:** [`add-binary-hex-editor`](openspec/changes/add-binary-hex-editor/proposal.md) now provides the [live binary workspace](docs/binary-editor.md): paged hex/ASCII inspection, literal search, exact goto, bookmarks, LE/BE interpretation and scoped owner API/SDK access. The user prioritized issue #94 on 2026-11-01 ahead of the unfinished plugins/hooks and daily-coding work. The native core and owner API/SDK now provide durable staged patches, draft undo/redo, byte review and explicit recovery on admitted Linux/macOS workspaces. The terminal now stages edits with draft undo/redo and explicit reviewed save/Restore Last Save through the same owner API. Acknowledged saves feed the native FileChanged producer. Authorized Sessions can review the shared saved-version catalog and Session diffs include binary references. Model operations, checkpoint/retention/rewind adapters and remaining acceptance are still open; this is not full editor acceptance.

**Untrusted-content defense (`untrusted-content`, `permissions-modes`, `system-context`, P1): Status: implemented** (change [`add-untrusted-content-defense`](openspec/changes/add-untrusted-content-defense/proposal.md)). It carries the `workspace-trust` provenance rule through every tool that returns third-party content. `webfetch`, `websearch`, MCP tool results, browser pages and MCP resources are returned in one escape-safe `<untrusted_content>` frame that replaces the MCP-resource frame. Hidden characters become visible markers, and a deterministic detector flags instruction-like text with a notice in the TUI, `exec` and the dashboard, without dropping content. After a flagged result, risky actions (network egress, writes outside the Location, classifier-allowed commands, secret reads) ask until the next user prompt, or are denied with no user attached; `bypass` shows the notice or refuses when configured. The base prompts cover the frame (version 3), and a labeled fixture set gates recall and false positives, with scripted end-to-end scenarios for secret reads, permission widening and provider redirection. The TUI and dashboard state each notice's consequence from the Location's effective `taint`/`bypass` settings (task 5.3). What remains: acceptance by hand on the real binary.

Expand Down
201 changes: 201 additions & 0 deletions crates/cyber-app/tests/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,207 @@ async fn binary_owner_drafts_patch_history_review_and_permission_revocation() {
);
}

#[cfg(any(target_os = "macos", target_os = "linux"))]
#[tokio::test]
async fn binary_reviewed_save_retry_and_restore_use_the_production_router_without_inference() {
let f = Fixture::with(json!({"read":"allow","edit":"allow"})).await;
let session = f.session().await;
let opened = f.binary(&session, "blob.bin").await;
let path = format!(
"/files/binary/documents/{}",
opened["document_id"].as_str().unwrap()
);
let (code, prepared) = f
.send(
"POST",
&format!("{path}/prepare"),
Some(json!({"session_id":session,"revision":opened["revision"]})),
)
.await;
assert_eq!(code, 200, "{prepared}");
let (code,edited)=f.send("POST",&format!("{path}/patches"),Some(json!({"session_id":session,"operation_id":"patch_save",
"revision":prepared["data"]["revision"],"splices":[{"offset":"1","delete_length":"2","expected_hex":"9f92","insert_hex":"ff0041"}]}))).await;
assert_eq!(code, 200, "{edited}");
let other_session = f.session().await;
let reader = f.binary(&other_session, "blob.bin").await;
let reader_path = format!(
"/files/binary/documents/{}",
reader["document_id"].as_str().unwrap()
);
let (code, private_history) = f
.get(&format!(
"{reader_path}/versions?session_id={other_session}"
))
.await;
assert_eq!(code, 200, "{private_history}");
assert!(
private_history["data"]["versions"]
.as_array()
.unwrap()
.is_empty(),
"unsaved draft leaked into shared history"
);
let (code, review) = f
.send(
"POST",
&format!("{path}/review"),
Some(json!({"session_id":session,"revision":edited["data"]["status"]["revision"]})),
)
.await;
assert_eq!(code, 200, "{review}");
let request = json!({"session_id":session,"operation_id":"save_one","review":review["data"]["fingerprint"]});
let mut stale = request.clone();
stale["review"] = json!("0".repeat(64));
let (code, refused) = f.send("POST", &format!("{path}/save"), Some(stale)).await;
assert!(code >= 400, "stale review accepted: {refused}");
let mut cross_session = request.clone();
cross_session["session_id"] = json!(other_session);
let (code, refused) = f
.send("POST", &format!("{path}/save"), Some(cross_session))
.await;
assert!(code >= 400, "cross-Session save accepted: {refused}");
f.set_permissions(json!({"read":"allow","edit":"deny"}));
let (code, refused) = f
.send("POST", &format!("{path}/save"), Some(request.clone()))
.await;
assert!(code >= 400, "revoked edit grant accepted: {refused}");
assert_eq!(
std::fs::read(f.project.join("blob.bin")).unwrap(),
[0, 159, 146, 150]
);
f.set_permissions(json!({"read":"allow","edit":"allow"}));
f._app
.store
.set_append_fault(Some(std::sync::Arc::new(|_, event| {
event.kind == "binary.source.handoff.1"
})));
let (code, saved) = f
.send("POST", &format!("{path}/save"), Some(request.clone()))
.await;
assert_eq!(code, 200, "{saved}");
assert_eq!(saved["data"]["operation"]["outcome"], "acknowledged");
assert_eq!(saved["data"]["operation"]["file_effect"], "changed");
assert_eq!(
std::fs::read(f.project.join("blob.bin")).unwrap(),
[0, 255, 0, 65, 150]
);
let (code, replayed) = f.send("POST", &format!("{path}/save"), Some(request)).await;
assert_eq!(code, 200, "{replayed}");
assert_eq!(replayed["data"]["operation"]["id"], "save_one");
assert_eq!(
f.get(&format!(
"{reader_path}/versions?session_id={other_session}"
))
.await
.0,
400,
"an invalidated live view must be reopened"
);
assert_eq!(
f.send(
"DELETE",
&format!("{reader_path}?session_id={other_session}"),
None
)
.await
.0,
200
);
let reader = f.binary(&other_session, "blob.bin").await;
let reader_path = format!(
"/files/binary/documents/{}",
reader["document_id"].as_str().unwrap()
);
let (code, shared) = f
.get(&format!(
"{reader_path}/versions?session_id={other_session}"
))
.await;
assert_eq!(code, 200, "{shared}");
let version = &shared["data"]["versions"][0];
assert_eq!(version["operation_id"], "save_one");
let version_path = format!(
"{reader_path}/versions/{}/{}/review?session_id={other_session}",
version["document_id"].as_str().unwrap(),
version["version_id"].as_str().unwrap()
);
let (code, shared_review) = f.get(&version_path).await;
assert_eq!(code, 200, "{shared_review}");
assert_eq!(shared_review["data"]["ranges"][0]["before_hex"], "9f92");
assert_eq!(shared_review["data"]["ranges"][0]["after_hex"], "ff0041");
f.set_permissions(json!({"read":"deny","edit":"allow"}));
assert_eq!(f.get(&version_path).await.0, 403);
assert_eq!(
f.get(&format!(
"{reader_path}/versions?session_id={other_session}"
))
.await
.0,
403
);
f.set_permissions(json!({"read":"allow","edit":"allow"}));
let (code, diff) = f.get(&format!("/sessions/{session}/diff")).await;
assert_eq!(code, 200, "{diff}");
let change = diff["data"]
.as_array()
.unwrap()
.iter()
.find(|change| change["file"] == "blob.bin")
.expect("non-Git binary source snapshot reference");
assert_eq!(change["binary"][0]["operation_id"], "save_one");
assert_eq!(change["patch"], "");
f.set_permissions(json!({"read":"deny","edit":"allow"}));
assert!(
f.get(&format!("/sessions/{session}/diff")).await.1["data"]
.as_array()
.unwrap()
.is_empty()
);
f.set_permissions(json!({"read":"allow","edit":"allow"}));
assert!(
f._app
.runtime
.binary_handoff(version["version_id"].as_str().unwrap())
.unwrap()
.is_none(),
"the fault must isolate snapshot references from notification delivery"
);
f._app.store.set_append_fault(None);
let (code, restore) = f
.send(
"POST",
&format!("{path}/restore-review"),
Some(json!({"session_id":session,
"revision":saved["data"]["status"]["revision"]})),
)
.await;
assert_eq!(code, 200, "{restore}");
let (code, restored) = f
.send(
"POST",
&format!("{path}/restore"),
Some(json!({"session_id":session,"operation_id":"restore_one",
"review":restore["data"]["fingerprint"]})),
)
.await;
assert_eq!(code, 200, "{restored}");
assert_eq!(restored["data"]["operation"]["outcome"], "acknowledged");
assert_eq!(
std::fs::read(f.project.join("blob.bin")).unwrap(),
[0, 159, 146, 150]
);
let (code, old_receipt) = f
.get(&format!("{path}/operations/save_one?session_id={session}"))
.await;
assert_eq!(
code, 200,
"historical notification lookup tried to re-observe the replaced source: {old_receipt}"
);
assert_eq!(old_receipt["data"]["outcome"], "acknowledged");
assert!(f._model.requests().is_empty());
f._app.runtime.shutdown().await;
}

#[tokio::test]
async fn binary_http_idempotency_keys_never_replay_private_data_after_read_revocation() {
let f = Fixture::with(json!({"read":"allow"})).await;
Expand Down
99 changes: 98 additions & 1 deletion crates/cyber-core/src/binary/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ use std::path::PathBuf;
mod catalog;
#[path = "editor/changes.rs"]
mod changes;
#[path = "editor/history.rs"]
mod history;
#[path = "editor/restore.rs"]
mod restore;
#[path = "editor/retention.rs"]
mod retention;
#[path = "editor/versions.rs"]
mod versions;
pub use catalog::{StoredDocument, StoredDocuments};
pub use catalog::{SavedReference, StoredDocument, StoredDocuments};
pub use changes::AcknowledgedChange;
pub use retention::Cleanup;
pub use versions::VersionView;
Expand Down Expand Up @@ -304,6 +306,22 @@ impl Editor {
operation_id(id)?;
Ok(self.operation(id))
}
pub fn lookup_installation(
&self,
id: &str,
review: &str,
restore: bool,
) -> Result<Option<Operation>, Error> {
let payload = signature(&(if restore { "restore-v1" } else { "save-v1" }, review))?;
let operation = self.lookup_operation(id)?;
if operation
.as_ref()
.is_some_and(|operation| operation.payload_sha256 != payload)
{
return Err(Error::Changed);
}
Ok(operation)
}
pub fn operation(&self, id: &str) -> Option<Operation> {
self.record
.operations
Expand Down Expand Up @@ -768,6 +786,20 @@ impl Editor {
/// Called only after the runtime durably publishes the acknowledged observation.
/// The operation ID is the producer's deduplication identity, not just a path.
pub fn notified(&mut self, id: &str) -> Result<(), Error> {
self.finish_notification(id, None)
}
/// A durable producer disposition can close notification work without
/// claiming that a handler ran or changing the acknowledged source outcome.
pub fn notification_suppressed(&mut self, id: &str, reason: &str) -> Result<(), Error> {
if reason.is_empty()
|| reason.len() > 64
|| !reason.bytes().all(|b| b.is_ascii_lowercase() || b == b'_')
{
return Err(Error::Invalid);
}
self.finish_notification(id, Some(format!("notification_suppressed:{reason}")))
}
fn finish_notification(&mut self, id: &str, reason: Option<String>) -> Result<(), Error> {
self.store.validate()?;
let operation = self
.record
Expand All @@ -779,7 +811,11 @@ impl Editor {
&& op.file_effect == FileEffect::Changed
})
.ok_or(Error::Invalid)?;
if !operation.notification_pending {
return Ok(());
}
operation.notification_pending = false;
operation.reason = reason;
persist(&mut self.store, &self.record)
}
}
Expand Down Expand Up @@ -838,6 +874,67 @@ mod tests {
_ => panic!("new save must prepare"),
}
}
#[test]
fn installation_retries_bind_the_original_review_and_notification_disposition() {
let (_root, mut editor, path, _owner) = setup();
patch(&mut editor, "patch_once");
let review = editor.review(0, 128, || false).unwrap();
let prepared = ready(&mut editor, "saved_once");
editor.commit_save(*prepared).unwrap();
let bytes = std::fs::read(&path).unwrap();
let notification = editor.notification_key("saved_once").unwrap().unwrap();
assert_eq!(
editor
.lookup_installation("saved_once", &review.fingerprint, false)
.unwrap()
.unwrap()
.outcome,
Outcome::Acknowledged
);
assert!(
editor
.lookup_installation("saved_once", &"0".repeat(64), false)
.is_err()
);
assert!(
editor
.lookup_installation("saved_once", &review.fingerprint, true)
.is_err()
);
assert!(
editor
.notification_suppressed("saved_once", "invalid reason")
.is_err()
);
editor
.notification_suppressed("saved_once", "native_unavailable")
.unwrap();
editor.notified("saved_once").unwrap();
let receipt = editor.lookup_operation("saved_once").unwrap().unwrap();
assert!(!receipt.notification_pending);
assert_eq!(receipt.outcome, Outcome::Acknowledged);
assert_eq!(receipt.file_effect, FileEffect::Changed);
assert_eq!(
receipt.reason.as_deref(),
Some("notification_suppressed:native_unavailable")
);
std::fs::write(&path, b"later external source").unwrap();
assert_eq!(
editor.notification_key("saved_once").unwrap().as_deref(),
Some(notification.as_str())
);
assert!(
editor
.acknowledged_change("saved_once")
.unwrap()
.unwrap()
.observed_stamp()
.is_err(),
"old metadata must not confirm a fresh source observation"
);
assert_ne!(std::fs::read(path).unwrap(), bytes);
}

#[test]
fn final_save_authority_refusal_preserves_source_draft_and_receipt_absence() {
let (_root, mut editor, path, _owner) = setup();
Expand Down
Loading
Loading