mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 00:44:07 +01:00
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.<pid>.<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>
This commit is contained in:
1 parent
0230fc29a5
commit
57ac5b0863
2 files changed
+36
-2
No files matched your search
@@ -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 `<record>.tmp.<pid>.<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<string, string> = {}) => ({
|
||||
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'));
|
||||
|
||||
@@ -12,7 +12,26 @@ import { readOnlyCommandAllowed } from './read-only-commands';
|
||||
export { docsNativeInterface };
|
||||
|
||||
export async function observeDocsWrites(fixture: ReturnType<typeof fixtureDocs>) {
|
||||
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 (`<record>.tmp.<pid>.<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<string> {
|
||||
const sources = new Set<string>();
|
||||
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}`);
|
||||
|
||||
Reference in new issue
Block a user