fix(security): close cross-model (Codex) adversarial findings

Codex adversarial review found a HIGH the Claude pass missed plus 3 mediums:
- C1 (HIGH): gstack-decision-search --all returned every decide and IGNORED redact
  events, so a redacted secret still resurfaced via --all until compact ran. --all
  now excludes redacted (redact = expunge from every read path), still showing
  superseded history.
- C-med: semantic (external gbrain) slug/snippet were printed raw — datamark them too
  so a gbrain hit can't spoof role markers / fences into agent context.
- C4: semanticRecall fell back to an UNSCOPED gbrain search when no curated-memory
  source resolved, pulling code/doc corpora mislabeled as 'related decisions'. Now
  returns null (degrade) when there's no worktree-backed memory source.
- C5: validateDecide scanned only decision/rationale/alternatives; branch and issue
  are stored + surfaced (raw via --json), so include them in the injection+secret scan.

C2 (snapshot staleness) / C3 (compact TOCTOU residual): accepted for a single-user
store — atomic appends never lose the event, rebuilds self-heal, and the compact
size-recheck leaves only a sub-ms window; full append-locking would break the
lock-free append design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-07 19:34:10 -07:00
parent 7fdd5a377d
commit e938fa7211
5 changed files with 48 additions and 12 deletions
+11 -4
View File
@@ -48,8 +48,13 @@ const semantic = args.includes("--semantic");
let rows: ActiveDecision[];
if (showAll) {
// include superseded: every decide event from the full log (no active filter)
rows = readEvents(paths).filter((e): e is ActiveDecision => e.kind === "decide");
// --all includes SUPERSEDED decisions (history), but NEVER redacted ones — a redact
// is an expunge, so it must remove the text from every read path, not just active.
const events = readEvents(paths);
const redacted = new Set(
events.filter((e) => e.kind === "redact" && e.supersedes).map((e) => e.supersedes as string),
);
rows = events.filter((e): e is ActiveDecision => e.kind === "decide" && !redacted.has(e.id));
} else {
rows = readSnapshot(paths);
// Rebuild only when a snapshot is absent but a log exists (don't write a snapshot
@@ -94,8 +99,10 @@ if (semantic && queryRaw) {
if (hits && hits.length) {
console.log("\nRelated from memory (gbrain semantic recall):");
for (const h of hits) {
const snip = h.snippet.length > 100 ? `${h.snippet.slice(0, 100)}…` : h.snippet;
console.log(` [${h.score.toFixed(2)}] ${h.slug}: ${snip}`);
// gbrain hits are EXTERNAL corpus content — datamark slug + snippet too so they
// can't spoof role markers / fences when printed into agent context.
const snip = datamark(h.snippet.length > 100 ? `${h.snippet.slice(0, 100)}…` : h.snippet);
console.log(` [${h.score.toFixed(2)}] ${datamark(h.slug)}: ${snip}`);
}
}
}