Files
gstack/lib/claude-owned-journal.ts
8fc9f2a58d v1.91.91.0 fix(review-gates): fail closed on unparsed severities, survive auto-compaction copies, read-only plan-eng-review reports back (Wave 9 Lane B) (#3143)
Every outside-review gate, execution included, reads P0-P3 labels in label
position, table cells and severity words; an unreadable label is unverified
(unparsed_severity) and "Verdict: not ready" with no severity blocks with
FINDINGS: unspecified. The owned journal reader collapses Claude Code
2.1.293's post-compaction copies (equal apart from uuid, parentUuid and
message.usage), so /autoplan's guard no longer denies with tool_identity,
init_own or a false disarm. /plan-eng-review with writes forbidden ends DONE
with PERSISTED: no and a final PERSIST: line; /ship names it when no Eng
Review is saved.

Fixes #3105
Fixes #3106
Fixes #3107

Co-authored-by: Kolla Prakyath <177159691+kolla23@users.noreply.github.com>
Co-authored-by: Mr-B-1 <214900604+Mr-B-1@users.noreply.github.com>
2026-10-11 18:16:33 +00:00

335 lines
19 KiB
TypeScript

/**
* The publication guard's bounded, append-tolerant read of one parent session
* journal (CEO-2). Pass one streams the bytes present at open, up to the last
* complete line, and keeps a small index per record: ownership metadata, byte
* range, and the record's public events with their content stripped. Ownership,
* ancestry and causal order are decided on that index. Pass two (the policy's
* `select`) reloads in full only the records the evaluation reads, under a named
* retained-data bound. Batch readers keep `readPlanCountTranscript`'s whole-file
* read and its cap.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { createHash } from 'node:crypto';
import { isUtf8 } from 'node:buffer';
import { isDeepStrictEqual } from 'node:util';
import { object, recordEvents, recordMeta, ownedCausalOrder, nativePathSpelling, ownedNativePath, sameNativePath,
type ClaudeParentPublicEvent, type OwnedTranscriptReason, type RecordMeta } from './claude-journal-records';
import type { PlanCountTranscript } from './claude-public-transcript';
/** The largest single journal record the guard verifies; any record over it is `too_large` (docs/autoplan-guard-troubleshooting.md). */
export const OWNED_RECORD_MAX_BYTES = 32 * 1024 * 1024;
/** The most record bytes pass two may reload in full for one evaluation; over it is `oversized_invocation`. */
export const OWNED_RETAINED_MAX_BYTES = 32 * 1024 * 1024;
const lowered = (name: string, max: number) => {
const value = Number(process.env[name]);
return Number.isInteger(value) && value > 0 && value < max ? value : max;
};
/** Test seam: GSTACK_TRANSCRIPT_TEST_MAX_BYTES can only LOWER the record bound, never raise it. */
export const ownedRecordLimit = () => lowered('GSTACK_TRANSCRIPT_TEST_MAX_BYTES', OWNED_RECORD_MAX_BYTES);
/** Test seam: GSTACK_TRANSCRIPT_TEST_RETAINED_BYTES can only LOWER the retained bound, never raise it. */
export const ownedRetainedLimit = () => lowered('GSTACK_TRANSCRIPT_TEST_RETAINED_BYTES', OWNED_RETAINED_MAX_BYTES);
const CHUNK_BYTES = 1024 * 1024;
/** Parsed records are garbage once indexed; collecting every 32 MiB keeps a 120 MiB journal's peak RSS under budget (CEO-2). */
const COLLECT_EVERY_BYTES = 32 * 1024 * 1024;
/** The bytes [0, size) one read saw; a later read in the same hook invocation must see the same prefix. */
export interface JournalPrefix { size: number; sha256: string }
/** One owned record in causal order: where it is, and its events (stripped until pass two loads it). */
export interface IndexedRecord {
readonly offset: number;
readonly length: number;
readonly uuid?: string;
/** The first event order this record owns; pass two keeps it. */
readonly base: number;
readonly slots: number;
/** Tool ids whose result text is a JSON object, so a selector can load structured output without every result. */
readonly jsonResults?: readonly string[];
events: ClaudeParentPublicEvent[];
full: boolean;
}
/** How the hook bounds a read: what the index keeps of a tool input, and which records pass two loads. */
export interface OwnedReadPolicy {
/** The part of a tool_use input pass one keeps (small by construction); undefined keeps none. */
indexInput(name: string, input: Record<string, unknown>): Record<string, unknown> | undefined;
/** Load the records the evaluation reads, in any number of rounds; `load` enforces the retained bound. */
select(records: readonly IndexedRecord[], load: (records: readonly IndexedRecord[]) => void): void;
}
/** What a refused owned read can report without content: inputs to the hook's guidance and advisory. */
export interface OwnedTranscriptDiagnostic {
/** Claude Code version recorded in the journal's own records, when present. */
claudeVersion?: string;
/** Record types from the journal head down to its first turn; never content. */
rootShape: string[];
/** Parent assistant tool_use ids present in complete records. */
toolUseIds: string[];
/** sha256 of the bytes present at open, so a second read can prove the same prefix. */
sha256: string;
/** The journal ended at a record boundary when it was opened. */
complete: boolean;
}
export interface OwnedRead {
transcript: PlanCountTranscript;
events: ClaudeParentPublicEvent[];
diagnostic?: OwnedTranscriptDiagnostic;
claudeVersion?: string;
}
/** What one read measured, filled in for a caller that passes it (kept out of the result so equal journals read equal). */
export interface OwnedReadMeasure {
/** The prefix this read hashed; pass it as `prior` to the next read in the same hook invocation. */
prefix?: JournalPrefix;
/** too_large only: the oversized record's length in bytes. */
recordBytes?: number;
/** Bytes of the pass-one index (stripped events); set it to 0 to request the measurement. */
indexBytes?: number;
/** Bytes of the records pass two reloaded. */
retainedBytes?: number;
}
class OwnedReadError extends Error {
constructor(readonly reason: OwnedTranscriptReason, readonly bytes?: number) { super(reason); }
}
/** Strip, in place, content the index never keeps: tool input beyond the policy's part, result bodies, message text. */
function strip(events: ClaudeParentPublicEvent[], policy: OwnedReadPolicy): ClaudeParentPublicEvent[] {
for (const e of events) {
if (e.kind === 'use') e.input = e.input && policy.indexInput(e.name ?? '', e.input);
else if (e.kind === 'result') { e.content = undefined; e.file = undefined; }
else if (e.kind === 'message') e.text = '';
}
return events;
}
/** One record's index entry: its ownership metadata, byte range and (stripped) events. */
interface Entry extends RecordMeta {
offset: number;
length: number;
events?: ClaudeParentPublicEvent[];
slots: number;
/** The message content is an array (the record can make a journal ready). */
array: boolean;
jsonResults?: string[];
/** Message records only: the parsed timestamp, which a post-compaction copy keeps with its type. */
stamp?: number;
}
interface Scanned {
entries: Entry[];
toolUseIds: string[];
claudeVersion?: string;
prefix: JournalPrefix;
complete: boolean;
}
const resultText = (b: Record<string, any>): string => typeof b.content === 'string' ? b.content
: Array.isArray(b.content) && b.content.length === 1 && typeof b.content[0]?.text === 'string' ? b.content[0].text : '';
/** Pass one: stream [0, size) to the last complete line; per-record bound; prefix hash; content-free index. */
function scan(fd: number, size: number, sessionId: string, prior: JournalPrefix | undefined,
policy: OwnedReadPolicy | undefined): Scanned {
const limit = ownedRecordLimit(), hash = createHash('sha256'), filename = `${sessionId}.jsonl`;
const out: Scanned = { entries: [], toolUseIds: [], prefix: { size, sha256: '' }, complete: true };
const chunk = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, Math.max(size, 1)));
// The unfinished line carried into the next chunk; past the record bound only its length is kept.
let carry: Buffer[] = [], carryBytes = 0, carryOffset = 0;
const record = (line: string, offset: number, length: number) => {
if (!line.length || (line.charCodeAt(0) <= 32 && !line.trim())) return;
let value: unknown;
try { value = JSON.parse(line); } catch { throw new OwnedReadError('malformed'); }
if (!object(value) || value.sessionId !== sessionId || filename !== `${value.sessionId}.jsonl`) return;
const entry = recordMeta(value, out.entries.length) as Entry;
entry.offset = offset; entry.length = length; entry.slots = 0; entry.array = false;
out.entries.push(entry);
if (!entry.agent && !entry.sidechain) {
out.claudeVersion ??= entry.version;
if (entry.role === 'assistant' && Array.isArray(value.message.content)) for (const block of value.message.content)
if (object(block) && block.type === 'tool_use' && typeof block.id === 'string') out.toolUseIds.push(block.id);
}
if (!entry.message) return;
entry.stamp = Date.parse(value.timestamp);
const full = recordEvents(value, true, entry.timestampValid);
entry.events = policy ? strip(full.events, policy) : full.events;
entry.slots = full.slots;
entry.array = Array.isArray(value.message.content);
if (entry.array) for (const b of value.message.content)
if (object(b) && b.type === 'tool_result' && typeof b.tool_use_id === 'string' && /^\s*\{/.test(resultText(b)))
(entry.jsonResults ??= []).push(b.tool_use_id);
};
const lines = (region: Buffer, start: number) => {
if (!isUtf8(region)) throw new OwnedReadError('malformed');
const text = region.toString('utf8'), ascii = text.length === region.length;
let offset = start;
for (const line of text.split('\n')) {
const length = ascii ? line.length : Buffer.byteLength(line);
if (length > limit) throw new OwnedReadError('too_large', length);
record(line, offset, length);
offset += length + 1;
}
};
for (let pos = 0; pos < size;) {
const got = fs.readSync(fd, chunk, 0, Math.min(chunk.length, size - pos), pos);
if (got <= 0) throw new OwnedReadError('identity');
const view = chunk.subarray(0, got);
if (prior && prior.size > 0 && pos < prior.size && pos + got >= prior.size) {
hash.update(view.subarray(0, prior.size - pos));
if (hash.copy().digest('hex') !== prior.sha256) throw new OwnedReadError('rewritten');
hash.update(view.subarray(prior.size - pos));
} else hash.update(view);
const last = view.lastIndexOf(10);
if (last >= 0) {
const first = view.indexOf(10);
if (carryBytes + first > limit) throw new OwnedReadError('too_large', carryBytes + first);
// The carried line joins its end in a small copy; the rest of the chunk decodes in place.
if (carry.length) lines(Buffer.concat([...carry, view.subarray(0, first)]), carryOffset);
if (carry.length ? last > first : true) lines(view.subarray(carry.length ? first + 1 : 0, last), carry.length ? pos + first + 1 : pos);
carry = []; carryBytes = 0; carryOffset = pos + last + 1;
}
const rest = view.subarray(last + 1);
// An oversized unfinished record keeps counting without retaining its bytes.
if (rest.length && carryBytes + rest.length <= limit) carry.push(Buffer.from(rest));
carryBytes += rest.length;
pos += got;
if (pos % COLLECT_EVERY_BYTES < got && typeof Bun !== 'undefined') Bun.gc(true);
}
if (carryBytes > limit) throw new OwnedReadError('too_large', carryBytes);
// A record still being written (no newline yet) is not evidence and is not read.
out.complete = carryBytes === 0;
out.prefix.sha256 = hash.digest('hex');
return out;
}
/**
* Claude Code's auto-compaction (2.1.293) writes the kept conversation again
* after its compact boundary: each copy has a new uuid, a relinked parentUuid
* and zeroed message.usage, and is otherwise identical. Read both records'
* bytes and compare them without those three fields.
*/
function compactionCopy(fd: number, original: Entry, copy: Entry): boolean {
const load = (entry: Entry): Record<string, any> | undefined => {
const bytes = Buffer.alloc(entry.length);
if (fs.readSync(fd, bytes, 0, entry.length, entry.offset) !== entry.length || !isUtf8(bytes)) return;
try {
const { uuid: _uuid, parentUuid: _parent, ...rest } = JSON.parse(bytes.toString('utf8'));
if (!object(rest.message)) return;
const { usage: _usage, ...message } = rest.message;
return { ...rest, message };
} catch { return; }
};
const before = load(original);
return before !== undefined && isDeepStrictEqual(before, load(copy));
}
/** Ownership on the index, for each accepted owner directory; the first ready owner wins. */
function owned(scanned: Scanned, owners: readonly string[]) {
const results = owners.map(cwd => {
const causal = ownedCausalOrder(scanned.entries, cwd);
if ('reason' in causal) return { status: causal.reason === 'competing_root' || causal.reason === 'cycle' ? 'error' as const : 'missing' as const,
reason: causal.reason, shape: causal.shape };
return { status: causal.order.some(i => scanned.entries[i]!.array) ? 'ready' as const : 'missing' as const,
order: causal.order, reason: undefined, shape: [] as string[] };
});
return results.find(r => r.status === 'ready') ?? results.find(r => r.reason !== 'foreign_cwd') ?? results[0]!;
}
/**
* Read exactly the native hook's parent file; never scan another session.
* Appends after open never fail the read; the same device and inode and no
* shrink are required. Without a policy every owned record is kept in full.
*/
export function readOwnedClaudePublicTranscript(file: string, owners: string | readonly string[], sessionId: string,
options: { prior?: JournalPrefix; policy?: OwnedReadPolicy; measure?: OwnedReadMeasure } = {}): OwnedRead {
const cwds = typeof owners === 'string' ? [owners] : owners, measure = options.measure ?? {};
let fd: number | undefined, scanned: Scanned | undefined;
try {
if (!/^[A-Za-z0-9_-]{1,160}$/.test(sessionId) || !cwds.length || !cwds.every(cwd => path.isAbsolute(cwd)) || !ownedNativePath(file) ||
path.basename(nativePathSpelling(file)) !== `${sessionId}.jsonl`) throw new OwnedReadError('identity');
file = nativePathSpelling(file);
const project = path.dirname(file), projects = path.dirname(project), config = path.dirname(projects);
if (path.basename(projects) !== 'projects' ||
[config, projects, project].some(dir => !fs.lstatSync(dir).isDirectory() || !sameNativePath(fs.realpathSync(dir), dir)))
throw new OwnedReadError('identity');
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
const before = fs.fstatSync(fd, { bigint: true });
if (!before.isFile()) throw new OwnedReadError('identity');
const size = Number(before.size);
if (options.prior && size < options.prior.size) throw new OwnedReadError('identity');
scanned = scan(fd, size, sessionId, options.prior, options.policy);
measure.prefix = scanned.prefix;
const after = fs.fstatSync(fd, { bigint: true }), current = fs.lstatSync(file, { bigint: true });
// Claude only appends: a different file or fewer bytes than at open is not this journal.
if (!current.isFile() || before.dev !== current.dev || before.ino !== current.ino ||
after.size < before.size || current.size < before.size) throw new OwnedReadError('identity');
const ownership = owned(scanned, cwds);
if (ownership.reason || ownership.status !== 'ready')
return { transcript: { status: ownership.status, calls: [], assistantMessages: [], ...(ownership.reason ? { reason: ownership.reason } : {}) },
events: [], diagnostic: { ...(scanned.claudeVersion ? { claudeVersion: scanned.claudeVersion } : {}), rootShape: ownership.shape,
toolUseIds: scanned.toolUseIds, sha256: scanned.prefix.sha256, complete: scanned.complete } };
const records: IndexedRecord[] = [];
let base = 0, claudeVersion: string | undefined, compactions = 0;
const boundary = (entry: Entry) => entry.type === 'system' && entry.subtype === 'compact_boundary' && entry.parentNull;
// Message records by copy identity, each with the number of owned compact boundaries before it.
const earlier = ownership.order!.some(i => boundary(scanned!.entries[i]!))
? new Map<string, Array<{ entry: Entry; compactions: number }>>() : undefined;
for (const i of ownership.order!) {
const entry = scanned.entries[i]!;
if (boundary(entry)) compactions++;
if (!entry.events) continue;
if (earlier) {
// Only a copy across a compact boundary adds no events; ancestry was already
// decided on the index, so records after a skipped copy stay owned.
const key = `${entry.type}\0${entry.stamp}`, prior = earlier.get(key) ?? [];
const copied = prior.some(p => p.compactions < compactions && compactionCopy(fd!, p.entry, entry));
prior.push({ entry, compactions }); earlier.set(key, prior);
if (copied) continue;
}
claudeVersion = entry.version ?? claudeVersion;
for (const e of entry.events) e.order += base;
records.push({ offset: entry.offset, length: entry.length, uuid: entry.uuid, base, slots: entry.slots,
jsonResults: entry.jsonResults, events: entry.events, full: !options.policy });
base += entry.slots;
}
// Measured only on request (tests, the benchmark): serializing the index costs as much as building it.
if (measure.indexBytes !== undefined) measure.indexBytes = records.reduce((sum, r) => sum + JSON.stringify(r.events).length + 64, 0);
if (options.policy) {
const reader = fd, limit = ownedRetainedLimit();
let retained = 0;
options.policy.select(records, wanted => {
for (const r of wanted) {
if (r.full) continue;
retained += r.length; measure.retainedBytes = retained;
if (retained > limit) throw new OwnedReadError('oversized_invocation', retained);
const bytes = Buffer.alloc(r.length);
if (fs.readSync(reader, bytes, 0, r.length, r.offset) !== r.length || !isUtf8(bytes)) throw new OwnedReadError('rewritten');
let value: any;
try { value = JSON.parse(bytes.toString('utf8')); } catch { throw new OwnedReadError('rewritten'); }
// Pass two must find the record pass one indexed at the same bytes.
if (!object(value) || value.uuid !== r.uuid || !object(value.message)) throw new OwnedReadError('rewritten');
const full = recordEvents(value, true);
if (full.slots !== r.slots || full.events.length !== r.events.length) throw new OwnedReadError('rewritten');
for (const e of full.events) e.order += r.base;
r.events = full.events;
r.full = true;
}
});
}
return { transcript: { status: 'ready', calls: [], assistantMessages: [] }, events: records.flatMap(r => r.events),
...(claudeVersion ? { claudeVersion } : {}) };
} catch (error) {
if (error instanceof OwnedReadError && error.reason === 'too_large') measure.recordBytes = error.bytes;
// A journal Claude has not created yet is unflushed, not an identity failure.
const reason = error instanceof OwnedReadError ? error.reason :
(error as NodeJS.ErrnoException)?.code === 'ENOENT' ? undefined : 'identity';
return { transcript: { status: 'error', calls: [], assistantMessages: [], ...(reason ? { reason } : {}),
error: `Owned native public transcript is unavailable (${reason ?? 'not created yet'})` }, events: [],
...(scanned ? { diagnostic: { ...(scanned.claudeVersion ? { claudeVersion: scanned.claudeVersion } : {}),
rootShape: [], toolUseIds: scanned.toolUseIds, sha256: scanned.prefix.sha256, complete: scanned.complete } } : {}) };
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
}