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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ 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 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. The model-facing `binary` tool supports bounded inspect/read/search and exact reviewed patches in isolated invocation drafts. Checkpoint/retention/rewind adapters and remaining acceptance work are 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. The model-facing `binary` tool supports bounded inspect/read/search and exact reviewed patches in isolated invocation drafts. Separate owner routes and SDK helpers discover and reconcile model-operation receipts. 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)

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. 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. The model-facing `binary` tool supports bounded inspect/read/search and expected-digest patches through separate invocation drafts and the retained native save engine. Checkpoint/retention/rewind adapters and remaining acceptance are still 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. The model-facing `binary` tool supports bounded inspect/read/search and expected-digest patches through separate invocation drafts and the retained native save engine. Model-operation discovery/receipt reconciliation stays separate from editable manual handles. 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
257 changes: 257 additions & 0 deletions crates/cyber-app/tests/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1365,6 +1365,263 @@ async fn binary_immutable_read_only_open_refuses_preparation_even_with_edit_allo
);
}

#[cfg(any(target_os = "macos", target_os = "linux"))]
fn retained_model(f: &Fixture, session: &str, id: &str, save: bool) {
use cyber_core::binary::{
Document, Offset,
draft::{PatchBatch, Splice},
editor::{DraftScope, Editor, Owner, SavePreparation},
};
let storage = f._app.runtime.binary_storage().unwrap();
let owner = Owner {
session_id: session.into(),
directory: f.project.clone(),
root: f.project.clone(),
scope: DraftScope::Model {
operation: format!("{session}:{id}:canonical_payload"),
},
};
let mut document = Document::open(&f.project, "blob.bin").unwrap();
let mut editor = Editor::model(&storage, id, owner, &mut document, || false).unwrap();
let revision = editor.draft().revision();
let before = editor.draft().page(&revision, Offset(0), 1).unwrap().bytes[0];
editor
.patch(
"model_patch",
&PatchBatch {
revision,
splices: vec![Splice {
offset: Offset(0),
delete_length: Offset(1),
expected_hex: format!("{before:02x}"),
insert_hex: format!("{:02x}", before ^ 255),
}],
},
)
.unwrap();
if save {
let review = editor.review(0, 128, || false).unwrap();
let SavePreparation::Ready(prepared) = editor
.prepare_save("model_save", &review.fingerprint, || false)
.unwrap()
else {
panic!("new source save")
};
assert_eq!(
editor.commit_save(*prepared).unwrap().outcome,
cyber_core::binary::receipts::Outcome::Acknowledged
);
}
}

#[cfg(any(target_os = "macos", target_os = "linux"))]
#[tokio::test]
async fn binary_model_receipt_recovery_stays_scoped_and_never_replays_source_edits() {
let f = Fixture::with(json!({"read":"allow", "edit":"allow"})).await;
let session = f.session().await;
retained_model(&f, &session, "bin_model_saved", true);
retained_model(&f, &session, "bin_model_unsaved", false);
let original = std::fs::read(f.project.join("blob.bin")).unwrap();
let identity = cyber_core::binary::native::Target::capture(&f.project, Path::new("blob.bin"))
.unwrap()
.file;
let (status, listing) = f
.get(&format!(
"/files/binary/model/recovery?session_id={session}&limit=1"
))
.await;
assert_eq!(status, 200, "{listing}");
assert_eq!(
listing["data"]["documents"][0]["document_id"],
"bin_model_saved"
);
let cursor = listing["data"]["next_cursor"].as_str().unwrap();
let (status, next) = f
.get(&format!(
"/files/binary/model/recovery?session_id={session}&after={cursor}"
))
.await;
assert_eq!(status, 200, "{next}");
assert_eq!(
next["data"]["documents"][0]["document_id"],
"bin_model_unsaved"
);
let (status, manual) = f
.get(&format!("/files/binary/recovery?session_id={session}"))
.await;
assert_eq!(status, 200, "{manual}");
assert_eq!(manual["data"]["documents"], json!([]));
let path = "/files/binary/model/documents/bin_model_saved/operations/model_save";
let (status, receipt) = f.get(&format!("{path}?session_id={session}")).await;
assert_eq!(status, 200, "{receipt}");
assert_eq!(receipt["data"]["outcome"], "acknowledged");
assert_eq!(receipt["data"]["file_effect"], "changed");
let url = format!("{}/api/v1{path}?session_id={session}", f.base);
assert_eq!(f.http.get(&url).send().await.unwrap().status(), 401);
let authorized = f
.http
.get(&url)
.basic_auth("cyber", Some(PASSWORD))
.header("x-cyber-directory", f.project.display().to_string())
.send()
.await
.unwrap();
assert_eq!(authorized.status(), 200);
assert!(
authorized.headers()["cache-control"]
.to_str()
.unwrap()
.contains("no-store")
);
assert_eq!(
f.http
.get(&url)
.basic_auth("cyber", Some(PASSWORD))
.header("x-cyber-directory", f.project.display().to_string())
.header("Origin", "https://untrusted.invalid")
.send()
.await
.unwrap()
.status(),
403
);
for _ in 0..2 {
let (status, reconciled) = f
.send(
"POST",
&format!("{path}/reconcile"),
Some(json!({"session_id":session})),
)
.await;
assert_eq!(status, 200, "{reconciled}");
assert_eq!(reconciled["data"], receipt["data"]);
}
let (status, absent) = f.get(&format!("/files/binary/model/documents/bin_model_unsaved/operations/model_save?session_id={session}")).await;
assert_eq!(status, 200, "{absent}");
assert!(absent["data"].is_null());
assert_ne!(
f.send(
"POST",
"/files/binary/documents/bin_model_saved/recover",
Some(json!({"session_id":session}))
)
.await
.0,
200
);
let opened = f.binary(&session, "blob.bin").await;
let manual_id = opened["document_id"].as_str().unwrap();
let (status, prepared) = f
.send(
"POST",
&format!("/files/binary/documents/{manual_id}/prepare"),
Some(json!({"session_id":session,"revision":opened["revision"]})),
)
.await;
assert_eq!(status, 200, "{prepared}");
assert_eq!(
f.get(&format!(
"/files/binary/model/documents/{manual_id}/operations/model_save?session_id={session}"
))
.await
.0,
404
);
let other = f.session().await;
assert_eq!(f.get(&format!("{path}?session_id={other}")).await.0, 404);
let (status, hidden) = f
.get(&format!("/files/binary/model/recovery?session_id={other}"))
.await;
assert_eq!(status, 200, "{hidden}");
assert_eq!(hidden["data"]["documents"], json!([]));
assert_eq!(std::fs::read(f.project.join("blob.bin")).unwrap(), original);
assert_eq!(
cyber_core::binary::native::Target::capture(&f.project, Path::new("blob.bin"))
.unwrap()
.file,
identity
);
let mut config = f._model.config();
config["permissions"] = json!({"read":"deny", "edit":"allow"});
std::fs::write(f.root.join("config/cyber.json"), config.to_string()).unwrap();
assert_eq!(f.get(&format!("{path}?session_id={session}")).await.0, 403);
assert_eq!(
f.send(
"POST",
&format!("{path}/reconcile"),
Some(json!({"session_id":session}))
)
.await
.0,
403
);
let (status, hidden) = f
.get(&format!(
"/files/binary/model/recovery?session_id={session}"
))
.await;
assert_eq!(status, 200, "{hidden}");
assert_eq!(hidden["data"]["documents"], json!([]));
assert!(f._model.requests().is_empty());
}

#[cfg(any(target_os = "macos", target_os = "linux"))]
#[tokio::test]
async fn binary_model_receipt_lookup_refreshes_read_authority_after_approval() {
for revoke in [false, true] {
let f = Fixture::with(json!({"read":"allow", "edit":"allow"})).await;
let session = f.session().await;
retained_model(&f, &session, "bin_model_review", true);
let mut config = f._model.config();
config["permissions"] = json!({"read":"ask", "edit":"allow"});
std::fs::write(f.root.join("config/cyber.json"), config.to_string()).unwrap();
let (status, hidden) = f
.get(&format!(
"/files/binary/model/recovery?session_id={session}"
))
.await;
assert_eq!(status, 200, "{hidden}");
assert_eq!(hidden["data"]["documents"], json!([]));
let request = f.background("GET", format!("/files/binary/model/documents/bin_model_review/operations/model_save?session_id={session}"), None);
let mut asked = vec![];
for _ in 0..500 {
asked = pending(&f, &session).await;
if !asked.is_empty() || request.is_finished() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(asked.len(), 1, "{asked:?}");
assert_eq!(asked[0]["action"], "read");
if revoke {
config["permissions"] = json!({"read":"deny", "edit":"allow"});
std::fs::write(f.root.join("config/cyber.json"), config.to_string()).unwrap();
}
f.send(
"POST",
&format!(
"/sessions/{session}/permissions/{}/reply",
asked[0]["id"].as_str().unwrap()
),
Some(json!({"reply":"once"})),
)
.await;
let (status, result) = tokio::time::timeout(Duration::from_secs(5), request)
.await
.unwrap()
.unwrap();
assert_eq!(status, if revoke { 403 } else { 200 }, "{result}");
if !revoke {
assert_eq!(result["data"]["outcome"], "acknowledged");
}
assert_eq!(
std::fs::read(f.project.join("blob.bin")).unwrap(),
[255, 159, 146, 150]
);
assert!(f._model.requests().is_empty());
}
}

#[cfg(any(target_os = "macos", target_os = "linux"))]
#[tokio::test]
async fn binary_closed_dirty_draft_is_recoverable_and_prevents_session_deletion() {
Expand Down
4 changes: 4 additions & 0 deletions crates/cyber-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,9 @@ windows-sys = { version = "0.61.2", features = ["Win32_System_Memory"] }
name = "untrusted_detector"
harness = false

[[bench]]
name = "binary_editor"
harness = false

[lints]
workspace = true
Loading
Loading