diff --git a/docs/bootstrap/policy-exceptions.md b/docs/bootstrap/policy-exceptions.md index 0ce69cd..d104836 100644 --- a/docs/bootstrap/policy-exceptions.md +++ b/docs/bootstrap/policy-exceptions.md @@ -20,3 +20,82 @@ exceptions: issue: https://github.com/OMT-Global/bootstrap/issues/55 expiresAt: 2026-09-01 ``` + +## Material-action notifications and hard stops + +Configure the second required notification destination by naming the +environment variable that contains its webhook URL. Store only the variable +name in the manifest; the URL and any secret-bearing routing token remain in +the execution environment. + +```yaml +notifications: + webhookUrlEnv: BOOTSTRAP_NOTIFICATION_WEBHOOK_URL +``` + +Describe one material action in a JSON file. The governing target accepts a +local issue or pull request such as `#55`, an `owner/repo#55` shorthand, or a +full GitHub issue or pull-request URL. + +```json +{ + "id": "release-policy-2026-07-16", + "action": "repository-settings-change", + "summary": "Enable private vulnerability reporting.", + "governingTarget": "#55" +} +``` + +Plan before delivery: + +```sh +bootstrap notifications plan --manifest project.bootstrap.yaml --input material-action.json +bootstrap notifications deliver --manifest project.bootstrap.yaml --input material-action.json +``` + +Bootstrap also converts the manifest's expiring-exception intents directly. +This is plan-only unless `--deliver` is explicit: + +```sh +bootstrap notifications exceptions --manifest project.bootstrap.yaml +bootstrap notifications exceptions --manifest project.bootstrap.yaml --deliver +``` + +All notification commands support `--json` and return stable `PRS-NOTIFY-001` and +`PRS-HARDSTOP-001` results. Delivery writes the redacted notification to the +governing GitHub issue or pull request and to the configured HTTPS webhook. A +failed destination blocks continuation. + +For a defined hard stop, add its category plus explicit human approval +evidence. Planning and delivery remain blocking until both fields are present: + +```json +{ + "id": "license-change-2026-07-16", + "action": "license-change", + "summary": "Apply the approved repository license change.", + "governingTarget": "https://github.com/acme/example/issues/55", + "approval": { + "evidence": "https://github.com/acme/example/issues/55#issuecomment-1" + } +} +``` + +The evidence comment must be on the governing issue or pull request, authored +by a configured `github.reviewers` maintainer, and contain this exact marker: + +```text +APPROVE PRS-HARDSTOP-001 action=license-change-2026-07-16 category=license-change digest=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +``` + +The action category is the canonical Flow hard-stop category; there is no +separate caller-controlled hard-stop flag. Planning records that verification +is required and returns the canonical `approvalDigest`; copy that exact digest +into the approval marker. The digest binds the action ID, category, summary, +and governing target so approval cannot be replayed after any of those fields +change. Delivery fetches and verifies the GitHub comment before returning an +approved result. + +Notification delivery still occurs for a blocked hard stop so the governing +record captures the request. Work continues only when both required +destinations succeed and the hard stop has explicit approval evidence. diff --git a/src/cli.ts b/src/cli.ts index 241ae8d..2d9139f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,6 +16,14 @@ import { import { planRepo, applyRepo } from "./render.js"; import { createPublicProvenance, validatePublicProvenance } from "./provenance.js"; import { planFleetPolicyUpgrades, type FleetUpgradeCandidate } from "./upgrades.js"; +import { + deliverExceptionNotifications, + deliverMaterialAction, + formatExceptionNotificationReport, + formatMaterialActionReport, + planExceptionNotifications, + planMaterialAction +} from "./notifications.js"; function defaultTargetDir(manifest: Awaited>, cwd = process.cwd()): string { const currentBasename = path.basename(cwd); @@ -75,6 +83,11 @@ function formatFleetReport(report: Awaited>): ].join("\n"); } +async function readJsonInput(inputPath: string): Promise { + const raw = await import("node:fs/promises").then(({ readFile }) => readFile(path.resolve(inputPath), "utf8")); + return JSON.parse(raw) as unknown; +} + async function main(): Promise { const program = new Command(); program @@ -273,6 +286,53 @@ async function main(): Promise { process.exitCode = report.exitCode; }); + const notifications = program + .command("notifications") + .description("Plan or deliver material-action notifications and enforce defined human hard stops."); + + notifications + .command("plan") + .description("Validate a material action and print its non-mutating notification plan.") + .requiredOption("--input ", "Path to a material-action JSON document") + .option("--manifest ", "Path to manifest") + .option("--json", "Emit versioned JSON") + .action(async (options) => { + const manifest = await loadManifest(resolveManifestPath(options.manifest)); + const report = planMaterialAction(manifest, await readJsonInput(options.input)); + process.stdout.write(`${options.json ? JSON.stringify(report, null, 2) : formatMaterialActionReport(report)}\n`); + process.exitCode = report.exitCode; + }); + + notifications + .command("deliver") + .description("Write a material-action notification to its governing GitHub target and configured webhook.") + .requiredOption("--input ", "Path to a material-action JSON document") + .option("--manifest ", "Path to manifest") + .option("--json", "Emit versioned JSON") + .action(async (options) => { + const manifest = await loadManifest(resolveManifestPath(options.manifest)); + const report = await deliverMaterialAction(manifest, await readJsonInput(options.input)); + process.stdout.write(`${options.json ? JSON.stringify(report, null, 2) : formatMaterialActionReport(report)}\n`); + process.exitCode = report.exitCode; + }); + + notifications + .command("exceptions") + .description("Plan expiring-exception notifications, or deliver them with --deliver.") + .option("--manifest ", "Path to manifest") + .option("--deliver", "Write notifications to the governing GitHub targets and configured webhook") + .option("--json", "Emit versioned JSON") + .action(async (options) => { + const manifest = await loadManifest(resolveManifestPath(options.manifest)); + const report = options.deliver + ? await deliverExceptionNotifications(manifest) + : planExceptionNotifications(manifest); + process.stdout.write( + `${options.json ? JSON.stringify(report, null, 2) : formatExceptionNotificationReport(report)}\n` + ); + process.exitCode = report.exitCode; + }); + const provenance = program.command("provenance").description("Validate public provenance manifests before publication."); provenance .command("validate") diff --git a/src/manifest.ts b/src/manifest.ts index c44927b..8f1e392 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -208,6 +208,10 @@ const publisherSchema = z.object({ .optional() }); +const notificationSchema = z.object({ + webhookUrlEnv: z.string().regex(/^[A-Z_][A-Z0-9_]*$/) +}); + const exceptionSchema = z.object({ id: z.string().min(1), policy: z.string().min(1), @@ -302,6 +306,7 @@ const manifestSchema = z.object({ defaultBranch: z.string().min(1).optional() }), publisher: publisherSchema.optional(), + notifications: notificationSchema.optional(), repo: z .object({ class: z @@ -431,7 +436,7 @@ const manifestSchema = z.object({ }).passthrough(); const KNOWN_MANIFEST_SETTINGS = new Set([ - "version", "project", "publisher", "repo", "archetype", "github", "ci", "release", "agents", "capabilities", "policy", "exceptions", "environments" + "version", "project", "publisher", "notifications", "repo", "archetype", "github", "ci", "release", "agents", "capabilities", "policy", "exceptions", "environments" ]); function slugify(value: string): string { @@ -776,6 +781,7 @@ export function normalizeManifest(raw: z.input): Bootstra ? { spendingApprovalThreshold: { ...parsed.publisher.spendingApprovalThreshold } } : {}) }, + ...(parsed.notifications ? { notifications: { ...parsed.notifications } } : {}), repo: normalizeRepo(parsed.repo), archetype: { kind: parsed.archetype.kind, @@ -944,6 +950,7 @@ export async function loadManifest(manifestPath: string): Promise; publisher?: Partial; + notifications?: BootstrapManifest["notifications"]; repo?: Partial; archetype?: Partial; github?: Partial; @@ -967,6 +974,7 @@ export function createSampleManifest(overrides?: ManifestOverrides): string { defaultBranch: overrides?.project?.defaultBranch ?? "main" }, publisher: overrides?.publisher, + notifications: overrides?.notifications, repo: overrides?.repo, archetype: { kind: overrides?.archetype?.kind ?? "node-ts-service", diff --git a/src/notifications.ts b/src/notifications.ts new file mode 100644 index 0000000..c79b870 --- /dev/null +++ b/src/notifications.ts @@ -0,0 +1,649 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod"; + +import { GitHubClient } from "./github/client.js"; +import { validatePolicyExceptions } from "./exceptions.js"; +import { containsCredential, redactPublicText } from "./provenance.js"; +import type { BootstrapManifest } from "./types.js"; + +export const MATERIAL_ACTIONS = [ + "adr-change", + "public-api-change", + "breaking-compatibility-change", + "database-migration", + "runtime-dependency-addition", + "authentication-authorization-change", + "security-finding-or-waiver", + "production-deployment", + "release-publication", + "repository-visibility-change", + "repository-settings-change", + "material-recurring-cost", + "policy-exception", + "major-feature-removal", + "provenance-capture-failure", + "conformance-bypass" +] as const; + +export const HUMAN_HARD_STOP_CATEGORIES = [ + "license-change", + "public-ownership-change", + "repository-ownership-transfer", + "expose-previously-private-data", + "irreversible-production-data-destruction", + "external-legal-contractual-obligation", + "spend-above-configured-threshold", + "grant-organization-owner-permission", + "grant-billing-permission", + "permanent-policy-exception" +] as const; + +export const ACTION_CATEGORIES = [...MATERIAL_ACTIONS, ...HUMAN_HARD_STOP_CATEGORIES] as const; +const hardStopCategorySet = new Set(HUMAN_HARD_STOP_CATEGORIES); + +export const materialActionSchema = z + .object({ + id: z.string().regex(/^[A-Za-z0-9._-]+$/), + action: z.enum(ACTION_CATEGORIES), + summary: z + .string() + .min(1) + .max(2_000) + .refine((value) => !/[\r\n\u2028\u2029]/.test(value), "Material-action summaries must be a single line."), + governingTarget: z.string().min(1), + approval: z + .object({ + evidence: z.string().url() + }) + .optional() + }) + .superRefine((action, context) => { + if (containsCredential(action.id)) { + context.addIssue({ code: "custom", path: ["id"], message: "Action IDs must not contain credential-like literals." }); + } + if (action.approval && !hardStopCategorySet.has(action.action)) { + context.addIssue({ code: "custom", path: ["approval"], message: "Approval evidence is valid only for a defined human hard-stop category." }); + } + if (containsCredential(action.governingTarget)) { + context.addIssue({ code: "custom", path: ["governingTarget"], message: "Governing targets must not contain credential-like literals." }); + } + }); + +export type MaterialAction = z.infer; + +const hardStopResultSchema = z.object({ + ruleId: z.literal("PRS-HARDSTOP-001"), + status: z.enum(["not-applicable", "verification-required", "approved", "blocking"]), + category: z.enum(HUMAN_HARD_STOP_CATEGORIES).optional(), + detail: z.string() +}); + +const notificationResultSchema = z.object({ + ruleId: z.literal("PRS-NOTIFY-001"), + status: z.enum(["ready", "delivered", "blocking"]), + destinations: z.array( + z.object({ + destination: z.enum(["governing-issue-or-pull-request", "configured-webhook"]), + status: z.enum(["planned", "delivered", "failed"]), + detail: z.string() + }) + ), + detail: z.string() +}); + +const webhookPayloadSchema = z.object({ + schemaVersion: z.literal(1), + ruleId: z.literal("PRS-NOTIFY-001"), + actionId: z.string(), + action: z.enum(ACTION_CATEGORIES), + summary: z.string(), + governingTarget: z.string(), + hardStopCategory: z.enum(HUMAN_HARD_STOP_CATEGORIES).optional(), + approvalDigest: z.string().regex(/^[0-9a-f]{64}$/).optional(), + approvalEvidence: z.string().optional() +}); + +export const materialActionPlanSchema = z.object({ + schemaVersion: z.literal(1), + actionId: z.string(), + action: z.enum(ACTION_CATEGORIES), + governingTarget: z.string(), + approvalDigest: z.string().regex(/^[0-9a-f]{64}$/).optional(), + notification: notificationResultSchema, + hardStop: hardStopResultSchema, + continueAfterNotification: z.boolean(), + redactions: z.number().int().nonnegative(), + commentBody: z.string(), + webhookPayload: webhookPayloadSchema, + exitCode: z.union([z.literal(0), z.literal(1)]) +}); + +export type MaterialActionPlan = z.infer; +export type MaterialActionDeliveryReport = MaterialActionPlan; + +export const exceptionNotificationReportSchema = z.object({ + schemaVersion: z.literal(1), + mode: z.enum(["plan", "deliver"]), + notifications: z.array(materialActionPlanSchema), + blockingExceptions: z.boolean(), + exitCode: z.union([z.literal(0), z.literal(1)]) +}); + +export type ExceptionNotificationReport = z.infer; + +interface GitHubTarget { + owner: string; + repo: string; + number: number; +} + +interface GitHubIssueComment { + body?: string; + html_url?: string; + issue_url?: string; + author_association?: string; + user?: { login?: string }; +} + +interface GitHubCollaboratorPermission { + permission?: string; + role_name?: string; +} + +const maintainerRoles = new Set(["admin", "maintain"]); + +export interface WebhookResult { + ok: boolean; + status: number; +} + +export type WebhookSender = (url: string, payload: MaterialActionPlan["webhookPayload"]) => Promise; + +export interface DeliveryOptions { + githubClient?: GitHubClient; + environment?: NodeJS.ProcessEnv; + webhookSender?: WebhookSender; +} + +export interface ExceptionDeliveryOptions extends DeliveryOptions { + now?: Date; +} + +function parseGitHubTarget(value: string, manifest: BootstrapManifest): GitHubTarget | undefined { + const local = /^#([1-9][0-9]*)$/.exec(value); + if (local) { + const number = safePositiveInteger(local[1]!); + return number && validGitHubOwner(manifest.project.owner) && validGitHubRepo(manifest.project.name) + ? { owner: manifest.project.owner, repo: manifest.project.name, number } + : undefined; + } + + const shorthand = /^([^/\s]+)\/([^/#\s]+)#([1-9][0-9]*)$/.exec(value); + if (shorthand) { + const number = safePositiveInteger(shorthand[3]!); + return number && validGitHubOwner(shorthand[1]!) && validGitHubRepo(shorthand[2]!) + ? { owner: shorthand[1]!, repo: shorthand[2]!, number } + : undefined; + } + + const url = /^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/([1-9][0-9]*)\/?$/.exec(value); + if (url) { + const number = safePositiveInteger(url[3]!); + return number && validGitHubOwner(url[1]!) && validGitHubRepo(url[2]!) + ? { owner: url[1]!, repo: url[2]!, number } + : undefined; + } + + return undefined; +} + +function validGitHubOwner(value: string): boolean { + return /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(value); +} + +function validGitHubRepo(value: string): boolean { + return /^[A-Za-z0-9_.-]{1,100}$/.test(value) && value !== "." && value !== ".."; +} + +function safePositiveInteger(value: string): number | undefined { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +} + +function parseApprovalEvidence(value: string): (GitHubTarget & { commentId: number }) | undefined { + const match = /^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/([1-9][0-9]*)#issuecomment-([1-9][0-9]*)$/.exec(value); + if (!match) return undefined; + const number = safePositiveInteger(match[3]!); + const commentId = safePositiveInteger(match[4]!); + return number && commentId && validGitHubOwner(match[1]!) && validGitHubRepo(match[2]!) + ? { owner: match[1]!, repo: match[2]!, number, commentId } + : undefined; +} + +function parseApiIssueUrl(value: string): GitHubTarget | undefined { + const match = /^https:\/\/api\.github\.com\/repos\/([^/\s]+)\/([^/\s]+)\/issues\/([1-9][0-9]*)\/?$/i.exec(value); + if (!match) return undefined; + const number = safePositiveInteger(match[3]!); + return number && validGitHubOwner(match[1]!) && validGitHubRepo(match[2]!) + ? { owner: match[1]!, repo: match[2]!, number } + : undefined; +} + +function sameGitHubTarget(left: GitHubTarget, right: GitHubTarget): boolean { + return ( + left.owner.toLowerCase() === right.owner.toLowerCase() && + left.repo.toLowerCase() === right.repo.toLowerCase() && + left.number === right.number + ); +} + +function approvalDigest(action: MaterialAction): string { + const canonical = JSON.stringify({ + schemaVersion: 1, + id: action.id, + action: action.action, + summary: action.summary, + governingTarget: action.governingTarget + }); + return createHash("sha256").update(canonical, "utf8").digest("hex"); +} + +async function verifiedHardStopResult( + action: MaterialAction, + manifest: BootstrapManifest, + governingTarget: GitHubTarget | undefined, + githubClient: GitHubClient +): Promise> { + const category = hardStopCategory(action); + if (!category || !action.approval || !governingTarget) return hardStopResult(action); + const evidence = parseApprovalEvidence(action.approval.evidence); + if (!evidence || !sameGitHubTarget(evidence, governingTarget)) { + return { + ruleId: "PRS-HARDSTOP-001", + status: "blocking", + category, + detail: `Approval evidence was not verified for ${category}.` + }; + } + + try { + const comment = await githubClient.api( + "GET", + `/repos/${evidence.owner}/${evidence.repo}/issues/comments/${evidence.commentId}` + ); + const author = comment.user?.login; + const returnedEvidence = comment.html_url ? parseApprovalEvidence(comment.html_url) : undefined; + const returnedIssue = comment.issue_url ? parseApiIssueUrl(comment.issue_url) : undefined; + const digest = approvalDigest(action); + const expectedMarker = `APPROVE PRS-HARDSTOP-001 action=${action.id} category=${category} digest=${digest}`; + const markerPresent = comment.body?.split(/\r?\n/).some((line) => line.trim() === expectedMarker) ?? false; + const commentVerified = + returnedEvidence !== undefined && + returnedEvidence.commentId === evidence.commentId && + sameGitHubTarget(returnedEvidence, evidence) && + returnedIssue !== undefined && + sameGitHubTarget(returnedIssue, evidence) && + author !== undefined && + manifest.github.reviewers.some((reviewer) => reviewer.toLowerCase() === author.toLowerCase()) && + markerPresent; + const permission = commentVerified + ? await githubClient.api( + "GET", + `/repos/${evidence.owner}/${evidence.repo}/collaborators/${encodeURIComponent(author!)}/permission` + ) + : undefined; + const verified = + commentVerified && + (maintainerRoles.has(permission?.role_name?.toLowerCase() ?? "") || permission?.permission?.toLowerCase() === "admin"); + return verified + ? { + ruleId: "PRS-HARDSTOP-001", + status: "approved", + category, + detail: `Verified GitHub approval from configured reviewer ${author}.` + } + : { + ruleId: "PRS-HARDSTOP-001", + status: "blocking", + category, + detail: `Approval evidence was not verified for ${category}.` + }; + } catch { + return { + ruleId: "PRS-HARDSTOP-001", + status: "blocking", + category, + detail: `Approval evidence was not verified for ${category}.` + }; + } +} + +function redacted(value: string): { value: string; replacements: number } { + return redactPublicText(value); +} + +function hardStopCategory(action: MaterialAction): (typeof HUMAN_HARD_STOP_CATEGORIES)[number] | undefined { + return HUMAN_HARD_STOP_CATEGORIES.find((category) => category === action.action); +} + +function hardStopResult(action: MaterialAction): z.infer { + const category = hardStopCategory(action); + if (!category) { + return { + ruleId: "PRS-HARDSTOP-001", + status: "not-applicable", + detail: "No defined human hard stop applies." + }; + } + + if (!action.approval) { + return { + ruleId: "PRS-HARDSTOP-001", + status: "blocking", + category, + detail: `Explicit human approval evidence is required for ${category}.` + }; + } + + return { + ruleId: "PRS-HARDSTOP-001", + status: "verification-required", + category, + detail: `GitHub approval evidence must be verified for ${category}.` + }; +} + +function buildPublicPayload(action: MaterialAction): { + summary: string; + approvalEvidence?: string; + redactions: number; +} { + const summary = redacted(action.summary); + const approvalEvidence = action.approval ? redacted(action.approval.evidence) : undefined; + return { + summary: summary.value, + ...(approvalEvidence ? { approvalEvidence: approvalEvidence.value } : {}), + redactions: summary.replacements + (approvalEvidence?.replacements ?? 0) + }; +} + +function escapeGitHubMarkdownText(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/([\\`*_{}\[\]()#+.!|~-])/g, "\\$1"); +} + +function materialActionCommentBody( + action: MaterialAction, + publicPayload: ReturnType, + hardStop: z.infer, + digest?: string +): string { + const renderedSummary = escapeGitHubMarkdownText(publicPayload.summary); + const renderedApprovalEvidence = publicPayload.approvalEvidence + ? escapeGitHubMarkdownText(publicPayload.approvalEvidence) + : undefined; + const approvalLine = action.approval + ? `- Approval evidence: ${renderedApprovalEvidence} (${hardStop.status === "approved" ? "verified" : "verification required"})` + : "- Approval: not supplied"; + const mayContinue = hardStop.status === "not-applicable" || hardStop.status === "approved"; + return [ + "## Material action notification", + "", + "- Rule: PRS-NOTIFY-001", + `- Action ID: ${action.id}`, + `- Action: ${action.action}`, + `- Summary: ${renderedSummary}`, + `- Hard stop: ${hardStop.category ?? "none"}`, + ...(digest ? [`- Approval digest: ${digest}`] : []), + approvalLine, + `- Decision: ${mayContinue ? "continue after required notifications are delivered" : "stop pending verified human approval"}` + ].join("\n"); +} + +export function planMaterialAction(manifest: BootstrapManifest, input: unknown): MaterialActionPlan { + const action = materialActionSchema.parse(input); + const githubTarget = parseGitHubTarget(action.governingTarget, manifest); + const publicGoverningTarget = githubTarget + ? `${githubTarget.owner}/${githubTarget.repo}#${githubTarget.number}` + : "[INVALID:GOVERNING-TARGET]"; + const publicPayload = buildPublicPayload(action); + const hardStop = hardStopResult(action); + const digest = hardStop.category ? approvalDigest(action) : undefined; + const configurationErrors = [ + ...(!githubTarget ? ["governingTarget must identify a GitHub issue or pull request."] : []), + ...(!manifest.notifications ? ["notifications.webhookUrlEnv is not configured."] : []) + ]; + const notificationStatus = configurationErrors.length === 0 ? "ready" : "blocking"; + const commentBody = materialActionCommentBody(action, publicPayload, hardStop, digest); + const webhookPayload = webhookPayloadSchema.parse({ + schemaVersion: 1, + ruleId: "PRS-NOTIFY-001", + actionId: action.id, + action: action.action, + summary: publicPayload.summary, + governingTarget: publicGoverningTarget, + ...(hardStop.category ? { hardStopCategory: hardStop.category } : {}), + ...(digest ? { approvalDigest: digest } : {}), + ...(publicPayload.approvalEvidence ? { approvalEvidence: publicPayload.approvalEvidence } : {}) + }); + const notification = notificationResultSchema.parse({ + ruleId: "PRS-NOTIFY-001", + status: notificationStatus, + destinations: [ + { + destination: "governing-issue-or-pull-request", + status: "planned", + detail: githubTarget ? action.governingTarget : "Invalid governing target." + }, + { + destination: "configured-webhook", + status: "planned", + detail: manifest.notifications?.webhookUrlEnv ?? "No webhook environment-variable reference is configured." + } + ], + detail: configurationErrors.length === 0 ? "Both required notification destinations are configured." : configurationErrors.join(" ") + }); + + return materialActionPlanSchema.parse({ + schemaVersion: 1, + actionId: action.id, + action: action.action, + governingTarget: publicGoverningTarget, + ...(digest ? { approvalDigest: digest } : {}), + notification, + hardStop, + continueAfterNotification: notification.status !== "blocking" && hardStop.status === "not-applicable", + redactions: publicPayload.redactions, + commentBody, + webhookPayload, + exitCode: notification.status === "blocking" || hardStop.status !== "not-applicable" ? 1 : 0 + }); +} + +const defaultWebhookSender: WebhookSender = async (url, payload) => { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + redirect: "error", + signal: AbortSignal.timeout(10_000) + }); + return { ok: response.ok, status: response.status }; +}; + +export async function deliverMaterialAction( + manifest: BootstrapManifest, + input: unknown, + options: DeliveryOptions = {} +): Promise { + const action = materialActionSchema.parse(input); + const plan = planMaterialAction(manifest, input); + const githubTarget = parseGitHubTarget(plan.governingTarget, manifest); + const githubClient = options.githubClient ?? new GitHubClient(); + const environment = options.environment ?? process.env; + const webhookSender = options.webhookSender ?? defaultWebhookSender; + const destinations: MaterialActionPlan["notification"]["destinations"] = []; + const hardStop = await verifiedHardStopResult(action, manifest, githubTarget, githubClient); + const commentBody = materialActionCommentBody(action, buildPublicPayload(action), hardStop, plan.approvalDigest); + + if (!githubTarget) { + destinations.push({ + destination: "governing-issue-or-pull-request", + status: "failed", + detail: "Invalid governing target." + }); + } else { + try { + await githubClient.api( + "POST", + `/repos/${githubTarget.owner}/${githubTarget.repo}/issues/${githubTarget.number}/comments`, + { body: commentBody } + ); + destinations.push({ + destination: "governing-issue-or-pull-request", + status: "delivered", + detail: plan.governingTarget + }); + } catch { + destinations.push({ + destination: "governing-issue-or-pull-request", + status: "failed", + detail: "GitHub comment delivery failed." + }); + } + } + + const webhookEnvironmentName = manifest.notifications?.webhookUrlEnv; + const webhookUrl = webhookEnvironmentName ? environment[webhookEnvironmentName] : undefined; + if (!githubTarget) { + destinations.push({ + destination: "configured-webhook", + status: "failed", + detail: "Webhook delivery skipped because the governing target is invalid." + }); + } else if (!webhookEnvironmentName || !webhookUrl) { + destinations.push({ + destination: "configured-webhook", + status: "failed", + detail: webhookEnvironmentName + ? `Environment variable ${webhookEnvironmentName} is not set.` + : "No webhook environment-variable reference is configured." + }); + } else { + try { + const parsedUrl = new URL(webhookUrl); + if (parsedUrl.protocol !== "https:") throw new Error("Webhook must use HTTPS."); + const response = await webhookSender(webhookUrl, plan.webhookPayload); + destinations.push({ + destination: "configured-webhook", + status: response.ok ? "delivered" : "failed", + detail: response.ok ? `Webhook returned HTTP ${response.status}.` : `Webhook rejected delivery with HTTP ${response.status}.` + }); + } catch { + destinations.push({ + destination: "configured-webhook", + status: "failed", + detail: "Webhook delivery failed." + }); + } + } + + const delivered = destinations.every((destination) => destination.status === "delivered"); + const notification = notificationResultSchema.parse({ + ruleId: "PRS-NOTIFY-001", + status: delivered ? "delivered" : "blocking", + destinations, + detail: delivered ? "Both required notification destinations were delivered." : "A required notification destination failed." + }); + + return materialActionPlanSchema.parse({ + ...plan, + notification, + hardStop, + commentBody, + continueAfterNotification: delivered && (hardStop.status === "not-applicable" || hardStop.status === "approved"), + exitCode: delivered && (hardStop.status === "not-applicable" || hardStop.status === "approved") ? 0 : 1 + }); +} + +function exceptionMaterialActions(manifest: BootstrapManifest, now: Date): { + actions: MaterialAction[]; + blocking: boolean; +} { + const validation = validatePolicyExceptions(manifest.exceptions, now); + const actions = validation.notifications.map((notification) => { + const exception = manifest.exceptions.find((entry) => entry.id === notification.exceptionId); + if (!exception?.expiresAt) { + throw new Error(`Notification intent ${notification.exceptionId} has no matching expiring exception.`); + } + const idDigest = createHash("sha256").update(exception.id, "utf8").digest("hex").slice(0, 12); + const displayId = redactPublicText(exception.id).value.replace(/[\r\n\u2028\u2029]+/g, " ").slice(0, 512); + const governingTarget = containsCredential(exception.issue) ? "invalid-governing-target" : exception.issue; + return materialActionSchema.parse({ + id: `exception-${idDigest}-expiring`, + action: "policy-exception", + summary: `Temporary policy exception ${displayId} expires on ${exception.expiresAt}.`, + governingTarget + }); + }); + return { actions, blocking: validation.blocking }; +} + +export function planExceptionNotifications( + manifest: BootstrapManifest, + now = new Date() +): ExceptionNotificationReport { + const exceptions = exceptionMaterialActions(manifest, now); + const notifications = exceptions.actions.map((action) => planMaterialAction(manifest, action)); + return exceptionNotificationReportSchema.parse({ + schemaVersion: 1, + mode: "plan", + notifications, + blockingExceptions: exceptions.blocking, + exitCode: exceptions.blocking || notifications.some((notification) => notification.exitCode === 1) ? 1 : 0 + }); +} + +export async function deliverExceptionNotifications( + manifest: BootstrapManifest, + options: ExceptionDeliveryOptions = {} +): Promise { + const exceptions = exceptionMaterialActions(manifest, options.now ?? new Date()); + const notifications: MaterialActionPlan[] = []; + for (const action of exceptions.actions) { + notifications.push(await deliverMaterialAction(manifest, action, options)); + } + return exceptionNotificationReportSchema.parse({ + schemaVersion: 1, + mode: "deliver", + notifications, + blockingExceptions: exceptions.blocking, + exitCode: exceptions.blocking || notifications.some((notification) => notification.exitCode === 1) ? 1 : 0 + }); +} + +export function formatMaterialActionReport(report: MaterialActionPlan): string { + return [ + `Material action ${report.actionId}: ${report.exitCode === 0 ? "ready" : "blocked"}`, + `- [${report.notification.status}] ${report.notification.ruleId}: ${report.notification.detail}`, + ...report.notification.destinations.map( + (destination) => ` - [${destination.status}] ${destination.destination}: ${destination.detail}` + ), + `- [${report.hardStop.status}] ${report.hardStop.ruleId}: ${report.hardStop.detail}`, + ...(report.approvalDigest ? [`- Approval digest: ${report.approvalDigest}`] : []), + `- Continue after notification: ${report.continueAfterNotification ? "yes" : "no"}`, + `- Redactions: ${report.redactions}` + ].join("\n"); +} + +export function formatExceptionNotificationReport(report: ExceptionNotificationReport): string { + return [ + `Exception notifications ${report.mode}: ${report.notifications.length} intent(s), ${report.blockingExceptions ? "blocking exceptions present" : "no blocking exceptions"}`, + ...report.notifications.flatMap((notification) => formatMaterialActionReport(notification).split("\n")), + `Exception notification result: ${report.exitCode === 0 ? "pass" : "block"}` + ].join("\n"); +} diff --git a/src/types.ts b/src/types.ts index c842197..7409e07 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,6 +37,10 @@ export interface PublisherConfig { spendingApprovalThreshold?: SpendingApprovalThreshold; } +export interface NotificationConfig { + webhookUrlEnv: string; +} + export interface CodeownerRule { pattern: string; owners: string[]; @@ -186,6 +190,7 @@ export interface BootstrapManifest { defaultBranch: string; }; publisher: PublisherConfig; + notifications?: NotificationConfig; repo: RepoConfig; archetype: { kind: ArchetypeKind; diff --git a/tests/notifications.test.ts b/tests/notifications.test.ts new file mode 100644 index 0000000..63ea8db --- /dev/null +++ b/tests/notifications.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, it, vi } from "vitest"; + +import { GitHubClient } from "../src/github/client.js"; +import { normalizeManifest, stringifyManifest } from "../src/manifest.js"; +import { + deliverExceptionNotifications, + deliverMaterialAction, + exceptionNotificationReportSchema, + materialActionPlanSchema, + planExceptionNotifications, + planMaterialAction, + type WebhookSender +} from "../src/notifications.js"; +import type { CommandRunner } from "../src/lib/process.js"; + +const githubPat = ["github", "pat"].join("_") + "_abcdefghijklmnopqrstuvwxyz123456"; + +function manifest(withNotifications = true) { + return normalizeManifest({ + project: { name: "notifications", owner: "acme" }, + ...(withNotifications ? { notifications: { webhookUrlEnv: "BOOTSTRAP_NOTIFICATION_WEBHOOK_URL" } } : {}), + archetype: { kind: "generic-empty" }, + github: { reviewers: ["maintainer"] } + }); +} + +function action(overrides: Record = {}) { + return { + id: "action-55", + action: "repository-settings-change", + summary: "Enable private vulnerability reporting.", + governingTarget: "#55", + ...overrides + }; +} + +describe("material-action notifications", () => { + it("plans both required destinations, redacts credentials, and preserves continue semantics", () => { + const report = planMaterialAction( + manifest(), + action({ summary: `Rotate token=${githubPat} before changing repository settings.` }) + ); + + expect(materialActionPlanSchema.parse(report)).toEqual(report); + expect(report.notification).toMatchObject({ ruleId: "PRS-NOTIFY-001", status: "ready" }); + expect(report.hardStop).toMatchObject({ ruleId: "PRS-HARDSTOP-001", status: "not-applicable" }); + expect(report.continueAfterNotification).toBe(true); + expect(report.redactions).toBeGreaterThan(0); + expect(JSON.stringify(report)).not.toContain(githubPat); + expect(report.notification.destinations.map((entry) => entry.destination)).toEqual([ + "governing-issue-or-pull-request", + "configured-webhook" + ]); + }); + + it("blocks defined hard stops without explicit approval evidence", () => { + const report = planMaterialAction(manifest(), action({ action: "license-change" })); + + expect(report.notification.status).toBe("ready"); + expect(report.hardStop).toMatchObject({ + ruleId: "PRS-HARDSTOP-001", + status: "blocking", + category: "license-change" + }); + expect(report.continueAfterNotification).toBe(false); + expect(report.exitCode).toBe(1); + }); + + it("delivers to GitHub and the configured webhook before allowing work to continue", async () => { + const approvalEvidence = "https://github.com/ACME/Notifications/issues/55#issuecomment-1"; + const actionInput = action({ + action: "license-change", + summary: "Still (verification required); never copy stop pending verified human approval from summary text.", + approval: { evidence: approvalEvidence } + }); + const planned = planMaterialAction(manifest(), actionInput); + const runner = vi.fn(async (_command, args) => ({ + stdout: args?.some((arg) => arg.toLowerCase() === "/repos/acme/notifications/issues/comments/1") + ? JSON.stringify({ + body: `APPROVE PRS-HARDSTOP-001 action=action-55 category=license-change digest=${planned.approvalDigest}`, + html_url: "https://github.com/acme/notifications/issues/55#issuecomment-1", + issue_url: "https://api.github.com/repos/acme/notifications/issues/55", + user: { login: "Maintainer" } + }) + : args?.some((arg) => arg.toLowerCase() === "/repos/acme/notifications/collaborators/maintainer/permission") + ? JSON.stringify({ permission: "write", role_name: "maintain" }) + : "{}", + stderr: "", + exitCode: 0 + })); + const webhookSender = vi.fn(async () => ({ ok: true, status: 204 })); + const report = await deliverMaterialAction( + manifest(), + actionInput, + { + githubClient: new GitHubClient({ runner }), + environment: { BOOTSTRAP_NOTIFICATION_WEBHOOK_URL: "https://notifications.example.test/hooks/material" }, + webhookSender + } + ); + + expect(planned.hardStop.status).toBe("verification-required"); + expect(planned.approvalDigest).toMatch(/^[0-9a-f]{64}$/); + expect(planned.continueAfterNotification).toBe(false); + expect(planned.exitCode).toBe(1); + expect(report.notification.status).toBe("delivered"); + expect(report.hardStop.status).toBe("approved"); + expect(report.continueAfterNotification).toBe(true); + expect(report.exitCode).toBe(0); + expect(report.commentBody).toContain("- Approval evidence: "); + expect(report.commentBody).toContain("(verified)"); + expect(report.commentBody).toContain("- Decision: continue after required notifications are delivered"); + expect(runner).toHaveBeenCalledWith( + "gh", + expect.arrayContaining(["/repos/acme/notifications/issues/55/comments"]), + expect.objectContaining({ input: expect.stringContaining("PRS-NOTIFY-001") }) + ); + expect(webhookSender).toHaveBeenCalledWith( + "https://notifications.example.test/hooks/material", + expect.objectContaining({ ruleId: "PRS-NOTIFY-001", actionId: "action-55", hardStopCategory: "license-change" }) + ); + }); + + it("rejects hard-stop approval from a configured reviewer without maintain permission", async () => { + const approvalEvidence = "https://github.com/acme/notifications/issues/55#issuecomment-2"; + const actionInput = action({ action: "license-change", approval: { evidence: approvalEvidence } }); + const planned = planMaterialAction(manifest(), actionInput); + const runner: CommandRunner = async (_command, args) => ({ + stdout: args?.includes("/repos/acme/notifications/issues/comments/2") + ? JSON.stringify({ + body: `APPROVE PRS-HARDSTOP-001 action=action-55 category=license-change digest=${planned.approvalDigest}`, + html_url: approvalEvidence, + issue_url: "https://api.github.com/repos/acme/notifications/issues/55", + user: { login: "maintainer" } + }) + : args?.includes("/repos/acme/notifications/collaborators/maintainer/permission") + ? JSON.stringify({ permission: "write", role_name: "write" }) + : "{}", + stderr: "", + exitCode: 0 + }); + const report = await deliverMaterialAction( + manifest(), + actionInput, + { + githubClient: new GitHubClient({ runner }), + environment: { BOOTSTRAP_NOTIFICATION_WEBHOOK_URL: "https://notifications.example.test/hooks/material" }, + webhookSender: async () => ({ ok: true, status: 204 }) + } + ); + + expect(report.notification.status).toBe("delivered"); + expect(report.hardStop).toMatchObject({ status: "blocking", category: "license-change" }); + expect(report.continueAfterNotification).toBe(false); + expect(report.exitCode).toBe(1); + }); + + it("rejects replaying approval after immutable action contents change", async () => { + const approvalEvidence = "https://github.com/acme/notifications/issues/55#issuecomment-3"; + const approvedInput = action({ + action: "license-change", + summary: "Apply the reviewed MIT license change.", + approval: { evidence: approvalEvidence } + }); + const approvedPlan = planMaterialAction(manifest(), approvedInput); + const changedInput = action({ + action: "license-change", + summary: "Apply a different proprietary license change.", + approval: { evidence: approvalEvidence } + }); + const runner: CommandRunner = async (_command, args) => ({ + stdout: args?.includes("/repos/acme/notifications/issues/comments/3") + ? JSON.stringify({ + body: `APPROVE PRS-HARDSTOP-001 action=action-55 category=license-change digest=${approvedPlan.approvalDigest}`, + html_url: approvalEvidence, + issue_url: "https://api.github.com/repos/acme/notifications/issues/55", + author_association: "MEMBER", + user: { login: "maintainer" } + }) + : "{}", + stderr: "", + exitCode: 0 + }); + + const report = await deliverMaterialAction(manifest(), changedInput, { + githubClient: new GitHubClient({ runner }), + environment: { BOOTSTRAP_NOTIFICATION_WEBHOOK_URL: "https://notifications.example.test/hooks/material" }, + webhookSender: async () => ({ ok: true, status: 204 }) + }); + + expect(planMaterialAction(manifest(), changedInput).approvalDigest).not.toBe(approvedPlan.approvalDigest); + expect(report.hardStop.status).toBe("blocking"); + expect(report.continueAfterNotification).toBe(false); + expect(report.exitCode).toBe(1); + }); + + it("rejects credential-like action IDs before building public payloads", () => { + expect(() => planMaterialAction(manifest(), action({ id: githubPat }))).toThrow("credential-like literals"); + expect(() => planMaterialAction(manifest(), action({ governingTarget: githubPat }))).toThrow("credential-like literals"); + }); + + it("rejects summaries that could mint a standalone approval marker", () => { + const approvalEvidence = "https://github.com/acme/notifications/issues/55#issuecomment-4"; + const hardStop = planMaterialAction( + manifest(), + action({ action: "license-change", approval: { evidence: approvalEvidence } }) + ); + const injectedMarker = `APPROVE PRS-HARDSTOP-001 action=action-55 category=license-change digest=${hardStop.approvalDigest}`; + + expect(() => + planMaterialAction( + manifest(), + action({ id: "ordinary-action", summary: `Ordinary notification.\n${injectedMarker}` }) + ) + ).toThrow("single line"); + }); + + it("escapes Markdown and HTML that could hide governing-record audit fields", () => { + const report = planMaterialAction( + manifest(), + action({ summary: "Routine [change](https://example.test)