diff --git a/CHANGELOG.md b/CHANGELOG.md index a1e23ead7..07fb8246a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### The trail says who a coworker was opened to + +Making a coworker public admits every signed-in person to it, and being admitted to a coworker is +being allowed to act as it — with the connectors, the tools and the browser it was granted. It is one +click in the coworker dialog. The `bot.created` and `bot.updated` rows recorded the name, the +endpoint and whether a key was set, and said nothing about this, so an edit that opened a coworker to +the whole deployment was byte-identical on the audit page to one that corrected its title. Both rows +now carry the visibility, on every edit rather than only the edit that moved it, so reading the trail +forward says who could reach each coworker at any point. ### Duplicating a Bot in the box keeps its instructions A coworker that runs on this deployment's own Bot has no endpoint — it has a prompt, which is the diff --git a/server/src/agents/routes.ts b/server/src/agents/routes.ts index 7a2302284..4eb9dbc5e 100644 --- a/server/src/agents/routes.ts +++ b/server/src/agents/routes.ts @@ -358,9 +358,15 @@ export function createAgentRoutes( /* * The endpoint, because that is where conversation content will be sent, and whether a key was * attached, because "this Bot authenticates" is a fact and the key itself never is. + * + * And who may reach it. `visibility` is not a display preference: `accessFilter` admits a + * `public` coworker to every signed-in person, and `canRunAgent` is `canAccessAgent`, so public + * means everybody in the deployment may act as this Bot and spend the grants it holds. A row + * that cannot say which it was cannot reconstruct who could use this coworker at the time. */ await record(context, "bot.created", agent.id, { name: parsed.value.name, + visibility: parsed.value.visibility, ...(parsed.value.endpoint ? { endpoint: parsed.value.endpoint } : {}), hasKey: Boolean(parsed.value.auth), }); @@ -385,10 +391,22 @@ export function createAgentRoutes( context.req.param("agentId"), parsed.value, ); - // What changed, not the new values. Repointing the endpoint is the dangerous edit and is worth - // naming; a replaced key is worth knowing about and is never worth recording. + /* + * What changed, not the new values. Repointing the endpoint is the dangerous edit and is worth + * naming; a replaced key is worth knowing about and is never worth recording. + * + * `visibility` is carried the way `name` is — on every row, whether or not this edit moved it — + * because it is the second dangerous edit and the route has no before to compare against. + * Public admits every signed-in person to this coworker, and `canRunAgent` is `canAccessAgent`, + * so it hands them the right to act as it and spend what it was granted. Without the value on + * each row, an edit that opened a coworker to the whole deployment is byte-identical to one + * that corrected its title, and the trail cannot say when it was opened or by whom. Recorded on + * every row rather than only on the row that changed it, so reading the trail forward tells you + * what was reachable at any point, which is what an incident asks. + */ await record(context, "bot.updated", agent.id, { name: parsed.value.name, + visibility: parsed.value.visibility, ...(parsed.value.endpoint ? { endpoint: parsed.value.endpoint } : {}), ...(parsed.value.auth ? { keyReplaced: true } : {}), }); diff --git a/server/tests/bot-lifecycle-audit.test.ts b/server/tests/bot-lifecycle-audit.test.ts index 934c08f26..00c18128c 100644 --- a/server/tests/bot-lifecycle-audit.test.ts +++ b/server/tests/bot-lifecycle-audit.test.ts @@ -89,6 +89,48 @@ describe("what a Bot is, on the trail", () => { expect(rows[0]?.payload.endpoint).toBe("https://elsewhere.example/ag-ui"); }); + /* + * The other dangerous edit, and the one the trail was silent about. + * + * `visibility` is not a display preference. `accessFilter` admits a public coworker to every + * signed-in person, and `canRunAgent` is `canAccessAgent`, so public hands everybody in the + * deployment the right to act as this Bot and spend the connector grants it holds. It is one click + * in the coworker dialog. + */ + test("creating one records who may reach it", async () => { + const { rows, hono } = app(); + + await hono.request("http://t/api/agents", json({ visibility: "private" })); + + expect(rows[0]?.eventType).toBe("bot.created"); + expect(rows[0]?.payload.visibility).toBe("private"); + }); + + test("opening one to the whole deployment says so", async () => { + const { rows, hono } = app(); + + await hono.request("http://t/api/agents/bot-1", { + ...json({ visibility: "public" }), + method: "PATCH", + }); + + expect(rows[0]?.eventType).toBe("bot.updated"); + expect(rows[0]?.payload.visibility).toBe("public"); + }); + + test("an edit that leaves it private says that too", async () => { + // Carried on every row rather than only on the row that moved it: reading the trail forward has + // to tell you what was reachable at each point, and the route has no before to compare against. + const { rows, hono } = app(); + + await hono.request("http://t/api/agents/bot-1", { + ...json({ visibility: "private", title: "Sales assistant, revised" }), + method: "PATCH", + }); + + expect(rows[0]?.payload.visibility).toBe("private"); + }); + test("a replaced key is noted and never recorded", async () => { const { rows, hono } = app();