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
28 changes: 27 additions & 1 deletion .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,21 @@ jobs:

- name: Install Linux native build dependencies
if: matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install --yes libdbus-1-dev libxcb1-dev pkg-config
run: |
# ⚠ Drop the runner image's Microsoft apt repositories FIRST.
# `packages.microsoft.com` has repeatedly answered 403 for the
# `azure-cli` and `prod` lists that the hosted image ships
# preinstalled, and `apt-get update` exits 100 on any repository it
# cannot refresh — so an outage on a third-party CDN we do not use
# fails this job with `E: ... is no longer signed`. Nothing here
# installs from them. Removing them makes the update depend only on
# the archives the packages below actually come from.
sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list \
/etc/apt/sources.list.d/azure-cli.list \
/etc/apt/sources.list.d/microsoft-prod.sources \
/etc/apt/sources.list.d/azure-cli.sources
sudo apt-get update
sudo apt-get install --yes libdbus-1-dev libxcb1-dev pkg-config

# Start narrow (lib + bins on every OS) so the matrix is green on arrival;
# widen as it proves stable. This selector leaves out `tests/`, so the
Expand Down Expand Up @@ -289,6 +303,18 @@ jobs:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Linux native build dependencies
run: |
# ⚠ Drop the runner image's Microsoft apt repositories FIRST.
# `packages.microsoft.com` has repeatedly answered 403 for the
# `azure-cli` and `prod` lists that the hosted image ships
# preinstalled, and `apt-get update` exits 100 on any repository it
# cannot refresh — so an outage on a third-party CDN we do not use
# fails this job with `E: ... is no longer signed`. Nothing here
# installs from them. Removing them makes the update depend only on
# the archives the packages below actually come from.
sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list \
/etc/apt/sources.list.d/azure-cli.list \
/etc/apt/sources.list.d/microsoft-prod.sources \
/etc/apt/sources.list.d/azure-cli.sources
sudo apt-get update -q
sudo apt-get install -y --no-install-recommends libdbus-1-dev libxdo-dev

Expand Down
19 changes: 15 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,12 +251,23 @@ what did not" section first**; the rest of that document is the design, not the
- **The master switch** lives in its own record beside `config.yaml`, **not in it** and **not in an
env var** — the agent has `developer__shell`, so a switch it can edit is not a switch. Loaded once
per process; a load error resolves to ON.
- **Lineage is NOT a boundary; the tier is the only one.** `may_write` ⇔ `may_read` ⇔ `VIS`, so an
agent may inject a prompt into any conversation it can see — a child, a sibling, an unrelated
chat. R6's old "steer what you spawned, read everything else" rule is retired and
`Lineage`/`lineage_of` are deleted. Two *other* one-hop rules survive and are constantly
confused with it: `McpMeta::workspace_child_scope_only` (which confines an auto-injected
supervision grant's read/close/watch to direct children) and the flat refusal of every
`workspace_*` tool to a `SessionType::SubAgent`. A private→public write raises a
**first-crossing approval showing the payload**, once per (caller, target) pair, in every
permission mode — `agents/workspace_inspector.rs` asks, `privacy/crossing.rs` remembers,
`handle_send_prompt`/`handle_set_tools` record only once the write lands.
- **Known gaps, do not assume otherwise.** The general filesystem read-deny (§9.5, DR-14) is
DEFERRED — a public chat with a shell still reads ordinary files, which is why the
non-private-model disclosure ships. And §7's cross-session matrix
(`privacy/visibility.rs::may_read`) is **written but wired to nothing**: `workspace_read_conversation`
checks only `session_type == Hidden`, so it still reads a private transcript that `chatrecall`
would refuse.
non-private-model disclosure ships. ⚠ This bullet also claimed §7's cross-session matrix was
"written but wired to nothing" and that `workspace_read_conversation` checked only
`session_type == Hidden`; **both were already false** — the read gate is
`visibility::refuse_unless_readable` and the write gate composes it with `may_write`. Grep the
symbol rather than trusting a summary, including this one.
- **Tests:** `cargo test -p biorouter --lib privacy::` and
`cargo test -p biorouter-mcp --lib knowledge::tier`, plus five integration binaries that are
**spread across three crates** — `-p biorouter`: `--test privacy_toggle`,
Expand Down
5 changes: 3 additions & 2 deletions crates/biorouter-server/src/routes/session_reach.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@
//! target's classification instead of asking for a human. Its refusal answers
//! "private" and "no such conversation" in one sentence, for the same reason
//! [`SESSION_OUT_OF_REACH`] does. `workspace_close` and `workspace_set_tools`
//! now enforce the same one-hop `may_write` lineage as
//! `workspace_send_prompt`; `workspace_watch` is parent-scoped through the
//! enforce the same `may_write` rule as `workspace_send_prompt` — which is
//! the tier and nothing else, the one-hop lineage clause having been
//! retired; `workspace_watch` is parent-scoped through the
//! caller's registered background handles rather than an arbitrary session
//! write;
//! * and the daemon still has no principal, which is the actual subject of #47.
Expand Down
164 changes: 164 additions & 0 deletions crates/biorouter-server/src/workspace/turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2778,4 +2778,168 @@ mod tests {
(TurnErrorScope::Inference, true, None)
);
}

/// **`workspace_send_prompt mode:"turn"` reflects in an open tab live.**
///
/// The injected prompt is persisted by `Agent::reply` like any other user
/// message, and `Agent::reply` deliberately does NOT yield a `Message` frame
/// for a user prompt — #66's rule, on the premise that "the client authored
/// it and already holds it". That premise is false for an injection: the
/// target's tab authored nothing, so with no frame carrying the body the
/// message appears only after a reload. `Agent::reply` therefore publishes
/// an agent-injected row straight onto the session bus, at the point it
/// becomes durable.
///
/// Driven through the real `run_turn` against a provider that RESTORES but
/// cannot infer (`ollama`, naming a model that is not there). Getting past
/// provider restore is what matters: a name the factory does not know fails
/// in setup, before `Agent::reply` runs at all, so the prompt is never
/// persisted and the test would pass for the wrong reason. Failing at
/// inference instead is the point — the publish must already have happened,
/// because that is where the row became durable.
#[tokio::test]
async fn an_injected_turns_prompt_reaches_the_targets_open_tab_live() {
use biorouter::conversation::message::{MessageProvenance, ProvenanceKind};

const INJECTED: &str = "br71-injected-turn-live-marker";

let state = crate::state::AppState::new().await.unwrap();
let workdir = tempfile::TempDir::new().unwrap();
let target = state
.session_manager()
.create_session(
workdir.path().to_path_buf(),
"br71 injected turn live".into(),
SessionType::User,
)
.await
.unwrap();
state
.session_manager()
.update(&target.id)
.provider_name("ollama")
.model_config(biorouter::model::ModelConfig::new("br71-turn-model").unwrap())
.apply()
.await
.unwrap();

// An observer, subscribed BEFORE the turn — the bus is a broadcast with
// no replay, so a subscription opened afterwards proves nothing.
let mut observer = biorouter::session_events::subscribe(&target.id);

let cancel = CancellationToken::new();
let guard = state
.try_begin_turn_idempotent(&target.id, cancel.clone(), None)
.expect("the target is idle");
let _ = tokio::time::timeout(
std::time::Duration::from_secs(60),
run_turn(
state,
TurnRequest::new(
target.id.clone(),
Message::user()
.with_text(INJECTED)
.with_provenance(MessageProvenance {
kind: ProvenanceKind::AgentInjection,
from_session_id: Some("br71-injecting-caller".into()),
from_session_name: Some("the other chat".into()),
}),
),
guard,
cancel,
),
)
.await;

let mut saw_body = false;
while let Ok(event) = observer.try_recv() {
if let SessionBusEvent::Agent(biorouter::agents::AgentEvent::Message(m)) = event {
if !message_text_contains(&m, INJECTED) {
continue;
}
// The DURABLE row, not a pre-write copy: `add_message_adopting_uid`
// stamps the minted uid, and a frame with `id: None` is one the
// renderer cannot reconcile against the stored twin that arrives
// with the next snapshot.
assert!(
m.id.is_some(),
"the injected prompt was published before it was durable"
);
saw_body = true;
}
}
assert!(
saw_body,
"no Message frame carried the injected prompt, so an open tab would \
show it only after a reload"
);
}

/// The control for the test above, and the thing that keeps it from being
/// vacuous: an ORDINARY user prompt still gets no `Message` frame. #66's
/// ordering rule depends on that — the client's own prompt is named by
/// `MessagesPersisted` and never yielded — so a publish that fired for every
/// user message would be a regression wearing the same green tick.
#[tokio::test]
async fn an_ordinary_user_prompt_is_still_never_published_as_a_message() {
const TYPED: &str = "br71-ordinary-prompt-marker";

let state = crate::state::AppState::new().await.unwrap();
let workdir = tempfile::TempDir::new().unwrap();
let target = state
.session_manager()
.create_session(
workdir.path().to_path_buf(),
"br71 ordinary prompt".into(),
SessionType::User,
)
.await
.unwrap();
state
.session_manager()
.update(&target.id)
.provider_name("ollama")
.model_config(biorouter::model::ModelConfig::new("br71-turn-model").unwrap())
.apply()
.await
.unwrap();

let mut observer = biorouter::session_events::subscribe(&target.id);

let cancel = CancellationToken::new();
let guard = state
.try_begin_turn_idempotent(&target.id, cancel.clone(), None)
.expect("the target is idle");
let _ = tokio::time::timeout(
std::time::Duration::from_secs(60),
run_turn(
state,
TurnRequest::new(target.id.clone(), Message::user().with_text(TYPED)),
guard,
cancel,
),
)
.await;

while let Ok(event) = observer.try_recv() {
if let SessionBusEvent::Agent(biorouter::agents::AgentEvent::Message(m)) = event {
assert!(
!message_text_contains(&m, TYPED),
"an ordinary typed prompt was published as a Message frame; \
#66's ordering rule assumes it never is"
);
}
}
}

fn message_text_contains(
message: &biorouter::conversation::message::Message,
needle: &str,
) -> bool {
message
.content
.iter()
.filter_map(|c| c.as_text())
.any(|t| t.contains(needle))
}
}
Loading
Loading