Skip to content

Commit 71fe667

Browse files
dcramercodex
andcommitted
fix(credentials): Bind delegated credential subjects
Carry a core-signed Slack DM binding with delegated credential subjects so trusted dispatch can verify the subject locally before storing it. This preserves the scheduler's previously verified Slack requester context without adding dispatch-time Slack API lookups. Refs #449 Co-Authored-By: GPT-5 Codex <codex@openai.com>
1 parent 3e8bbdd commit 71fe667

21 files changed

Lines changed: 504 additions & 129 deletions

File tree

packages/junior-plugin-api/src/index.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export interface ToolRegistrationHookContext extends AgentPluginContext {
110110
canPostToChannel: boolean;
111111
};
112112
channelId?: string;
113+
credentialSubject?: AgentPluginCredentialSubject;
113114
messageTs?: string;
114115
requester?: AgentPluginRequester;
115116
state: AgentPluginState;
@@ -118,12 +119,20 @@ export interface ToolRegistrationHookContext extends AgentPluginContext {
118119
userText?: string;
119120
}
120121

121-
export interface DispatchOptions {
122-
credentialSubject?: {
123-
type: "user";
124-
userId: string;
125-
allowedWhen: "private-direct-conversation";
122+
export interface AgentPluginCredentialSubject {
123+
type: "user";
124+
userId: string;
125+
allowedWhen: "private-direct-conversation";
126+
binding: {
127+
type: "slack-direct-conversation";
128+
teamId: string;
129+
channelId: string;
130+
signature: string;
126131
};
132+
}
133+
134+
export interface DispatchOptions {
135+
credentialSubject?: AgentPluginCredentialSubject;
127136
destination: {
128137
platform: "slack";
129138
teamId: string;

packages/junior-scheduler/src/plugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ function createSchedulerToolContext(
4747
canPostToChannel: false,
4848
},
4949
channelId: ctx.channelId,
50+
credentialSubject: ctx.credentialSubject,
5051
messageTs: ctx.messageTs,
5152
requester: ctx.requester,
5253
state: ctx.state,

packages/junior-scheduler/src/schedule-tools.ts

Lines changed: 71 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface SchedulerToolContext {
2727
canPostToChannel: boolean;
2828
};
2929
channelId?: string;
30+
credentialSubject?: ScheduledTaskCredentialSubject;
3031
messageTs?: string;
3132
requester?: AgentPluginRequester;
3233
state: AgentPluginState;
@@ -126,19 +127,15 @@ function getConversationAccess(
126127

127128
function getCredentialSubject(args: {
128129
access: ScheduledTaskConversationAccess;
129-
requester: ScheduledTaskPrincipal;
130+
subject: ScheduledTaskCredentialSubject | undefined;
130131
}): ScheduledTaskCredentialSubject | undefined {
131132
if (
132133
args.access.audience !== "direct" ||
133134
args.access.visibility !== "private"
134135
) {
135136
return undefined;
136137
}
137-
return {
138-
type: "user",
139-
userId: args.requester.slackUserId,
140-
allowedWhen: "private-direct-conversation",
141-
};
138+
return args.subject;
142139
}
143140

144141
function sameDestination(
@@ -340,20 +337,35 @@ export function createSlackScheduleCreateTaskTool(
340337
inputSchema: Type.Object({
341338
task: Type.String({ minLength: 1, maxLength: 4000 }),
342339
schedule: Type.String({ minLength: 1, maxLength: 300 }),
343-
timezone: Type.Optional(Type.String({ minLength: 1, maxLength: 80, description: "IANA timezone, e.g. 'America/Los_Angeles'. Defaults to the channel's configured timezone." })),
340+
timezone: Type.Optional(
341+
Type.String({
342+
minLength: 1,
343+
maxLength: 80,
344+
description:
345+
"IANA timezone, e.g. 'America/Los_Angeles'. Defaults to the channel's configured timezone.",
346+
}),
347+
),
344348
next_run_at: Type.Optional(
345349
Type.String({
346350
minLength: 1,
347351
description:
348352
"Exact next run time as an ISO timestamp, computed from the user's requested schedule.",
349353
}),
350354
),
351-
recurrence: Type.Optional(Type.Union([
352-
Type.Literal("daily"),
353-
Type.Literal("weekly"),
354-
Type.Literal("monthly"),
355-
Type.Literal("yearly"),
356-
], { description: "Provide only for explicitly repeating schedules; omit for one-time requests like 'in 1 minute', 'tomorrow', or a specific date. Recurring tasks run at most once per day: use daily, weekly, monthly, or yearly only." })),
355+
recurrence: Type.Optional(
356+
Type.Union(
357+
[
358+
Type.Literal("daily"),
359+
Type.Literal("weekly"),
360+
Type.Literal("monthly"),
361+
Type.Literal("yearly"),
362+
],
363+
{
364+
description:
365+
"Provide only for explicitly repeating schedules; omit for one-time requests like 'in 1 minute', 'tomorrow', or a specific date. Recurring tasks run at most once per day: use daily, weekly, monthly, or yearly only.",
366+
},
367+
),
368+
),
357369
}),
358370
execute: async (input) => {
359371
const destination = requireActiveDestination(context);
@@ -377,7 +389,7 @@ export function createSlackScheduleCreateTaskTool(
377389
const conversationAccess = getConversationAccess(destination);
378390
const credentialSubject = getCredentialSubject({
379391
access: conversationAccess,
380-
requester,
392+
subject: context.credentialSubject,
381393
});
382394

383395
const task: ScheduledTask = {
@@ -450,20 +462,47 @@ export function createSlackScheduleUpdateTaskTool(
450462
description:
451463
"Edit, pause, resume, or reschedule an existing Junior scheduled task in the active Slack conversation. Use only task IDs returned for this destination. Do not move scheduled tasks across conversations.",
452464
inputSchema: Type.Object({
453-
task_id: Type.String({ minLength: 1, description: "ID of the task to update. Must be from this active Slack destination." }),
465+
task_id: Type.String({
466+
minLength: 1,
467+
description:
468+
"ID of the task to update. Must be from this active Slack destination.",
469+
}),
454470
task: Type.Optional(Type.String({ minLength: 1, maxLength: 4000 })),
455471
schedule: Type.Optional(Type.String({ minLength: 1, maxLength: 300 })),
456472
timezone: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })),
457-
next_run_at: Type.Optional(Type.String({ minLength: 1, description: "Exact ISO timestamp when changing the next run time." })),
473+
next_run_at: Type.Optional(
474+
Type.String({
475+
minLength: 1,
476+
description: "Exact ISO timestamp when changing the next run time.",
477+
}),
478+
),
458479
recurrence: Type.Optional(
459-
Type.Union([Type.Literal("daily"), Type.Literal("weekly"), Type.Literal("monthly"), Type.Literal("yearly"), Type.Null()], { description: "Provide only for repeating schedules. Omit for one-time requests. Set to null to convert a recurring task to one-time." }),
480+
Type.Union(
481+
[
482+
Type.Literal("daily"),
483+
Type.Literal("weekly"),
484+
Type.Literal("monthly"),
485+
Type.Literal("yearly"),
486+
Type.Null(),
487+
],
488+
{
489+
description:
490+
"Provide only for repeating schedules. Omit for one-time requests. Set to null to convert a recurring task to one-time.",
491+
},
492+
),
460493
),
461494
status: Type.Optional(
462-
Type.Union([
463-
Type.Literal("active"),
464-
Type.Literal("paused"),
465-
Type.Literal("blocked"),
466-
], { description: "Set to active, paused, or blocked to resume, pause, or block the task." }),
495+
Type.Union(
496+
[
497+
Type.Literal("active"),
498+
Type.Literal("paused"),
499+
Type.Literal("blocked"),
500+
],
501+
{
502+
description:
503+
"Set to active, paused, or blocked to resume, pause, or block the task.",
504+
},
505+
),
467506
),
468507
}),
469508
execute: async (input) => {
@@ -540,7 +579,11 @@ export function createSlackScheduleDeleteTaskTool(
540579
description:
541580
"Delete one scheduled Junior task from the active Slack conversation. Use only task IDs returned for this destination. Do not delete schedules from threads, other channels, or another user's DM.",
542581
inputSchema: Type.Object({
543-
task_id: Type.String({ minLength: 1, description: "ID of the task to delete. Must be from this active Slack destination." }),
582+
task_id: Type.String({
583+
minLength: 1,
584+
description:
585+
"ID of the task to delete. Must be from this active Slack destination.",
586+
}),
544587
}),
545588
execute: async ({ task_id }) => {
546589
const lookup = await getWritableTask({ context, taskId: task_id });
@@ -571,7 +614,11 @@ export function createSlackScheduleRunTaskNowTool(
571614
description:
572615
"Queue an existing active scheduled Junior task to run as soon as possible, without changing its cadence. Use when the user asks to run an existing scheduled task now. Use only task IDs returned for this destination.",
573616
inputSchema: Type.Object({
574-
task_id: Type.String({ minLength: 1, description: "ID of the active task to run now. Must be from this active Slack destination." }),
617+
task_id: Type.String({
618+
minLength: 1,
619+
description:
620+
"ID of the active task to run now. Must be from this active Slack destination.",
621+
}),
575622
}),
576623
execute: async ({ task_id }) => {
577624
const lookup = await getWritableTask({ context, taskId: task_id });

packages/junior-scheduler/src/types.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { AgentPluginCredentialSubject } from "@sentry/junior-plugin-api";
2+
13
export type ScheduledTaskStatus = "active" | "paused" | "blocked" | "deleted";
24

35
export type ScheduledRunStatus =
@@ -35,11 +37,7 @@ export interface ScheduledTaskConversationAccess {
3537
visibility: "private" | "public" | "unknown";
3638
}
3739

38-
export interface ScheduledTaskCredentialSubject {
39-
type: "user";
40-
userId: string;
41-
allowedWhen: "private-direct-conversation";
42-
}
40+
export type ScheduledTaskCredentialSubject = AgentPluginCredentialSubject;
4341

4442
export type ScheduledCalendarFrequency =
4543
| "daily"

packages/junior/src/chat/agent-dispatch/context.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import type { HeartbeatHookContext } from "@sentry/junior-plugin-api";
22
import { createAgentPluginLogger } from "@/chat/plugins/logging";
33
import { createPluginState } from "@/chat/plugins/state";
4+
import { createSlackDirectCredentialSubject } from "@/chat/credentials/subject";
45
import {
56
createOrGetDispatch,
67
getPluginDispatchProjection,
78
isTerminalDispatchStatus,
89
} from "./store";
910
import { scheduleDispatchCallback } from "./signing";
10-
import type { DispatchRecord } from "./types";
11+
import type { DispatchOptions, DispatchRecord } from "./types";
1112
import {
1213
validateDispatchOptions,
1314
verifyDispatchCredentialSubjectAccess,
1415
} from "./validation";
1516

1617
const MAX_DISPATCHES_PER_HEARTBEAT = 25;
18+
const SCHEDULER_PLUGIN_NAME = "scheduler";
1719

1820
function shouldScheduleDispatch(
1921
record: DispatchRecord,
@@ -29,6 +31,34 @@ function shouldScheduleDispatch(
2931
);
3032
}
3133

34+
function bindLegacySchedulerCredentialSubject(args: {
35+
options: DispatchOptions;
36+
plugin: string;
37+
}): DispatchOptions {
38+
const { credentialSubject } = args.options;
39+
if (
40+
args.plugin !== SCHEDULER_PLUGIN_NAME ||
41+
!credentialSubject ||
42+
credentialSubject.binding
43+
) {
44+
return args.options;
45+
}
46+
47+
const boundSubject = createSlackDirectCredentialSubject({
48+
channelId: args.options.destination.channelId,
49+
teamId: args.options.destination.teamId,
50+
userId: credentialSubject.userId,
51+
});
52+
if (!boundSubject) {
53+
return args.options;
54+
}
55+
56+
return {
57+
...args.options,
58+
credentialSubject: boundSubject,
59+
};
60+
}
61+
3262
/** Build the plugin-scoped heartbeat context that gates durable dispatch access. */
3363
export function createHeartbeatContext(args: {
3464
legacyStatePrefixes?: string[];
@@ -45,14 +75,18 @@ export function createHeartbeatContext(args: {
4575
log: createAgentPluginLogger(args.plugin),
4676
agent: {
4777
async dispatch(options) {
48-
validateDispatchOptions(options);
78+
const dispatchOptions = bindLegacySchedulerCredentialSubject({
79+
plugin: args.plugin,
80+
options,
81+
});
82+
validateDispatchOptions(dispatchOptions);
4983
if (dispatchCount >= MAX_DISPATCHES_PER_HEARTBEAT) {
5084
throw new Error("Plugin heartbeat exceeded the dispatch limit");
5185
}
52-
await verifyDispatchCredentialSubjectAccess(options);
86+
await verifyDispatchCredentialSubjectAccess(dispatchOptions);
5387
const result = await createOrGetDispatch({
5488
plugin: args.plugin,
55-
options,
89+
options: dispatchOptions,
5690
nowMs: args.nowMs,
5791
});
5892
dispatchCount += 1;

packages/junior/src/chat/agent-dispatch/validation.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { DispatchOptions } from "./types";
2-
import { isSlackDirectConversationForUser } from "@/chat/slack/channel";
2+
import { verifySlackDirectCredentialSubject } from "@/chat/credentials/subject";
33
import { isDmChannel } from "@/chat/slack/client";
44
import { isSlackConversationId, isSlackTeamId } from "@/chat/slack/ids";
55

@@ -80,9 +80,10 @@ export async function verifyDispatchCredentialSubjectAccess(
8080
return;
8181
}
8282

83-
const verified = await isSlackDirectConversationForUser({
83+
const verified = verifySlackDirectCredentialSubject({
8484
channelId: options.destination.channelId,
85-
userId: options.credentialSubject.userId,
85+
teamId: options.destination.teamId,
86+
subject: options.credentialSubject,
8687
});
8788
if (!verified) {
8889
throw new Error(

packages/junior/src/chat/credentials/context.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
1+
import type { AgentPluginCredentialSubject } from "@sentry/junior-plugin-api";
2+
13
export type CredentialSystemActor = {
24
type: "system";
35
id: string;
46
};
57

6-
export type CredentialSubject = {
7-
type: "user";
8-
userId: string;
9-
allowedWhen: "private-direct-conversation";
10-
};
8+
export type CredentialSubject = AgentPluginCredentialSubject;
119

1210
export type CredentialContext =
1311
| {
@@ -93,12 +91,32 @@ function parseSubject(
9391
record.type === "user" &&
9492
typeof record.userId === "string" &&
9593
record.userId &&
96-
record.allowedWhen === "private-direct-conversation"
94+
record.allowedWhen === "private-direct-conversation" &&
95+
record.binding &&
96+
typeof record.binding === "object"
9797
) {
98+
const binding = record.binding as Partial<CredentialSubject["binding"]>;
99+
if (
100+
binding.type !== "slack-direct-conversation" ||
101+
typeof binding.teamId !== "string" ||
102+
!binding.teamId ||
103+
typeof binding.channelId !== "string" ||
104+
!binding.channelId ||
105+
typeof binding.signature !== "string" ||
106+
!binding.signature
107+
) {
108+
return undefined;
109+
}
98110
return {
99111
type: "user",
100112
userId: record.userId,
101113
allowedWhen: "private-direct-conversation",
114+
binding: {
115+
type: "slack-direct-conversation",
116+
teamId: binding.teamId,
117+
channelId: binding.channelId,
118+
signature: binding.signature,
119+
},
102120
};
103121
}
104122
}

0 commit comments

Comments
 (0)