import type { ActionEntry } from "../agent/production-agent.js"; import { getDbExec } from "../db/client.js"; import { resourcePut, resourceGetByPath, resourceList, resourceDelete, organizationIdFromResourceOwner, sharedResourceOwner, SHARED_OWNER, } from "../resources/store.js"; import { getRequestUserEmail, getRequestOrgId, getIntegrationRequestContext, } from "../server/request-context.js"; import { isValidCron, nextOccurrence, describeCron } from "./cron.js"; import { parseJobFrontmatter, buildJobContent, normalizeJobMcpTools, type JobFrontmatter, } from "./scheduler.js"; function getOwner(): string { const email = getRequestUserEmail(); if (!email) throw new Error("no authenticated user"); return email; } function getSharedOwner(): string { return sharedResourceOwner(getRequestOrgId()); } /** * Determine if the current request's user is an org owner/admin in the * given org. Used to allow privileged users to update or delete shared * jobs created by other org members. Returns false when there is no org, * no user, no membership, or any error querying — fail closed. */ async function isCurrentUserOrgAdmin( orgId: string | undefined, ): Promise { if (!orgId) return false; const email = getRequestUserEmail(); if (!email) return false; try { const client = getDbExec(); const { rows } = await client.execute({ sql: `SELECT role FROM org_members WHERE org_id = ? AND LOWER(email) = ? LIMIT 1`, args: [orgId, email.toLowerCase()], }); if (rows.length === 0) return false; const role = String((rows[0] as any).role ?? "").toLowerCase(); return role === "owner" || role === "admin"; } catch { return false; } } /** * Authorise a mutation (update / delete) against a job resource. When the * job is in the SHARED scope the caller must either be the original * `createdBy` user or an org owner/admin — otherwise any user could rewrite * another user's shared job and have it run as that user on the next cron * tick (the privilege-escalation chain documented in audit * `/tmp/security-audit/12-mcp-a2a-agent.md`, finding #3). * * Returns null when the mutation is allowed, or an error string suitable * for returning to the caller when not. */ export async function authorizeJobMutation( resourceOwner: string, meta: JobFrontmatter, ): Promise { const resourceOrgId = organizationIdFromResourceOwner(resourceOwner); if (resourceOwner !== SHARED_OWNER && !resourceOrgId) { // Personal-scope job — owner is the request's user. resourceGetByPath is // already scoped to the caller, so we know meta.createdBy must match. return null; } const caller = getOwner(); const createdBy = meta.createdBy?.toLowerCase(); if (createdBy && createdBy === caller.toLowerCase()) return null; // Allow org owners/admins to manage shared jobs created by other members. const isAdmin = await isCurrentUserOrgAdmin( resourceOrgId ?? meta.orgId ?? getRequestOrgId() ?? undefined, ); if (isAdmin) return null; return "Only the job's creator (or an org admin) can update or delete it."; } async function runCreate(args: Record): Promise { const { name, schedule, instructions, scope, runAs, model } = args; if (!name || !schedule || !instructions) { return JSON.stringify({ error: "name, schedule, and instructions are required", }); } if (!isValidCron(schedule)) { return JSON.stringify({ error: `Invalid cron expression: "${schedule}". Use 5 fields: minute hour day-of-month month day-of-week.`, }); } let mcpTools: string[] | undefined; try { mcpTools = normalizeJobMcpTools(args.mcpTools); } catch (err) { return JSON.stringify({ error: (err as Error).message }); } const owner = scope === "personal" ? getOwner() : getSharedOwner(); const path = `jobs/${name}.md`; const now = new Date(); const next = nextOccurrence(schedule, now); const integration = getIntegrationRequestContext(); const channelId = integration?.incoming.platformContext.channelId; const threadRef = integration?.incoming.threadRef; const meta: JobFrontmatter = { schedule, enabled: true, createdBy: getOwner(), orgId: getRequestOrgId() || undefined, runAs: runAs === "shared" ? "shared" : "creator", nextRun: next.toISOString(), ...(integration?.scopeId ? { originScopeId: integration.scopeId } : {}), ...(integration?.incoming.platform ? { deliveryPlatform: integration.incoming.platform } : {}), ...(typeof channelId === "string" ? { deliveryDestination: channelId } : {}), ...(typeof threadRef === "string" ? { deliveryThreadRef: threadRef } : {}), ...(integration?.incoming.tenantId ? { deliveryTenantId: integration.incoming.tenantId } : {}), ...(typeof model === "string" && model.trim() ? { model: model.trim() } : {}), ...(mcpTools?.length ? { mcpTools } : {}), }; const content = buildJobContent(meta, instructions); await resourcePut(owner, path, content); return JSON.stringify({ created: true, name, path, schedule, scheduleDescription: describeCron(schedule), nextRun: next.toISOString(), scope: scope || "shared", ...(mcpTools?.length ? { mcpTools } : {}), }); } async function runList(args: Record): Promise { const owner = getOwner(); const sharedOwner = getSharedOwner(); // Fetch only current user's and shared jobs (not other users') const [personal, shared] = await Promise.all([ resourceList(owner, "jobs/"), resourceList(sharedOwner, "jobs/"), ]); let resources = [...personal, ...shared]; if (args.scope === "personal") resources = personal; else if (args.scope === "shared") resources = shared; const metas = resources.filter( (r) => r.path.endsWith(".md") && !r.path.endsWith(".keep"), ); const jobs = await Promise.all( metas.map(async (r) => { const full = await resourceGetByPath(r.owner, r.path); const { meta } = parseJobFrontmatter(full?.content || ""); return { name: r.path.replace(/^jobs\//, "").replace(/\.md$/, ""), path: r.path, scope: r.owner === sharedOwner ? "shared" : "personal", schedule: meta.schedule, scheduleDescription: meta.schedule ? describeCron(meta.schedule) : "", enabled: meta.enabled, lastRun: meta.lastRun || null, lastStatus: meta.lastStatus || null, lastError: meta.lastError || null, nextRun: meta.nextRun || null, originScopeId: meta.originScopeId || null, deliveryPlatform: meta.deliveryPlatform || null, deliveryDestination: meta.deliveryDestination || null, model: meta.model || null, mcpTools: meta.mcpTools || [], }; }), ); if (jobs.length === 0) { return "No recurring jobs configured. Use manage-jobs with action 'create' to create one."; } return JSON.stringify(jobs, null, 2); } async function runUpdate(args: Record): Promise { const { name, schedule, instructions, enabled, scope, runAs, model } = args; const path = `jobs/${name}.md`; // Try to find the resource let resource = await resourceGetByPath(getSharedOwner(), path); if (!resource && scope !== "shared") { resource = await resourceGetByPath(getOwner(), path); } if (!resource) { return JSON.stringify({ error: `Job "${name}" not found` }); } const { meta, body } = parseJobFrontmatter(resource.content); // Reject when the caller doesn't own the shared job and isn't an org // admin. Without this check, any user could rewrite a shared job whose // `createdBy` is alice@…, and the next cron tick would run the // attacker's instructions as alice (creator-runAs schedules in // jobs/scheduler.ts line 273-278). const denied = await authorizeJobMutation(resource.owner, meta); if (denied) { return JSON.stringify({ error: denied }); } if (schedule) { if (!isValidCron(schedule)) { return JSON.stringify({ error: `Invalid cron expression: "${schedule}"`, }); } meta.schedule = schedule; meta.nextRun = nextOccurrence(schedule).toISOString(); } if (enabled !== undefined) { // Accept both the schema's string enum ("true"/"false") and a real boolean // from non-LLM callers. `enabled === "true"` alone treats a boolean `true` // as false — silently *disabling* a job the caller meant to enable. meta.enabled = enabled === true || enabled === "true"; } if (runAs === "creator" || runAs === "shared") { meta.runAs = runAs; } if (typeof model === "string" && model.trim()) meta.model = model.trim(); if (args.mcpTools !== undefined) { try { const mcpTools = normalizeJobMcpTools(args.mcpTools) ?? []; if (mcpTools.length) meta.mcpTools = mcpTools; else delete meta.mcpTools; } catch (err) { return JSON.stringify({ error: (err as Error).message }); } } const newBody = instructions || body; const content = buildJobContent(meta, newBody); await resourcePut(resource.owner, resource.path, content); return JSON.stringify({ updated: true, name, schedule: meta.schedule, scheduleDescription: describeCron(meta.schedule), enabled: meta.enabled, nextRun: meta.nextRun, mcpTools: meta.mcpTools || [], }); } async function runDelete(args: Record): Promise { const { name, scope } = args; const path = `jobs/${name}.md`; let resource = await resourceGetByPath(getSharedOwner(), path); if (!resource && scope !== "shared") { resource = await resourceGetByPath(getOwner(), path); } if (!resource) { return JSON.stringify({ error: `Job "${name}" not found` }); } // Same access check as runUpdate — only the creator or an org admin can // remove a shared job. Otherwise any user could break another tenant's // recurring schedule. const { meta } = parseJobFrontmatter(resource.content); const denied = await authorizeJobMutation(resource.owner, meta); if (denied) { return JSON.stringify({ error: denied }); } await resourceDelete(resource.id); return JSON.stringify({ deleted: true, name }); } export function createJobTools(): Record { return { "manage-jobs": { tool: { description: `Manage recurring jobs that run on a cron schedule. Actions: - "create": Create a new recurring job. Requires name, schedule, and instructions. - "list": List all recurring jobs and their status (schedule, enabled, last run, next run). - "update": Update a job's schedule, instructions, or enabled state. Requires name. - "delete": Delete a recurring job. Requires name. Always confirm with the user first. Cron format is 5 fields: minute hour day-of-month month day-of-week. Common patterns: '0 9 * * *' (daily 9am), '0 9 * * 1-5' (weekdays 9am), '0 * * * *' (every hour), '0 9 * * 1' (Mondays 9am), '*/30 * * * *' (every 30 min). For jobs that use a connected MCP, pass the exact tool names in mcpTools. This binds only those tools to the background run; OAuth credentials remain in the connector and are resolved for the job's user/org context.`, parameters: { type: "object", properties: { action: { type: "string", description: "The action to perform.", enum: ["create", "list", "update", "delete"], }, name: { type: "string", description: "Job name (hyphen-case, e.g. 'daily-scorecard-check'). Required for create and update.", }, schedule: { type: "string", description: "Cron expression (5 fields: minute hour day-of-month month day-of-week). Required for create, optional for update.", }, instructions: { type: "string", description: "What the agent should do when this job runs. Be specific — include which actions to call and what to do with the results. Required for create, optional for update.", }, enabled: { type: "string", description: "Enable or disable a job: 'true' or 'false'. Only used with update.", enum: ["true", "false"], }, scope: { type: "string", description: "For create: personal or shared (default: shared). For list: personal, shared, or all (default: all). For update: which scope to search (default: all).", enum: ["personal", "shared", "all"], }, runAs: { type: "string", description: "Who shared jobs execute as: creator or shared. Default: creator. Used with create and update.", enum: ["creator", "shared"], }, model: { type: "string", description: "Optional model id for this routine. The channel/app/engine default is used when omitted.", }, mcpTools: { type: "array", items: { type: "string" }, description: 'Optional explicit MCP capabilities for this job. Use the connected tool names exactly as advertised, for example ["mcp__meeting-notes__list_meetings", "mcp__meeting-notes__get_transcript"]. The job runs only with these tools; credentials remain in the connector.', }, }, required: ["action"], }, }, run: async (args) => { switch (args.action) { case "create": return runCreate(args); case "list": return runList(args); case "update": return runUpdate(args); case "delete": return runDelete(args); default: return JSON.stringify({ error: `Unknown action "${args.action}". Use "create", "list", or "update".`, }); } }, }, }; }