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
23 changes: 23 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,29 @@ loads and the driver answers the whole time.
description and read as visible). `browser_snapshot` reads the page wherever
it is.

## A grant says what may be seen, not only what may be changed

`Capability` answers what a call changes. Which project a read lands in is a
second question, and for a while nothing asked it: a credentials file holds
`ReadProject` for its own project, and `GET /v1/projects`, `/v1/timeline`,
`/v1/search`, `/v1/snapshot` and `/v1/transcript` each answered for the whole
workspace. `/mcp` too, because that door dispatches into the very same
handlers with the caller it already proved.

`Grant::reads_across` is that second question and `routes::confined_to` is the
one place it is asked. A terminal Boite opened reads everything, which is what
`/v1/transcript` is for: the user is watching that terminal, and "why did the
thread next door stop" is the question the route exists to answer. A
credentials file reads the one project it was issued for.

**The scope goes into the query, never over the answer.** `store.search`,
`search::transcripts` and `snapshot::take` each take it, so a confined caller
spends its limit on rows it may read, and a section added to the snapshot later
is cut by the same argument rather than by a filter somebody has to remember to
apply. A transcript file is named after the thread that wrote it and says
nothing about a project, so whatever reads those files is handed
`store.thread_ids_of_project` rather than left to work it out from a filename.

## The orchestrator is a role, not a claim

A thread is an orchestrator because the workspace stamped `role` on its row
Expand Down
64 changes: 63 additions & 1 deletion crates/boite-agent-api/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,15 @@ mod tests {
}

async fn hit(workspace: Shared, headers: &[(&str, &str)], body: Value) -> (u16, Value) {
hit_as(workspace, caller(), headers, body).await
}

async fn hit_as(
workspace: Shared,
caller: Caller,
headers: &[(&str, &str)],
body: Value,
) -> (u16, Value) {
let mut map = HeaderMap::new();
for (name, value) in headers {
map.insert(
Expand All @@ -328,7 +337,7 @@ mod tests {
}
let response = endpoint(
State(workspace),
Extension(caller()),
Extension(caller),
map,
Bytes::from(body.to_string()),
)
Expand Down Expand Up @@ -392,6 +401,59 @@ mod tests {
assert_eq!(out["result"]["protocolVersion"], "2025-06-18");
}


/// The same clamp through the other door. `/mcp` dispatches into the very
/// `/v1` handlers with the caller it already proved, so a credential that
/// cannot read the project next door over HTTP cannot read it by speaking
/// MCP either. The tool answers with the refusal rather than an empty
/// list, which is what an agent needs to stop asking.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_credentials_file_reads_one_project_through_the_mcp_door() {
let fake = Fake::new("mcp-scope")
.with_project("p1", "/w/one")
.with_project("p2", "/w/two")
.with_thread("t1", "p1")
.with_thread("t2", "p2")
.with_transcript("t2", "gremlin in two\n");
let workspace: Shared = Arc::new(fake);
let issued = Caller {
project_id: "p1".into(),
thread_id: None,
grant: Grant::Project,
agent: None,
};
let call = |name: &'static str, arguments: Value| {
let workspace = workspace.clone();
let issued = issued.clone();
async move {
hit_as(
workspace,
issued,
&[
("mcp-protocol-version", "2026-07-28"),
("mcp-method", "tools/call"),
("mcp-name", name),
],
json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": name, "arguments": arguments, "_meta": meta() } }),
)
.await
}
};

let (status, out) = call("projects_list", json!({})).await;
assert_eq!(status, 200);
let text = out["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("/w/one"), "{text}");
assert!(!text.contains("/w/two"), "{text}");

let (status, out) = call("terminal_transcript", json!({ "threadId": "t2" })).await;
assert_eq!(status, 200);
assert_eq!(out["result"]["isError"], true, "{out}");
let text = out["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("issued it for another one"), "{text}");
}

/// The transport's own rejections: a mirrored header that disagrees with
/// the body, a version this does not speak, and a browser origin.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
Expand Down
135 changes: 119 additions & 16 deletions crates/boite-agent-api/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,57 @@ fn permitted(
Err(Json(body))
}

/// The project this caller's reads are confined to, when they are.
///
/// `None` is the workspace-wide answer, and it is what a terminal Boite opened
/// gets: a user is watching that terminal, and "why did the thread next door
/// stop" is the question this endpoint exists to answer. `Some` is a
/// credentials file, issued for one project by a workspace that never launched
/// the process holding it.
///
/// One function rather than a `match` in each of the five reads that need it:
/// `/v1/projects`, `/v1/timeline`, `/v1/search`, `/v1/snapshot` and
/// `/v1/transcript` were each written with their own idea of scope, which is
/// how four of them ended up with none. The grant answers, in
/// `boite_core::capability`.
fn confined_to(caller: &Caller) -> Option<&str> {
(!caller.grant.reads_across()).then_some(caller.project_id.as_str())
}

/// Refuses a read that lands outside the project this caller may read, and says
/// so in the log.
///
/// The read side of [`permitted`], and separate from it because a capability
/// cannot answer this: a credentials file holds `ReadProject`, and the question
/// here is *which* project. `project_of` is called only when there is a scope
/// to check against, so a caller that reads the workspace pays for no lookup.
fn reachable(
workspace: &dyn Workspace,
caller: &Caller,
of: &str,
about: &str,
project_of: impl FnOnce() -> String,
) -> Result<(), Json<Value>> {
let Some(mine) = confined_to(caller) else {
return Ok(());
};
if project_of() == mine {
return Ok(());
}
// The same sentence a capability refusal carries, for the same reason: the
// credential is the thing that is wrong, and asking again changes nothing.
let Json(mut body) = deny(
workspace,
caller,
&caller.project_id,
of,
about,
Capability::ReadProject.refusal(),
);
body["retryable"] = json!(false);
Err(Json(body))
}

/// Puts a dispatch in front of the user instead of carrying it out, and answers
/// the agent.
///
Expand Down Expand Up @@ -471,16 +522,20 @@ async fn pulse(
"agent-{}",
caller.thread_id.as_deref().unwrap_or(&caller.project_id)
);
// A scoped orchestrator reads its own project's pulse whatever it asks
// for. The clamp is the read side of the dispatch guard: one project,
// never a window into the rest of the workspace.
let clamp = caller
.thread_id
.as_deref()
.and_then(|id| workspace.store().thread_orchestration(id))
.filter(|(role, _, _)| role.as_deref() == Some(boite_core::orchestrator::ROLE))
.and_then(|(_, scope, _)| scope);
let project = clamp.or(q.project);
// A project credential has no thread to carry the orchestrator scope, so
// apply the grant-level read boundary before checking the optional thread
// scope. Otherwise `?project=` would let it select a neighbouring project.
let project = confined_to(&caller)
.map(str::to_string)
.or_else(|| {
caller
.thread_id
.as_deref()
.and_then(|id| workspace.store().thread_orchestration(id))
.filter(|(role, _, _)| role.as_deref() == Some(boite_core::orchestrator::ROLE))
.and_then(|(_, scope, _)| scope)
})
.or(q.project);
let answered = tokio::task::spawn_blocking(move || {
boite_core::command::conduct::read_pulse(
workspace.store(),
Expand Down Expand Up @@ -622,12 +677,15 @@ async fn thread_dismiss(

async fn snapshot(
State(workspace): State<Shared>,
Extension(_caller): Extension<Caller>,
Extension(caller): Extension<Caller>,
) -> Result<Json<Value>, StatusCode> {
let live = workspace.live_ptys();
// Read here rather than inside the blocking closure: it is a lock on this
// process's own state, and the point of that closure is the database.
let screen = workspace.on_screen();
// A credentials file gets the same snapshot of its own project: every
// section cut to it, rather than the workspace with a caveat.
let only = confined_to(&caller).map(str::to_string);
let taken = blocking({
let workspace = workspace.clone();
move || {
Expand All @@ -637,6 +695,7 @@ async fn snapshot(
workspace.roots(),
live,
screen,
only.as_deref(),
))
}
})
Expand All @@ -646,7 +705,8 @@ async fn snapshot(

#[derive(Deserialize)]
struct TranscriptIn {
/// Which terminal. Any thread in the workspace, not only the caller's.
/// Which terminal. Any thread the caller may read, which for a terminal
/// Boite opened is any thread in the workspace.
#[serde(rename = "threadId")]
thread_id: Option<String>,
bytes: Option<u32>,
Expand All @@ -660,6 +720,13 @@ struct TranscriptIn {
/// workspace and a transcript is what was on somebody's screen — and it is the
/// single most useful thing an agent can be handed when something is wrong.
///
/// Not scoped to the caller's own *project* either, for a terminal Boite
/// opened. A credentials file is another matter: it is held by a process the
/// workspace never launched, and a transcript is the most detailed thing this
/// endpoint hands out, so it reads its own project's terminals and is refused
/// the rest, a thread id that names nothing included: that answers the same
/// way rather than saying which ids exist.
///
/// Defaults to the caller's own terminal, which is the other half of it: an
/// agent that lost track of what it printed can re-read itself.
async fn transcript(
Expand All @@ -676,6 +743,14 @@ async fn transcript(
Some(id) => id,
None => caller.thread()?.to_string(),
};
if let Err(refusal) = reachable(&*workspace, &caller, "transcript", &thread_id, || {
workspace
.store()
.project_of_thread(&thread_id)
.unwrap_or_default()
}) {
return Ok(refusal);
}
// A terminal prints more in a minute than anybody reads, and this answer
// goes into a context window.
let bytes = query.bytes.unwrap_or(16_384).min(1024 * 1024) as usize;
Expand All @@ -698,9 +773,13 @@ struct SearchIn {
/// terminals printed. An agent looking for where an error came from should not
/// have to know which of the three it is in, and until this existed the answer
/// was "none of them, because nothing was written down".
///
/// A caller confined to one project searches that project, and the scope goes
/// into both queries rather than over the answer: the limit is then spent on
/// hits the caller may read instead of on ones it is about to be shown none of.
async fn search(
State(workspace): State<Shared>,
Extension(_caller): Extension<Caller>,
Extension(caller): Extension<Caller>,
axum::extract::Query(query): axum::extract::Query<SearchIn>,
) -> Result<Json<Value>, StatusCode> {
let needle = query.q.unwrap_or_default().trim().to_string();
Expand All @@ -709,18 +788,30 @@ async fn search(
}
let limit = query.limit.unwrap_or(20).clamp(1, 100) as usize;
let dir = workspace.transcripts_dir();
let scope = confined_to(&caller).map(str::to_string);
let hits = blocking({
let workspace = workspace.clone();
move || {
let mut hits = workspace.store().search(&needle, limit);
let mut hits = workspace.store().search(&needle, limit, scope.as_deref());
if let Some(dir) = dir {
// A transcript is a file named after the thread that wrote it
// and says nothing about a project, so the terminals a confined
// caller may read are worked out here and handed over.
let only = scope.as_deref().map(|project| {
workspace
.store()
.thread_ids_of_project(project)
.into_iter()
.collect::<std::collections::HashSet<String>>()
});
// The rows first: a todo or a refusal is a shorter answer than
// a line of terminal output, and a caller reading a list wants
// the short ones at the top.
hits.extend(boite_core::search::transcripts(
&dir,
&needle,
limit.saturating_sub(hits.len()),
only.as_ref(),
));
}
hits
Expand All @@ -745,11 +836,16 @@ struct TimelineIn {
/// row, and a terminal being opened is only on the thread.
async fn timeline(
State(workspace): State<Shared>,
Extension(_caller): Extension<Caller>,
Extension(caller): Extension<Caller>,
axum::extract::Query(query): axum::extract::Query<TimelineIn>,
) -> Result<Json<Value>, StatusCode> {
let limit = query.limit.unwrap_or(40).clamp(1, 200) as usize;
let project = query.project.filter(|p| !p.is_empty());
// A caller confined to one project reads its own timeline whatever it asks
// for, the same clamp `pulse` applies to a scoped orchestrator: naming
// another project is not an error, it simply is not a way to read it.
let project = confined_to(&caller)
.map(str::to_string)
.or_else(|| query.project.filter(|p| !p.is_empty()));
let moments = blocking({
let workspace = workspace.clone();
move || workspace.store().timeline(project.as_deref(), limit)
Expand Down Expand Up @@ -924,17 +1020,24 @@ async fn artifacts_set(
/// project the user put away is still the right place to go back to, and leaving
/// it off the list is how an agent ends up creating a second one on top of the
/// first.
///
/// Every project the *caller* has, which for a credentials file is the one it
/// was issued for. It cannot move into another or spawn there, `MutateAcross`
/// stops that, so a list of the rest is names and folder paths handed to a
/// process Boite never launched, and nothing it could act on.
async fn projects(
State(workspace): State<Shared>,
Extension(caller): Extension<Caller>,
) -> Result<Json<Value>, StatusCode> {
let current = caller.project_id.clone();
let mine_only = confined_to(&caller).is_some();
let projects = workspace
.store()
.load_projects()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let rows: Vec<Value> = projects
.into_iter()
.filter(|p| !mine_only || p.id == current)
.map(|p| {
json!({
"id": p.id,
Expand Down
Loading
Loading