From a1a8741a0daa175810d3d04304c914d191b02ea1 Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:41:22 +0000 Subject: [PATCH] feat(jira): identity trust gate with Discord-only context notes When the identity map is on, mapped Jira accountIds still run full agent turns. Unmapped actors no longer get host control: post a context note to the unique Discord thread linked to the issue (links.json + bot token), reply on Jira, and never start a turn or write the T3 transcript. Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --- .../src/identity/IdentityService.test.ts | 12 ++ apps/server/src/identity/IdentityService.ts | 15 ++ apps/server/src/jira/JiraIssueBridge.ts | 148 ++++++++++++++++-- apps/server/src/jira/JiraThreadLookup.ts | 79 ++++++++-- apps/server/src/jira/JiraWebhook.test.ts | 33 +++- apps/server/src/jira/JiraWebhookPayload.ts | 13 +- apps/server/src/jira/jiraActorTrust.test.ts | 88 +++++++++++ apps/server/src/jira/jiraActorTrust.ts | 53 +++++++ .../src/jira/jiraDiscordContext.test.ts | 30 ++++ apps/server/src/jira/jiraDiscordContext.ts | 84 ++++++++++ apps/server/src/server.ts | 1 + docs/architecture/source-and-identity.md | 14 +- docs/integrations/jira-issue-conversations.md | 27 +++- packages/shared/src/identityMap.test.ts | 21 ++- packages/shared/src/identityMap.ts | 27 +++- 15 files changed, 603 insertions(+), 42 deletions(-) create mode 100644 apps/server/src/jira/jiraActorTrust.test.ts create mode 100644 apps/server/src/jira/jiraActorTrust.ts create mode 100644 apps/server/src/jira/jiraDiscordContext.test.ts create mode 100644 apps/server/src/jira/jiraDiscordContext.ts diff --git a/apps/server/src/identity/IdentityService.test.ts b/apps/server/src/identity/IdentityService.test.ts index ecc1e90c66ac..424298c305aa 100644 --- a/apps/server/src/identity/IdentityService.test.ts +++ b/apps/server/src/identity/IdentityService.test.ts @@ -11,6 +11,7 @@ const people = [ personId: "patroza", username: "patroza", name: "Patrick Roza", + jira: { accountId: "712020:pat-account" }, }, { personId: "julius", @@ -119,6 +120,17 @@ describe("IdentityService", () => { }).pipe(Effect.provide(TestLayer)), ); + it.effect("resolves mapped Jira account ids and reports map enabled", () => + Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + expect(yield* identity.isMapEnabled()).toBe(true); + const hit = yield* identity.resolveByJiraAccountId("accountid:712020:PAT-ACCOUNT"); + expect(hit?.username).toBe("patroza"); + const miss = yield* identity.resolveByJiraAccountId("712020:stranger"); + expect(miss).toBeNull(); + }).pipe(Effect.provide(TestLayer)), + ); + it.effect("non-bot sessions still require a claim when map is enabled", () => Effect.gen(function* () { const identity = yield* IdentityService.IdentityService; diff --git a/apps/server/src/identity/IdentityService.ts b/apps/server/src/identity/IdentityService.ts index 0f856a917884..d09ea392ee00 100644 --- a/apps/server/src/identity/IdentityService.ts +++ b/apps/server/src/identity/IdentityService.ts @@ -33,6 +33,7 @@ import { } from "@t3tools/contracts"; import { parseIdentityMapDocument, + resolvePersonByJiraAccountId, toIdentityPersonPublic, type IdentityMapPerson, IdentityMapParseError, @@ -84,6 +85,15 @@ export class IdentityService extends Context.Service< readonly clientDeviceType?: AuthClientMetadataDeviceType; }, ) => Effect.Effect; + /** + * Resolve a closed-set person from a Jira actor accountId. + * Returns null when the map is off, accountId is missing, or unmapped. + */ + readonly resolveByJiraAccountId: ( + accountId: string | null | undefined, + ) => Effect.Effect; + /** True when T3_IDENTITY_MAP_PATH loaded at least one person. */ + readonly isMapEnabled: () => Effect.Effect; } >()("t3/identity/IdentityService") {} @@ -247,6 +257,11 @@ function makeService( } return toPublicClaim(existing); }), + + resolveByJiraAccountId: (accountId) => + Effect.succeed(enabled ? resolvePersonByJiraAccountId(people, accountId) : null), + + isMapEnabled: () => Effect.succeed(enabled), }; } diff --git a/apps/server/src/jira/JiraIssueBridge.ts b/apps/server/src/jira/JiraIssueBridge.ts index e261d628e1cd..ad90a7cb98e8 100644 --- a/apps/server/src/jira/JiraIssueBridge.ts +++ b/apps/server/src/jira/JiraIssueBridge.ts @@ -5,8 +5,10 @@ import { ProjectId, ThreadId, type OrchestrationThread, + type SourceRef, type TurnId, } from "@t3tools/contracts"; +import type { IdentityMapPerson } from "@t3tools/shared/identityMap"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -39,7 +41,13 @@ import { resolveT3ProjectIdForJiraKey, } from "./JiraAppConfig.ts"; import { JiraDeliveryStore, type StoredJiraDelivery } from "./JiraDeliveryStore.ts"; -import { resolveThreadIdForJiraIssue } from "./JiraThreadLookup.ts"; +import { resolveDiscordLinkForJiraIssue, resolveThreadIdForJiraIssue } from "./JiraThreadLookup.ts"; +import { classifyJiraActorTrust, type JiraActorTrustDecision } from "./jiraActorTrust.ts"; +import { + formatDiscordJiraContextNote, + postDiscordChannelMessage, + resolveDiscordBotToken, +} from "./jiraDiscordContext.ts"; import { buildJiraTurnPrompt, type JiraIssueInvocation } from "./JiraWebhookPayload.ts"; const NOT_LINKED_RESPONSE = @@ -56,8 +64,34 @@ const EMPTY_PROMPT_RESPONSE = "Provide a prompt after the mention (for example: `@omegent investigate the packing failure`)."; const CREATE_FAILED_RESPONSE = "T3 could not create a thread for this Jira issue. Check server logs or link an existing thread."; +const CONTEXT_UNLINKED_RESPONSE = + "Your Jira account is not in the T3 identity map, so this is context-only — and there is no Discord thread linked to this issue yet. A trusted operator needs to open a Discord-linked thread first; then untrusted mentions can post context there."; +const CONTEXT_AMBIGUOUS_RESPONSE = + "Your Jira account is not in the T3 identity map (context-only), but multiple Discord threads are linked to this issue, so the bot could not pick which one to use."; +const CONTEXT_NOTED_RESPONSE = + "Noted as **context only** on the linked Discord thread (no agent run). Your Jira account is not in the T3 identity map; a trusted operator can act on it."; +const CONTEXT_FAILED_RESPONSE = + "Could not post context to the linked Discord thread (bot token missing or Discord API error). Ask a trusted operator to check the bot config."; +const CONTEXT_NO_LINKS_PATH_RESPONSE = + "Your Jira account is not in the T3 identity map (context-only), but Discord links are not configured on this server (`T3CODE_JIRA_DISCORD_LINKS_PATH`)."; const MAX_JIRA_COMMENT_LENGTH = 32_000; +function jiraSourceRef( + invocation: JiraIssueInvocation, + people: ReadonlyArray, +): SourceRef { + return buildIntegrationSourceRef({ + people, + channel: "jira", + platformId: invocation.actorAccountId, + displayName: invocation.actorDisplayName, + location: { + ...(invocation.projectKey ? { projectKey: invocation.projectKey } : {}), + issueKey: invocation.issueKey, + }, + }); +} + export function formatJiraComment(body: string): string { const trimmed = body.trim(); if (trimmed.length <= MAX_JIRA_COMMENT_LENGTH) return trimmed; @@ -96,6 +130,17 @@ const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const createLock = yield* Semaphore.make(1); + const resolveActorTrust = (invocation: JiraIssueInvocation) => + Effect.gen(function* () { + const people = yield* identity.listMapPeople(); + const mapEnabled = yield* identity.isMapEnabled(); + return classifyJiraActorTrust({ + identityMapEnabled: mapEnabled, + actorAccountId: invocation.actorAccountId, + people, + }) satisfies JiraActorTrustDecision; + }); + /** * Post a bridge response as a **threaded reply** when possible. * Uses delivery.replyToCommentId (thread root, or the mention itself when top-level). @@ -453,12 +498,98 @@ const make = Effect.gen(function* () { return; } + const trust = yield* resolveActorTrust(input.invocation); + yield* Effect.logInfo("Classified Jira actor trust", { + deliveryId: input.deliveryId, + issueKey: input.invocation.issueKey, + actorAccountId: input.invocation.actorAccountId, + mode: trust.mode, + reason: trust.reason, + personId: trust.person?.personId ?? null, + }); + const link = yield* resolveLinkedThreadId(input.invocation.issueKey); if (link._tag === "ambiguous") { yield* finishDelivery(acknowledged, AMBIGUOUS_RESPONSE, "rejected"); return; } + // Untrusted actors: Discord context only (no agent, no T3 transcript write). + // Requires a unique Discord-linked issue in links.json. Never auto-creates. + if (trust.mode === "context-only") { + const linksPath = config.discordLinksPath; + if (linksPath === null || linksPath.length === 0) { + yield* finishDelivery(acknowledged, CONTEXT_NO_LINKS_PATH_RESPONSE, "rejected"); + return; + } + const linksRaw = yield* fileSystem + .readFileString(linksPath) + .pipe(Effect.orElseSucceed(() => "")); + const discordLink = resolveDiscordLinkForJiraIssue({ + issueKey: input.invocation.issueKey, + linksJson: linksRaw, + }); + if (discordLink._tag === "unlinked") { + yield* finishDelivery(acknowledged, CONTEXT_UNLINKED_RESPONSE, "rejected"); + return; + } + if (discordLink._tag === "ambiguous") { + yield* finishDelivery(acknowledged, CONTEXT_AMBIGUOUS_RESPONSE, "rejected"); + return; + } + + const token = yield* Effect.promise(() => resolveDiscordBotToken()); + if (token === null) { + yield* finishDelivery(acknowledged, CONTEXT_FAILED_RESPONSE, "rejected"); + return; + } + + const requester = + input.invocation.actorDisplayName ?? input.invocation.actorAccountId ?? "unknown"; + const content = formatDiscordJiraContextNote({ + issueKey: input.invocation.issueKey, + requester, + prompt: input.invocation.prompt, + commentUrl: input.invocation.commentUrl, + }); + const posted = yield* Effect.promise(() => + postDiscordChannelMessage({ + token, + channelId: discordLink.discordThreadId, + content, + }) + .then((message) => ({ _tag: "ok" as const, message })) + .catch((cause) => ({ _tag: "err" as const, cause })), + ); + if (posted._tag === "err") { + yield* Effect.logError("Failed to post Jira context-only note to Discord", { + deliveryId: input.deliveryId, + issueKey: input.invocation.issueKey, + discordThreadId: discordLink.discordThreadId, + cause: posted.cause, + }); + yield* finishDelivery(acknowledged, CONTEXT_FAILED_RESPONSE, "rejected"); + return; + } + + yield* Effect.logInfo("Posted Jira context-only note to Discord (no agent run)", { + deliveryId: input.deliveryId, + issueKey: input.invocation.issueKey, + discordThreadId: discordLink.discordThreadId, + t3ThreadId: discordLink.t3ThreadId, + discordMessageId: posted.message.id, + }); + const notedDelivery: StoredJiraDelivery = { + ...acknowledged, + threadId: + discordLink.t3ThreadId !== null + ? (discordLink.t3ThreadId as ThreadId) + : acknowledged.threadId, + }; + yield* finishDelivery(notedDelivery, CONTEXT_NOTED_RESPONSE, "completed"); + return; + } + let thread: OrchestrationThread; if (link._tag === "linked") { const snapshot = yield* projection @@ -480,7 +611,7 @@ const make = Effect.gen(function* () { thread = snapshot.value; } } else { - // unlinked — join-or-create + // unlinked — join-or-create (trusted actors only) if (!config.enabled || !config.autoCreateThread) { yield* finishDelivery(acknowledged, CREATE_DISABLED_RESPONSE, "rejected"); return; @@ -525,19 +656,12 @@ const make = Effect.gen(function* () { threadId: thread.id, issueKey: input.invocation.issueKey, userMessageId: messageId, + trustMode: trust.mode, + personId: trust.person?.personId ?? null, }); const mapPeople = yield* identity.listMapPeople(); - const source = buildIntegrationSourceRef({ - people: mapPeople, - channel: "jira", - platformId: input.invocation.actorAccountId, - displayName: input.invocation.actorDisplayName, - location: { - ...(input.invocation.projectKey ? { projectKey: input.invocation.projectKey } : {}), - issueKey: input.invocation.issueKey, - }, - }); + const source = jiraSourceRef(input.invocation, mapPeople); const dispatched = yield* engine .dispatch({ type: "thread.turn.start", diff --git a/apps/server/src/jira/JiraThreadLookup.ts b/apps/server/src/jira/JiraThreadLookup.ts index 57b49714ba74..80b4c2467d9a 100644 --- a/apps/server/src/jira/JiraThreadLookup.ts +++ b/apps/server/src/jira/JiraThreadLookup.ts @@ -3,6 +3,8 @@ * * Preferred resolution is the server-native {@link ThreadWorkItemStore}. This helper remains * for migration/fallback when Discord still holds associations that have not been imported yet. + * + * Discord destinations (for untrusted context notes) also come from the same links.json. */ import type { ThreadId } from "@t3tools/contracts"; @@ -11,6 +13,8 @@ import * as Schema from "effect/Schema"; const DiscordThreadLink = Schema.Struct({ discordThreadId: Schema.optional(Schema.String), t3ThreadId: Schema.String, + channelId: Schema.optional(Schema.String), + guildId: Schema.optional(Schema.String), status: Schema.optional(Schema.String), jiraIssueKeys: Schema.optional(Schema.Array(Schema.String)), }); @@ -27,26 +31,47 @@ export type JiraThreadLookupResult = | { readonly _tag: "ambiguous"; readonly threadIds: ReadonlyArray } | { readonly _tag: "linked"; readonly threadId: ThreadId }; -export function resolveThreadIdForJiraIssue(input: { - readonly issueKey: string; - readonly linksJson: string; -}): JiraThreadLookupResult { - const issueKey = input.issueKey.trim().toUpperCase(); - if (issueKey.length === 0) return { _tag: "unlinked" }; +export type JiraDiscordLinkLookupResult = + | { readonly _tag: "unlinked" } + | { + readonly _tag: "ambiguous"; + readonly discordThreadIds: ReadonlyArray; + } + | { + readonly _tag: "linked"; + readonly discordThreadId: string; + readonly t3ThreadId: string | null; + readonly channelId: string | null; + readonly guildId: string | null; + }; + +function activeLinksWithIssue( + linksJson: string, + issueKeyRaw: string, +): ReadonlyArray { + const issueKey = issueKeyRaw.trim().toUpperCase(); + if (issueKey.length === 0) return []; let links: ReadonlyArray; try { - links = decodeLinksFile(input.linksJson).links; + links = decodeLinksFile(linksJson).links; } catch { - return { _tag: "unlinked" }; + return []; } - const matches = new Set(); - for (const link of links) { - if (link.status !== undefined && link.status !== "active") continue; + return links.filter((link) => { + if (link.status !== undefined && link.status !== "active") return false; const keys = link.jiraIssueKeys ?? []; - const hit = keys.some((key) => key.trim().toUpperCase() === issueKey); - if (!hit) continue; + return keys.some((key) => key.trim().toUpperCase() === issueKey); + }); +} + +export function resolveThreadIdForJiraIssue(input: { + readonly issueKey: string; + readonly linksJson: string; +}): JiraThreadLookupResult { + const matches = new Set(); + for (const link of activeLinksWithIssue(input.linksJson, input.issueKey)) { const threadId = link.t3ThreadId.trim(); if (threadId.length > 0) matches.add(threadId); } @@ -61,3 +86,31 @@ export function resolveThreadIdForJiraIssue(input: { const [only] = matches; return { _tag: "linked", threadId: only as ThreadId }; } + +/** + * Resolve the Discord thread to post untrusted Jira context into. + * Requires a unique active links.json row with both the issue key and a discordThreadId. + */ +export function resolveDiscordLinkForJiraIssue(input: { + readonly issueKey: string; + readonly linksJson: string; +}): JiraDiscordLinkLookupResult { + const withDiscord = activeLinksWithIssue(input.linksJson, input.issueKey).filter( + (link) => (link.discordThreadId?.trim().length ?? 0) > 0, + ); + if (withDiscord.length === 0) return { _tag: "unlinked" }; + if (withDiscord.length > 1) { + return { + _tag: "ambiguous", + discordThreadIds: withDiscord.map((link) => link.discordThreadId!.trim()), + }; + } + const only = withDiscord[0]!; + return { + _tag: "linked", + discordThreadId: only.discordThreadId!.trim(), + t3ThreadId: only.t3ThreadId.trim() || null, + channelId: only.channelId?.trim() || null, + guildId: only.guildId?.trim() || null, + }; +} diff --git a/apps/server/src/jira/JiraWebhook.test.ts b/apps/server/src/jira/JiraWebhook.test.ts index fc98198a3c66..f5b789e2dde7 100644 --- a/apps/server/src/jira/JiraWebhook.test.ts +++ b/apps/server/src/jira/JiraWebhook.test.ts @@ -8,7 +8,7 @@ import { resolveT3ProjectIdForJiraKey, } from "./JiraAppConfig.ts"; import { formatJiraComment } from "./JiraIssueBridge.ts"; -import { resolveThreadIdForJiraIssue } from "./JiraThreadLookup.ts"; +import { resolveDiscordLinkForJiraIssue, resolveThreadIdForJiraIssue } from "./JiraThreadLookup.ts"; import { classifyWebhookBodyFailure, previewWebhookBody, @@ -321,11 +321,13 @@ describe("Jira thread lookup", () => { links: [ { t3ThreadId: "thread-a", + discordThreadId: "discord-a", status: "active", jiraIssueKeys: ["SA-402", "SA-409"], }, { t3ThreadId: "thread-b", + discordThreadId: "discord-b", status: "tombstone", jiraIssueKeys: ["SA-402"], }, @@ -335,6 +337,13 @@ describe("Jira thread lookup", () => { _tag: "linked", threadId: "thread-a", }); + expect(resolveDiscordLinkForJiraIssue({ issueKey: "SA-402", linksJson })).toEqual({ + _tag: "linked", + discordThreadId: "discord-a", + t3ThreadId: "thread-a", + channelId: null, + guildId: null, + }); }); it("reports unlinked and ambiguous cases", () => { @@ -356,6 +365,28 @@ describe("Jira thread lookup", () => { }), }), ).toMatchObject({ _tag: "ambiguous" }); + + expect( + resolveDiscordLinkForJiraIssue({ + issueKey: "SA-1", + linksJson: JSON.stringify({ + links: [ + { + t3ThreadId: "a", + discordThreadId: "d1", + status: "active", + jiraIssueKeys: ["SA-1"], + }, + { + t3ThreadId: "b", + discordThreadId: "d2", + status: "active", + jiraIssueKeys: ["SA-1"], + }, + ], + }), + }), + ).toMatchObject({ _tag: "ambiguous" }); }); }); diff --git a/apps/server/src/jira/JiraWebhookPayload.ts b/apps/server/src/jira/JiraWebhookPayload.ts index 22033b503260..fe4b784110a5 100644 --- a/apps/server/src/jira/JiraWebhookPayload.ts +++ b/apps/server/src/jira/JiraWebhookPayload.ts @@ -384,10 +384,9 @@ export function parseJiraCommentInvocation( }; } -export function buildJiraTurnPrompt(invocation: JiraIssueInvocation): string { +function jiraPromptHeaderLines(invocation: JiraIssueInvocation): Array { const requester = invocation.actorDisplayName ?? invocation.actorAccountId ?? "unknown"; - const isUpdate = invocation.webhookEvent === "comment_updated"; - const lines = [ + return [ "", "", isUpdate diff --git a/apps/server/src/jira/jiraActorTrust.test.ts b/apps/server/src/jira/jiraActorTrust.test.ts new file mode 100644 index 000000000000..b0955aa2a13d --- /dev/null +++ b/apps/server/src/jira/jiraActorTrust.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + classifyJiraActorTrust, + normalizeJiraAccountId, + resolvePersonByJiraAccountId, +} from "./jiraActorTrust.ts"; + +const people = [ + { + personId: "patroza", + username: "patroza", + name: "Patrick Roza", + jira: { accountId: "712020:abc-trusted" }, + }, + { + personId: "julius", + username: "julius", + jira: { accountId: "accountid:712020:def-other" }, + }, +] as const; + +describe("normalizeJiraAccountId", () => { + it("strips accountid: prefix and lowercases", () => { + expect(normalizeJiraAccountId("accountid:712020:ABC")).toBe("712020:abc"); + expect(normalizeJiraAccountId("712020:ABC")).toBe("712020:abc"); + }); + + it("returns null for empty", () => { + expect(normalizeJiraAccountId(null)).toBeNull(); + expect(normalizeJiraAccountId(" ")).toBeNull(); + }); +}); + +describe("resolvePersonByJiraAccountId", () => { + it("matches mapped account ids with prefix variants", () => { + expect(resolvePersonByJiraAccountId(people, "712020:abc-trusted")?.username).toBe("patroza"); + expect(resolvePersonByJiraAccountId(people, "accountid:712020:ABC-TRUSTED")?.username).toBe( + "patroza", + ); + expect(resolvePersonByJiraAccountId(people, "712020:def-other")?.username).toBe("julius"); + }); + + it("returns null when unmapped", () => { + expect(resolvePersonByJiraAccountId(people, "unknown")).toBeNull(); + }); +}); + +describe("classifyJiraActorTrust", () => { + it("allows full access when the identity map is disabled", () => { + expect( + classifyJiraActorTrust({ + identityMapEnabled: false, + actorAccountId: "stranger", + people: [], + }), + ).toEqual({ mode: "full", person: null, reason: "identity_map_disabled" }); + }); + + it("trusts mapped Jira account ids for full agent turns", () => { + const decision = classifyJiraActorTrust({ + identityMapEnabled: true, + actorAccountId: "712020:abc-trusted", + people, + }); + expect(decision.mode).toBe("full"); + expect(decision.reason).toBe("mapped_jira_account"); + expect(decision.person?.username).toBe("patroza"); + }); + + it("restricts unmapped and missing account ids to context-only", () => { + expect( + classifyJiraActorTrust({ + identityMapEnabled: true, + actorAccountId: "712020:stranger", + people, + }), + ).toMatchObject({ mode: "context-only", reason: "unmapped_jira_account", person: null }); + + expect( + classifyJiraActorTrust({ + identityMapEnabled: true, + actorAccountId: null, + people, + }), + ).toMatchObject({ mode: "context-only", reason: "missing_jira_account_id", person: null }); + }); +}); diff --git a/apps/server/src/jira/jiraActorTrust.ts b/apps/server/src/jira/jiraActorTrust.ts new file mode 100644 index 000000000000..454d20263865 --- /dev/null +++ b/apps/server/src/jira/jiraActorTrust.ts @@ -0,0 +1,53 @@ +/** + * Jira actor trust relative to the closed-set identity map. + * + * When the map is off, all actors keep full agent turns (legacy behaviour). + * When the map is on, only people with a mapped Jira accountId may run the + * agent; everyone else may only append context to an already-linked thread. + */ +import { + normalizeJiraAccountId, + resolvePersonByJiraAccountId, + type IdentityMapPerson, +} from "@t3tools/shared/identityMap"; + +export type JiraActorTrustMode = "full" | "context-only"; + +export type JiraActorTrustDecision = { + readonly mode: JiraActorTrustMode; + /** Mapped person when trusted; null when map is off or actor is unmapped. */ + readonly person: IdentityMapPerson | null; + readonly reason: + | "identity_map_disabled" + | "mapped_jira_account" + | "unmapped_jira_account" + | "missing_jira_account_id"; +}; + +export { normalizeJiraAccountId, resolvePersonByJiraAccountId }; + +/** + * Classify a Jira mention actor for agent execution. + * + * - Map off → full (backward compatible) + * - Map on + accountId in map → full + * - Map on + missing/unmapped accountId → context-only + */ +export function classifyJiraActorTrust(input: { + readonly identityMapEnabled: boolean; + readonly actorAccountId: string | null | undefined; + readonly people: ReadonlyArray; +}): JiraActorTrustDecision { + if (!input.identityMapEnabled) { + return { mode: "full", person: null, reason: "identity_map_disabled" }; + } + const normalized = normalizeJiraAccountId(input.actorAccountId); + if (normalized === null) { + return { mode: "context-only", person: null, reason: "missing_jira_account_id" }; + } + const person = resolvePersonByJiraAccountId(input.people, input.actorAccountId); + if (person === null) { + return { mode: "context-only", person: null, reason: "unmapped_jira_account" }; + } + return { mode: "full", person, reason: "mapped_jira_account" }; +} diff --git a/apps/server/src/jira/jiraDiscordContext.test.ts b/apps/server/src/jira/jiraDiscordContext.test.ts new file mode 100644 index 000000000000..dd96703a6674 --- /dev/null +++ b/apps/server/src/jira/jiraDiscordContext.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { formatDiscordJiraContextNote } from "./jiraDiscordContext.ts"; + +describe("formatDiscordJiraContextNote", () => { + it("formats a short context note", () => { + const text = formatDiscordJiraContextNote({ + issueKey: "SA-402", + requester: "Ada", + prompt: "packing fails on stage", + commentUrl: "https://example.atlassian.net/browse/SA-402?focusedCommentId=1", + }); + expect(text).toContain("SA-402"); + expect(text).toContain("Ada"); + expect(text).toContain("no agent run"); + expect(text).toContain("packing fails on stage"); + expect(text).toContain("focusedCommentId=1"); + }); + + it("truncates long prompts to Discord limits", () => { + const prompt = "x".repeat(3000); + const text = formatDiscordJiraContextNote({ + issueKey: "SA-1", + requester: "Bob", + prompt, + }); + expect(text.length).toBeLessThanOrEqual(2000); + expect(text).toContain("…(truncated)"); + }); +}); diff --git a/apps/server/src/jira/jiraDiscordContext.ts b/apps/server/src/jira/jiraDiscordContext.ts new file mode 100644 index 000000000000..946666bc5c68 --- /dev/null +++ b/apps/server/src/jira/jiraDiscordContext.ts @@ -0,0 +1,84 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetch:off globalFetchInEffect:off +/** + * Minimal Discord post helper for untrusted Jira context notes. + * Intentionally small — does not depend on MCP tools or the full Discord bot. + */ +import * as NodeFSP from "node:fs/promises"; + +const DISCORD_API_BASE_URL = "https://discord.com/api/v10"; +const DISCORD_DEFAULT_ENV_FILE = "/run/secrets/discord-bot.env"; + +function extractEnvAssignment(raw: string, key: string): string | undefined { + for (const line of raw.split(/\r?\n/u)) { + if (!line.startsWith(`${key}=`)) continue; + const value = line.slice(key.length + 1).trim(); + if (value.length === 0) return undefined; + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + return value; + } + return undefined; +} + +export async function resolveDiscordBotToken(): Promise { + const fromEnv = process.env.DISCORD_BOT_TOKEN?.trim(); + if (fromEnv) return fromEnv; + try { + const envFile = await NodeFSP.readFile(DISCORD_DEFAULT_ENV_FILE, "utf8"); + const fromFile = extractEnvAssignment(envFile, "DISCORD_BOT_TOKEN")?.trim(); + return fromFile && fromFile.length > 0 ? fromFile : null; + } catch { + return null; + } +} + +/** Discord message body length limit (UTF-16 code units; we treat as chars). */ +const DISCORD_CONTENT_MAX = 2000; + +export function formatDiscordJiraContextNote(input: { + readonly issueKey: string; + readonly requester: string; + readonly prompt: string; + readonly commentUrl?: string | null; +}): string { + const header = `**Jira context** (no agent run) · \`${input.issueKey}\` · ${input.requester}`; + const link = input.commentUrl ? `\n${input.commentUrl}` : ""; + const body = input.prompt.trim(); + const combined = `${header}${link}\n\n${body}`; + if (combined.length <= DISCORD_CONTENT_MAX) return combined; + const budget = DISCORD_CONTENT_MAX - header.length - link.length - 20; + const clipped = body.slice(0, Math.max(0, budget)); + return `${header}${link}\n\n${clipped}\n…(truncated)`; +} + +export async function postDiscordChannelMessage(input: { + readonly token: string; + readonly channelId: string; + readonly content: string; +}): Promise<{ readonly id: string; readonly channelId: string }> { + const url = `${DISCORD_API_BASE_URL}/channels/${input.channelId}/messages`; + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bot ${input.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: input.content, + allowed_mentions: { parse: [] as string[] }, + }), + }); + if (!response.ok) { + const body = await response.text(); + throw new Error(`Discord create message failed (${response.status}): ${body}`); + } + const json = (await response.json()) as { id?: string; channel_id?: string }; + return { + id: json.id ?? "", + channelId: json.channel_id ?? input.channelId, + }; +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2a9242ee29d7..afd389ba6ea8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -322,6 +322,7 @@ const JiraIssueBridgeLive = JiraIssueBridge.layer.pipe( Layer.provideMerge(JiraAppDependenciesLive), // Prefer the instance already provided by GitHubPrBridgeLive when merged below. Layer.provideMerge(ThreadWorkItemStoreLive), + // Closed-set map for trusted vs context-only Jira actors. Layer.provideMerge(IdentityLayerLive), ); diff --git a/docs/architecture/source-and-identity.md b/docs/architecture/source-and-identity.md index efb567513406..474acf3239af 100644 --- a/docs/architecture/source-and-identity.md +++ b/docs/architecture/source-and-identity.md @@ -198,12 +198,12 @@ type SessionIdentityClaim = { ### Bootstrap / bots -| Client | Claim path | -| ------------------------------ | ----------------------------------------------------------------------------------- | -| Web / desktop / mobile | After pairing: **typeahead claim** against map usernames; must claim before operate | -| Discord bot | Auto-resolve sender snowflake → person; stamp on turn; no UI | -| Jira bot | Auto-resolve accountId/email → person | -| Headless CLI / admin bootstrap | Optional claim; if identity-on and operate without claim → reject operate RPCs | +| Client | Claim path | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | +| Web / desktop / mobile | After pairing: **typeahead claim** against map usernames; must claim before operate | +| Discord bot | Auto-resolve sender snowflake → person; stamp on turn; no UI | +| Jira bot | Auto-resolve accountId → person; **mapped = full agent turn**, unmapped = Discord context-only note when issue is Discord-linked (no agent) | +| Headless CLI / admin bootstrap | Optional claim; if identity-on and operate without claim → reject operate RPCs | ### Gate @@ -226,6 +226,8 @@ On `thread.turn.start` / user `thread.message-sent`: **v1 policy:** unresolved external actor → stamp channel + actor only, `personId` null; does not count as mine for anyone. Log once per turn. + **Jira (when map enabled):** mapped `accountId` → `thread.turn.start` with SourceRef person fields. Unmapped/missing accountId → Discord context note only (no agent), and only if the issue is already linked to exactly one Discord thread in `links.json`. + 3. Projector copies `source` onto `OrchestrationMessage`. 4. Shell projector maintains `originSource` and `participantPersonIds`. diff --git a/docs/integrations/jira-issue-conversations.md b/docs/integrations/jira-issue-conversations.md index 5688d9681caa..9d3b57af2240 100644 --- a/docs/integrations/jira-issue-conversations.md +++ b/docs/integrations/jira-issue-conversations.md @@ -171,12 +171,29 @@ are logged and never block the turn. - Require a shared secret on every delivery (`Authorization: Bearer …` or `X-T3-Webhook-Secret`). - Cap body size at 1 MiB. -- Ignore events that are not `comment_created`. -- Allowlist projects when configured. +- Ignore events that are not `comment_created` / `comment_updated`. +- Allowlist projects when configured (`T3CODE_JIRA_ALLOWED_PROJECTS`). +- **Identity map trust gate** (when `T3_IDENTITY_MAP_PATH` has people): + - **Trusted** — Jira `accountId` appears on a map person (`jira.accountId` / `jiraAccountId`) → full agent turn (same as today, including auto-create when enabled). + - **Untrusted** — map on but actor missing/unmapped → **Discord context only** (no agent, no T3 transcript write). Posts a note into the unique Discord thread linked to the issue in `links.json` (`T3CODE_JIRA_DISCORD_LINKS_PATH` + `DISCORD_BOT_TOKEN`). Requires exactly one active Discord link with that issue key; never auto-creates. Jira reply explains the note was filed. + - Map **off** / empty → legacy full access for all mentioners (backward compatible). - Do not put secrets in prompts, delivery logs, or git. - Prefer the free Atlassian **service account** for REST replies (see [atlassian-service-accounts](./atlassian-service-accounts.md) when present on the branch). +Map people with Jira links, for example: + +```yaml +people: + patroza: + username: patroza + name: Patrick Roza + jira: + accountId: "712020:your-atlassian-account-id" + discord: + id: "95218063095377920" +``` + ## Outbound comments Responses are posted as issue comments authored by the service account, preferably as a @@ -194,9 +211,13 @@ agent explicitly mentions users. 2. Unit: webhook secret acceptance / rejection; project allowlist. 3. Unit: delivery dedupe on redelivery of the same comment id. 4. Unit: webhook debug retention prune (24h) and body failure classification. -5. Integration (manual): register a Jira webhook or Automation rule → `POST /api/jira/webhook` +5. Unit: identity map trust — mapped accountId → full; unmapped/missing → context-only; map off → full. +6. Unit: Discord links.json resolve by Jira issue key (unique / unlinked / ambiguous). +7. Integration (manual): register a Jira webhook or Automation rule → `POST /api/jira/webhook` with the shared secret; mention the bot on a linked issue; confirm a reply comment and a matching `accepted_202` (or `invalid_400` with preview) line in `jira-webhook-debug.ndjson`. +8. Integration (manual, map on): unmapped Jira user mention on a Discord-linked issue → note in the + Discord thread + “context only” Jira reply; unmapped on unlinked issue → refuse without agent run. ## Non-goals (this foundation) diff --git a/packages/shared/src/identityMap.test.ts b/packages/shared/src/identityMap.test.ts index 93e0d0d35743..dda0239d8511 100644 --- a/packages/shared/src/identityMap.test.ts +++ b/packages/shared/src/identityMap.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { parseIdentityMapDocument, IdentityMapParseError } from "./identityMap.ts"; +import { + IdentityMapParseError, + normalizeJiraAccountId, + parseIdentityMapDocument, + resolvePersonByJiraAccountId, +} from "./identityMap.ts"; describe("parseIdentityMapDocument", () => { it("parses people map with usernames", () => { @@ -47,4 +52,18 @@ describe("parseIdentityMapDocument", () => { expect(parseIdentityMapDocument({})).toEqual([]); expect(parseIdentityMapDocument({ people: [] })).toEqual([]); }); + + it("resolves people by Jira accountId", () => { + const people = parseIdentityMapDocument({ + people: { + patroza: { + username: "patroza", + jira: { accountId: "712020:abc" }, + }, + }, + }); + expect(normalizeJiraAccountId("accountid:712020:ABC")).toBe("712020:abc"); + expect(resolvePersonByJiraAccountId(people, "712020:abc")?.username).toBe("patroza"); + expect(resolvePersonByJiraAccountId(people, "nope")).toBeNull(); + }); }); diff --git a/packages/shared/src/identityMap.ts b/packages/shared/src/identityMap.ts index 9668bd63d16a..9ba48aedf29b 100644 --- a/packages/shared/src/identityMap.ts +++ b/packages/shared/src/identityMap.ts @@ -271,13 +271,34 @@ export function findPersonByGithubId( return people.find((person) => person.github?.id === id) ?? null; } +/** Normalize Atlassian account ids for map lookup (`accountid:` prefix, case). */ +export function normalizeJiraAccountId(accountId: string | null | undefined): string | null { + if (accountId === null || accountId === undefined) return null; + const trimmed = accountId.trim(); + if (trimmed.length === 0) return null; + const withoutPrefix = trimmed.replace(/^accountid:/iu, ""); + return withoutPrefix.length > 0 ? withoutPrefix.toLowerCase() : null; +} + +/** Resolve a closed-set person by Jira Cloud accountId (prefix/case-insensitive). */ +export function resolvePersonByJiraAccountId( + people: ReadonlyArray, + accountId: string | null | undefined, +): IdentityMapPerson | null { + const normalized = normalizeJiraAccountId(accountId); + if (normalized === null) return null; + for (const person of people) { + const mapped = normalizeJiraAccountId(person.jira?.accountId); + if (mapped !== null && mapped === normalized) return person; + } + return null; +} + export function findPersonByJiraAccountId( people: ReadonlyArray, accountId: string, ): IdentityMapPerson | null { - const id = accountId.trim(); - if (id.length === 0) return null; - return people.find((person) => person.jira?.accountId === id) ?? null; + return resolvePersonByJiraAccountId(people, accountId); } export function findPersonByJiraEmail(