UNPKG

@blundergoat/goat-flow

Version:

AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.

220 lines 9.13 kB
/** * Shared evidence envelope for local runtime producers. * * The envelope adapts the existing CheckEvidence provenance contract instead * of inventing a parallel event schema. Producers add the small runtime field * set needed to answer "what happened locally?" while payloads stay redacted by * default and writes remain non-fatal. */ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, } from "node:fs"; import { join } from "node:path"; import { validateProvenance } from "../audit/provenance-types.js"; import { isRedactedEvidenceValue, } from "./redaction.js"; const EVENTS_LOG_RELATIVE_DIR = ".goat-flow/logs/events"; const ENVELOPE_FRAMEWORK_EVIDENCE = "src/cli/evidence/envelope.ts"; // Cap tails at 500 entries so dashboard reads stay bounded even when daily JSONL logs grow. const MAX_TAIL_LIMIT = 500; const SENSITIVE_PAYLOAD_KEY = /^(?:prompt|output|terminal_output|terminal_scrollback|scrollback|upload_content|upload_data|screenshot|raw_json|raw_html|raw_tool_output|tool_output|bucket_body)$/iu; const VALID_ACTORS = new Set(["dashboard", "cli", "server"]); /** Resolve the gitignored event-log directory under the selected project root. */ function eventsLogDir(projectRoot) { return join(projectRoot, EVENTS_LOG_RELATIVE_DIR); } /** Normalize optional caller timestamps while defaulting local events to current time. */ function timestampString(timestamp) { if (timestamp instanceof Date) return timestamp.toISOString(); return timestamp ?? new Date().toISOString(); } /** Check the timestamp format expected by envelope filenames and provenance dates. */ function isIsoTimestamp(timestamp) { return (/^\d{4}-\d{2}-\d{2}T/u.test(timestamp) && !Number.isNaN(Date.parse(timestamp))); } /** Narrow parsed JSON and payload values to object records before key inspection. */ function isRecord(candidate) { return (typeof candidate === "object" && candidate !== null && !Array.isArray(candidate)); } /** Recursively require redaction markers for sensitive payload keys. */ function validatePayloadValue(key, value, path) { if (isRedactedEvidenceValue(value)) return []; if (SENSITIVE_PAYLOAD_KEY.test(key)) { return [`${path} must be a redacted evidence value`]; } if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") { return []; } if (Array.isArray(value)) { return value.flatMap((item, index) => validatePayloadValue(key, item, `${path}[${index}]`)); } return Object.entries(value).flatMap(([childKey, childValue]) => validatePayloadValue(childKey, childValue, `${path}.${childKey}`)); } /** Validate the optional payload without rejecting the envelope when no payload exists. */ function validatePayload(payload) { if (payload === undefined) return []; if (!isRecord(payload)) return ["payload must be an object"]; return Object.entries(payload).flatMap(([key, value]) => validatePayloadValue(key, value, `payload.${key}`)); } function applyEnvelopeOptionalFields(envelope, input) { if (input.provenance?.evidence_paths) { envelope.evidence_paths = input.provenance.evidence_paths; } if (input.provenance?.target_evidence_paths) { envelope.target_evidence_paths = input.provenance.target_evidence_paths; } if (input.provenance?.reason) { envelope.reason = input.provenance.reason; } if (input.payload) { envelope.payload = input.payload; } } /** * Build a validated envelope shape from one local runtime event. * * @param input - Runtime event details and optional provenance override. * @returns Evidence envelope ready for validation or append. */ export function createEvidenceEnvelope(input) { const timestamp = timestampString(input.timestamp); const envelope = { source_type: "spec", source_urls: input.provenance?.source_urls ?? [], verified_on: timestamp.slice(0, 10), normative_level: "BEST_PRACTICE", framework_evidence_paths: input.provenance?.framework_evidence_paths ?? [ ENVELOPE_FRAMEWORK_EVIDENCE, ], producer: input.producer ?? "goat-flow", event_kind: input.eventType, actor: input.actor, timestamp, project_path: input.projectRoot, }; applyEnvelopeOptionalFields(envelope, input); return envelope; } /** * Validate runtime envelope fields while delegating provenance rules. * * @param envelope - Envelope to validate. * @param pathExists - Optional path-existence predicate for provenance checks. * @returns Human-readable validation errors; an empty array means valid. */ export function validateEvidenceEnvelope(envelope, pathExists) { const errors = validateProvenance(envelope, pathExists); if (!envelope.producer.trim()) errors.push("producer must be non-empty"); if (!VALID_ACTORS.has(envelope.actor)) { errors.push(`actor must be one of: ${Array.from(VALID_ACTORS).join(", ")}`); } if (!envelope.event_kind.trim()) errors.push("event_kind must be non-empty"); if (!isIsoTimestamp(envelope.timestamp)) { errors.push(`timestamp must be an ISO-8601 timestamp, got ${envelope.timestamp}`); } if (envelope.verified_on !== envelope.timestamp.slice(0, 10)) { errors.push("verified_on must match the timestamp date"); } if (!envelope.project_path.trim()) { errors.push("project_path must be non-empty"); } errors.push(...validatePayload(envelope.payload)); return errors; } function warn(options, message) { if (options?.onWarning) { options.onWarning(message); return; } console.warn(message); } /** * Append one envelope to the local gitignored JSONL event log. Never throws. * * @param projectRoot - Project root that owns `.goat-flow/logs/events`. * @param envelope - Validated or caller-created envelope to append. * @param options - Optional warning callback for non-fatal validation or filesystem failures. * @returns Append outcome with a path on success and an error string on failure. */ export function appendEvidenceEnvelope(projectRoot, envelope, options) { try { const errors = validateEvidenceEnvelope(envelope); if (errors.length > 0) { const error = `invalid evidence envelope: ${errors.join("; ")}`; warn(options, `[evidence] ${error}`); return { ok: false, path: null, error }; } const dir = eventsLogDir(projectRoot); mkdirSync(dir, { recursive: true }); const path = join(dir, `${envelope.timestamp.slice(0, 10)}.jsonl`); appendFileSync(path, `${JSON.stringify(envelope)}\n`, "utf-8"); return { ok: true, path }; } catch (error) { const message = error instanceof Error ? error.message : String(error); warn(options, `[evidence] failed to append event: ${message}`); return { ok: false, path: null, error: message }; } } /** * Convenience producer helper for common dashboard/server event emission. * * @param input - Runtime event details to envelope and append. * @param options - Optional warning callback for append failures. * @returns Append outcome from the underlying non-throwing writer. */ export function recordEvidenceEvent(input, options) { return appendEvidenceEnvelope(input.projectRoot, createEvidenceEnvelope(input), options); } /** Return sorted daily JSONL event logs; invariant: filenames must preserve chronology. */ function eventLogFiles(projectRoot) { const dir = eventsLogDir(projectRoot); if (!existsSync(dir)) return []; return readdirSync(dir) .filter((name) => /^\d{4}-\d{2}-\d{2}\.jsonl$/u.test(name)) .sort() .map((name) => join(dir, name)); } /** Parse one JSONL entry; swallows malformed JSON or invalid envelopes as unreadable history. */ function parseEnvelopeLine(line) { if (!line.trim()) return null; try { const parsed = JSON.parse(line); if (!isRecord(parsed)) return null; const candidate = parsed; return validateEvidenceEnvelope(candidate).length === 0 ? candidate : null; } catch { return null; } } /** * Read the newest local event envelopes, preserving chronological order. * * @param projectRoot - Project root whose gitignored event logs should be tailed. * @param limit - Requested maximum number of newest envelopes; capped for bounded reads. * @returns Valid envelopes from the newest tail window. */ export function tailEvidenceEvents(projectRoot, limit = 20) { const boundedLimit = Math.max(1, Math.min(limit, MAX_TAIL_LIMIT)); const entries = eventLogFiles(projectRoot).flatMap((path) => readFileSync(path, "utf-8") .split(/\r?\n/u) .flatMap((line) => { const envelope = parseEnvelopeLine(line); return envelope ? [envelope] : []; })); return entries.slice(-boundedLimit); } //# sourceMappingURL=envelope.js.map