fix(docsync): the receipt record is housekeeping for read-only audits and the fault cases too

Pass 9's outside voice found two gaps in the fixture change (2524ec4): a
read-only audit (ship-docsync-current) must still write .gstack/tmp/docs-audit.json,
but docsToolFailures flagged every product write under readOnly before the
record allowance; and the unseeded fault cases (missing-asset, legacy-completion)
run the Entry too, so their kernel-write check saw .git/info/exclude as a
forbidden write because only the lifecycle test named the housekeeping paths.

- docsToolFailures: the record write is permitted under readOnly; the document
  and every other product file stay refused.
- docsWriteFailures: DOCS_AUDIT_RECORD_PATHS are permitted for every caller, so
  the fault evaluator needs no per-scenario list; anything else under .git or
  .gstack stays forbidden.
- test/docsync-command-grammar.test.ts pins both: the record under readOnly, the
  document refused there, the observed housekeeping paths accepted with an empty
  allowance and two neighbours refused.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-27 00:46:04 +00:00
1 parent 2524ec4475
commit 8aa3578753
3 files changed
+17 -10

No files matched your search

+10 -3
View File
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { DOC_PATH, fixtureDocs, gitAt, repoSnapshot } from './helpers/docsync-fixture';
import { docsCommandAllowed, docsCompletedRead, docsNativeInterface, docsSessionOptions, docsToolFailures } from './helpers/docsync-observer';
import { docsCommandAllowed, docsCompletedRead, docsNativeInterface, docsSessionOptions, docsToolFailures, docsWriteFailures } from './helpers/docsync-observer';
import { parseNDJSON, type SkillTestResult } from './helpers/session-runner';
let fixture: ReturnType<typeof fixtureDocs>;
@@ -507,7 +507,7 @@ test('the docs interface names the Claude config dir and private actor state as
// generated section, and nothing adjacent: a reworded exclude line, a bare rm, another receipt subcommand or another
// write under .gstack/ stay outside it (gate: ship-docsync-completion/current red twice at 775d9804 before they were declared).
test('Step 14.5 housekeeping commands and the docs record are declared exactly as the generated section writes them', () => {
const { DOCS_AUDIT_RECORD, DOCS_AUDIT_RESET, DOCS_TMP_EXCLUDE, docsCapsCommand } = require('./helpers/docsync-interface') as typeof import('./helpers/docsync-interface');
const { DOCS_AUDIT_RECORD, DOCS_AUDIT_RECORD_PATHS, DOCS_AUDIT_RESET, DOCS_TMP_EXCLUDE, docsCapsCommand } = require('./helpers/docsync-interface') as typeof import('./helpers/docsync-interface');
const section = fs.readFileSync(path.resolve(import.meta.dir, '..', 'ship/sections/documentation.md'), 'utf8');
expect(section).toContain(DOCS_TMP_EXCLUDE);
expect(section).toContain(DOCS_AUDIT_RESET);
@@ -532,7 +532,14 @@ test('Step 14.5 housekeeping commands and the docs record are declared exactly a
return { ...parsed, exitReason: 'success' } as unknown as SkillTestResult;
};
expect(docsToolFailures(write(path.join(fixture.repo, DOCS_AUDIT_RECORD)), fixture, [], false)).toEqual([]);
expect(docsToolFailures(write(path.join(fixture.repo, DOCS_AUDIT_RECORD)), fixture, [], true)).toEqual(['read-only docs write attempt']);
expect(docsToolFailures(write(path.join(fixture.repo, DOCS_AUDIT_RECORD)), fixture, [], true)).toEqual([]);
expect(docsToolFailures(write(path.join(fixture.repo, DOC_PATH)), fixture, [], true)).toEqual(['read-only docs write attempt']);
// The kernel observer sees the exclude file and the record's directories in every scenario that runs the Entry, the fault cases'
// unseeded first attempts included, so the shared list is permitted without each caller naming it; anything else under .git or .gstack is not.
const observed = (paths: string[]) => ({ complete: true, failures: [], events: paths.map(p => ({ path: p, mask: 0x2, cookie: 0, at: 0 })), changed: paths, before: {}, after: {}, limits: [] });
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']);
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'));
+6 -5
View File
@@ -5,7 +5,7 @@ import { observeQAWrites, type QAWriteObservation } from './qa-functional-observ
import { nativeCalls } from './qa-checkpoint-evidence';
import type { SkillTestResult } from './session-runner';
import { DOC_PATH, type DocsScenario, type fixtureDocs } from './docsync-fixture';
import { docsNativeInterface, DOCS_AUDIT_RECORD, DOCS_AUDIT_RESET, DOCS_TMP_EXCLUDE, docsCapsCommand } from './docsync-interface';
import { docsNativeInterface, DOCS_AUDIT_RECORD, DOCS_AUDIT_RECORD_PATHS, DOCS_AUDIT_RESET, DOCS_TMP_EXCLUDE, docsCapsCommand } from './docsync-interface';
import { sliceBetween } from './skill-fixture';
import { readOnlyCommandAllowed } from './read-only-commands';
@@ -137,7 +137,7 @@ export function docsWriteFailures(observation: QAWriteObservation, allowed: stri
if (!observation.complete) failures.push('incomplete docs write observation');
const atomicSources = docsAtomicSources(observation, allowed, context);
for (const file of new Set([...observation.events.map(e => e.path), ...observation.changed])) {
if (file !== '.qa-state/.observer-check' && !allowed.includes(file) && !atomicSources.has(file))
if (file !== '.qa-state/.observer-check' && !allowed.includes(file) && !DOCS_AUDIT_RECORD_PATHS.includes(file) && !atomicSources.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}`);
@@ -458,9 +458,10 @@ export function docsToolFailures(result: SkillTestResult, fixture: ReturnType<ty
const file = path.resolve(fixture.repo, call.input?.file_path ?? '');
const actual = actualWritePath(file);
const productWrite = within(file, fixture.repo) || (actual !== null && within(actual, repo));
if (readOnly && productWrite) failures.push('read-only docs write attempt');
const allowedDoc = (file === path.join(fixture.repo, DOC_PATH) && actual === authoredDoc) ||
(file === path.join(fixture.repo, DOCS_AUDIT_RECORD) && actual === path.join(repo, DOCS_AUDIT_RECORD));
// Step 14.5's receipt record is housekeeping every scenario may write, a read-only audit included; the document stays read-only.
const recordWrite = file === path.join(fixture.repo, DOCS_AUDIT_RECORD) && actual === path.join(repo, DOCS_AUDIT_RECORD);
if (readOnly && productWrite && !recordWrite) failures.push('read-only docs write attempt');
const allowedDoc = (file === path.join(fixture.repo, DOC_PATH) && actual === authoredDoc) || recordWrite;
if (productWrite && !allowedDoc) {
failures.push('non-document product write attempt');
}
+1 -2
View File
@@ -8,7 +8,6 @@ import { describeE2ETier } from './helpers/e2e-gate';
import { docsDispatchIndex, parseDocsCompletion, vetDocsCompletion } from './helpers/docsync-contract';
import { fixtureDocs, repoSnapshot, changedFiles, DOC_PATH, preserveDocsEvidence, sawSpawnedMarker, childReportedSpawnedMarker, type DocsScenario } from './helpers/docsync-fixture';
import { observeDocsWrites, docsWriteFailures, docsShipPhase, docsToolFailures, docsCompletedRead, docsSessionOptions } from './helpers/docsync-observer';
import { DOCS_AUDIT_RECORD_PATHS } from './helpers/docsync-interface';
import type { SkillTestResult } from './helpers/session-runner';
import { runShipDocsFault } from './helpers/docsync-fault-eval';
@@ -60,7 +59,7 @@ async function runShipDocs(testName: string, scenario: DocsScenario) {
expect(calls[dispatch].input.run_in_background).toBe(false);
if (publishCall >= 0) expect(dispatch).toBeLessThan(publishCall);
expect(result.exitReason).toBe('success');
expect(docsWriteFailures(observation!, scenario === 'store' ? [] : [...(scenario === 'current' ? [] : [DOC_PATH]), ...DOCS_AUDIT_RECORD_PATHS], {
expect(docsWriteFailures(observation!, scenario === 'current' || scenario === 'store' ? [] : [DOC_PATH], {
result, fixture, scripts: [publish], readOnly: scenario === 'current' || scenario === 'store',
})).toEqual([]);
expect(docsToolFailures(result, fixture, [publish], scenario === 'current' || scenario === 'store')).toEqual([]);