Skip to content

chore(iris): remove scratch plan file, formatter pass - #641

Closed
mezotv wants to merge 15 commits into
mainfrom
emdash/sweet-stamps-remain-ttn9c
Closed

chore(iris): remove scratch plan file, formatter pass#641
mezotv wants to merge 15 commits into
mainfrom
emdash/sweet-stamps-remain-ttn9c

Conversation

@mezotv

@mezotv mezotv commented Aug 1, 2026

Copy link
Copy Markdown
Member

Post-merge cleanup for #640: removes the scratch planning file that slipped into the squash, plus a formatter-only pass on iris-billing.


Summary by cubic

Introduces Iris, an autonomous marketing agent with a Slack ship-it loop and a new dashboard page. It ingests signals, plans and executes content tasks, and delivers drafts to Slack for approval, gated by an org-scoped feature flag.

  • New Features

    • Dashboard Iris page with start/running states, stats, run history, artifacts, and signals; nav visibility gated by @databuddy/sdk flag.
    • ORPC endpoints for Iris lifecycle: start, pause/resume, list runs/signals/stats, and mandate management.
    • GitHub webhook integration records Iris signals (push, release, PR merged) and triggers runs.
    • New @notra/ai autonomy modules: planning, capabilities (blog/changelog/social), run store, gate/quotas, claims/leases, signals, outbox, and delivery.
    • Slack delivery via @notra/ai integrations: post/update messages with backoff; interactive ship/skip actions persist approvals and publish posts.
    • Wake scheduling with @upstash/qstash and an authenticated route to start periodic runs.
    • Billing/usage tracking with markup parity and idempotent claims to prevent double-charging.
  • Migration

    • Enable the org flag IRIS_FLAG_KEY to show Iris in nav.
    • Configure the org Slack notification channel.
    • Ensure GitHub webhooks are connected; Iris consumes push, release published, and PR merged events.
    • Set QSTASH_TOKEN and point the Upstash schedule to the Iris wake route.

Written for commit c2b6263. Summary will update on new commits.

Review in cubic

Summary by Comp AI

2 issues found.

Written for commit c2b6263. New commits will trigger a re-review. Generated by Comp AI.

@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Too many files changed for review. (110 files found, 100 file limit)

Bypass the limit by tagging @greptile-apps to review.

@cursor

cursor Bot commented Aug 1, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
notra Error Error Aug 1, 2026 11:21am
notra-agent Ready Ready Preview Aug 1, 2026 11:21am
notra-console Ready Ready Preview Aug 1, 2026 11:21am
notra-onboarding-agent Ready Ready Preview Aug 1, 2026 11:21am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
notra-web Skipped Skipped Aug 1, 2026 11:21am

Request Review

@comp-ai-code-review

comp-ai-code-review Bot commented Aug 1, 2026

Copy link
Copy Markdown

Comp AI code review complete — 2 issues found.

Commit c2b6263 · Posted by Comp AI Code Reviews.

@capy-ai

capy-ai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Capy auto-review is paused for this organization because the usage-cycle auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

export async function irisControllerRun(
payload: IrisWorkflowPayload
): Promise<IrisControllerResult> {
"use workflow";
executionId: string;
claimToken: string;
}): Promise<{ claimed: boolean }> {
"use step";
organizationId: string;
ownerToken: string;
}): Promise<{ acquired: boolean; fencingToken: number | null }> {
"use step";
organizationId: string;
ownerToken: string;
}): Promise<boolean> {
"use step";
export async function isIrisMandateActive(
organizationId: string
): Promise<boolean> {
"use step";
runId: string;
costCents: number;
}): Promise<void> {
"use step";
organizationId: string;
ownerToken: string;
}): Promise<void> {
"use step";
export async function loadIrisMandate(
organizationId: string
): Promise<IrisMandateContext> {
"use step";
export async function resolveIrisFlagForRun(
organizationId: string
): Promise<IrisFlagState> {
"use step";
export async function gatherIrisContext(
organizationId: string
): Promise<IrisGatherResult> {
"use step";

@comp-ai-code-review comp-ai-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 2 files.

Prompt for AI agents (all issues)
Check whether each issue below is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it, and use sub-agents to handle independent issues in parallel.

<file name="apps/agent/agent/lib/utils/iris-interaction.ts">
<issue n="1" at="apps/agent/agent/lib/utils/iris-interaction.ts:28-233" severity="MEDIUM">Any workspace member can ship/skip any Iris post — no per-user approval binding or resource-level authorization — handleIrisInteraction processes Slack button clicks for Iris approvals. The interaction payload value (organizationId/outboxId/postId) is fully attacker-controlled within the Slack interaction envelope. The file correctly binds the Slack workspace's installation to the payload's organizationId (line ~141: `installation?.organizationId !== parsed.organizationId`), which prevents cross-organization forgery. However, there is NO check that the Slack user who clicked the button (actorId/actorName) is actually permitted to approve posts for this organization, or that they own/target the specific outbox/post. The action.user.id is only recorded as metadata via recordIrisApproval, never verified. Consequences: - Any member of a connected Slack workspace (including guests/limited members, depending on Slack workspace settings) can ship (publish) or skip ANY pending Iris post in that workspace's organization by replaying a captured interaction payload with a different postId, or by crafting the value JSON if they can observe/guess outbox/post IDs. The shipIrisPost call flips a draft post to 'published' (packages/ai/src/autonomy/ship.ts line ~135). - There is no per-post approval owner or quorum: a single click publishes. The organization-mismatch check is the only authorization gate. The trust boundary is 'any member of the Slack workspace', which for workspaces with broad/guest membership may be wider than 'organization members with content publish permission'. This is a resource-level authorization gap: auth confirms 'this workspace is linked to this org' but not 'this Slack user may publish this post'. Note: Slack interaction signature verification is assumed to be enforced by the eve channel framework at the HTTP boundary (out of scope for this file), so this is not a 'no auth at all' issue — it is a missing finer-grained authorization issue. Fix: Before recording approval and publishing, verify the actor is a member of the organization with publish/approve permission (e.g., resolve the Slack user to a Notra user via the installation and check membership role, or maintain an allowlist of approver Slack user IDs on the mandate/outbox). Consider requiring explicit approver authorization rather than trusting 'any workspace member'. If collaborative-in-workspace approval is intentional, document that trust boundary and restrict which Slack users can be added to the workspace, and consider a quorum/owner-approval policy for publishing.</issue>
</file>

<file name="packages/ai/src/autonomy/ship.ts">
<issue n="2" at="packages/ai/src/autonomy/ship.ts:149-193" severity="MEDIUM">Iris post publish (ship) is authorized by Slack workspace linkage only — no per-user authorization or approval verification — shipIrisPost publishes a post (sets status to 'published') based solely on (postId, organizationId) with no internal authorization check and no verification that the post was actually approved. The only authorization lives in the caller (apps/agent/.../iris-interaction.ts), which parses the Slack button `value` ({ postId, organizationId, outboxId } — client-supplied via the Slack interaction payload) and performs a single check: `resolveSlackInstallation(teamId)?.organizationId === parsed.organizationId`. This verifies only that the Slack workspace is linked to the organization; it does NOT verify that the clicking Slack user is a member of the organization, has publish/post permissions, or is the intended approver. Consequently any member of a linked Slack workspace (including guests, contractors, or low-privilege members who happen to be in the workspace) can ship (publicly publish) or skip any Iris draft post whose card is visible to them. The DB queries in recordIrisApproval/shipIrisPost are correctly scoped by organizationId (cross-tenant access is prevented), but within a tenant the publish operation has no per-user authorization. Publishing is a sensitive write_external action (content goes live), so missing per-user authorization is a real privilege gap. Note: crafting an arbitrary postId/outboxId requires forging the Slack button value, which a normal user cannot do through the Slack UI — but the value is trusted verbatim with no server-side binding of the value to the card/user, so the effective authorization boundary is workspace membership alone. Fix: Add per-user authorization to the Iris interaction flow before calling shipIrisPost: verify the Slack user (action.user.id) maps to an organization member with permission to publish/approve posts, not just that the workspace is linked. Additionally, have shipIrisPost (or its caller) verify that an approval actually exists for the postId in the corresponding outbox payload before publishing, as a defense-in-depth measure rather than relying entirely on the caller. Consider binding the interaction value to the originating card/run and validating outboxId↔postId consistency server-side.</issue>
</file>

Commit c2b6263 · Posted by Comp AI Code Reviews.

Comment on lines +28 to +233

export function isIrisInteraction(actionId: string): boolean {
return IRIS_ACTION_IDS.has(actionId);
}

function parseIrisInteractionValue(
value: string | undefined
): IrisInteractionValue | null {
if (!value) {
return null;
}

let raw: unknown;
try {
raw = JSON.parse(value);
} catch {
return null;
}

const parsed = irisInteractionValueSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
}

function resolveActorName(action: SlackInteractionAction): string | null {
return action.user.username ?? action.user.name ?? null;
}

const updateDecisionCard = Effect.fn("agent.iris.updateCard")(
function* (input: {
organizationId: string;
teamId: string | null;
channel: string;
ts: string;
content: SlackMessageContent;
}) {
yield* updateSlackMessage({
organizationId: input.organizationId,
teamId: input.teamId,
channel: input.channel,
ts: input.ts,
text: input.content.text,
blocks: input.content.blocks,
});
}
);

function approvalDisplayName(approval: IrisApproval): string {
return approval.slackUserName ?? approval.slackUserId;
}

function toDecisions(
approvals: readonly IrisApproval[]
): readonly IrisArtifactDecision[] {
return approvals.map((approval) => ({
postId: approval.postId,
decidedBy: approvalDisplayName(approval),
outcome: approval.action,
}));
}

const buildDecisionContent = Effect.fn("agent.iris.buildDecisionCard")(
function* (input: {
value: IrisInteractionValue;
approvals: readonly IrisApproval[];
outcome: IrisDecisionOutcome;
actorName: string;
}) {
const card = yield* loadIrisOutboxCard({
organizationId: input.value.organizationId,
outboxId: input.value.outboxId,
});

const decisions = toDecisions(input.approvals);
const decided = decisions.find(
(decision) => decision.postId === input.value.postId
);
const decidedBy = decided?.decidedBy ?? input.actorName;
const decidedOutcome = decided?.outcome ?? input.outcome;

if (!card) {
const fallback: SlackMessageContent = {
text:
decidedOutcome === "shipped"
? `Shipped by ${decidedBy}.`
: `Skipped by ${decidedBy}.`,
blocks: [],
};
return fallback;
}

const shippedPostIds = new Set(
input.approvals
.filter((approval) => approval.action === "shipped")
.map((approval) => approval.postId)
);

return buildShippedBlocks({
organizationId: input.value.organizationId,
organizationSlug: card.organizationSlug,
outboxId: card.row.id,
runId: card.payload.runId,
headline: card.payload.headline,
signalCount: card.payload.signalCount,
trigger: card.payload.trigger,
artifacts: card.payload.artifacts.map((artifact) =>
shippedPostIds.has(artifact.postId)
? { ...artifact, status: "published" }
: artifact
),
decisions:
decisions.length > 0
? decisions
: [
{
postId: input.value.postId,
decidedBy,
outcome: decidedOutcome,
},
],
});
}
);

const runIrisInteraction = Effect.fn("agent.iris.interaction")(
function* (input: {
actionId: string;
value: string | undefined;
messageTs: string | undefined;
channelId: string;
teamId: string | undefined;
actorId: string;
actorName: string | null;
}) {
const parsed = parseIrisInteractionValue(input.value);
const teamId = input.teamId;

if (!(parsed && teamId)) {
yield* Effect.logWarning("Iris interaction payload was not usable").pipe(
Effect.annotateLogs({ actionId: input.actionId })
);
return;
}

const installation = yield* Effect.tryPromise({
try: () => resolveSlackInstallation(teamId),
catch: (cause) =>
new IrisInteractionError({
operation: "resolveInstallation",
cause,
}),
});

if (installation?.organizationId !== parsed.organizationId) {
yield* Effect.logWarning(
"Iris interaction rejected for an organization mismatch"
).pipe(
Effect.annotateLogs({
teamId,
actionOrganizationId: parsed.organizationId,
})
);
return;
}

const outcome: IrisDecisionOutcome =
input.actionId === IRIS_SHIP_ACTION_ID ? "shipped" : "skipped";

const approval = yield* recordIrisApproval({
organizationId: parsed.organizationId,
outboxId: parsed.outboxId,
postId: parsed.postId,
action: outcome,
slackUserId: input.actorId,
slackUserName: input.actorName,
});

if (!approval.recorded) {
yield* Effect.logInfo("Iris decision already recorded").pipe(
Effect.annotateLogs({
organizationId: parsed.organizationId,
postId: parsed.postId,
existingAction: approval.existingAction ?? "none",
})
);

if (approval.existingAction === null) {
yield* Effect.logWarning(
"Iris decision could not be recorded, the card was left unchanged"
).pipe(
Effect.annotateLogs({
organizationId: parsed.organizationId,
outboxId: parsed.outboxId,
postId: parsed.postId,
})
);
return;
}
}

const shouldPublish =
outcome === "shipped" &&
(approval.recorded || approval.existingAction === "shipped");

if (shouldPublish) {
const result = yield* shipIrisPost({
organizationId: parsed.organizationId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM: Any workspace member can ship/skip any Iris post — no per-user approval binding or resource-level authorization

handleIrisInteraction processes Slack button clicks for Iris approvals. The interaction payload value (organizationId/outboxId/postId) is fully attacker-controlled within the Slack interaction envelope. The file correctly binds the Slack workspace's installation to the payload's organizationId (line ~141: installation?.organizationId !== parsed.organizationId), which prevents cross-organization forgery. However, there is NO check that the Slack user who clicked the button (actorId/actorName) is actually permitted to approve posts for this organization, or that they own/target the specific outbox/post. The action.user.id is only recorded as metadata via recordIrisApproval, never verified.

Consequences:

  • Any member of a connected Slack workspace (including guests/limited members, depending on Slack workspace settings) can ship (publish) or skip ANY pending Iris post in that workspace's organization by replaying a captured interaction payload with a different postId, or by crafting the value JSON if they can observe/guess outbox/post IDs. The shipIrisPost call flips a draft post to 'published' (packages/ai/src/autonomy/ship.ts line ~135).
  • There is no per-post approval owner or quorum: a single click publishes.

The organization-mismatch check is the only authorization gate. The trust boundary is 'any member of the Slack workspace', which for workspaces with broad/guest membership may be wider than 'organization members with content publish permission'. This is a resource-level authorization gap: auth confirms 'this workspace is linked to this org' but not 'this Slack user may publish this post'.

Note: Slack interaction signature verification is assumed to be enforced by the eve channel framework at the HTTP boundary (out of scope for this file), so this is not a 'no auth at all' issue — it is a missing finer-grained authorization issue.

Suggestion: Before recording approval and publishing, verify the actor is a member of the organization with publish/approve permission (e.g., resolve the Slack user to a Notra user via the installation and check membership role, or maintain an allowlist of approver Slack user IDs on the mandate/outbox). Consider requiring explicit approver authorization rather than trusting 'any workspace member'. If collaborative-in-workspace approval is intentional, document that trust boundary and restrict which Slack users can be added to the workspace, and consider a quorum/owner-approval policy for publishing.

Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.

<issue at="apps/agent/agent/lib/utils/iris-interaction.ts:28-233" severity="MEDIUM">Any workspace member can ship/skip any Iris post — no per-user approval binding or resource-level authorization — handleIrisInteraction processes Slack button clicks for Iris approvals. The interaction payload value (organizationId/outboxId/postId) is fully attacker-controlled within the Slack interaction envelope. The file correctly binds the Slack workspace's installation to the payload's organizationId (line ~141: `installation?.organizationId !== parsed.organizationId`), which prevents cross-organization forgery. However, there is NO check that the Slack user who clicked the button (actorId/actorName) is actually permitted to approve posts for this organization, or that they own/target the specific outbox/post. The action.user.id is only recorded as metadata via recordIrisApproval, never verified. Consequences: - Any member of a connected Slack workspace (including guests/limited members, depending on Slack workspace settings) can ship (publish) or skip ANY pending Iris post in that workspace's organization by replaying a captured interaction payload with a different postId, or by crafting the value JSON if they can observe/guess outbox/post IDs. The shipIrisPost call flips a draft post to 'published' (packages/ai/src/autonomy/ship.ts line ~135). - There is no per-post approval owner or quorum: a single click publishes. The organization-mismatch check is the only authorization gate. The trust boundary is 'any member of the Slack workspace', which for workspaces with broad/guest membership may be wider than 'organization members with content publish permission'. This is a resource-level authorization gap: auth confirms 'this workspace is linked to this org' but not 'this Slack user may publish this post'. Note: Slack interaction signature verification is assumed to be enforced by the eve channel framework at the HTTP boundary (out of scope for this file), so this is not a 'no auth at all' issue — it is a missing finer-grained authorization issue. Fix: Before recording approval and publishing, verify the actor is a member of the organization with publish/approve permission (e.g., resolve the Slack user to a Notra user via the installation and check membership role, or maintain an allowlist of approver Slack user IDs on the mandate/outbox). Consider requiring explicit approver authorization rather than trusting 'any workspace member'. If collaborative-in-workspace approval is intentional, document that trust boundary and restrict which Slack users can be added to the workspace, and consider a quorum/owner-approval policy for publishing.</issue>

Commit c2b6263.

Comment on lines +149 to +193
export const shipIrisPost = Effect.fn("iris.post.ship")(function* (
input: ShipIrisPostInput
) {
const rows = yield* Effect.tryPromise({
try: () =>
db
.select({
id: posts.id,
title: posts.title,
status: posts.status,
})
.from(posts)
.where(
and(
eq(posts.id, input.postId),
eq(posts.organizationId, input.organizationId)
)
)
.limit(1),
catch: (cause) =>
new IrisShipError({
postId: input.postId,
operation: "loadPost",
cause,
}),
});

const post = rows[0];
if (!post) {
const missing: ShipIrisPostResult = {
status: "not_found",
postId: input.postId,
title: null,
};
return missing;
}

if (post.status === "published") {
const alreadyPublished: ShipIrisPostResult = {
status: "already_published",
postId: post.id,
title: post.title,
};
return alreadyPublished;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM: Iris post publish (ship) is authorized by Slack workspace linkage only — no per-user authorization or approval verification

shipIrisPost publishes a post (sets status to 'published') based solely on (postId, organizationId) with no internal authorization check and no verification that the post was actually approved. The only authorization lives in the caller (apps/agent/.../iris-interaction.ts), which parses the Slack button value ({ postId, organizationId, outboxId } — client-supplied via the Slack interaction payload) and performs a single check: resolveSlackInstallation(teamId)?.organizationId === parsed.organizationId. This verifies only that the Slack workspace is linked to the organization; it does NOT verify that the clicking Slack user is a member of the organization, has publish/post permissions, or is the intended approver. Consequently any member of a linked Slack workspace (including guests, contractors, or low-privilege members who happen to be in the workspace) can ship (publicly publish) or skip any Iris draft post whose card is visible to them. The DB queries in recordIrisApproval/shipIrisPost are correctly scoped by organizationId (cross-tenant access is prevented), but within a tenant the publish operation has no per-user authorization. Publishing is a sensitive write_external action (content goes live), so missing per-user authorization is a real privilege gap. Note: crafting an arbitrary postId/outboxId requires forging the Slack button value, which a normal user cannot do through the Slack UI — but the value is trusted verbatim with no server-side binding of the value to the card/user, so the effective authorization boundary is workspace membership alone.

Suggestion: Add per-user authorization to the Iris interaction flow before calling shipIrisPost: verify the Slack user (action.user.id) maps to an organization member with permission to publish/approve posts, not just that the workspace is linked. Additionally, have shipIrisPost (or its caller) verify that an approval actually exists for the postId in the corresponding outbox payload before publishing, as a defense-in-depth measure rather than relying entirely on the caller. Consider binding the interaction value to the originating card/run and validating outboxId↔postId consistency server-side.

Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.

<issue at="packages/ai/src/autonomy/ship.ts:149-193" severity="MEDIUM">Iris post publish (ship) is authorized by Slack workspace linkage only — no per-user authorization or approval verification — shipIrisPost publishes a post (sets status to 'published') based solely on (postId, organizationId) with no internal authorization check and no verification that the post was actually approved. The only authorization lives in the caller (apps/agent/.../iris-interaction.ts), which parses the Slack button `value` ({ postId, organizationId, outboxId } — client-supplied via the Slack interaction payload) and performs a single check: `resolveSlackInstallation(teamId)?.organizationId === parsed.organizationId`. This verifies only that the Slack workspace is linked to the organization; it does NOT verify that the clicking Slack user is a member of the organization, has publish/post permissions, or is the intended approver. Consequently any member of a linked Slack workspace (including guests, contractors, or low-privilege members who happen to be in the workspace) can ship (publicly publish) or skip any Iris draft post whose card is visible to them. The DB queries in recordIrisApproval/shipIrisPost are correctly scoped by organizationId (cross-tenant access is prevented), but within a tenant the publish operation has no per-user authorization. Publishing is a sensitive write_external action (content goes live), so missing per-user authorization is a real privilege gap. Note: crafting an arbitrary postId/outboxId requires forging the Slack button value, which a normal user cannot do through the Slack UI — but the value is trusted verbatim with no server-side binding of the value to the card/user, so the effective authorization boundary is workspace membership alone. Fix: Add per-user authorization to the Iris interaction flow before calling shipIrisPost: verify the Slack user (action.user.id) maps to an organization member with permission to publish/approve posts, not just that the workspace is linked. Additionally, have shipIrisPost (or its caller) verify that an approval actually exists for the postId in the corresponding outbox payload before publishing, as a defense-in-depth measure rather than relying entirely on the caller. Consider binding the interaction value to the originating card/run and validating outboxId↔postId consistency server-side.</issue>

Commit c2b6263.

@mezotv mezotv closed this Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant