Explorar el Código

fix(notes): anchor archive seals to prior Git state

Tianyi Cui hace 1 mes
padre
commit
d52f8bfdfe

+ 7 - 0
.github/workflows/ci.yml

@@ -37,8 +37,10 @@ jobs:
     env:
       DSH_GATE_CONCURRENCY: '8'
     steps:
+      # The archive gate reads the PR base manifest from the synthetic merge commit's first parent.
       - uses: actions/checkout@v6
         with:
+          fetch-depth: 2
           persist-credentials: false
 
       # Pull requests consume the default-branch cache but do not put cache
@@ -60,6 +62,8 @@ jobs:
           pnpm install --frozen-lockfile
 
       - name: Run static gates
+        env:
+          DSH_ARCHIVE_BASE_REF: ${{ github.event.pull_request.base.sha }}
         run: pnpm run check:ci:static
 
       - name: Pack built tree
@@ -323,6 +327,8 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v6
+        with:
+          fetch-depth: 2
 
       - uses: actions/setup-node@v6
         with:
@@ -357,6 +363,7 @@ jobs:
 
       - name: Run complete unsharded primary Node CI serially
         env:
+          DSH_ARCHIVE_BASE_REF: ${{ github.event.before }}
           DSH_COVERAGE_MAX_WORKERS: '1'
           DSH_E2E_MAX_WORKERS: '1'
           DSH_ESLINT_CACHE: '1'

+ 24 - 0
scripts/archived-agent-notes.spec.ts

@@ -5,6 +5,7 @@ import {
   parseArchiveManifest,
   renderArchiveManifest,
   validateArchiveArtifacts,
+  validateArchiveManifestExtension,
   type ArchiveManifest,
 } from './archived-agent-notes.ts'
 
@@ -54,6 +55,29 @@ describe('archived Agent Notes', () => {
     )
   })
 
+  it('rejects replacing manifest seals alongside changed archive content', () => {
+    const artifacts = fixture()
+    const initial = extendArchiveManifest({ version: 1, files: {} }, artifacts)
+    const baseline: ArchiveManifest = { version: 1, files: initial.files }
+    const path = 'process/2026-07-26-example.md'
+    const changedArtifacts = new Map(artifacts)
+    changedArtifacts.set(path, Buffer.from('changed'))
+    const replacement = extendArchiveManifest({ version: 1, files: {} }, changedArtifacts)
+    const current: ArchiveManifest = { version: 1, files: replacement.files }
+
+    expect(extendArchiveManifest(current, changedArtifacts).errors).toEqual([])
+    expect(validateArchiveManifestExtension(baseline, current)).toEqual([
+      `${path}: sealed manifest hash changed`,
+    ])
+    const removed: ArchiveManifest = {
+      version: 1,
+      files: Object.fromEntries(Object.entries(current.files).filter(([candidate]) => candidate !== path)),
+    }
+    expect(validateArchiveManifestExtension(baseline, removed)).toContain(
+      `${path}: sealed manifest entry is missing`,
+    )
+  })
+
   it('round-trips the deterministic manifest schema', () => {
     const content = renderArchiveManifest({ 'process/z.md': `sha256:${'a'.repeat(64)}` })
     expect(parseArchiveManifest(content)).toEqual({

+ 14 - 0
scripts/archived-agent-notes.ts

@@ -53,6 +53,20 @@ export function renderArchiveManifest(files: Readonly<Record<string, string>>):
   }, null, 2)}\n`
 }
 
+/** Reject changes or removals of entries sealed by a prior manifest. */
+export function validateArchiveManifestExtension(
+  baseline: ArchiveManifest,
+  current: ArchiveManifest,
+): string[] {
+  const errors: string[] = []
+  for (const [path, expected] of Object.entries(baseline.files)) {
+    const actual = current.files[path]
+    if (actual === undefined) errors.push(`${path}: sealed manifest entry is missing`)
+    else if (actual !== expected) errors.push(`${path}: sealed manifest hash changed`)
+  }
+  return errors
+}
+
 function validDate(value: string): boolean {
   const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
   if (match === null) return false

+ 27 - 0
scripts/verify-archived-agent-notes.ts

@@ -1,5 +1,6 @@
 /** Verify and append-seal the frozen Agent Note archive. */
 
+import { spawnSync } from 'node:child_process'
 import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
 import { resolve } from 'node:path'
 import { AGENT_NOTE_CLASSES, agentNoteRoot } from './agent-note-tree.ts'
@@ -8,6 +9,7 @@ import {
   parseArchiveManifest,
   renderArchiveManifest,
   validateArchiveArtifacts,
+  validateArchiveManifestExtension,
   type ArchiveManifest,
 } from './archived-agent-notes.ts'
 
@@ -20,6 +22,8 @@ if (args.length > 0 && !writeMode) {
 
 const archiveRoot = resolve(agentNoteRoot, 'archived')
 const manifestPath = resolve(archiveRoot, 'manifest.json')
+const repoRoot = resolve(agentNoteRoot, '../..')
+const manifestRepoPath = '.agents/notes/archived/manifest.json'
 const errors: string[] = []
 const allowedRootFiles = new Set(['AGENTS.md', 'manifest.json'])
 const kinds = new Set<string>()
@@ -54,6 +58,20 @@ for (const kind of AGENT_NOTE_CLASSES) {
 }
 errors.push(...validateArchiveArtifacts(artifacts))
 
+function runGit(args: string[]): string {
+  const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' })
+  if (result.error !== undefined) throw result.error
+  if (result.status !== 0) throw new Error(result.stderr.trim() || `git exited with status ${result.status}`)
+  return result.stdout
+}
+
+function readBaselineManifest(ref: string): ArchiveManifest {
+  runGit(['cat-file', '-e', `${ref}^{commit}`])
+  const manifestEntry = runGit(['ls-tree', '--name-only', ref, '--', manifestRepoPath]).trim()
+  if (manifestEntry === '') return { version: 1, files: {} }
+  return parseArchiveManifest(runGit(['show', `${ref}:${manifestRepoPath}`]))
+}
+
 let manifest: ArchiveManifest = { version: 1, files: {} }
 if (existsSync(manifestPath)) {
   try {
@@ -65,6 +83,15 @@ if (existsSync(manifestPath)) {
   errors.push('archived/manifest.json is required; seal new artifacts with `pnpm run verify-archived-agent-notes --write`')
 }
 
+// CI supplies its trusted pre-change commit; local writes compare with committed HEAD.
+const baselineRef = process.env.DSH_ARCHIVE_BASE_REF ?? 'HEAD'
+try {
+  const baseline = readBaselineManifest(baselineRef)
+  errors.push(...validateArchiveManifestExtension(baseline, manifest))
+} catch (error: unknown) {
+  errors.push(`archived/manifest.json: cannot read baseline ${JSON.stringify(baselineRef)}: ${error instanceof Error ? error.message : String(error)}`)
+}
+
 const extended = extendArchiveManifest(manifest, artifacts)
 errors.push(...extended.errors)
 if (!writeMode) {