@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
JavaScript
/**
* 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