Files
gstack/lib/redact-audit-log.ts
T
Garry TanandClaude Fable 5 ef0fa9e9ff fix(lib): jsonl-store's docstring stops lying; mode option added; lib bypasses adopted
The header claimed 'single source of truth... the ONLY copy' with write-time
injection REJECTION — while appendJsonl never screened anything, only 1 of
~10 JSONL stores imported it, and a bypass appender lived in the same
directory. Now: the contract is explicit (screening is the CALLER's job via
hasInjection/firstInjectionMatch; the enforcing callers are named), a
option applies 0600 at create for sensitive stores, and the lib bypasses are
adopted (gstack-memory-helpers ×2, redact-audit-log — which keeps its chmod
backstop for files created looser by pre-mode versions). browse/src keeps
its own appenders by design (compiled-binary surface, own secure-append
helper) and the header now says so. gstack-decision's batched archive append
stays deliberate (single-write crash-window semantics appendJsonl's
one-record contract can't express).

New pins: 0600-at-create, and a test that documents appendJsonl does NOT
self-screen — so nobody can re-document it as self-screening without making
it true.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 21:12:22 -07:00

93 lines
3.4 KiB
TypeScript

/**
* redact-audit-log — append-only forensic trail for the Phase 4.5a semantic
* review (D5). Records WHETHER the semantic pass marked a body clean/flagged and
* WHICH categories fired — never the body content. A body_sha256 lets a later
* investigation confirm "the pass saw this exact draft and called it clean."
*
* The file (`~/.gstack/security/semantic-reviews.jsonl`) is sensitive metadata,
* not "safe": it leaks repo names, timing, and a membership oracle via the hash.
* Written 0600. Local-only — no third-party egress.
*
* Usable two ways:
* - CLI: bun lib/redact-audit-log.ts '<json-line-without-ts/hash>' [body-file]
* (the skill passes the outcome JSON + a path to the scanned body; we
* stamp ts + body_sha256 and append.)
* - import { appendSemanticReview } from "./redact-audit-log";
*/
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { createHash } from "crypto";
import { appendJsonl } from "./jsonl-store";
export interface SemanticReviewEntry {
ts: string;
spec_archive_path?: string;
repo_visibility: string;
outcome: "clean" | "flagged";
categories_flagged: string[];
body_sha256: string;
}
function securityDir(): string {
const home = process.env.GSTACK_HOME || path.join(os.homedir(), ".gstack");
return path.join(home, "security");
}
export function sha256(s: string): string {
return createHash("sha256").update(s, "utf8").digest("hex");
}
/** Append one entry. Best-effort: never throws into the caller's flow. */
export function appendSemanticReview(entry: SemanticReviewEntry): void {
try {
const dir = securityDir();
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "semantic-reviews.jsonl");
// 0600 at create via appendJsonl's mode opt; the chmod backstop covers
// files created looser by pre-mode versions.
appendJsonl(file, entry, { mode: 0o600 });
try {
fs.chmodSync(file, 0o600);
} catch {
// chmod can fail on some filesystems; the append still happened.
}
} catch {
// audit log is best-effort, not the security boundary
}
}
// ── CLI ───────────────────────────────────────────────────────────────────────
function now(): string {
// Date is allowed here (CLI process, not a resumable workflow).
return new Date().toISOString();
}
if (import.meta.main) {
const json = process.argv[2];
const bodyFile = process.argv[3];
if (!json) {
process.stderr.write(
'usage: redact-audit-log \'{"repo_visibility":"public","outcome":"flagged","categories_flagged":["legal"],"spec_archive_path":"..."}\' [body-file]\n',
);
process.exit(1);
}
let partial: Partial<SemanticReviewEntry>;
try {
partial = JSON.parse(json);
} catch {
process.stderr.write("redact-audit-log: invalid JSON\n");
process.exit(1);
}
const body = bodyFile && fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : "";
appendSemanticReview({
ts: now(),
repo_visibility: partial.repo_visibility ?? "unknown",
outcome: partial.outcome === "flagged" ? "flagged" : "clean",
categories_flagged: partial.categories_flagged ?? [],
body_sha256: sha256(body),
...(partial.spec_archive_path ? { spec_archive_path: partial.spec_archive_path } : {}),
});
}