Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a216ff8
feat(tools): frame external tool results as untrusted content
leonardoaraujosantos Oct 30, 2026
d99cccc
Replay provider streams interrupted after partial output
leonardoaraujosantos Oct 30, 2026
429007c
feat(untrusted): detect injection signals and record them on events
leonardoaraujosantos Oct 30, 2026
386f374
feat(untrusted): show injection signal notices in the TUI, exec and d…
leonardoaraujosantos Oct 30, 2026
fdebb6c
feat(untrusted): taint runs that read injected content and gate risky…
leonardoaraujosantos Oct 30, 2026
e7620c9
feat(untrusted): prompt guidance, injection eval gate, end-to-end sce…
leonardoaraujosantos Oct 30, 2026
c379d84
fix(untrusted): close taint and framing gaps found in review
leonardoaraujosantos Oct 30, 2026
0d0390f
test: run the shell scan check on Unix only and read whole SSE events
leonardoaraujosantos Oct 30, 2026
35906b1
feat(core): shared secret redaction with kinded markers and tool_outp…
leonardoaraujosantos Oct 30, 2026
b56c360
feat(tools): redact tool output before truncation and refuse redactio…
leonardoaraujosantos Oct 30, 2026
0130456
test: read whole SSE events before parsing them
leonardoaraujosantos Oct 30, 2026
f3044ab
feat(clients): show how many secrets a tool result hid from the model
leonardoaraujosantos Oct 30, 2026
09a7854
spec: add tool output redaction change and document it
leonardoaraujosantos Oct 30, 2026
b7f9fdd
test: gate Unix-only redaction helpers so the Windows lint build comp…
leonardoaraujosantos Oct 30, 2026
fa24681
Merge branch 'main' into feat/retry-interrupted-streams
vanedaltro Oct 30, 2026
3130be1
fix(redaction): hide intersecting secret ranges completely
vanedaltro Oct 30, 2026
23ee3b9
Merge main into feat/redact-tool-output
vanedaltro Oct 30, 2026
ee5c453
Merge main into feat/retry-interrupted-streams
vanedaltro Oct 30, 2026
3f4a5a0
Merge stream recovery and usage waits into untrusted content defense
vanedaltro Oct 30, 2026
c6beff9
fix(untrusted): escape frame tags after long whitespace
vanedaltro Oct 30, 2026
013c63b
Merge untrusted content defense and preserve redaction boundaries
vanedaltro Oct 30, 2026
df22698
Fix queue-head test fixture after redaction integration
vanedaltro Oct 30, 2026
583558a
Separate webfetch summary privacy assertions
vanedaltro Oct 30, 2026
158ec84
Verify framed MCP redaction after feature composition
vanedaltro Oct 30, 2026
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
9 changes: 7 additions & 2 deletions .config/nextest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ binary_id(cyber-account)
| binary_id(cyber-cli::exec_hooks_windows)
| binary_id(cyber-cli::exec_session_usage)
| binary_id(cyber-cli::exec_template_expansion)
| binary_id(cyber-cli::exec_untrusted)
| binary_id(cyber-cli::git_commit_msg)
| binary_id(cyber-cli::hook_history)
| binary_id(cyber-cli::hook_test_cli)
Expand Down Expand Up @@ -96,6 +97,8 @@ binary_id(cyber-account)
| binary_id(cyber-core::paths) & test(ensure_)
| binary_id(cyber-core::plugins)
| binary_id(cyber-core::skills_compat)
| binary_id(cyber-core::untrusted_detector)
| binary_id(cyber-core::untrusted_eval)
| binary_id(cyber-core::worktree_lock)
| binary_id(cyber-core::worktree_repository)
| binary_id(cyber-core::worktrees)
Expand Down Expand Up @@ -139,7 +142,7 @@ binary_id(cyber-account)
| binary_id(cyber-server::pty)
| binary_id(cyber-server::requests)
| binary_id(cyber-server::resume_children)
| binary_id(cyber-server::runtime) & (test(loaded_) | test(registration) | test(complete_tool_call_is_durable_before_the_stream_finishes) | test(transport_failure_preserves_tool_outcomes_without_replaying_actions) | test(delete_) | test(shutdown_))
| binary_id(cyber-server::runtime) & (test(loaded_) | test(registration) | test(a_complete_tool_call_is_recorded_when_its_stream_completes) | test(a_mid_stream_retry_records_only_the_replayed_attempt) | test(transport_failure_preserves_tool_outcomes_without_replaying_actions) | test(delete_) | test(shutdown_))
| binary_id(cyber-server::structured_results)
| binary_id(cyber-server::subagent_names)
| binary_id(cyber-server::subtask_cancellation)
Expand All @@ -154,7 +157,7 @@ binary_id(cyber-account)
| binary_id(cyber-snapshot)
| binary_id(cyber-store) & (test(migrate::tests::messaging_) | test(migrate::tests::usage_origin_))
| binary_id(cyber-store::store)
| binary_id(cyber-tools) & (test(tools::bash::shell_tests) | test(plugin_host::) | test(browser::) | test(dev_server::) | test(permissions::engine::tests::message_send) | test(permissions::engine::tests::remote_attach) | test(remote_skills) | test(lsp::) | test(mcp::stdio::tests) | test(mcp::roots::tests) | test(mcp::elicitation::tests) | test(mcp::sampling::tests) | test(tools::process::tests) | test(tools::tool_search::tests) | test(mcp::pool::tests) | test(mcp::launch::tests) | test(mcp::http::tests) | test(mcp::oauth) | test(hook_commands::launch::tests) | test(sandboxing::tests) | test(permissions::filesystem) | test(tools::background::tests) | test(tools::monitor::) | test(tools::deliver::tests) | test(tools::powershell::tests) | test(permissions::dry_run) | test(sandboxing::tests::refusal_lines) | test(permissions::engine::tests::sandbox_boundary_needs_an_explicit_user_allow) | test(permissions::engine::tests::workflow_run))
| binary_id(cyber-tools) & (test(tools::bash::shell_tests) | test(plugin_host::) | test(browser::) | test(dev_server::) | test(permissions::engine::tests::message_send) | test(permissions::engine::tests::remote_attach) | test(remote_skills) | test(lsp::) | test(mcp::stdio::tests) | test(mcp::roots::tests) | test(mcp::elicitation::tests) | test(mcp::sampling::tests) | test(tools::process::tests) | test(tools::tool_search::tests) | test(mcp::pool::tests) | test(mcp::launch::tests) | test(mcp::http::tests) | test(mcp::oauth) | test(hook_commands::launch::tests) | test(sandboxing::tests) | test(permissions::filesystem) | test(tools::background::tests) | test(tools::monitor::) | test(tools::deliver::tests) | test(tools::powershell::tests) | test(permissions::dry_run) | test(sandboxing::tests::refusal_lines) | test(permissions::engine::tests::sandbox_boundary_needs_an_explicit_user_allow) | test(permissions::engine::tests::workflow_run) | test(permissions::taint::) | test(untrusted::))
| binary_id(cyber-tools::agent_context)
| binary_id(cyber-tools::agent_limits)
| binary_id(cyber-tools::agent_models)
Expand Down Expand Up @@ -209,6 +212,8 @@ binary_id(cyber-account)
| binary_id(cyber-tools::subagents)
| binary_id(cyber-tools::subtasks)
| binary_id(cyber-tools::template_injection)
| binary_id(cyber-tools::untrusted_e2e)
| binary_id(cyber-tools::untrusted_taint) & !test(classifier_sees_the_taint_and_its_allow_still_asks_for_egress)
| binary_id(cyber-tools::tools) & (test(successful_read_warms_in_background_without_waiting_for_discovery) | test(bypass_refuses_rm_rf_of_git_toplevel_substitution_without_execution) | test(accept_edits_allows_write_but_keeps_non_edit_and_recursive_rm_asks) | test(grep_and_glob_refuse_a_hidden_base_path))
| binary_id(cyber-tools::web_skills)
| binary_id(cyber-tools::windows_container_process)
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ jobs:
# One retry per failing test; see .config/nextest.toml. Doctests are not run by nextest.
- run: cargo nextest run --workspace --profile ci
- run: cargo test --workspace --doc
# The injection detector's speed target (`untrusted-content`): 5 ms for a 50 KiB
# page on this runner; the bench fails above twice that.
- name: Untrusted-content detector benchmark
if: runner.os == 'Linux'
run: cargo bench -p cyber-core --bench untrusted_detector

windows-lint:
name: Windows lint
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ opt-level = 3
opt-level = 3
[profile.dev.package.miniz_oxide]
opt-level = 3
# Secret redaction scans every tool result before it reaches the model; unoptimized
# regex matching would make large results slow in dev builds and tests.
[profile.dev.package.regex]
opt-level = 3
[profile.dev.package.regex-automata]
opt-level = 3
[profile.dev.package.regex-syntax]
opt-level = 3
[profile.dev.package.aho-corasick]
opt-level = 3
[profile.dev.package.memchr]
opt-level = 3

[profile.release]
lto = "thin"
Expand Down
12 changes: 10 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ The first release prioritizes reliable editing, recovery and measurable coding q

**Provider base prompts and output styles (`system-context`): Status: implemented** (change `add-output-styles`). Base prompts follow the catalog model's family rather than the provider id; built-in, user and trust-gated project output styles are selected per primary Session (`/output-style`, `cyber exec --output-style`, `output_style`) and apply through a new Context Epoch. Plugin-distributed styles and `cyber import` of `.claude/output-styles` are deferred.

**Model protocol fidelity (`session-runtime`, `provider-catalog`, `system-context`, `builtin-tools`, `tool-registry`): Status: in progress** (change `improve-model-protocol-fidelity`). Reasoning is kept as ordered items with opaque provider state and replayed natively to the model that produced it; per-model behavior profiles replace family checks; requests gain the Responses fields and routing headers the models use; streams have an idle timeout; known provider error codes map to typed reasons with server-advised delays; the base prompts are rewritten per family, versioned, and checked clean-room against a reference product's prompts. Live tool-loop tests for OpenAI, Anthropic, DeepSeek and Gemini 3 are written and run with keys (`cargo test -p cyber-llm --test live -- --ignored`); they have not yet been run. The profile's `edit_tool` picks the edit tools, with a freeform `apply_patch` constrained by our own envelope grammar on OpenAI Responses; hunks match tolerantly (whitespace, typographic punctuation) and fail as ambiguous rather than guess; a shell `apply_patch` heredoc runs as the patch tool when no such program is installed; and the profile's token budget with `middle` truncation keeps both ends of long output. Remaining: a run of the live tests with provider keys and the before/after pinned-repository eval on `openai/gpt-6-sol`. The Responses WebSocket transport, zstd bodies and server-side compaction are separate follow-up changes.
**Model protocol fidelity (`session-runtime`, `provider-catalog`, `system-context`, `builtin-tools`, `tool-registry`): Status: in progress** (change `improve-model-protocol-fidelity`). Reasoning is kept as ordered items with opaque provider state and replayed natively to the model that produced it; per-model behavior profiles replace family checks; requests gain the Responses fields and routing headers the models use; streams have an idle timeout; known provider error codes map to typed reasons with server-advised delays; the base prompts are rewritten per family, versioned, and checked clean-room against a reference product's prompts. Live tool-loop tests for OpenAI, Anthropic, DeepSeek and Gemini 3 are written and run with keys (`cargo test -p cyber-llm --test live -- --ignored`); they have not yet been run. The profile's `edit_tool` picks the edit tools, with a freeform `apply_patch` constrained by our own envelope grammar on OpenAI Responses; hunks match tolerantly (whitespace, typographic punctuation) and fail as ambiguous rather than guess; a shell `apply_patch` heredoc runs as the patch tool when no such program is installed; and the profile's token budget with `middle` truncation keeps both ends of long output. A retryable stream failure after partial output now discards the partial answer and replays the request, within the same retry budget and only while none of its tool calls has been dispatched (change `retry-interrupted-streams`, which followed a pilot task lost to a mid-answer transport error). Remaining: a run of the live tests with provider keys and the before/after pinned-repository eval on `openai/gpt-6-sol`. The Responses WebSocket transport, zstd bodies and server-side compaction are separate follow-up changes.

**Exit criteria**
- Runs a 50-turn coding task end-to-end with OpenAI, Anthropic and a local model through the compatible adapter. Each P0 adapter also passes the published live coding baseline in `harness-evaluation`; turn count alone is insufficient.
Expand Down Expand Up @@ -87,11 +87,19 @@ The [current inventory](docs/implementation/spec-doc-reconciliation.md) contains

**Sound alerts (`tui`, P1): Status: implemented** (change [`add-sound-alerts`](openspec/changes/add-sound-alerts/proposal.md); the check on real hardware remains). Six distinct alert sounds: input needed, done, error, workflow completed, workflow failed, and message. They play while the terminal is unfocused. There are four packs synthesized in code (`cyber`, `soft`, `retro` and `bell`), played through the OS player (`afplay`, `pw-play`/`paplay`/`aplay`, PowerShell), with a terminal-bell fallback over SSH or when no player is found. `/sounds` picks the pack and volume, toggles events and previews each sound. It also closes the gap where a permission or question request raised no attention notification. No native audio dependency.

**Tool output redaction (`tool-registry`, `builtin-tools`, `configuration`, P1 security): Status: implemented** (change [`add-tool-output-redaction`](openspec/changes/add-tool-output-redaction/proposal.md)). Secrets in tool results no longer reach the model provider. Every result is redacted before truncation: built-in tools, MCP, browser and plugin tools, subagent results, and Job and workflow handbacks.
- Each secret becomes `[REDACTED:<kind>]`. Detection covers provider credential formats, the exact values of secret-named environment variables, provider keys and MCP secrets, and secret-looking assignments. It is never based on entropy.
- The stored `session.tool.settled` output is the redacted text, so clients never show more than the model saw. Raw overflow files and Job logs stay local.
- `write`, `edit`, `apply_patch` and shell input refuse markers.
- `tool_output.redact` adds `patterns` and `allow` entries. Project and plugin config can only add patterns.
- `redactions: {count, kinds}` reaches the TUI, dashboard, `exec` and SDK.
- Fragments without a recognizable shape and tool input remain out of scope.

**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.

**Proposed binary hex editor (P1):** [`add-binary-hex-editor`](openspec/changes/add-binary-hex-editor/proposal.md) extends the current file explorer with paged hex/ASCII inspection, staged byte edits, review/undo/recovery and a bounded model-facing binary tool. Specification only; implementation awaits review and scheduling after the existing Plugins/hooks and Daily coding workstreams. It does not imply that `/files` already provides a general manual text editor.

**Proposed untrusted-content defense (`untrusted-content`, `permissions-modes`, `system-context`, P1):** [`add-untrusted-content-defense`](openspec/changes/add-untrusted-content-defense/proposal.md) implements the existing `workspace-trust` provenance rule for every tool that returns third-party content. `webfetch`, `websearch`, MCP tool results and browser pages are returned in one escape-safe `<untrusted_content>` frame that replaces today's 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; `bypass` shows the notice or refuses when configured. Specification only.
**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.

**Proposed repository map (`code-intelligence`, `system-context`, `provider-catalog`, P1):** [`add-repository-map`](openspec/changes/add-repository-map/proposal.md) adds a ranked overview of the repository's files and symbols. Symbols come from tree-sitter tag queries, ranked by PageRank over references and personalised by the Session's recent files. The map is fitted to a token budget set by the model profile, offered as a read-only `repo_map` tool, and optionally included as an Epoch-pinned `core/repo-map` Context Source in trusted checkouts. Pinning means it never breaks the cached prompt prefix. The index is a local cache shared across worktrees; inspection is through `cyber lsp map` and `/map`. Semantic code search is deferred. Specification only; the budget field depends on archiving `improve-model-protocol-fidelity`.

Expand Down
1 change: 1 addition & 0 deletions crates/cyber-app/src/git_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ impl Reader {
home: cyber_core::paths::home_dir(&cyber_core::env::ProcessEnv).unwrap_or_default(),
plan_file: PathBuf::new(),
ownership: None,
taint: Default::default(),
};
Ok(Self {
policy,
Expand Down
28 changes: 28 additions & 0 deletions crates/cyber-app/src/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,34 @@ impl ToolHost for AppHost {
self.builtin.take_call_metadata(session_id, call_id)
}

fn redact_model_text(
&self,
directory: &str,
text: String,
) -> (String, cyber_core::redact::Redactions) {
self.builtin.redact_model_text(directory, text)
}

fn take_call_redactions(
&self,
session_id: &str,
call_id: &str,
) -> cyber_core::redact::Redactions {
self.builtin.take_call_redactions(session_id, call_id)
}

fn take_call_untrusted(
&self,
session_id: &str,
call_id: &str,
) -> Option<cyber_core::untrusted::UntrustedRecord> {
self.builtin.take_call_untrusted(session_id, call_id)
}

fn untrusted_policy(&self, directory: &str) -> cyber_core::untrusted::NoticePolicy {
self.builtin.untrusted_policy(directory)
}

fn file_decisions<'a>(
&'a self,
turn: &'a TurnContext,
Expand Down
7 changes: 7 additions & 0 deletions crates/cyber-app/tests/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,13 @@ async fn unvetted_browser_tools_are_neither_offered_nor_called() {
.collect();
assert_eq!(called, [json!("browser_snapshot")], "{:?}", f.results());
let results = f.results();
// Scenario "Browser page framed as browser": the page reaches the model as data.
assert!(
results[0].starts_with(
"<untrusted_content source=\"browser\" origin=\"mcp:browser/browser_snapshot\">\n"
) && results[0].ends_with("\n</untrusted_content>"),
"{results:?}"
);
assert!(results[1].contains("Unknown tool"), "{results:?}");
assert!(results[2].contains("Unknown tool"), "{results:?}");
f.app.runtime.shutdown().await;
Expand Down
Loading
Loading