Skip to content

Commit 3eaca67

Browse files
committed
fix(eve): keep a turn's principal fixed while it runs
Another principal's typed text could answer a running turn's ctx.ask, ask_question or relayed child prompt and drive the turn under its auth, and a mixed delivery's stale answer let that sender stand in as the turn's caller. Only the turn's own principal now joins its turn by text, with refreshed claims; another principal's text waits for its own turn, each answer to an open approval carries its responder, and another principal's answer to a request that is no longer open is dropped. Signed-off-by: ruiconti <1834568+ruiconti@users.noreply.github.com>
1 parent 7deee87 commit 3eaca67

14 files changed

Lines changed: 371 additions & 50 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"eve": patch
3+
---
4+
5+
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.

‎docs/concepts/security-model.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ A [channel](../channels/overview) is your agent's front door, so authenticating
7575

7676
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.
7777

78+
## A turn runs as one principal
79+
80+
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).
81+
7882
## Authored markdown is data
7983

8084
[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.

‎docs/guides/auth-and-route-protection.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ Inside runtime code, `ctx.session.auth` carries the result of the channel's rout
298298
- `auth.current`: the caller on the active inbound turn.
299299
- `auth.initiator`: the caller that started the durable session.
300300
- 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.
301+
- 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`.
301302
- `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.
302303

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

‎docs/guides/hooks.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ export default defineHook({
251251

252252
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.
253253

254-
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.
254+
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.
255255

256256
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.
257257

‎packages/eve/src/execution/proxied-deliver-step.ts‎

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { AuthKey, TurnDeliveryIdsKey } from "#context/keys.js";
1313
import { setChannelContext } from "#execution/channel-context.js";
1414
import { coalesceDeliverPayloads } from "#execution/deliver-payloads.js";
1515
import { type DurableSessionState, readDurableSession } from "#execution/durable-session-store.js";
16+
import { isSteeringDelivery } from "#execution/session/input-queue.js";
17+
import { continueTurnAuth, resolveTurnPrincipal } from "#execution/session/principal.js";
1618
import {
1719
type PublishedSessionEvents,
1820
type SessionStepState,
@@ -82,16 +84,24 @@ async function routeProxiedDeliver(
8284
const parentPayloads = new Map<number, DeliverPayload>();
8385
const children = new Map<string, ChildBucket>();
8486
let parentAction: RoutedDeliverPayload["parentAction"];
85-
// Only a person's own message may answer a pending prompt by text.
87+
// A typed answer joins the open turn, so only the turn's own principal may
88+
// send one, as only they steer it. Anyone else's text waits for the turn to
89+
// end; their button answers still settle a request as its responder.
8690
const resolveMessage =
87-
!hasDelegatedSessionContext(serializedContext) && sourceDelivery.caller === undefined;
91+
!hasDelegatedSessionContext(serializedContext) &&
92+
isSteeringDelivery(
93+
sourceDelivery,
94+
{ callerCallId: undefined, principal: resolveTurnPrincipal(undefined, serializedContext) },
95+
{ heldOnPerson: true },
96+
);
8897
// Every payload routes against the same state, so a request a `ctx.ask()`
8998
// answer or typed reply resolved in an earlier payload is hidden from later
9099
// ones; it takes one answer, and later messages must reach the parent instead.
91100
const resolvedRequests = new Set<string>();
92101
// A message that answers a question is still the person's turn in the
93102
// conversation, so the stream records it with its delivery ids.
94103
const answerDeliveryIds: string[] = [];
104+
let typedAnswer = false;
95105
// Relayed answers belong to the asking turn's resumed work, even without message text.
96106
const tables = storedProjection(durableSession.state).view;
97107
const currentTurn = activeTurn(tables);
@@ -133,6 +143,7 @@ async function routeProxiedDeliver(
133143
const turnId = askedIn === undefined ? undefined : askedTurn(askedIn);
134144
if (routed.forSelf === undefined && turnId !== undefined) {
135145
answerDeliveryIds.push(...payloadMetadata.map((metadata) => metadata.deliveryId));
146+
if (message !== undefined) typedAnswer = true;
136147
}
137148
for (const metadata of payloadMetadata) {
138149
// Input left for this session goes on to its turn, which admits nothing twice.
@@ -202,8 +213,12 @@ async function routeProxiedDeliver(
202213
}
203214

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

351-
function joinTurnDeliveryIds(
366+
function joinAnswerToTurn(
352367
serializedContext: Record<string, unknown>,
353-
deliveryIds: readonly string[],
368+
answer: { readonly auth: DeliverHookPayload["auth"]; readonly deliveryIds: readonly string[] },
354369
): Record<string, unknown> {
355-
if (deliveryIds.length === 0) return serializedContext;
356-
const current =
357-
(serializedContext[TurnDeliveryIdsKey.name] as readonly string[] | undefined) ?? [];
358-
return {
359-
...serializedContext,
360-
[TurnDeliveryIdsKey.name]: [...new Set([...current, ...deliveryIds])],
361-
};
370+
const joined = { ...serializedContext };
371+
if (answer.auth !== undefined) {
372+
joined[AuthKey.name] = continueTurnAuth(
373+
serializedContext[AuthKey.name] as DeliverHookPayload["auth"],
374+
answer.auth,
375+
);
376+
}
377+
if (answer.deliveryIds.length > 0) {
378+
const current = (joined[TurnDeliveryIdsKey.name] as readonly string[] | undefined) ?? [];
379+
joined[TurnDeliveryIdsKey.name] = [...new Set([...current, ...answer.deliveryIds])];
380+
}
381+
return joined;
362382
}
363383

364384
/**

‎packages/eve/src/execution/session/answer-caller.ts‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
import type { SessionAuthContext } from "#channel/types.js";
2+
import { principalOf } from "#execution/session/principal.js";
23
import { openRequests } from "#protocol/session-projection/reader.js";
34
import { storedProjection } from "#harness/session-machine/view.js";
45
import { interactionOwner } from "#protocol/session-projection/selectors.js";
56
import type { SessionStateMap, HarnessStepInput } from "#harness/types.js";
67

78
/**
89
* Answering a request does not make the responder the turn's caller. When
9-
* `stepInput` only answers pending requests, returns it with each approval
10-
* answer naming its responder, so approval policies and the approved call
11-
* see the responder while the turn keeps its caller. Returns `undefined` when
12-
* the input carries anything else, such as a message or a stale answer; its
13-
* sender becomes the caller.
10+
* `stepInput` only answers requests, returns it with each answer to an open
11+
* approval naming its responder, so approval policies and the approved call
12+
* see the responder while the turn keeps its caller. Another principal's
13+
* answer to a request that is no longer open settles nothing, and as text it
14+
* would steer the open turn, so it is dropped. Returns `undefined` when the
15+
* input carries a message, or only stale answers the sender may turn into
16+
* text; its sender becomes the caller.
1417
*/
1518
export function attributeAnswers(input: {
19+
/** The open turn's caller; `undefined` when the delivery starts a turn. */
20+
readonly owner?: SessionAuthContext | null;
1621
readonly responder: SessionAuthContext | null;
1722
readonly state: SessionStateMap | undefined;
1823
readonly stepInput: HarnessStepInput | undefined;
@@ -22,22 +27,29 @@ export function attributeAnswers(input: {
2227
if (stepInput === undefined || stepInput.message !== undefined || responses.length === 0) {
2328
return undefined;
2429
}
25-
// The session's own requests; a relayed one belongs to the run that asked.
2630
const view = storedProjection(input.state).view;
27-
const requests = new Map(
28-
openRequests(view).flatMap((row) =>
31+
const open = openRequests(view);
32+
// The session's own requests; a relayed one belongs to the run that asked.
33+
const own = new Map(
34+
open.flatMap((row) =>
2935
row.origin === undefined && interactionOwner(view, row).taskId === undefined
3036
? [[row.interactionId, row.request.kind] as const]
3137
: [],
3238
),
3339
);
34-
if (!responses.every((answer) => requests.has(answer.interactionId))) return undefined;
40+
const openIds = new Set(open.map((row) => row.interactionId));
41+
const foreign =
42+
input.owner !== undefined && principalOf(input.owner) !== principalOf(input.responder);
43+
const kept = responses.filter((answer) => !foreign || openIds.has(answer.interactionId));
44+
if (kept.length === responses.length && !kept.some((answer) => own.has(answer.interactionId))) {
45+
return undefined;
46+
}
3547
// An unauthenticated approval stays attributed to no one; it must never pass
3648
// as the turn's caller.
3749
return {
3850
...stepInput,
39-
answers: responses.map((answer) =>
40-
requests.get(answer.interactionId) === "approval"
51+
answers: kept.map((answer) =>
52+
own.get(answer.interactionId) === "approval"
4153
? { ...answer, responder: input.responder }
4254
: answer,
4355
),

‎packages/eve/src/execution/session/approval-caller.integration.test.ts‎

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
toolCallsStreamResult,
1010
} from "#internal/testing/approval-resume.js";
1111
import { defineAgent } from "#public/definitions/agent.js";
12+
import { defineHook } from "#public/definitions/hook.js";
1213
import { defineDynamic } from "#public/definitions/model.js";
1314
import type { Approval, ApprovalResponsePolicy } from "#approval/definition.js";
1415
import { workflowEntry } from "#execution/session/entry.js";
@@ -130,8 +131,15 @@ interface ChainRun {
130131
) => Promise<Stage>;
131132
/** Reads the session's next turn segment. */
132133
readonly next: (label: string) => Promise<Stage>;
133-
/** Sends `payload` as one delivery on the session's command inbox. */
134-
readonly deliver: (auth: User, payload: Record<string, unknown>) => Promise<Stage>;
134+
/**
135+
* Sends `payload` as one delivery on the session's command inbox, and reads to the turn's
136+
* next boundary, or to the `response.settled` of an answer the session refuses.
137+
*/
138+
readonly deliver: (
139+
auth: User,
140+
payload: Record<string, unknown>,
141+
until?: "response.settled",
142+
) => Promise<Stage>;
135143
readonly send: (auth: User, message: string) => Promise<Stage>;
136144
readonly stages: Stage[];
137145
}
@@ -241,13 +249,21 @@ async function withChainRun(
241249
}
242250
return await next("approved");
243251
},
244-
async deliver(auth, payload) {
252+
async deliver(auth, payload, until) {
245253
await resumeHook(commandInbox, {
246254
auth,
247255
kind: "send",
248256
delivery: createChannelDeliveryMetadata({ channelKind: "http", channelName: "test" }),
249257
payload,
250258
});
259+
if (until === "response.settled") {
260+
const stage = await withTimeout(
261+
stream.nextUntil((event) => event.type === "response.settled"),
262+
"refused",
263+
);
264+
stages.push(stage);
265+
return stage;
266+
}
251267
return await next("delivered");
252268
},
253269
async send(auth, message) {
@@ -478,6 +494,67 @@ describe("approval caller", () => {
478494
]);
479495
}, 60_000);
480496

497+
it("keeps another person's approval theirs when their delivery also carries a stale answer", async () => {
498+
const turnAuth: Array<string | null> = [];
499+
const { seen, stages } = await withChainRun(
500+
"approval-caller-stale-mixed",
501+
async (run) => {
502+
const request = approvalsIn(run.stages)[0]!;
503+
// Requester-only: Bob's answer can't settle Alice's call, even beside a stale one.
504+
const refused = await run.deliver(
505+
BOB,
506+
{
507+
answers: [
508+
{
509+
interactionId: "answered-earlier",
510+
optionId: "approve",
511+
text: "Also drop the staging db.",
512+
},
513+
{ interactionId: request.interactionId, optionId: "approve" },
514+
],
515+
},
516+
"response.settled",
517+
);
518+
expect(filterEventsByType(refused, "interaction.settled")).toEqual([]);
519+
await run.approve(ALICE, "deploy_change");
520+
await run.approve(ALICE, "publish_change");
521+
},
522+
{
523+
firstApproval: always(),
524+
modules: [
525+
{
526+
logicalPath: "hooks/turn-auth.ts",
527+
loadNamespace: async () => ({
528+
default: defineHook({
529+
events: {
530+
"response.settled"(_event, ctx) {
531+
turnAuth.push(ctx.session.auth.current?.principalId ?? null);
532+
},
533+
"turn.paused"(_event, ctx) {
534+
turnAuth.push(ctx.session.auth.current?.principalId ?? null);
535+
},
536+
"turn.settled"(_event, ctx) {
537+
turnAuth.push(ctx.session.auth.current?.principalId ?? null);
538+
},
539+
},
540+
}),
541+
}),
542+
},
543+
],
544+
},
545+
);
546+
547+
expect(stages.map(turnBoundaries).flat()).toEqual([
548+
"turn.started turn_0",
549+
"turn.settled turn_0",
550+
]);
551+
expect(seen.map((entry) => entry.caller)).toEqual(["alice", "alice", "alice"]);
552+
// Bob's delivery never made him the turn's caller, not even while it was refused.
553+
expect(new Set(turnAuth)).toEqual(new Set(["alice"]));
554+
// Bob's stale answer never reached Alice's turn as text.
555+
expect(JSON.stringify(stages)).not.toContain("staging db");
556+
}, 60_000);
557+
481558
it("never credits an approval sent with null auth to the turn's caller", async () => {
482559
const { secondRequesters, seen, stages } = await withChainRun(
483560
"approval-caller-null-auth",

0 commit comments

Comments
 (0)