Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/typed-answer-caller-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

A turn's principal no longer changes while it runs. Only the turn's own principal can answer a pending `ctx.ask()` or `ask_question` by typing, whether a workflow tool, a subagent or a remote agent asked; another person's text waits and runs as their own turn instead of answering the question under the asker's auth. A typed answer updates the claims on `ctx.session.auth.current`, so its `delivery.consumed` hook and the rest of the turn see the request that sent it. Someone else's button answer still settles the request as its responder.
4 changes: 4 additions & 0 deletions docs/concepts/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ A [channel](../channels/overview) is your agent's front door, so authenticating

A custom channel that accepts dashboard-style webhooks should follow the same shape: authenticate the raw body with an HMAC, compare signatures in constant time, and trust any body-supplied principal only after the signature verifies.

## A turn runs as one principal

A turn's principal (its authenticator, issuer, principal type and id) never changes while the turn runs; only its claims may update. Only that principal's messages steer the turn or answer its pending questions by text. In a shared thread, another person's message waits for the turn to end and then runs as their own turn, so their text never drives the turn's credentials and the turn never continues under their identity. Another person can still settle a pending request with a button or structured answer; they are recorded as its responder, and the turn keeps its caller. See [What reaches `ctx.session.auth`](../guides/auth-and-route-protection#what-reaches-ctxsessionauth).

## Authored markdown is data

[Skill](../skills) and [schedule](../schedules) files are markdown with YAML frontmatter, and eve treats that frontmatter strictly as data. The code-capable engines (`---js` / `---javascript`, which would `eval()` the frontmatter body the moment the file is parsed) are disabled, so such a fence throws rather than running. Frontmatter has to parse to a plain YAML object.
Expand Down
1 change: 1 addition & 0 deletions docs/guides/auth-and-route-protection.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ Inside runtime code, `ctx.session.auth` carries the result of the channel's rout
- `auth.current`: the caller on the active inbound turn.
- `auth.initiator`: the caller that started the durable session.
- A follow-up message updates `auth.current` but leaves `auth.initiator` alone. When a different caller follows up on the same session, `auth.current` tracks the new caller for that turn while `auth.initiator` stays pinned to whoever started it.
- Within a turn the principal is fixed; only its claims may update. A message from the turn's own principal, including a typed answer to a pending `ctx.ask()` or `ask_question`, joins the turn and updates `auth.current` with its claims. A message from anyone else waits for the turn to end and runs as its own turn. Someone else can still answer a pending request with a button or structured response; that settles the request with them as its responder, and the turn keeps its `auth.current`.
- `auth.current` is whatever the channel passed as `auth` when it dispatched the message. Route auth either accepts the request or returns `401`, but a route handler or channel hook can still dispatch with `auth: null`, such as `source.send(message, { auth: null })` or a Slack `onMessage` hook that returns `{ auth: null }`. On a new session, both values are then `null`. On a continuation, `auth.current` is `null` while `auth.initiator` stays pinned to the session's original caller. Internal runtime paths, such as subagents, can also have no caller auth.

Use the principal on `auth.current` (or `auth.initiator`) to scope tools, resolve [dynamic capabilities](./dynamic-capabilities) per principal, or enforce tenant boundaries. There's no second per-session ownership ACL stacked on top of route auth. Access is decided at the HTTP boundary, and the durable session carries the caller snapshot forward into your runtime code.
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ export default defineHook({

The remaining subscribers for the event still run. Then eve cancels the turn the same way [`session.cancel()`](./client/streaming) does: in-flight model and tool work is aborted, delegated child turns are cancelled, and the turn settles with `outcome: "cancelled"` and `cause: { hook }` naming the hook. No failure is recorded. A cancel from `turn.started` or `model.requested` takes effect before that model call. In a conversation, the next message starts a new turn. A delegated task reports the cancellation to its caller.

A plain-text message that answers a pending question, such as one from `ask_question`, is recorded as `delivery.consumed` before eve delivers it. Calling `ctx.cancel()` from that event's hook cancels the turn instead: the question settles as `interrupted`, and the waiting tool call never receives the text.
A plain-text message that answers a pending question, such as one from `ask_question`, is recorded as `delivery.consumed` before eve delivers it. Only the turn's own principal can answer by text; anyone else's text waits and runs as its own turn. Like a follow-up message, the answer updates the claims on `ctx.session.auth.current` for that event's hooks and the rest of the turn. Calling `ctx.cancel()` from that event's hook cancels the turn instead: the question settles as `interrupted`, and the waiting tool call never receives the text.

The same goes for the `call.progress` a workflow tool reports while the turn waits on it: `ctx.cancel()` from its hook cancels the turn, and the run's result never reaches the model.

Expand Down
44 changes: 32 additions & 12 deletions packages/eve/src/execution/proxied-deliver-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { AuthKey, TurnDeliveryIdsKey } from "#context/keys.js";
import { setChannelContext } from "#execution/channel-context.js";
import { coalesceDeliverPayloads } from "#execution/deliver-payloads.js";
import { type DurableSessionState, readDurableSession } from "#execution/durable-session-store.js";
import { isSteeringDelivery } from "#execution/session/input-queue.js";
import { continueTurnAuth, resolveTurnPrincipal } from "#execution/session/principal.js";
import {
type PublishedSessionEvents,
type SessionStepState,
Expand Down Expand Up @@ -82,16 +84,24 @@ async function routeProxiedDeliver(
const parentPayloads = new Map<number, DeliverPayload>();
const children = new Map<string, ChildBucket>();
let parentAction: RoutedDeliverPayload["parentAction"];
// Only a person's own message may answer a pending prompt by text.
// A typed answer joins the open turn, so only the turn's own principal may
// send one, as only they steer it. Anyone else's text waits for the turn to
// end; their button answers still settle a request as its responder.
const resolveMessage =
!hasDelegatedSessionContext(serializedContext) && sourceDelivery.caller === undefined;
!hasDelegatedSessionContext(serializedContext) &&
isSteeringDelivery(
sourceDelivery,
{ callerCallId: undefined, principal: resolveTurnPrincipal(undefined, serializedContext) },
{ heldOnPerson: true },
);
// Every payload routes against the same state, so a request a `ctx.ask()`
// answer or typed reply resolved in an earlier payload is hidden from later
// ones; it takes one answer, and later messages must reach the parent instead.
const resolvedRequests = new Set<string>();
// A message that answers a question is still the person's turn in the
// conversation, so the stream records it with its delivery ids.
const answerDeliveryIds: string[] = [];
let typedAnswer = false;
// Relayed answers belong to the asking turn's resumed work, even without message text.
const tables = storedProjection(durableSession.state).view;
const currentTurn = activeTurn(tables);
Expand Down Expand Up @@ -133,6 +143,7 @@ async function routeProxiedDeliver(
const turnId = askedIn === undefined ? undefined : askedTurn(askedIn);
if (routed.forSelf === undefined && turnId !== undefined) {
answerDeliveryIds.push(...payloadMetadata.map((metadata) => metadata.deliveryId));
if (message !== undefined) typedAnswer = true;
}
for (const metadata of payloadMetadata) {
// Input left for this session goes on to its turn, which admits nothing twice.
Expand Down Expand Up @@ -202,8 +213,12 @@ async function routeProxiedDeliver(
}

// Answers that resume a turn own its later events, whether they came from text or buttons.
// A typed answer also runs as the turn's caller, so it updates the turn's claims.
const published: PublishedSessionEvents = {
serializedContext: joinTurnDeliveryIds(serializedContext, answerDeliveryIds),
serializedContext: joinAnswerToTurn(serializedContext, {
auth: typedAnswer ? sourceDelivery.auth : undefined,
deliveryIds: answerDeliveryIds,
}),
sessionState: input.sessionState,
};
// The typed answer's hooks run before it reaches the waiting tool. A hook cancel stops the
Expand Down Expand Up @@ -348,17 +363,22 @@ export async function answersIdempotencyKey(
return `eve-answers:${hex.join("")}`;
}

function joinTurnDeliveryIds(
function joinAnswerToTurn(
serializedContext: Record<string, unknown>,
deliveryIds: readonly string[],
answer: { readonly auth: DeliverHookPayload["auth"]; readonly deliveryIds: readonly string[] },
): Record<string, unknown> {
if (deliveryIds.length === 0) return serializedContext;
const current =
(serializedContext[TurnDeliveryIdsKey.name] as readonly string[] | undefined) ?? [];
return {
...serializedContext,
[TurnDeliveryIdsKey.name]: [...new Set([...current, ...deliveryIds])],
};
const joined = { ...serializedContext };
if (answer.auth !== undefined) {
joined[AuthKey.name] = continueTurnAuth(
serializedContext[AuthKey.name] as DeliverHookPayload["auth"],
answer.auth,
);
}
if (answer.deliveryIds.length > 0) {
const current = (joined[TurnDeliveryIdsKey.name] as readonly string[] | undefined) ?? [];
joined[TurnDeliveryIdsKey.name] = [...new Set([...current, ...answer.deliveryIds])];
}
return joined;
}

/**
Expand Down
34 changes: 23 additions & 11 deletions packages/eve/src/execution/session/answer-caller.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import type { SessionAuthContext } from "#channel/types.js";
import { principalOf } from "#execution/session/principal.js";
import { openRequests } from "#protocol/session-projection/reader.js";
import { storedProjection } from "#harness/session-machine/view.js";
import { interactionOwner } from "#protocol/session-projection/selectors.js";
import type { SessionStateMap, HarnessStepInput } from "#harness/types.js";

/**
* Answering a request does not make the responder the turn's caller. When
* `stepInput` only answers pending requests, returns it with each approval
* answer naming its responder, so approval policies and the approved call
* see the responder while the turn keeps its caller. Returns `undefined` when
* the input carries anything else, such as a message or a stale answer; its
* sender becomes the caller.
* `stepInput` only answers requests, returns it with each answer to an open
* approval naming its responder, so approval policies and the approved call
* see the responder while the turn keeps its caller. Another principal's
* answer to a request that is no longer open settles nothing, and as text it
* would steer the open turn, so it is dropped. Returns `undefined` when the
* input carries a message, or only stale answers the sender may turn into
* text; its sender becomes the caller.
*/
export function attributeAnswers(input: {
/** The open turn's caller; `undefined` when the delivery starts a turn. */
readonly owner?: SessionAuthContext | null;
readonly responder: SessionAuthContext | null;
readonly state: SessionStateMap | undefined;
readonly stepInput: HarnessStepInput | undefined;
Expand All @@ -22,22 +27,29 @@ export function attributeAnswers(input: {
if (stepInput === undefined || stepInput.message !== undefined || responses.length === 0) {
return undefined;
}
// The session's own requests; a relayed one belongs to the run that asked.
const view = storedProjection(input.state).view;
const requests = new Map(
openRequests(view).flatMap((row) =>
const open = openRequests(view);
// The session's own requests; a relayed one belongs to the run that asked.
const own = new Map(
open.flatMap((row) =>
row.origin === undefined && interactionOwner(view, row).taskId === undefined
? [[row.interactionId, row.request.kind] as const]
: [],
),
);
if (!responses.every((answer) => requests.has(answer.interactionId))) return undefined;
const openIds = new Set(open.map((row) => row.interactionId));
const foreign =
input.owner !== undefined && principalOf(input.owner) !== principalOf(input.responder);
const kept = responses.filter((answer) => !foreign || openIds.has(answer.interactionId));
if (kept.length === responses.length && !kept.some((answer) => own.has(answer.interactionId))) {
return undefined;
}
// An unauthenticated approval stays attributed to no one; it must never pass
// as the turn's caller.
return {
...stepInput,
answers: responses.map((answer) =>
requests.get(answer.interactionId) === "approval"
answers: kept.map((answer) =>
own.get(answer.interactionId) === "approval"
? { ...answer, responder: input.responder }
: answer,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
toolCallsStreamResult,
} from "#internal/testing/approval-resume.js";
import { defineAgent } from "#public/definitions/agent.js";
import { defineHook } from "#public/definitions/hook.js";
import { defineDynamic } from "#public/definitions/model.js";
import type { Approval, ApprovalResponsePolicy } from "#approval/definition.js";
import { workflowEntry } from "#execution/session/entry.js";
Expand Down Expand Up @@ -130,8 +131,15 @@ interface ChainRun {
) => Promise<Stage>;
/** Reads the session's next turn segment. */
readonly next: (label: string) => Promise<Stage>;
/** Sends `payload` as one delivery on the session's command inbox. */
readonly deliver: (auth: User, payload: Record<string, unknown>) => Promise<Stage>;
/**
* Sends `payload` as one delivery on the session's command inbox, and reads to the turn's
* next boundary, or to the `response.settled` of an answer the session refuses.
*/
readonly deliver: (
auth: User,
payload: Record<string, unknown>,
until?: "response.settled",
) => Promise<Stage>;
readonly send: (auth: User, message: string) => Promise<Stage>;
readonly stages: Stage[];
}
Expand Down Expand Up @@ -241,13 +249,21 @@ async function withChainRun(
}
return await next("approved");
},
async deliver(auth, payload) {
async deliver(auth, payload, until) {
await resumeHook(commandInbox, {
auth,
kind: "send",
delivery: createChannelDeliveryMetadata({ channelKind: "http", channelName: "test" }),
payload,
});
if (until === "response.settled") {
const stage = await withTimeout(
stream.nextUntil((event) => event.type === "response.settled"),
"refused",
);
stages.push(stage);
return stage;
}
return await next("delivered");
},
async send(auth, message) {
Expand Down Expand Up @@ -478,6 +494,67 @@ describe("approval caller", () => {
]);
}, 60_000);

it("keeps another person's approval theirs when their delivery also carries a stale answer", async () => {
const turnAuth: Array<string | null> = [];
const { seen, stages } = await withChainRun(
"approval-caller-stale-mixed",
async (run) => {
const request = approvalsIn(run.stages)[0]!;
// Requester-only: Bob's answer can't settle Alice's call, even beside a stale one.
const refused = await run.deliver(
BOB,
{
answers: [
{
interactionId: "answered-earlier",
optionId: "approve",
text: "Also drop the staging db.",
},
{ interactionId: request.interactionId, optionId: "approve" },
],
},
"response.settled",
);
expect(filterEventsByType(refused, "interaction.settled")).toEqual([]);
await run.approve(ALICE, "deploy_change");
await run.approve(ALICE, "publish_change");
},
{
firstApproval: always(),
modules: [
{
logicalPath: "hooks/turn-auth.ts",
loadNamespace: async () => ({
default: defineHook({
events: {
"response.settled"(_event, ctx) {
turnAuth.push(ctx.session.auth.current?.principalId ?? null);
},
"turn.paused"(_event, ctx) {
turnAuth.push(ctx.session.auth.current?.principalId ?? null);
},
"turn.settled"(_event, ctx) {
turnAuth.push(ctx.session.auth.current?.principalId ?? null);
},
},
}),
}),
},
],
},
);

expect(stages.map(turnBoundaries).flat()).toEqual([
"turn.started turn_0",
"turn.settled turn_0",
]);
expect(seen.map((entry) => entry.caller)).toEqual(["alice", "alice", "alice"]);
// Bob's delivery never made him the turn's caller, not even while it was refused.
expect(new Set(turnAuth)).toEqual(new Set(["alice"]));
// Bob's stale answer never reached Alice's turn as text.
expect(JSON.stringify(stages)).not.toContain("staging db");
}, 60_000);

it("never credits an approval sent with null auth to the turn's caller", async () => {
const { secondRequesters, seen, stages } = await withChainRun(
"approval-caller-null-auth",
Expand Down
Loading
Loading