From 57ac5b0863fd136aee1208f6c5f1fff693e60f7a Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 27 Oct 2026 01:04:00 +0000 Subject: [PATCH] fix(docsync): admit the Write tool's temp sibling of the receipt record (0230fc2 rerun: both lifecycle cases red on it) The paid rerun at 0230fc2 cleared every earlier refusal and both cases then failed on one path: forbidden docs write: .gstack/tmp/docs-audit.json.tmp..<12 hex>. Claude Code's Write and Edit tools land every file, new ones included, through that sibling plus fsync and rename, and the fixture's kernel observer proved such a landing for the document only (atomicTargets: [DOC_PATH]). The observer now treats the record's sibling as a transient file too, and docsWriteFailures admits it only as the cookie-paired MOVED_FROM source of a MOVED_TO on the record whose other events are create/modify/attrib/close_write and which is left in neither snapshot nor in changed. A lingering sibling, an unpaired move, a move onto another path or a sibling of the document moved onto the record stay forbidden (grammar test). A live check on this machine saw the second landing's create/modify/close_write/moved_from events admitted and a stray sibling refused. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/docsync-command-grammar.test.ts | 14 ++++++++++++++ test/helpers/docsync-observer.ts | 24 ++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/test/docsync-command-grammar.test.ts b/test/docsync-command-grammar.test.ts index c08452e7c..48c02ed3a 100644 --- a/test/docsync-command-grammar.test.ts +++ b/test/docsync-command-grammar.test.ts @@ -540,6 +540,20 @@ test('Step 14.5 housekeeping commands and the docs record are declared exactly a expect(docsWriteFailures(observed(['.qa-state/.observer-check', ...DOCS_AUDIT_RECORD_PATHS]), [])).toEqual([]); expect(docsWriteFailures(observed(['.git/info/attributes']), [])).toEqual(['forbidden docs write: .git/info/attributes']); expect(docsWriteFailures(observed(['.gstack/tmp/review-loop.json']), [DOC_PATH])).toEqual(['forbidden docs write: .gstack/tmp/review-loop.json']); + // The Write tool lands the record through `.tmp..<12 hex>` + rename (gate: both lifecycle cases red at 0230fc2 on exactly + // that sibling). The sibling is admitted only as the cookie-paired source of a move onto the record that leaves nothing behind. + const temp = `${DOCS_AUDIT_RECORD}.tmp.564.6161e6c7f0b7`; + const moved = (edits: { path: string; mask: number; cookie?: number }[], after: Record = {}) => ({ + complete: true, failures: [], events: edits.map((e, at) => ({ path: e.path, mask: e.mask, cookie: e.cookie ?? 0, at })), changed: Object.keys(after), before: {}, after, limits: [], + }); + const landing = [{ path: temp, mask: 0x100 }, { path: temp, mask: 0x2 }, { path: temp, mask: 0x8 }, { path: temp, mask: 0x40, cookie: 7 }, { path: DOCS_AUDIT_RECORD, mask: 0x80, cookie: 7 }]; + expect(docsWriteFailures(moved(landing), [])).toEqual([]); + expect(docsWriteFailures(moved(landing.slice(1)), [])).toEqual([]); + expect(docsWriteFailures(moved(landing, { [temp]: '0:' + 'a'.repeat(64) }), [])).toEqual([`forbidden docs write: ${temp}`]); + expect(docsWriteFailures(moved(landing.slice(0, 3)), [])).toEqual([`forbidden docs write: ${temp}`]); + expect(docsWriteFailures(moved([...landing.slice(0, 4), { path: '.gstack/tmp/other.json', mask: 0x80, cookie: 7 }]), [])).toEqual([`forbidden docs write: ${temp}`, 'forbidden docs write: .gstack/tmp/other.json']); + expect(docsWriteFailures(moved([...landing.slice(0, 3), { path: temp, mask: 0x40, cookie: 7 }, { path: DOCS_AUDIT_RECORD, mask: 0x80, cookie: 7 }, { path: temp, mask: 0x100 }]), [])).toEqual([`forbidden docs write: ${temp}`]); + expect(docsWriteFailures(moved([{ path: `${DOC_PATH}.tmp.564.6161e6c7f0b7`, mask: 0x40, cookie: 9 }, { path: DOCS_AUDIT_RECORD, mask: 0x80, cookie: 9 }]), [])).toEqual([`forbidden docs write: ${DOC_PATH}.tmp.564.6161e6c7f0b7`]); expect(docsToolFailures(write(path.join(fixture.repo, '.gstack/tmp/review-loop.json')), fixture, [], false)).toEqual(['non-document product write attempt', 'write outside docs fixture authority']); expect(docsToolFailures(write(path.join(fixture.repo, '.gstack/ship-policy.json')), fixture, [], false)).toEqual(['non-document product write attempt', 'write outside docs fixture authority']); expect(gitAt(fixture.repo, 'rev-parse', '--verify', 'origin/main')).toBe(gitAt(fixture.repo, 'rev-parse', '--verify', 'main')); diff --git a/test/helpers/docsync-observer.ts b/test/helpers/docsync-observer.ts index 8a7833107..b2c7e294c 100644 --- a/test/helpers/docsync-observer.ts +++ b/test/helpers/docsync-observer.ts @@ -12,7 +12,26 @@ import { readOnlyCommandAllowed } from './read-only-commands'; export { docsNativeInterface }; export async function observeDocsWrites(fixture: ReturnType) { - return observeQAWrites(fixture.repo, { atomicTargets: [DOC_PATH] }); + return observeQAWrites(fixture.repo, { atomicTargets: [DOC_PATH, DOCS_AUDIT_RECORD] }); +} + +/** Temp files the Write/Edit tool renamed onto the receipt record (`.tmp..<12 hex>`): created, written and closed + * under the record's directory, then moved onto the record by a cookie-paired MOVED_FROM/MOVED_TO, and not left behind. */ +function docsRecordSources(observation: QAWriteObservation): Set { + const sources = new Set(); + const events = observation.events; + for (const [index, event] of events.entries()) { + if (event.path !== DOCS_AUDIT_RECORD || event.mask !== 0x80 || !Number.isInteger(event.cookie) || event.cookie <= 0) continue; + const pair = events.flatMap((candidate, at) => candidate.cookie === event.cookie ? [at] : []); + if (pair.length !== 2 || pair[1] !== index) continue; + const source = events[pair[0]]; + if (source.mask !== 0x40 || !source.path.startsWith(DOCS_AUDIT_RECORD + '.tmp.') || !/^\.tmp\.[1-9]\d*\.[0-9a-f]{12}$/.test(source.path.slice(DOCS_AUDIT_RECORD.length)) + || Object.hasOwn(observation.before, source.path) || Object.hasOwn(observation.after, source.path) || observation.changed.includes(source.path)) continue; + const lifecycle = events.filter(candidate => candidate.path === source.path); + if (lifecycle.at(-1) !== source || lifecycle.some(candidate => candidate !== source && (candidate.cookie !== 0 || ![0x100, 0x2, 0x4, 0x8].includes(candidate.mask)))) continue; + sources.add(source.path); + } + return sources; } type DocsWriteContext = { @@ -136,8 +155,9 @@ export function docsWriteFailures(observation: QAWriteObservation, allowed: stri const failures = [...observation.failures]; if (!observation.complete) failures.push('incomplete docs write observation'); const atomicSources = docsAtomicSources(observation, allowed, context); + const recordSources = docsRecordSources(observation); for (const file of new Set([...observation.events.map(e => e.path), ...observation.changed])) { - if (file !== '.qa-state/.observer-check' && !allowed.includes(file) && !DOCS_AUDIT_RECORD_PATHS.includes(file) && !atomicSources.has(file)) + if (file !== '.qa-state/.observer-check' && !allowed.includes(file) && !DOCS_AUDIT_RECORD_PATHS.includes(file) && !atomicSources.has(file) && !recordSources.has(file)) failures.push(`forbidden docs write: ${file}${atomicSources.deniedAt && path.dirname(file) === path.dirname(DOC_PATH) ? ` (atomic replacement unproven at ${atomicSources.deniedAt})` : ''}`); if (allowed.includes(file) && observation.before[file] && observation.after[file] && observation.before[file].split(':')[0] !== observation.after[file].split(':')[0]) failures.push(`document mode changed: ${file}`);