/*
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 };
}