fix(memory-ingest): store the normalized git_remote so unattributed pages hit the policy filter

buildTranscriptPage wrote the normalized '_unattributed' sentinel into the page FRONTMATTER but stored the raw resolved remote ('' when unresolvable) on the page object. The policy filter fast-paths !p.git_remote, so under --include-unattributed an explicit '_unattributed → deny' (or read-only) policy never applied to exactly the pages it names — they ingested unpoliced. The stored value now matches the frontmatter.

Regression test (fails on v1.68.3.0): seeds the REAL bin/gstack-gbrain-repo-policy store with '_unattributed → deny' through its own set verb, ingests an unresolvable-remote session with --include-unattributed, and asserts nothing reaches gbrain — pre-fix the '' remote bypassed the filter and the import ran. A fake echoing tiers would pass on both sides of the fix; the real helper prints 'none' for unknown keys, so only a genuinely applied deny distinguishes the two.

Fixes #2353
This commit is contained in:
Garry Tan
2026-08-22 02:11:30 +00:00
parent 1cbb3cbfd6
commit 88209d542a
2 changed files with 43 additions and 1 deletions
+5 -1
View File
@@ -788,7 +788,11 @@ function buildTranscriptPage(path: string, session: ParsedSession): PageRecord {
source_path: path,
session_id: session.session_id,
cwd: session.cwd,
git_remote: remote,
// Store the normalized sentinel, matching the frontmatter above: a raw ""
// is falsy and slid through the policy filter's !p.git_remote fast-path,
// so under --include-unattributed a `_unattributed → deny` policy never
// applied to exactly the pages it names (#2353).
git_remote: remote || "_unattributed",
start_time: session.start_time,
end_time: session.end_time,
partial: session.partial,
+38
View File
@@ -484,6 +484,44 @@ describe("gstack-memory-ingest writer (gbrain v0.20+ batch `import` interface)",
expect(stagedList).toMatch(/^\.\/transcripts\/claude-code\/.+\.md$/m);
});
// #2353: buildTranscriptPage stored the RAW resolved remote ("" when
// unresolvable) while the frontmatter wrote the normalized "_unattributed".
// The policy filter fast-paths !p.git_remote, so under --include-unattributed
// a `_unattributed → deny` policy never applied to exactly the pages it
// names. Uses the REAL bin/gstack-gbrain-repo-policy (resolved by the client
// relative to lib/, and seeded here through its own `set` verb) — a fake
// echoing tiers would pass on both sides of the fix.
it("a deny policy keyed _unattributed applies to unattributable transcripts (#2353)", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const { binDir, logFile } = installFakeGbrain(home);
const POLICY = join(import.meta.dir, "..", "bin", "gstack-gbrain-repo-policy");
const seeded = spawnSync("bash", [POLICY, "set", "_unattributed", "deny"], {
encoding: "utf-8",
env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome },
});
expect(seeded.status).toBe(0);
expect(existsSync(join(gstackHome, "gbrain-repo-policy.json"))).toBe(true);
const session =
`{"type":"user","message":{"role":"user","content":"hi"},"timestamp":"2026-05-01T00:00:00Z","cwd":"/tmp/foo"}\n` +
`{"type":"assistant","message":{"role":"assistant","content":"hello"},"timestamp":"2026-05-01T00:00:01Z"}\n`;
writeClaudeCodeSession(home, "tmp-foo", "abc123", session);
const r = runScript(["--bulk", "--include-unattributed", "--quiet"], {
HOME: home,
GSTACK_HOME: gstackHome,
PATH: `${binDir}:${process.env.PATH || ""}`,
});
// The only candidate page is policy-denied, so nothing may reach gbrain:
// pre-fix, the "" remote bypassed the filter and gbrain import ran.
expect(r.exitCode).toBe(0);
expect(existsSync(logFile)).toBe(false);
});
// Silent-data-loss regression: gbrain accepts the import call, exits 0, and
// reports imported=0 because collect_files found nothing in the staging dir
// (real-world cause: gstack-artifacts-init writes `.gitignore = "*"` into