/* RFC-0684: Zod schemas, loaders, merger, and post-filter for the Axiom finding suppression layer. Defines the shape for suppression rules, loads workshop-level and per-site configs, merges them, and applies suppressions to findings. Does not execute Axiom checks — that belongs in mission.check and the external Axiom package. Does not validate suppression config — that belongs in suppressions-validate.ts. Does not modify evidence files — applySuppressions returns a new array; the caller decides whether to write it back. RFC-0684: initial implementation of suppression config schema, loaders, merger, and applySuppressions post-filter. RFC-0688: add titlePattern field to match against finding.title (always populated). messagePattern/descriptionPattern kept for forward compatibility but match against non-existent Finding fields. RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared RFC-1208: add expiresOn/reviewAfter lifecycle fields; expired rules inactive in applySuppressions via asOf-aware UTC calendar-date comparison */ import { join } from "node:path"; import { createDefaultIO, type WorkspaceIO } from "../kernel/workspace-io.ts"; import { z } from "zod"; import { parse as parseYaml } from "yaml"; /** * Minimal Finding shape matching @syrokomskyi/axiom-study's Finding interface. * Defined locally so the package typechecks without axiom-study installed. * See share/types/axiom-study.d.ts for the full canonical declaration. */ interface Finding { findingId: string; semanticFingerprint: { algorithm: "sha256"; digest: string; size: number; mediaType: string }; methodologyId: string; ruleId: string; affectedSubjectId: string; title: string; severity: "info" | "low" | "medium" | "high" | "critical"; evidence: Array<{ evidenceRef: { artifactId: string; rootDigest: { algorithm: "sha256"; digest: string; size: number; mediaType: string }; schema: string; }; selector: string; evidenceClass: string; }>; uncertainty: unknown[]; extension: Record; } const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; export const suppressionRuleSchema = z.object({ ruleId: z.string().min(1), category: z.string().min(1), channel: z.enum(["dev", "alt", "main"]).optional(), channelNot: z.enum(["dev", "alt", "main"]).optional(), contentType: z.array(z.string()).optional(), urlPattern: z.string().optional(), titlePattern: z.string().optional(), messagePattern: z.string().optional(), descriptionPattern: z.string().optional(), // RFC-1208: UTC calendar dates (YYYY-MM-DD). A rule stays active ON its // expiresOn date and deactivates the day after; reviewAfter follows the // same boundary. Absent fields mean the rule never expires. expiresOn: z.string().regex(ISO_DATE).optional(), reviewAfter: z.string().regex(ISO_DATE).optional(), reason: z.string().min(1), }); export const suppressionsConfigSchema = z.object({ suppressions: z.array(suppressionRuleSchema), }); export type SuppressionRule = z.infer; export type SuppressionsConfig = z.infer; export interface SuppressedBy { ruleIndex: number; ruleId: string; category: string; reason: string; } export type SuppressedFinding = Finding & { suppressed?: boolean; suppressedBy?: SuppressedBy; }; export const WORKSHOP_SUPPRESSIONS_PATH = "systems/axiom-suppressions.yaml"; export const WORKPIECE_SUPPRESSIONS_PATH = "axiom-suppressions.yaml"; export function parseSuppressionsConfig(content: string): SuppressionsConfig { const raw = parseYaml(content) as unknown; return suppressionsConfigSchema.parse(raw); } export async function loadWorkshopSuppressions( workspaceRoot: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const absPath = join(workspaceRoot, WORKSHOP_SUPPRESSIONS_PATH); if (!(await io.exists(absPath))) return undefined; const content = await io.readFile(absPath); return parseSuppressionsConfig(content); } export async function loadWorkpieceSuppressions( missionDir: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const absPath = join(missionDir, "workpiece", WORKPIECE_SUPPRESSIONS_PATH); if (!(await io.exists(absPath))) return undefined; const content = await io.readFile(absPath); return parseSuppressionsConfig(content); } export function mergeSuppressions( workshop: SuppressionsConfig | undefined, workpiece: SuppressionsConfig | undefined, ): SuppressionRule[] { const workshopRules = workshop?.suppressions ?? []; const workpieceRules = workpiece?.suppressions ?? []; // Per-site rules can only ADD new suppressions — they cannot remove workshop-level rules. // Concatenation is sufficient: workshop rules fire first, workpiece rules fire for // findings not already suppressed by workshop rules (first-match-wins in applySuppressions). return [...workshopRules, ...workpieceRules]; } function matchesCondition( finding: Finding, rule: SuppressionRule, context: { channel: string }, ): boolean { // ruleId must match if (finding.ruleId !== rule.ruleId) return false; // channel: suppress if context.channel === channel if (rule.channel !== undefined && context.channel !== rule.channel) return false; // channelNot: suppress if context.channel !== channelNot if (rule.channelNot !== undefined && context.channel === rule.channelNot) return false; // contentType: suppress if the finding's URL ends with one of the listed extensions if (rule.contentType !== undefined && rule.contentType.length > 0) { const url = finding.affectedSubjectId; const matched = rule.contentType.some((ext) => url.endsWith(ext)); if (!matched) return false; } // urlPattern: suppress if the finding's URL matches the regex if (rule.urlPattern !== undefined) { try { const regex = new RegExp(rule.urlPattern); if (!regex.test(finding.affectedSubjectId)) return false; } catch { return false; } } // titlePattern: suppress if the finding's title contains the pattern (substring match) if (rule.titlePattern !== undefined) { if (!finding.title.includes(rule.titlePattern)) return false; } // messagePattern: suppress if the finding's message contains the pattern (substring match) if (rule.messagePattern !== undefined) { const message = extractMessage(finding); if (message === undefined || !message.includes(rule.messagePattern)) return false; } // descriptionPattern: suppress if the finding's description contains the pattern (substring match) if (rule.descriptionPattern !== undefined) { const description = extractDescription(finding); if (description === undefined || !description.includes(rule.descriptionPattern)) return false; } return true; } function extractMessage(finding: Finding): string | undefined { const ext = finding.extension as Record | undefined; if (ext && typeof ext.message === "string") return ext.message; return undefined; } function extractDescription(finding: Finding): string | undefined { const ext = finding.extension as Record | undefined; if (ext && typeof ext.description === "string") return ext.description; return undefined; } /** * RFC-1208: a rule is active ON its expiresOn date and inactive from the * following day. `asOf` is an ISO calendar date (YYYY-MM-DD); plain string * comparison is a correct UTC calendar-date ordering. */ export function isSuppressionExpired(rule: SuppressionRule, asOf: string): boolean { return rule.expiresOn !== undefined && rule.expiresOn < asOf; } /** * RFC-1208: review-due = reviewAfter passed while the rule is still active * (expiresOn absent or not yet reached). */ export function isSuppressionReviewDue(rule: SuppressionRule, asOf: string): boolean { return ( rule.reviewAfter !== undefined && rule.reviewAfter < asOf && !isSuppressionExpired(rule, asOf) ); } function defaultAsOf(): string { return new Date().toISOString().slice(0, 10); } export function applySuppressions( findings: Finding[], rules: SuppressionRule[], context: { channel: string; asOf?: string }, ): SuppressedFinding[] { const asOf = context.asOf ?? defaultAsOf(); return findings.map((finding) => { // If already suppressed, keep as-is if ((finding as SuppressedFinding).suppressed) return finding; for (let i = 0; i < rules.length; i++) { const rule = rules[i]; if (isSuppressionExpired(rule, asOf)) continue; if (matchesCondition(finding, rule, context)) { return { ...finding, suppressed: true, suppressedBy: { ruleIndex: i, ruleId: rule.ruleId, category: rule.category, reason: rule.reason, }, } as SuppressedFinding; } } return finding; }); } export function countSuppressedByCategory(findings: SuppressedFinding[]): { totalSuppressed: number; byCategory: Record; } { let totalSuppressed = 0; const byCategory: Record = {}; for (const f of findings) { if (f.suppressed && f.suppressedBy) { totalSuppressed++; byCategory[f.suppressedBy.category] = (byCategory[f.suppressedBy.category] ?? 0) + 1; } } return { totalSuppressed, byCategory }; }