fix(gsd): harden audit log persistence — errors-only, sanitized, demote probe warnings

Only persist error-severity entries to audit-log.jsonl (warnings stay
ephemeral in stderr + buffer). Sanitize persisted entries with message
truncation and context field allowlisting. Demote expected main/master
branch probe failures to silent control flow. Remove JSON.stringify of
diagnostic objects embedding cwd/paths in warning messages.

Addresses Codex adversarial review findings on workflow-logger migration.
This commit is contained in:
Jeremy 2026-04-04 14:18:13 -05:00
parent 104d103d14
commit 2396ecf1db
3 changed files with 47 additions and 19 deletions

View file

@ -112,9 +112,8 @@ function detectMainBranch(basePath: string): string {
encoding: "utf-8",
});
if (result.trim()) return "main";
} catch (err) {
// main doesn't exist
logWarning("recovery", `main branch not found: ${err instanceof Error ? err.message : String(err)}`);
} catch {
// Expected — main doesn't exist, try master next
}
try {
const result = execFileSync("git", ["rev-parse", "--verify", "master"], {
@ -123,11 +122,12 @@ function detectMainBranch(basePath: string): string {
encoding: "utf-8",
});
if (result.trim()) return "master";
} catch (err) {
// master doesn't exist either
logWarning("recovery", `master branch not found: ${err instanceof Error ? err.message : String(err)}`);
} catch {
// Expected — master doesn't exist either
}
return "main"; // default fallback
// Neither main nor master found — warn and fall back
logWarning("recovery", "neither main nor master branch found, defaulting to main");
return "main";
}
/**

View file

@ -104,16 +104,10 @@ export async function ensureDbOpen(): Promise<boolean> {
return opened;
}
logWarning("bootstrap", `ensureDbOpen failed — no .gsd directory found (resolvedPath=${resolveProjectRootDbPath(basePath)}, cwd=${basePath})`);
logWarning("bootstrap", "ensureDbOpen failed — no .gsd directory found");
return false;
} catch (err) {
const basePath = process.cwd();
const diagnostic = {
resolvedPath: resolveProjectRootDbPath(basePath),
cwd: basePath,
error: (err as Error).message ?? String(err),
};
logWarning("bootstrap", `ensureDbOpen failed — ${JSON.stringify(diagnostic)}`);
logWarning("bootstrap", `ensureDbOpen failed: ${(err as Error).message ?? String(err)}`);
return false;
}
}

View file

@ -2,7 +2,9 @@
// Centralized warning/error accumulator for the workflow engine pipeline.
// Captures structured entries that the auto-loop can drain after each unit
// to surface root causes for stuck loops, silent degradation, and blocked writes.
// All entries are also persisted to .gsd/audit-log.jsonl for post-mortem analysis.
// Error-severity entries are persisted to .gsd/audit-log.jsonl (sanitized) for
// post-mortem analysis. Warnings are ephemeral (stderr + buffer only) to avoid
// log amplification from expected-control-flow catch paths.
//
// Stderr policy: every logWarning/logError call writes immediately to stderr
// for terminal visibility. This is intentional — unlike debug-logger (which is
@ -243,15 +245,47 @@ function _push(
_buffer.shift();
}
// Persist to .gsd/audit-log.jsonl so entries survive context resets
if (_auditBasePath) {
// Persist errors to .gsd/audit-log.jsonl so they survive context resets.
// Only error-severity entries are persisted — warnings are ephemeral (stderr + buffer)
// to avoid log amplification from expected-control-flow catch paths.
if (_auditBasePath && severity === "error") {
try {
const auditDir = join(_auditBasePath, ".gsd");
mkdirSync(auditDir, { recursive: true });
appendFileSync(join(auditDir, "audit-log.jsonl"), JSON.stringify(entry) + "\n", "utf-8");
const sanitized = _sanitizeForAudit(entry);
appendFileSync(join(auditDir, "audit-log.jsonl"), JSON.stringify(sanitized) + "\n", "utf-8");
} catch (auditErr) {
// Best-effort — never let audit write failures bubble up
process.stderr.write(`[gsd:audit] failed to persist log entry: ${(auditErr as Error).message}\n`);
}
}
}
/**
* Sanitize a log entry before persisting to the audit JSONL file.
* Strips potentially sensitive context (raw paths, cwd, full error text)
* to avoid leaking local environment details into durable telemetry.
*/
function _sanitizeForAudit(entry: LogEntry): LogEntry {
const sanitized: LogEntry = {
ts: entry.ts,
severity: entry.severity,
component: entry.component,
// Truncate message to avoid persisting oversized raw error dumps
message: entry.message.length > 200 ? entry.message.slice(0, 200) + "…[truncated]" : entry.message,
};
if (entry.context) {
// Allowlist: only persist known-safe structured keys
const SAFE_KEYS = new Set(["fn", "tool", "mid", "sid", "tid", "worktree"]);
const filtered: Record<string, string> = {};
for (const [k, v] of Object.entries(entry.context)) {
if (SAFE_KEYS.has(k)) {
filtered[k] = v;
}
}
if (Object.keys(filtered).length > 0) {
sanitized.context = filtered;
}
}
return sanitized;
}