/* Sole runtime and type owner of DiagnosticSeverity, DiagnosticEvidence, and Diagnostic strict Zod schemas (RFC-0852). Types are inferred from schemas. kernel/types.ts re-exports these types; the site plugin imports the schemas. Do not define canonical JSON bytes or limits; RFC-0849 owns them. Do not define EvidenceEnvelopeV1, certification identities, or dossier admission; RFC-0853 owns those. Do not add command status vocabularies or suppression systems. RFC-0852: Move canonical Diagnostic ownership from site plugin into the engine. RFC-1027: Add structured remediation hints — remediationRefSchema and remediation field on Diagnostic. */ import { z } from "zod"; import { isCanonicalJsonObjectV1, canonicalJsonBytesV1, type CanonicalJsonObjectV1, } from "../fingerprint/canonical-json.ts"; // --------------------------------------------------------------------------- // Limits (RFC-0852 §Bounds) // --------------------------------------------------------------------------- export const DIAGNOSTIC_LIMITS = { ruleIdChars: 128, messageBytes: 4 * 1024, fixHintBytes: 8 * 1024, pathBytes: 1024, urlBytes: 4 * 1024, snippetBytes: 16 * 1024, evidenceItems: 32, dataBytes: 64 * 1024, diagnosticBytes: 128 * 1024, diagnosticsPerResult: 1000, remediationActionBytes: 512, remediationTargetFiles: 8, } as const; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function utf8ByteLength(value: string): number { return Buffer.byteLength(value, "utf8"); } // --------------------------------------------------------------------------- // Diagnostic rule ID — [A-Z0-9][A-Z0-9._-]* , max 128 ASCII chars // --------------------------------------------------------------------------- const diagnosticRuleIdPattern = /^[A-Z0-9][A-Z0-9._-]*$/; export const diagnosticRuleIdSchema = z .string() .max(DIAGNOSTIC_LIMITS.ruleIdChars) .regex( diagnosticRuleIdPattern, "CERT-DIAGNOSTIC-SCHEMA-01: ruleId must match [A-Z0-9][A-Z0-9._-]*", ); // --------------------------------------------------------------------------- // Safe workspace-relative POSIX path // --------------------------------------------------------------------------- const unsafePathPatterns = [ /[/\\]\.\.([/\\]|$)/, // .. component /[/\\]\.([/\\]|$)/, // . component (but allow leading . for dotfiles? No — RFC says reject empty/.) /^\//, // absolute path /^[/\\]/, // leading separator /\\/, // backslash /\0/, // NUL /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, // URI scheme /^~([/\\]|$)/, // home expansion /^[a-zA-Z]:[\\/]/, // Windows drive letter ]; function isSafeWorkspaceRelativePath(value: string): boolean { if (value.length === 0) return false; if (utf8ByteLength(value) > DIAGNOSTIC_LIMITS.pathBytes) return false; for (const pattern of unsafePathPatterns) { if (pattern.test(value)) return false; } // Reject empty components from split const segments = value.split("/"); for (const seg of segments) { if (seg.length === 0) return false; // empty component (e.g. "a//b") if (seg === "." || seg === "..") return false; } return true; } export const safeWorkspaceRelativePathSchema = z.string().refine(isSafeWorkspaceRelativePath, { message: "CERT-DIAGNOSTIC-LOCATOR-01: path must be a workspace-relative POSIX path without absolute paths, backslashes, empty/./.. components, NUL, URI schemes, or home expansion", }); // --------------------------------------------------------------------------- // Safe diagnostic URL — absolute http/https, no userinfo, no credentials // --------------------------------------------------------------------------- function isSafeDiagnosticUrl(value: string): boolean { if (utf8ByteLength(value) > DIAGNOSTIC_LIMITS.urlBytes) return false; if (/\0/.test(value)) return false; if (/[\x00-\x1f\x7f]/.test(value)) return false; // control characters let parsed: URL; try { parsed = new URL(value); } catch { return false; } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; // Reject userinfo (credentials in authority) if (parsed.username || parsed.password) return false; // Reject credential-bearing query parameters (best-effort high-confidence) const query = parsed.searchParams; const credentialKeys = [ "token", "access_token", "secret", "api_key", "apikey", "key", "password", "passwd", "pwd", "authorization", "auth", "signature", "sig", ]; for (const key of credentialKeys) { if (query.has(key)) return false; } return true; } export const safeDiagnosticUrlSchema = z.string().refine(isSafeDiagnosticUrl, { message: "CERT-DIAGNOSTIC-LOCATOR-01: URL must be an absolute http/https URL with no userinfo, control characters, or credential-bearing query parameters", }); // --------------------------------------------------------------------------- // Redacted diagnostic text — no known secrets/PII/absolute paths // --------------------------------------------------------------------------- const secretPatterns = [ /sk-[A-Za-z0-9_-]{20,}/, // OpenAI-style API key /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+/, // JWT /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/, // private key /Bearer\s+[A-Za-z0-9_-]{20,}/, // bearer token /Authorization:\s*Bearer\s+[A-Za-z0-9_-]{20,}/i, // auth header /(?:mongodb|postgres|postgresql|mysql|redis):\/\/[^\s@"']+:[^\s@"']+@/, // connection string /(?:aws_access_key_id|aws_secret_access_key)\s*=\s*[A-Za-z0-9/+=]{16,}/, // AWS creds ]; const absolutePathPatterns = [ /^(?:\/|[A-Za-z]:[\\/]|~[\\/])/, // absolute or home path ]; const piiPatterns = [ /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/, // email /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/, // phone (US-like) ]; function isRedactedDiagnosticText(value: string, maxBytes: number): boolean { if (utf8ByteLength(value) > maxBytes) return false; if (value.trim().length === 0) return false; for (const pattern of secretPatterns) { if (pattern.test(value)) return false; } for (const pattern of absolutePathPatterns) { if (pattern.test(value)) return false; } for (const pattern of piiPatterns) { if (pattern.test(value)) return false; } return true; } function redactedDiagnosticTextSchema(maxBytes: number) { return z.string().refine((val) => isRedactedDiagnosticText(val, maxBytes), { message: `CERT-DIAGNOSTIC-REDACTION-01: text must not contain known secrets, PII, or absolute paths and must be within ${maxBytes} UTF-8 bytes`, }); } // --------------------------------------------------------------------------- // Canonical JSON object V1 schema for Diagnostic.data // --------------------------------------------------------------------------- // RFC-0852: data must be a runtime-branded CanonicalJsonObjectV1 at parse time. // The TypeScript type is Record for structural compatibility // with existing producers; the Zod custom check enforces canonical validation // before persistence. z.custom is used instead of z.record because Zod 4's // z.record structural validation rejects branded frozen objects created by // snapshotCanonicalJsonObjectV1. const canonicalJsonObjectV1Schema = z .custom>( (val): val is CanonicalJsonObjectV1 => isCanonicalJsonObjectV1(val), { message: "CERT-DIAGNOSTIC-SCHEMA-01: data must be a runtime-branded CanonicalJsonObjectV1", }, ) .refine( (val) => { if (!isCanonicalJsonObjectV1(val)) return false; const bytes = canonicalJsonBytesV1(val); return bytes.length <= DIAGNOSTIC_LIMITS.dataBytes; }, { message: `CERT-DIAGNOSTIC-LIMIT-01: canonical data bytes must not exceed ${DIAGNOSTIC_LIMITS.dataBytes} bytes`, }, ); // --------------------------------------------------------------------------- // Severity // --------------------------------------------------------------------------- export const diagnosticSeveritySchema = z.enum(["error", "warning", "info"]); export type DiagnosticSeverity = z.infer; // --------------------------------------------------------------------------- // Evidence // --------------------------------------------------------------------------- export const diagnosticEvidenceSchema = z .object({ kind: z.enum(["rule", "rendered", "source", "config", "cache", "runtime"]), ruleFile: safeWorkspaceRelativePathSchema.optional(), ruleId: diagnosticRuleIdSchema.optional(), file: safeWorkspaceRelativePathSchema.optional(), url: safeDiagnosticUrlSchema.optional(), snippet: redactedDiagnosticTextSchema(DIAGNOSTIC_LIMITS.snippetBytes).optional(), }) .strict(); export type DiagnosticEvidence = z.infer; // --------------------------------------------------------------------------- // Remediation reference (RFC-1027) // --------------------------------------------------------------------------- export const remediationRefSchema = z .object({ ruleId: diagnosticRuleIdSchema, action: redactedDiagnosticTextSchema(DIAGNOSTIC_LIMITS.remediationActionBytes), template: redactedDiagnosticTextSchema(DIAGNOSTIC_LIMITS.fixHintBytes).optional(), docRef: safeWorkspaceRelativePathSchema.optional(), targetFiles: z .array(safeWorkspaceRelativePathSchema) .max(DIAGNOSTIC_LIMITS.remediationTargetFiles) .optional(), }) .strict(); export type RemediationRef = z.infer; // --------------------------------------------------------------------------- // Diagnostic // --------------------------------------------------------------------------- export const diagnosticSchema = z .object({ ruleId: diagnosticRuleIdSchema, severity: diagnosticSeveritySchema, message: redactedDiagnosticTextSchema(DIAGNOSTIC_LIMITS.messageBytes), file: safeWorkspaceRelativePathSchema.optional(), line: z.number().int().positive().optional(), column: z.number().int().positive().optional(), fixHint: redactedDiagnosticTextSchema(DIAGNOSTIC_LIMITS.fixHintBytes).optional(), remediation: remediationRefSchema.optional(), evidence: z.array(diagnosticEvidenceSchema).max(DIAGNOSTIC_LIMITS.evidenceItems).optional(), data: canonicalJsonObjectV1Schema.optional(), }) .strict(); export type Diagnostic = z.infer;