From e525fac52c5dd5473659ee8860f5cb1d31c864a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 08:08:18 +0900 Subject: [PATCH 01/36] fix(acquisition): bind commercial source records --- CHANGELOG.md | 1 + docs/TRACEABILITY.md | 2 +- docs/acquisition-readiness-2b.md | 2 +- docs/buyer-due-diligence-index.md | 2 +- .../revenue-evidence.example.json | 10 ++++- .../transfer-evidence.example.json | 10 ++++- docs/product-technical-gap-baseline.md | 2 +- scripts/acquisition-readiness-audit.mjs | 12 +++++- test/acquisition-artifact-rights-json.test.ts | 6 ++- test/acquisition-evidence-iso-date.test.ts | 8 +++- test/acquisition-readiness-audit.test.ts | 19 ++++++--- .../acquisition-revenue-metric-domain.test.ts | 41 ++++++++++++++++++- test/acquisition-transfer-rights.test.ts | 7 +++- 13 files changed, 102 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a217758..5aa6ba731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- revenue/transfer acquisition evidence의 `source_documents`를 임의 문자열 label 대신 stable retained artifact의 `{path, sha256}` binding으로 검증한다. Digest 일치는 보존된 bytes의 무결성만 증명하며 CRM·계약·매출·법률 기록의 진실성이나 승인 권한은 계속 별도 buyer evidence로 요구한다. - production runtime credential envelope parsing을 fail-closed로 강화한다. GitHub App PKCS#1 key의 canonical PKCS#8 변환은 유지하되, bare carriage return처럼 비정규 body bytes가 포함된 PKCS#8 PEM은 readiness/import 단계의 암묵적 정규화에 넘기지 않고 즉시 거부해 malformed secret이 ready 상태로 승인되지 않게 한다. - Governance and Maintainer App GitHub CLI subprocesses now keep CLI config and XDG state inside the validated capability file's private parent directory, preventing a missing ambient home from writing `.local` state into the checkout. - Maintainer App readiness now requires the retained governance audit's `protected_main_sha` to equal the freshly collected default-branch head, preventing evidence from different protected-main revisions from being combined into one passing report. The governance collector authenticates every tracked checkout byte against exact HEAD before and after live collection, so modified audit source cannot emit PASS evidence attributed to protected main. Governance and readiness report paths also retain their existing non-symlink private-output authority. The product/technical gap baseline is refreshed to the same protected-main and live issue/run/release observation, and describes the hourly loop through `contextual-orchestrator` rather than retired direct-provider execution. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 9f287d5e7..a57fd95ed 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -57,7 +57,7 @@ Each arrow is a separate authority. Success at an earlier stage cannot fabricate | Credential/security coverage truth | protected main | protected `src/index.ts`, `docs/TEST_STRATEGY.md` and coverage contracts | exact configured 100% statement/branch/function/line gates; no broad credential/security V8-ignore contract | current protected-main CI remains observation-scoped | Implemented on protected main | | Patch-validator image supply chain | issue #66 + protected implementation | `Dockerfile.patch-validator`, image workflow, validator runtime/profile, SBOM/scanner/receipt validators | exact build/runtime/smoke/SBOM/vulnerability/receipt/final-head verification | protected-main operational receipt and later publication/signing/activation evidence | Source/runtime/supply-chain implementation is integrated on protected main; later operational/publication authority remains separate | | Licensing/IP authority | licensing/IP contract | rights/evidence validators | duplicate-key/UTF-8/exact-artifact and rights-metadata tests | owner/legal grant and transfer evidence | Technical controls exist; legal authority external | -| Release/acquisition readiness | release/provenance/acquisition contracts | release verification and evidence scripts | exact-source package/SBOM/provenance/readiness tests | immutable release/deployment/customer/revenue/legal evidence | Incomplete; no readiness claim from docs alone | +| Release/acquisition readiness | release/provenance/acquisition contracts | release verification and evidence scripts, digest-bound revenue/transfer source documents | exact-source package/SBOM/provenance/readiness and retained-source byte-integrity tests | immutable release/deployment/customer/revenue/legal authority | Technical byte binding implemented; commercial/legal authenticity remains external | ## 3. Live governance traceability diff --git a/docs/acquisition-readiness-2b.md b/docs/acquisition-readiness-2b.md index d42159839..42991b6da 100644 --- a/docs/acquisition-readiness-2b.md +++ b/docs/acquisition-readiness-2b.md @@ -141,7 +141,7 @@ Product Design 기준으로 구매자와 파일럿 고객이 제품 가치를 - security evidence: `artifacts/security/security-validation-evidence.json` (`npm run security:evidence`로 단독 검증) - production pilot log: `docs/pilot-readiness-log.md` 또는 `NOEMA_PILOT_LOG_PATH` - saleable readiness evidence: `artifacts/saleable-readiness//goal-audit.json` -- revenue/transfer evidence는 `owner`, `source_documents`, 최근 `updated_at`을 포함해야 한다. +- revenue/transfer evidence는 `owner`, 최근 `updated_at`, 그리고 retained source bytes에 결합된 `source_documents` `{path, sha256}` 항목을 포함해야 한다. 이 digest 검증은 보존된 bytes의 무결성만 증명하며 CRM·계약·매출·법률 기록의 진실성이나 승인 권한을 대신하지 않는다. - `updated_at`은 기본 45일 이내 증빙이어야 하며, 필요 시 `NOEMA_ACQUISITION_EVIDENCE_MAX_AGE_DAYS`로 조정한다. - Strategic pipeline route는 `buyer_due_diligence_qna`에 구매자별 보안/운영 실사 Q&A 로그 경로를 1개 이상 포함해야 한다. - production pilot log는 production HTTPS `NOEMA URL`, `증빙 출처: production`, KPI threshold, trace sample, support channel, 계약/매출 증빙 경로가 있는 완료 항목 1건 이상을 요구한다. diff --git a/docs/buyer-due-diligence-index.md b/docs/buyer-due-diligence-index.md index 19752bfde..20fca75e1 100644 --- a/docs/buyer-due-diligence-index.md +++ b/docs/buyer-due-diligence-index.md @@ -86,7 +86,7 @@ Production 파일럿 로그는 `npm run acquisition:audit`에서도 직접 검 ## Commercial -`artifacts/acquisition/revenue-evidence.json`에는 `owner`, `source_documents`, 기본 45일 이내 `updated_at`이 있어야 한다. +`artifacts/acquisition/revenue-evidence.json`에는 `owner`, 기본 45일 이내 `updated_at`, retained source bytes를 지정하는 `source_documents` `{path, sha256}` 항목이 있어야 한다. SHA-256 일치는 byte integrity일 뿐 CRM·계약·지급·법률 기록의 진실성 또는 승인 권한은 별도 authoritative evidence다. 작성 템플릿은 `docs/evidence-templates/revenue-evidence.example.json`이다. `replace-with-*`, `.example.json`, `docs/evidence-templates/` 값은 evidence로 인정하지 않는다. | 항목 | Evidence | 상태 | diff --git a/docs/evidence-templates/revenue-evidence.example.json b/docs/evidence-templates/revenue-evidence.example.json index 20ac49d5d..c993e7bc1 100644 --- a/docs/evidence-templates/revenue-evidence.example.json +++ b/docs/evidence-templates/revenue-evidence.example.json @@ -11,7 +11,13 @@ "updated_at": "replace-with-YYYY-MM-DD", "owner": "replace-with-finance-or-sales-owner", "source_documents": [ - "replace-with-crm-arr-report", - "replace-with-contract-or-loi-path" + { + "path": "replace-with-retained-crm-arr-report-path", + "sha256": "replace-with-retained-crm-arr-report-sha256" + }, + { + "path": "replace-with-retained-contract-or-loi-path", + "sha256": "replace-with-retained-contract-or-loi-sha256" + } ] } diff --git a/docs/evidence-templates/transfer-evidence.example.json b/docs/evidence-templates/transfer-evidence.example.json index 9e21de398..0dfa05af3 100644 --- a/docs/evidence-templates/transfer-evidence.example.json +++ b/docs/evidence-templates/transfer-evidence.example.json @@ -9,8 +9,14 @@ "updated_at": "replace-with-YYYY-MM-DD", "owner": "replace-with-legal-or-security-owner", "source_documents": [ - "replace-with-license-review-path", - "replace-with-transfer-runbook-or-approval-path" + { + "path": "replace-with-retained-license-review-path", + "sha256": "replace-with-retained-license-review-sha256" + }, + { + "path": "replace-with-retained-transfer-approval-path", + "sha256": "replace-with-retained-transfer-approval-sha256" + } ], "licensing_ip": { "owner_legal_decision": { diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 79c3189a0..67090beea 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -23,7 +23,7 @@ | Hourly product-development loop | `contextual-orchestrator` inference와 별도 Maintainer App publication identity를 사용하는 work-conserving loop | `.github/workflows/hourly-product-development.yml`, orchestrator gateway contract, publication/readiness validators | workflow shape, gateway preflight, lease, publication prerequisite and stale-head refusal tests | zero-PR scheduled proposal publication과 rollback/recovery exercise | Implemented source; production activation incomplete | | Patch-validator supply chain | exact source/image/receipt binding과 fail-closed vulnerability policy | `Dockerfile.patch-validator`, image workflow, validator/SBOM/receipt modules | build, runtime, smoke, SBOM, vulnerability and receipt tests | protected-main operational receipt와 registry publication/signing/attestation | Implemented source; operational/publication evidence incomplete | | Release and deployment | source → package/SBOM/provenance → immutable publication → deployment/rollback | release, publication, deployment and readiness scripts | exact-source/reproducibility/receipt/rollback contract tests | immutable release, protected deployment, recovery and production smoke evidence | Incomplete; repository evidence cannot establish deployment | -| KPI, customer and acquisition | authentic evidence must retain source, time and buyer/legal authority | KPI, acquisition manifest/integrity/readiness and license validators | bounded input, provenance, ordering, integrity and fail-closed tests | authentic 30-day production KPI, customer/revenue and transfer evidence | Incomplete; no commercial-readiness claim | +| KPI, customer and acquisition | authentic evidence must retain source, time and buyer/legal authority | KPI, acquisition manifest/integrity/readiness and license validators; revenue/transfer source documents are retained-byte digest bindings | bounded input, provenance, ordering, source-document byte integrity and fail-closed tests | authentic 30-day production KPI, customer/revenue and transfer authority | Technical integrity is implemented; no commercial-readiness claim from digest equality | ## Prioritized residual gaps diff --git a/scripts/acquisition-readiness-audit.mjs b/scripts/acquisition-readiness-audit.mjs index df2e9da7d..fec9be2fd 100644 --- a/scripts/acquisition-readiness-audit.mjs +++ b/scripts/acquisition-readiness-audit.mjs @@ -17,6 +17,7 @@ import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evi const fatalUtf8Decoder = new TextDecoder("utf-8", { fatal: true }); const isoDateOrTimestampRegex = /^(\d{4}-\d{2}-\d{2})(?:T(?:[01]\d|2[0-3]):\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2}))?$/; const MAX_ISO_UTC_OFFSET_MS = 14 * 60 * 60 * 1000; +const MAX_SOURCE_DOCUMENTS = 32; const now = new Date().toISOString(); const configuredOutputDir = process.env.NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR; if (configuredOutputDir) { @@ -198,8 +199,15 @@ function validateEvidenceMetadata(value) { } else if (isPlaceholderEvidence(value.owner)) { failures.push("owner cannot be a placeholder"); } - const sourceDocuments = validateEvidenceRefs(value.source_documents, "source_documents"); - failures.push(...sourceDocuments.failures); + if (!Array.isArray(value.source_documents) || value.source_documents.length === 0) { + failures.push("source_documents must contain at least one retained artifact binding"); + } else if (value.source_documents.length > MAX_SOURCE_DOCUMENTS) { + failures.push(`source_documents must contain at most ${MAX_SOURCE_DOCUMENTS} artifact bindings`); + } else { + value.source_documents.forEach((document, index) => { + validateDigestBoundArtifact(document, `source_documents[${index}]`, failures); + }); + } if (!updatedAt || Number.isNaN(updatedAtMs)) { failures.push("updated_at must be an ISO date or timestamp"); } else if (updatedAtMs > futureBoundaryMs) { diff --git a/test/acquisition-artifact-rights-json.test.ts b/test/acquisition-artifact-rights-json.test.ts index 7048a586f..bc6c76b17 100644 --- a/test/acquisition-artifact-rights-json.test.ts +++ b/test/acquisition-artifact-rights-json.test.ts @@ -88,7 +88,11 @@ describe("acquisition artifact-rights JSON evidence", () => { "artifacts/acquisition/transfer-evidence.json", `${JSON.stringify({ owner: "Acquisition counsel", - source_documents: ["legal/review-record.pdf"], + source_documents: [digestArtifact( + root, + "artifacts/acquisition/transfer-source.json", + '{"source":"test-counsel-record"}\n', + )], updated_at: new Date().toISOString(), license_review: "pass", third_party_review: "pass", diff --git a/test/acquisition-evidence-iso-date.test.ts b/test/acquisition-evidence-iso-date.test.ts index ad9e0e767..a981173da 100644 --- a/test/acquisition-evidence-iso-date.test.ts +++ b/test/acquisition-evidence-iso-date.test.ts @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -43,6 +44,8 @@ function prepareAuditRoot(prefix: string): string { } function runAuditWithRevenueTimestamp(root: string, updatedAt: string, nowMs?: number) { + const sourceBytes = '{"source":"test-ledger"}\n'; + writeFixture(root, "artifacts/acquisition/revenue-source.json", sourceBytes); const revenuePath = writeFixture(root, "revenue.json", JSON.stringify({ arr_krw: 300_000_000, gross_margin: 0.75, @@ -52,7 +55,10 @@ function runAuditWithRevenueTimestamp(root: string, updatedAt: string, nowMs?: n customer_concentration_top1: 0.5, updated_at: updatedAt, owner: "finance", - source_documents: ["crm:noema-arr-report"], + source_documents: [{ + path: "artifacts/acquisition/revenue-source.json", + sha256: createHash("sha256").update(sourceBytes).digest("hex"), + }], })); const outputDir = join(root, "audit-output"); const inheritedEnvironment = Object.fromEntries( diff --git a/test/acquisition-readiness-audit.test.ts b/test/acquisition-readiness-audit.test.ts index c9ba6ea7e..a73700c13 100644 --- a/test/acquisition-readiness-audit.test.ts +++ b/test/acquisition-readiness-audit.test.ts @@ -32,6 +32,12 @@ function writeFixture(root: string, relativePath: string, content: string): stri return path; } +function writeSourceDocument(root: string, relativePath = "artifacts/acquisition/source-record.json") { + const content = '{"source":"authenticated-test-fixture"}\n'; + writeFixture(root, relativePath, content); + return { path: relativePath, sha256: createHash("sha256").update(content).digest("hex") }; +} + function prepareAuditRoot(prefix: string): string { const root = mkdtempSync(join(tmpdir(), prefix)); writeFixture( @@ -203,7 +209,7 @@ function writePassingTransfer(root: string, path: string) { privacy_review: "pass", updated_at: today(), owner: "legal", - source_documents: ["legal/transfer-review.pdf"], + source_documents: [writeSourceDocument(root, "artifacts/acquisition/transfer-source.json")], licensing_ip: passingLicensingIp(root), })); } @@ -216,6 +222,7 @@ function writePassingSaleable(path: string) { } function writeArrRevenue(path: string, overrides: Record = {}) { + const root = dirname(path); writeFileSync(path, JSON.stringify({ arr_krw: 300_000_000, gross_margin: 0.75, @@ -225,7 +232,7 @@ function writeArrRevenue(path: string, overrides: Record = {}) customer_concentration_top1: 0.5, updated_at: today(), owner: "finance", - source_documents: ["crm:noema-arr-report"], + source_documents: [writeSourceDocument(root, "artifacts/acquisition/revenue-source.json")], ...overrides, })); } @@ -398,14 +405,14 @@ describe("acquisition-readiness-audit", () => { ); expect(revenueCheck.details.metadataFailures).toContain("owner cannot be a placeholder"); expect(revenueCheck.details.metadataFailures).toContain( - "source_documents must reference reviewed evidence, not placeholders or templates", + "source_documents[0] artifact binding required", ); expect(revenueCheck.details.buyerQnaFailures).toContain( "buyer_due_diligence_qna must reference reviewed evidence, not placeholders or templates", ); expect(transferCheck.details.metadataFailures).toContain("owner cannot be a placeholder"); expect(transferCheck.details.metadataFailures).toContain( - "source_documents must reference reviewed evidence, not placeholders or templates", + "source_documents[0] artifact binding required", ); expect(transferCheck.details.licensingIpFailures).toContain( "licensing_ip evidence object required", @@ -463,7 +470,7 @@ describe("acquisition-readiness-audit", () => { customer_concentration_top1: 1, updated_at: today(), owner: "sales", - source_documents: ["crm:noema-enterprise-pipeline"], + source_documents: [writeSourceDocument(root, "artifacts/acquisition/pipeline-source.json")], })); writePassingTransfer(root, paths.transferPath); writePassingSaleable(paths.saleablePath); @@ -484,7 +491,7 @@ describe("acquisition-readiness-audit", () => { buyer_due_diligence_qna: ["crm:noema-enterprise-security-qna"], updated_at: today(), owner: "sales", - source_documents: ["crm:noema-enterprise-pipeline"], + source_documents: [writeSourceDocument(root, "artifacts/acquisition/pipeline-source.json")], })); const withQna = runAudit(root, passingEnv(paths)); diff --git a/test/acquisition-revenue-metric-domain.test.ts b/test/acquisition-revenue-metric-domain.test.ts index 035c5c204..0cdc656e0 100644 --- a/test/acquisition-revenue-metric-domain.test.ts +++ b/test/acquisition-revenue-metric-domain.test.ts @@ -1,4 +1,5 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; @@ -37,6 +38,7 @@ function runRevenueAudit(revenue: Record) { } function passingRevenue(overrides: Record = {}) { + const sourceBytes = readFileSync("README.md"); return { arr_krw: 300_000_000, gross_margin: 0.75, @@ -46,7 +48,10 @@ function passingRevenue(overrides: Record = {}) { customer_concentration_top1: 0.5, updated_at: new Date().toISOString(), owner: "finance", - source_documents: ["crm:noema-arr-report"], + source_documents: [{ + path: "README.md", + sha256: createHash("sha256").update(sourceBytes).digest("hex"), + }], ...overrides, }; } @@ -74,4 +79,38 @@ describe("acquisition revenue metric authority", () => { expect(revenueCheck.pass).toBe(true); expect(revenueCheck.details.metricFailures).toEqual([]); }); + + it("rejects an arbitrary source-system label without retained bytes", () => { + const { revenueCheck } = runRevenueAudit(passingRevenue({ + source_documents: ["crm:noema-arr-report"], + })); + + expect(revenueCheck.pass).toBe(false); + expect(revenueCheck.details.metadataFailures).toContain( + "source_documents[0] artifact binding required", + ); + }); + + it("rejects retained source bytes whose digest does not match", () => { + const { revenueCheck } = runRevenueAudit(passingRevenue({ + source_documents: [{ path: "README.md", sha256: "0".repeat(64) }], + })); + + expect(revenueCheck.pass).toBe(false); + expect(revenueCheck.details.metadataFailures).toContain( + "source_documents[0].sha256 does not match retained artifact bytes", + ); + }); + + it("bounds the retained source-document set", () => { + const binding = passingRevenue().source_documents[0]; + const { revenueCheck } = runRevenueAudit(passingRevenue({ + source_documents: Array.from({ length: 33 }, () => binding), + })); + + expect(revenueCheck.pass).toBe(false); + expect(revenueCheck.details.metadataFailures).toContain( + "source_documents must contain at most 32 artifact bindings", + ); + }); }); diff --git a/test/acquisition-transfer-rights.test.ts b/test/acquisition-transfer-rights.test.ts index 6e1d566b6..ff0082087 100644 --- a/test/acquisition-transfer-rights.test.ts +++ b/test/acquisition-transfer-rights.test.ts @@ -93,12 +93,17 @@ function writeTransferEvidence( root: string, licensingIp?: Record, ): string { + const sourceDocument = writeDigestArtifact( + root, + "artifacts/acquisition/transfer-source.json", + '{"source":"test-counsel-record"}\n', + ); return writeFixture( root, "artifacts/acquisition/transfer-evidence.json", `${JSON.stringify({ owner: "Acquisition counsel", - source_documents: ["legal/review-record.pdf"], + source_documents: [sourceDocument], updated_at: new Date().toISOString(), license_review: "pass", third_party_review: "pass", From 0f837398995d5f5dadc89f3cfe4be8ce5da589fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 09:05:37 +0900 Subject: [PATCH 02/36] docs(acquisition): align transfer source binding contract --- docs/buyer-due-diligence-index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/buyer-due-diligence-index.md b/docs/buyer-due-diligence-index.md index 20fca75e1..c2dd14cb5 100644 --- a/docs/buyer-due-diligence-index.md +++ b/docs/buyer-due-diligence-index.md @@ -100,7 +100,7 @@ Production 파일럿 로그는 `npm run acquisition:audit`에서도 직접 검 ## Transfer -`artifacts/acquisition/transfer-evidence.json`에는 `owner`, `source_documents`, 기본 45일 이내 `updated_at`이 있어야 한다. +`artifacts/acquisition/transfer-evidence.json`에는 `owner`, 기본 45일 이내 `updated_at`, 그리고 1~32개의 retained source binding으로 구성된 `source_documents`가 있어야 한다. 각 항목은 canonical repository-relative `path`와 그 보존 파일 bytes의 lowercase/uppercase 64-hex `sha256`을 담는 `{path, sha256}` 레코드여야 하며 placeholder나 template 경로는 인정하지 않는다. SHA-256 일치는 보존 bytes의 무결성만 증명하고, 법률·IP·계정 이전 기록의 진실성이나 승인 권한은 별도 authoritative evidence로 확인해야 한다. 작성 템플릿은 `docs/evidence-templates/transfer-evidence.example.json`이다. `replace-with-*`, `.example.json`, `docs/evidence-templates/` 값은 evidence로 인정하지 않는다. | 항목 | Evidence | 상태 | From f204a658f674a0b318762983158c94a0544b6aa7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 09:07:32 +0900 Subject: [PATCH 03/36] docs(acquisition): fix source binding examples --- docs/acquisition-readiness-2b.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/acquisition-readiness-2b.md b/docs/acquisition-readiness-2b.md index 42991b6da..f2b547d51 100644 --- a/docs/acquisition-readiness-2b.md +++ b/docs/acquisition-readiness-2b.md @@ -141,13 +141,13 @@ Product Design 기준으로 구매자와 파일럿 고객이 제품 가치를 - security evidence: `artifacts/security/security-validation-evidence.json` (`npm run security:evidence`로 단독 검증) - production pilot log: `docs/pilot-readiness-log.md` 또는 `NOEMA_PILOT_LOG_PATH` - saleable readiness evidence: `artifacts/saleable-readiness//goal-audit.json` -- revenue/transfer evidence는 `owner`, 최근 `updated_at`, 그리고 retained source bytes에 결합된 `source_documents` `{path, sha256}` 항목을 포함해야 한다. 이 digest 검증은 보존된 bytes의 무결성만 증명하며 CRM·계약·매출·법률 기록의 진실성이나 승인 권한을 대신하지 않는다. +- revenue/transfer evidence의 `source_documents`는 1~32개의 retained `{path, sha256}` 레코드로 구성해야 한다. `path`는 canonical repository-relative evidence 경로여야 하고 `sha256`은 그 보존 파일의 64-hex SHA-256이어야 한다. 이 digest 검증은 보존된 bytes의 무결성만 증명하며 CRM·계약·매출·법률 기록의 진실성이나 승인 권한을 대신하지 않는다. - `updated_at`은 기본 45일 이내 증빙이어야 하며, 필요 시 `NOEMA_ACQUISITION_EVIDENCE_MAX_AGE_DAYS`로 조정한다. - Strategic pipeline route는 `buyer_due_diligence_qna`에 구매자별 보안/운영 실사 Q&A 로그 경로를 1개 이상 포함해야 한다. - production pilot log는 production HTTPS `NOEMA URL`, `증빙 출처: production`, KPI threshold, trace sample, support channel, 계약/매출 증빙 경로가 있는 완료 항목 1건 이상을 요구한다. - 작성 템플릿은 `docs/evidence-templates/revenue-evidence.example.json`, `docs/evidence-templates/transfer-evidence.example.json`에 둔다. 템플릿은 `artifacts/acquisition/*.json`으로 복사한 뒤 placeholder를 실제 owner/source/evidence 값으로 교체해야 한다. `replace-with-*`, `.example.json`, `docs/evidence-templates/` 값은 `npm run acquisition:audit`에서 evidence로 인정하지 않는다. -예시는 다음과 같다. +예시는 다음과 같다. 예시 digest는 형식만 보여 주는 값이며 실제 제출 시 해당 retained bytes의 SHA-256으로 교체해야 한다. ```json { @@ -163,8 +163,14 @@ Product Design 기준으로 구매자와 파일럿 고객이 제품 가치를 "updated_at": "2026-07-02", "owner": "finance", "source_documents": [ - "crm:noema-arr-report", - "contracts/noema-paid-customers.pdf" + { + "path": "artifacts/acquisition/source-records/noema-arr-report.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "path": "artifacts/acquisition/source-records/noema-paid-customers.pdf", + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } ] } ``` @@ -181,8 +187,14 @@ Product Design 기준으로 구매자와 파일럿 고객이 제품 가치를 "updated_at": "2026-07-02", "owner": "legal", "source_documents": [ - "docs/buyer-due-diligence-index.md", - "legal/noema-transfer-review.pdf" + { + "path": "legal/noema-transfer-review.pdf", + "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + { + "path": "legal/noema-ip-assignment-register.pdf", + "sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + } ] } ``` From e466a31bba9f1027b2c5674fb03a1882beb45669 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 09:12:25 +0900 Subject: [PATCH 04/36] docs(acquisition): retain evidence owner requirement --- docs/acquisition-readiness-2b.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/acquisition-readiness-2b.md b/docs/acquisition-readiness-2b.md index f2b547d51..b7ce79a52 100644 --- a/docs/acquisition-readiness-2b.md +++ b/docs/acquisition-readiness-2b.md @@ -141,7 +141,7 @@ Product Design 기준으로 구매자와 파일럿 고객이 제품 가치를 - security evidence: `artifacts/security/security-validation-evidence.json` (`npm run security:evidence`로 단독 검증) - production pilot log: `docs/pilot-readiness-log.md` 또는 `NOEMA_PILOT_LOG_PATH` - saleable readiness evidence: `artifacts/saleable-readiness//goal-audit.json` -- revenue/transfer evidence의 `source_documents`는 1~32개의 retained `{path, sha256}` 레코드로 구성해야 한다. `path`는 canonical repository-relative evidence 경로여야 하고 `sha256`은 그 보존 파일의 64-hex SHA-256이어야 한다. 이 digest 검증은 보존된 bytes의 무결성만 증명하며 CRM·계약·매출·법률 기록의 진실성이나 승인 권한을 대신하지 않는다. +- revenue/transfer evidence는 `owner`, 최근 `updated_at`, 그리고 1~32개의 retained `{path, sha256}` 레코드로 구성된 `source_documents`를 포함해야 한다. `path`는 canonical repository-relative evidence 경로여야 하고 `sha256`은 그 보존 파일의 64-hex SHA-256이어야 한다. 이 digest 검증은 보존된 bytes의 무결성만 증명하며 CRM·계약·매출·법률 기록의 진실성이나 승인 권한을 대신하지 않는다. - `updated_at`은 기본 45일 이내 증빙이어야 하며, 필요 시 `NOEMA_ACQUISITION_EVIDENCE_MAX_AGE_DAYS`로 조정한다. - Strategic pipeline route는 `buyer_due_diligence_qna`에 구매자별 보안/운영 실사 Q&A 로그 경로를 1개 이상 포함해야 한다. - production pilot log는 production HTTPS `NOEMA URL`, `증빙 출처: production`, KPI threshold, trace sample, support channel, 계약/매출 증빙 경로가 있는 완료 항목 1건 이상을 요구한다. From 6a1093c1817071b9f1b92417034e6cb6da190888 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 09:20:07 +0900 Subject: [PATCH 05/36] docs(acquisition): complete revenue source binding contract --- docs/buyer-due-diligence-index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/buyer-due-diligence-index.md b/docs/buyer-due-diligence-index.md index c2dd14cb5..1dbcc1011 100644 --- a/docs/buyer-due-diligence-index.md +++ b/docs/buyer-due-diligence-index.md @@ -86,7 +86,7 @@ Production 파일럿 로그는 `npm run acquisition:audit`에서도 직접 검 ## Commercial -`artifacts/acquisition/revenue-evidence.json`에는 `owner`, 기본 45일 이내 `updated_at`, retained source bytes를 지정하는 `source_documents` `{path, sha256}` 항목이 있어야 한다. SHA-256 일치는 byte integrity일 뿐 CRM·계약·지급·법률 기록의 진실성 또는 승인 권한은 별도 authoritative evidence다. +`artifacts/acquisition/revenue-evidence.json`에는 `owner`, 기본 45일 이내 `updated_at`, 그리고 1~32개의 retained source binding으로 구성된 `source_documents`가 있어야 한다. 각 항목은 canonical repository-relative `path`와 그 보존 파일 bytes의 64-hex `sha256`을 담는 `{path, sha256}` 레코드여야 하며 placeholder나 template 경로는 인정하지 않는다. SHA-256 일치는 byte integrity일 뿐 CRM·계약·지급·법률 기록의 진실성 또는 승인 권한은 별도 authoritative evidence다. 작성 템플릿은 `docs/evidence-templates/revenue-evidence.example.json`이다. `replace-with-*`, `.example.json`, `docs/evidence-templates/` 값은 evidence로 인정하지 않는다. | 항목 | Evidence | 상태 | From b183bbbe88ba51382706d6abe63b9c9b9200ad52 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 09:21:13 +0900 Subject: [PATCH 06/36] docs(acquisition): keep evidence freshness examples current --- docs/acquisition-readiness-2b.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/acquisition-readiness-2b.md b/docs/acquisition-readiness-2b.md index b7ce79a52..d8e349f43 100644 --- a/docs/acquisition-readiness-2b.md +++ b/docs/acquisition-readiness-2b.md @@ -147,7 +147,7 @@ Product Design 기준으로 구매자와 파일럿 고객이 제품 가치를 - production pilot log는 production HTTPS `NOEMA URL`, `증빙 출처: production`, KPI threshold, trace sample, support channel, 계약/매출 증빙 경로가 있는 완료 항목 1건 이상을 요구한다. - 작성 템플릿은 `docs/evidence-templates/revenue-evidence.example.json`, `docs/evidence-templates/transfer-evidence.example.json`에 둔다. 템플릿은 `artifacts/acquisition/*.json`으로 복사한 뒤 placeholder를 실제 owner/source/evidence 값으로 교체해야 한다. `replace-with-*`, `.example.json`, `docs/evidence-templates/` 값은 `npm run acquisition:audit`에서 evidence로 인정하지 않는다. -예시는 다음과 같다. 예시 digest는 형식만 보여 주는 값이며 실제 제출 시 해당 retained bytes의 SHA-256으로 교체해야 한다. +예시는 형식 설명용이다. 실제 제출에서는 예시 digest를 해당 retained bytes의 SHA-256으로 교체하고, `updated_at`도 제출 시점의 freshness window(기본 45일) 안에 있는 실제 증빙 갱신일로 반드시 교체해야 한다. ```json { @@ -160,7 +160,7 @@ Product Design 기준으로 구매자와 파일럿 고객이 제품 가치를 "crm:noema-enterprise-security-qna" ], "customer_concentration_top1": 0.5, - "updated_at": "2026-07-02", + "updated_at": "2026-09-01", "owner": "finance", "source_documents": [ { @@ -184,7 +184,7 @@ Product Design 기준으로 구매자와 파일럿 고객이 제품 가치를 "secrets_rotation_plan": "pass", "owner_transfer_plan": "pass", "privacy_review": "pass", - "updated_at": "2026-07-02", + "updated_at": "2026-09-01", "owner": "legal", "source_documents": [ { From 1e83376b7dd3d415cd2131bc3978300097a3633c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 13:30:55 +0900 Subject: [PATCH 07/36] test(acquisition): reject hardlinked retained evidence --- ...isition-retained-artifact-hardlink.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 test/acquisition-retained-artifact-hardlink.test.ts diff --git a/test/acquisition-retained-artifact-hardlink.test.ts b/test/acquisition-retained-artifact-hardlink.test.ts new file mode 100644 index 000000000..63657e759 --- /dev/null +++ b/test/acquisition-retained-artifact-hardlink.test.ts @@ -0,0 +1,23 @@ +import { linkSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { readStableFile } from "../scripts/lib/acquisition-data-room-integrity.mjs"; + +describe("acquisition retained artifact link authority", () => { + it("rejects a retained evidence path that hardlinks another filesystem object", () => { + const root = mkdtempSync(join(tmpdir(), "noema-acquisition-hardlink-")); + const originalPath = join(root, "authoritative-source.json"); + const retainedPath = join(root, "retained-evidence.json"); + const bytes = "{\"source\":\"authenticated-record\"}\n"; + + try { + writeFileSync(originalPath, bytes, "utf8"); + linkSync(originalPath, retainedPath); + + expect(readStableFile(retainedPath, 1024)).toBeNull(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); From 6a9fe825619765d36d4af04ffaf725a388ddc9f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 13:33:52 +0900 Subject: [PATCH 08/36] fix(acquisition): reject hardlinked retained evidence --- scripts/lib/acquisition-data-room-integrity.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/lib/acquisition-data-room-integrity.mjs b/scripts/lib/acquisition-data-room-integrity.mjs index 23b2e7e3f..38f8071b3 100644 --- a/scripts/lib/acquisition-data-room-integrity.mjs +++ b/scripts/lib/acquisition-data-room-integrity.mjs @@ -184,6 +184,7 @@ function isSafeRegularMetadata(metadata, maximumBytes) { && typeof metadata.isSymbolicLink === "function" && metadata.isFile() && !metadata.isSymbolicLink() + && (metadata.nlink === undefined || metadata.nlink === 1) && Number.isSafeInteger(metadata.size) && metadata.size >= 0 && metadata.size <= maximumBytes, @@ -203,10 +204,11 @@ function sameIdentity(left, right) { } /** - * Read a bounded regular file through O_NOFOLLOW and require path/descriptor + * Read a bounded single-link regular file through O_NOFOLLOW and require path/descriptor * identity to remain stable before and after the complete read. The returned * bytes are suitable for hashing or fatal UTF-8 decoding; unsafe evidence is - * represented as null rather than partially trusted data. + * represented as null rather than partially trusted data. Injectable test + * metadata may omit nlink; real filesystem metadata must report exactly one link. */ export function readStableFile(path, maximumBytes = MAX_DATA_ROOM_EVIDENCE_BYTES, fileSystem = defaultFileSystem) { let descriptor = null; From f4b096a4afcf2573b831d67c7d0b4988f6728c73 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 13:36:01 +0900 Subject: [PATCH 09/36] docs(acquisition): refresh protected readiness snapshot --- docs/product-technical-gap-baseline.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 67090beea..538095ce6 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -4,14 +4,14 @@ 이 문서는 제품 요구, 구현, 검증, 운영 증거 사이의 현재 차이를 한곳에서 추적한다. 저장소 파일과 테스트는 revision-local 또는 protected-source 구현만 증명한다. PR 상태는 exact head와 live base에서, 운영·배포·고객·매출·법적 증거는 해당 외부 권한에서 각각 다시 확인해야 한다. 문서나 성공 boolean만으로 이후 단계의 증거를 만들지 않는다. -이 baseline의 source snapshot은 protected `main` `8ae8f5eee4e913a8ee106c3e2e550b0c9316c0e4`이며, issues #3, #5, #27, #29, #66, #227은 2026-09-01 KST에 GitHub에서 모두 `OPEN`으로 다시 확인했다. 상태가 바뀌면 live GitHub를 우선하며 이 표를 갱신한다. +이 baseline의 source snapshot은 protected `main` `dd6ff2aa46f8daa8aa9a4e19e0d6825f4a98f383`이며, issues #3, #5, #27, #29, #66, #227은 2026-09-01 KST에 GitHub에서 모두 `OPEN`으로 다시 확인했다. 상태가 바뀌면 live GitHub를 우선하며 이 표를 갱신한다. ## Live external observation — 2026-09-01 KST | Authority | Observation | Consequence | | --- | --- | --- | -| Pull requests | #510, #512, #513, #521 are open; #510/#512/#521의 exact-head Application·reviewer-ci·Security Scan은 terminal-success이고 image gate는 `in_progress`, #513의 current exact-head gates는 `queued` | zero-PR hourly activation canary와 merge completion은 아직 입증되지 않았다 | -| Hourly product development | scheduled run `33408669511` stopped at the zero-open-PR gate; gateway, OpenCode, package, and publication stages were skipped | retired direct-provider/NVIDIA run을 현재 상태로 사용하지 않으며, zero-PR 이후 `contextual-orchestrator` canary가 필요하다 | +| Pull requests | #510, #521, #524, #526, #527 are open. #510의 Application·reviewer-ci·Security Scan은 terminal-success이나 image gate가 `in_progress`이고, #521/#524/#526/#527에는 현재 exact-head queued/pending gate가 남아 있다. | 현재 어느 open PR도 merge-authoritative 하지 않으며, zero-PR hourly activation canary도 아직 실행할 수 없다 | +| Hourly product development | 최근 관찰된 scheduled run은 open-PR single-flight gate에서 후속 proposal/publication 단계를 실행하지 않았다 | retired direct-provider/NVIDIA run을 현재 상태로 사용하지 않으며, zero-PR 이후 `contextual-orchestrator` canary가 필요하다 | | Release/publication | GitHub release와 protected-main patch-validator workflow-dispatch receipt가 없다 | immutable publication, signing, deployment, KPI, acquisition evidence는 계속 미완료다 | ## Current baseline From 4e24e00d4fc5e47d60c69c1d34f4c37b23bda9f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 13:37:45 +0900 Subject: [PATCH 10/36] test(acquisition): reject retained evidence close failure --- ...isition-retained-artifact-hardlink.test.ts | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/test/acquisition-retained-artifact-hardlink.test.ts b/test/acquisition-retained-artifact-hardlink.test.ts index 63657e759..27de4d316 100644 --- a/test/acquisition-retained-artifact-hardlink.test.ts +++ b/test/acquisition-retained-artifact-hardlink.test.ts @@ -1,4 +1,15 @@ -import { linkSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + closeSync, + constants, + fstatSync, + linkSync, + lstatSync, + mkdtempSync, + openSync, + readSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -20,4 +31,28 @@ describe("acquisition retained artifact link authority", () => { rmSync(root, { recursive: true, force: true }); } }); + + it("rejects retained evidence when descriptor close reports failure", () => { + const root = mkdtempSync(join(tmpdir(), "noema-acquisition-close-")); + const retainedPath = join(root, "retained-evidence.json"); + + try { + writeFileSync(retainedPath, "{\"source\":\"authenticated-record\"}\n", "utf8"); + const fileSystem = { + closeSync(descriptor: number) { + closeSync(descriptor); + throw new Error("simulated close completion failure"); + }, + constants, + fstatSync, + lstatSync, + openSync, + readSync, + }; + + expect(readStableFile(retainedPath, 1024, fileSystem)).toBeNull(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); From 07aab9adea728feabd6498699d789d2becf88345 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 13:39:12 +0900 Subject: [PATCH 11/36] fix(acquisition): fail closed on retained evidence close --- scripts/lib/acquisition-data-room-integrity.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/lib/acquisition-data-room-integrity.mjs b/scripts/lib/acquisition-data-room-integrity.mjs index 38f8071b3..39004d8da 100644 --- a/scripts/lib/acquisition-data-room-integrity.mjs +++ b/scripts/lib/acquisition-data-room-integrity.mjs @@ -212,6 +212,7 @@ function sameIdentity(left, right) { */ export function readStableFile(path, maximumBytes = MAX_DATA_ROOM_EVIDENCE_BYTES, fileSystem = defaultFileSystem) { let descriptor = null; + let result = null; try { if (!Number.isSafeInteger(maximumBytes) || maximumBytes <= 0) { return null; @@ -250,7 +251,7 @@ export function readStableFile(path, maximumBytes = MAX_DATA_ROOM_EVIDENCE_BYTES if (!sameIdentity(opened, afterDescriptor) || !sameIdentity(opened, afterPath)) { return null; } - return bytes; + result = bytes; } catch { return null; } finally { @@ -258,11 +259,11 @@ export function readStableFile(path, maximumBytes = MAX_DATA_ROOM_EVIDENCE_BYTES try { fileSystem.closeSync(descriptor); } catch { - // A failed close cannot make evidence more trustworthy; the read result - // is already bounded and callers remain fail-closed on validation. + result = null; } } } + return result; } function canonicalRelativePath(rootDir, candidate) { From e32755edde541a9e17d687eed61c40a0ccde335f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 13:40:34 +0900 Subject: [PATCH 12/36] test(acquisition): align close failure contract --- test/acquisition-review-regressions.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/acquisition-review-regressions.test.ts b/test/acquisition-review-regressions.test.ts index c68b35a50..813c2fe89 100644 --- a/test/acquisition-review-regressions.test.ts +++ b/test/acquisition-review-regressions.test.ts @@ -120,7 +120,7 @@ describe("acquisition review regressions", () => { } }); - it("fails closed on invalid read bounds but tolerates a close failure after a stable empty read", () => { + it("fails closed on invalid read bounds and on close failure after a stable empty read", () => { const metadata = { dev: 1, ino: 2, @@ -143,7 +143,7 @@ describe("acquisition review regressions", () => { expect(readStableFile("unused", 0, fileSystem)).toBeNull(); expect(fileSystem.lstatSync).not.toHaveBeenCalled(); - expect(readStableFile("empty", 16, fileSystem)).toEqual(Buffer.alloc(0)); + expect(readStableFile("empty", 16, fileSystem)).toBeNull(); expect(fileSystem.closeSync).toHaveBeenCalledWith(7); }); From 8747679060b44281f04e73a873c6a6204524f655 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:04:59 +0900 Subject: [PATCH 13/36] test(acquisition): reject post-close retained-path replacement --- ...isition-retained-artifact-hardlink.test.ts | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/test/acquisition-retained-artifact-hardlink.test.ts b/test/acquisition-retained-artifact-hardlink.test.ts index 27de4d316..df8da4bb9 100644 --- a/test/acquisition-retained-artifact-hardlink.test.ts +++ b/test/acquisition-retained-artifact-hardlink.test.ts @@ -8,6 +8,7 @@ import { openSync, readSync, rmSync, + unlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -55,4 +56,29 @@ describe("acquisition retained artifact link authority", () => { rmSync(root, { recursive: true, force: true }); } }); -}); + + it("rejects retained evidence when the path is replaced after descriptor close", () => { + const root = mkdtempSync(join(tmpdir(), "noema-acquisition-post-close-replace-")); + const retainedPath = join(root, "retained-evidence.json"); + + try { + writeFileSync(retainedPath, "{\"source\":\"authenticated-record\"}\n", "utf8"); + const fileSystem = { + closeSync(descriptor: number) { + closeSync(descriptor); + unlinkSync(retainedPath); + writeFileSync(retainedPath, "{\"source\":\"replacement-record\"}\n", "utf8"); + }, + constants, + fstatSync, + lstatSync, + openSync, + readSync, + }; + + expect(readStableFile(retainedPath, 1024, fileSystem)).toBeNull(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); \ No newline at end of file From 4db70c22b8b123ec6676ae7deb656de08656e174 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:07:12 +0900 Subject: [PATCH 14/36] fix(acquisition): revalidate retained path after close --- .../lib/acquisition-data-room-integrity.mjs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/scripts/lib/acquisition-data-room-integrity.mjs b/scripts/lib/acquisition-data-room-integrity.mjs index 39004d8da..ccf1d95cd 100644 --- a/scripts/lib/acquisition-data-room-integrity.mjs +++ b/scripts/lib/acquisition-data-room-integrity.mjs @@ -205,13 +205,14 @@ function sameIdentity(left, right) { /** * Read a bounded single-link regular file through O_NOFOLLOW and require path/descriptor - * identity to remain stable before and after the complete read. The returned - * bytes are suitable for hashing or fatal UTF-8 decoding; unsafe evidence is - * represented as null rather than partially trusted data. Injectable test + * identity to remain stable before, during, and after the complete read and descriptor + * close. The returned bytes are suitable for hashing or fatal UTF-8 decoding; unsafe + * evidence is represented as null rather than partially trusted data. Injectable test * metadata may omit nlink; real filesystem metadata must report exactly one link. */ export function readStableFile(path, maximumBytes = MAX_DATA_ROOM_EVIDENCE_BYTES, fileSystem = defaultFileSystem) { let descriptor = null; + let opened = null; let result = null; try { if (!Number.isSafeInteger(maximumBytes) || maximumBytes <= 0) { @@ -227,7 +228,7 @@ export function readStableFile(path, maximumBytes = MAX_DATA_ROOM_EVIDENCE_BYTES return null; } descriptor = fileSystem.openSync(path, readOnly | noFollow); - const opened = fileSystem.fstatSync(descriptor); + opened = fileSystem.fstatSync(descriptor); if (!isSafeRegularMetadata(opened, maximumBytes) || !sameIdentity(before, opened)) { return null; } @@ -262,6 +263,19 @@ export function readStableFile(path, maximumBytes = MAX_DATA_ROOM_EVIDENCE_BYTES result = null; } } + if (result !== null && opened !== null) { + try { + const afterClosePath = fileSystem.lstatSync(path); + if ( + !isSafeRegularMetadata(afterClosePath, maximumBytes) + || !sameIdentity(opened, afterClosePath) + ) { + result = null; + } + } catch { + result = null; + } + } } return result; } From 6f69acced25eacbddda031b4a4673988f3406bec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:35:26 +0900 Subject: [PATCH 15/36] test(acquisition): preserve concurrent failed-output replacement --- ...te-output-new-file-failure-cleanup.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/acquisition-private-output-new-file-failure-cleanup.test.ts b/test/acquisition-private-output-new-file-failure-cleanup.test.ts index 650c97b88..2f9023332 100644 --- a/test/acquisition-private-output-new-file-failure-cleanup.test.ts +++ b/test/acquisition-private-output-new-file-failure-cleanup.test.ts @@ -8,6 +8,7 @@ import { lstatSync, mkdtempSync, openSync, + readFileSync, rmSync, unlinkSync, writeFileSync as fsWriteFileSync, @@ -76,4 +77,44 @@ describe("acquisition private output new-file failure cleanup", () => { } }, ); + + it.skipIf(process.platform === "win32")( + "preserves a replacement installed after failed-output cleanup observes the writer inode", + () => { + const directory = mkdtempSync(join(tmpdir(), "noema-private-new-cleanup-race-")); + const output = join(directory, "evidence.json"); + let replaced = false; + const fileSystem = { + constants, + lstatSync(path: Parameters[0], options?: Parameters[1]) { + const metadata = lstatSync(path, options as never); + if (String(path) === output && metadata && !replaced) { + unlinkSync(output); + fsWriteFileSync(output, "concurrent-evidence\n", { encoding: "utf8", mode: 0o600 }); + replaced = true; + } + return metadata; + }, + openSync, + fstatSync, + fchmodSync, + ftruncateSync, + closeSync, + unlinkSync, + writeFileSync(descriptor: number) { + fsWriteFileSync(descriptor, "partial\n", { encoding: "utf8" }); + throw new Error("simulated acquisition write failure"); + }, + }; + + try { + expect(() => writeAcquisitionPrivateFile(output, "complete\n", fileSystem as never)) + .toThrow("simulated acquisition write failure"); + expect(replaced).toBe(true); + expect(readFileSync(output, "utf8")).toBe("concurrent-evidence\n"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, + ); }); From 428764a0368308051a71d29f83521401ccc68b56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:37:05 +0900 Subject: [PATCH 16/36] fix(acquisition): avoid pathname-delete race on failed evidence --- scripts/lib/acquisition-private-output.mjs | 89 +++++++++++++++++----- 1 file changed, 69 insertions(+), 20 deletions(-) diff --git a/scripts/lib/acquisition-private-output.mjs b/scripts/lib/acquisition-private-output.mjs index dd4224f10..cdfbcd649 100644 --- a/scripts/lib/acquisition-private-output.mjs +++ b/scripts/lib/acquisition-private-output.mjs @@ -91,10 +91,58 @@ function cleanupIdentityMatchedPath(path, expectedMetadata, fileSystem) { fileSystem.unlinkSync(path); } } catch { - // Preserve the original write/validation error. Cleanup authority requires - // unchanged real-directory parent traversal plus the same safe single-link - // inode at deletion time; an unsafe parent, replaced pathname, or unsafe - // multi-link/non-file object is never unlinked. + // Lock and staging cleanup is best-effort only. Final evidence paths use + // descriptor-bound neutralization below so cleanup can never unlink a + // concurrent replacement after a pathname identity check. + } +} + +function neutralizeIdentityMatchedPath(path, expectedMetadata, fileSystem) { + if ( + !safeOutputMetadata(expectedMetadata) + || typeof fileSystem.openSync !== "function" + || typeof fileSystem.fstatSync !== "function" + || typeof fileSystem.ftruncateSync !== "function" + || typeof fileSystem.closeSync !== "function" + ) { + return; + } + + const writeOnly = fileSystem.constants?.O_WRONLY; + const noFollow = fileSystem.constants?.O_NOFOLLOW; + if (!Number.isInteger(writeOnly) || !Number.isInteger(noFollow)) { + return; + } + + let descriptor = null; + try { + assertAcquisitionPrivatePathParents(path, fileSystem); + descriptor = fileSystem.openSync(path, writeOnly | noFollow); + const opened = fileSystem.fstatSync(descriptor); + const retained = fileSystem.lstatSync(path, { throwIfNoEntry: false }) ?? null; + assertAcquisitionPrivatePathParents(path, fileSystem); + if ( + safeOutputMetadata(opened) + && safeOutputMetadata(retained) + && sameOutputIdentity(expectedMetadata, opened) + && sameOutputIdentity(opened, retained) + ) { + fileSystem.ftruncateSync(descriptor, 0); + } + } catch { + // Preserve the original write/validation failure. The cleanup descriptor is + // bound before the final pathname check; if the pathname is concurrently + // replaced, only the writer-owned inode can be truncated and the replacement + // remains untouched. An uncertain failed output therefore requires operator + // inspection instead of destructive pathname cleanup. + } finally { + if (descriptor !== null) { + try { + fileSystem.closeSync(descriptor); + } catch { + // Cleanup close failure does not replace the original operation error. + } + } } } @@ -237,7 +285,7 @@ function writeNewPrivateFile(path, contents, fileSystem, flags) { closeError = error; } if (!accepted || closeFailed) { - cleanupIdentityMatchedPath(path, createdMetadata, fileSystem); + neutralizeIdentityMatchedPath(path, createdMetadata, fileSystem); } } if (closeFailed && !operationFailed) { @@ -263,20 +311,21 @@ function writeNewPrivateFile(path, contents, fileSystem, flags) { * against its pre-rename identity, mode, size, and mtime before acceptance. POSIX * rename may itself advance ctime, so ctime remains an exact guard before rename * but is not compared across the rename operation. If the writer-owned inode - * changes at the final handoff, the operation fails closed and removes it only - * when the target pathname still names that exact safe single-link inode. A - * failed or stale replacement therefore cannot truncate, chmod, partially - * overwrite, or silently clobber a concurrent update to trusted prior evidence. - * A safe existing target may itself be read-only because replacement authority - * comes from the containing directory; verification never requires write access - * to the old inode. Newly created targets use O_EXCL directly and remove their - * identity-matched leaf only while parent traversal still resolves through real - * directories and the created metadata remains safe single-link deletion - * authority. Existing parent components are required to be real directories, - * never symbolic links or non-directory objects, and the configured output path - * must already be lexically canonical before and immediately after each - * leaf/staging open and again before a new file is accepted or an existing target - * is atomically replaced. + * changes at the final handoff, the operation fails closed and neutralizes only + * the writer-owned inode through a no-follow descriptor; it never unlinks a + * concurrent replacement after a pathname check. A failed or stale replacement + * therefore cannot truncate, chmod, partially overwrite, or silently clobber a + * concurrent update to trusted prior evidence. A safe existing target may itself + * be read-only because replacement authority comes from the containing directory; + * verification never requires write access to the old inode. Newly created + * targets use O_EXCL directly; failed publication leaves an identity-bound + * non-authoritative leaf (truncated when the writer inode can still be proven) + * for operator inspection rather than deleting by pathname. Existing parent + * components are required to be real directories, never symbolic links or + * non-directory objects, and the configured output path must already be + * lexically canonical before and immediately after each leaf/staging open and + * again before a new file is accepted or an existing target is atomically + * replaced. */ export function writeAcquisitionPrivateFile( path, @@ -403,7 +452,7 @@ export function writeAcquisitionPrivateFile( if (staged && stagedMetadata) { cleanupIdentityMatchedPath(tempPath, stagedMetadata, fileSystem); } else if (replacementCommitted && !replacementAccepted && stagedMetadata) { - cleanupIdentityMatchedPath(path, stagedMetadata, fileSystem); + neutralizeIdentityMatchedPath(path, stagedMetadata, fileSystem); } } } finally { From aa8bc5252eb6fbd59668f3d7aeba4c91492d95b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:37:35 +0900 Subject: [PATCH 17/36] test(acquisition): expect descriptor-bound failure neutralization --- ...quisition-private-output-new-file-failure-cleanup.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/acquisition-private-output-new-file-failure-cleanup.test.ts b/test/acquisition-private-output-new-file-failure-cleanup.test.ts index 2f9023332..573b59065 100644 --- a/test/acquisition-private-output-new-file-failure-cleanup.test.ts +++ b/test/acquisition-private-output-new-file-failure-cleanup.test.ts @@ -20,7 +20,7 @@ import { writeAcquisitionPrivateFile } from "../scripts/lib/acquisition-private- describe("acquisition private output new-file failure cleanup", () => { it.skipIf(process.platform === "win32")( - "removes the identity-matched partial leaf when a new private write fails", + "neutralizes the identity-matched partial leaf when a new private write fails", () => { const directory = mkdtempSync(join(tmpdir(), "noema-private-new-failure-")); const output = join(directory, "evidence.json"); @@ -42,7 +42,8 @@ describe("acquisition private output new-file failure cleanup", () => { try { expect(() => writeAcquisitionPrivateFile(output, "complete\n", fileSystem as never)) .toThrow("simulated acquisition write failure"); - expect(existsSync(output)).toBe(false); + expect(existsSync(output)).toBe(true); + expect(readFileSync(output, "utf8")).toBe(""); } finally { rmSync(directory, { recursive: true, force: true }); } From a0d28d04117b3046228bd16059c48152fe860b41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:37:50 +0900 Subject: [PATCH 18/36] test(acquisition): retain failed output instead of pathname delete --- test/acquisition-private-output-close-cleanup.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/acquisition-private-output-close-cleanup.test.ts b/test/acquisition-private-output-close-cleanup.test.ts index 013715f0f..be416bafd 100644 --- a/test/acquisition-private-output-close-cleanup.test.ts +++ b/test/acquisition-private-output-close-cleanup.test.ts @@ -58,19 +58,21 @@ function newFileSystem({ writeFails = false } = {}) { } describe("acquisition private output close failure cleanup", () => { - it("removes an identity-matched new output when close fails after a successful write", () => { + it("neutralizes an identity-matched new output when close fails after a successful write", () => { const fileSystem = newFileSystem(); expect(() => writeAcquisitionPrivateFile("output", "replacement\n", fileSystem as never)) .toThrow("close failed"); - expect(fileSystem.unlinkSync).toHaveBeenCalledWith("output"); + expect(fileSystem.ftruncateSync).toHaveBeenCalled(); + expect(fileSystem.unlinkSync).not.toHaveBeenCalledWith("output"); }); - it("preserves the original write error while still cleaning up when close also fails", () => { + it("preserves the original write error while neutralizing when close also fails", () => { const fileSystem = newFileSystem({ writeFails: true }); expect(() => writeAcquisitionPrivateFile("output", "replacement\n", fileSystem as never)) .toThrow("write failed"); - expect(fileSystem.unlinkSync).toHaveBeenCalledWith("output"); + expect(fileSystem.ftruncateSync).toHaveBeenCalled(); + expect(fileSystem.unlinkSync).not.toHaveBeenCalledWith("output"); }); }); From 61a321e80966a2af01f661e27c9253857ce9c4f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:44:38 +0900 Subject: [PATCH 19/36] test(acquisition): bound FIFO cleanup race --- ...te-output-new-file-failure-cleanup.test.ts | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/test/acquisition-private-output-new-file-failure-cleanup.test.ts b/test/acquisition-private-output-new-file-failure-cleanup.test.ts index 573b59065..a90210158 100644 --- a/test/acquisition-private-output-new-file-failure-cleanup.test.ts +++ b/test/acquisition-private-output-new-file-failure-cleanup.test.ts @@ -1,3 +1,4 @@ +import { execFileSync, spawnSync } from "node:child_process"; import { closeSync, constants, @@ -14,7 +15,8 @@ import { writeFileSync as fsWriteFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import { writeAcquisitionPrivateFile } from "../scripts/lib/acquisition-private-output.mjs"; @@ -118,4 +120,65 @@ describe("acquisition private output new-file failure cleanup", () => { } }, ); + + it.skipIf(process.platform === "win32")( + "returns promptly when failed output is replaced by a FIFO before cleanup", + () => { + const moduleUrl = pathToFileURL(resolve("scripts/lib/acquisition-private-output.mjs")).href; + const childScript = ` + import { execFileSync } from "node:child_process"; + import { + closeSync, constants, fchmodSync, fstatSync, ftruncateSync, + lstatSync, mkdtempSync, openSync, rmSync, unlinkSync, + writeFileSync, + } from "node:fs"; + import { tmpdir } from "node:os"; + import { join } from "node:path"; + import { writeAcquisitionPrivateFile } from ${JSON.stringify(moduleUrl)}; + + const directory = mkdtempSync(join(tmpdir(), "noema-private-new-fifo-race-")); + const output = join(directory, "evidence.json"); + const fileSystem = { + constants, + lstatSync, + openSync, + fstatSync, + fchmodSync, + ftruncateSync, + closeSync, + unlinkSync, + writeFileSync(descriptor, contents, options) { + writeFileSync(descriptor, contents, options); + unlinkSync(output); + execFileSync("mkfifo", [output]); + throw new Error("simulated acquisition write failure"); + }, + }; + + try { + writeAcquisitionPrivateFile(output, "complete\\n", fileSystem); + process.exitCode = 2; + } catch (error) { + if (error?.message !== "simulated acquisition write failure") { + console.error(error); + process.exitCode = 3; + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + `; + + const mkfifoProbe = spawnSync("mkfifo", ["--help"], { encoding: "utf8" }); + if (mkfifoProbe.error?.code === "ENOENT") return; + + const child = spawnSync( + process.execPath, + ["--input-type=module", "--eval", childScript], + { encoding: "utf8", timeout: 1_000 }, + ); + + expect(child.error && "code" in child.error ? child.error.code : undefined).not.toBe("ETIMEDOUT"); + expect(child.status, child.stderr).toBe(0); + }, + ); }); From 22764ce2a978154f02b5f9054f43274d194ca328 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:45:54 +0900 Subject: [PATCH 20/36] fix(acquisition): make failed-evidence cleanup nonblocking --- scripts/lib/acquisition-private-output.mjs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/lib/acquisition-private-output.mjs b/scripts/lib/acquisition-private-output.mjs index cdfbcd649..71ec4d062 100644 --- a/scripts/lib/acquisition-private-output.mjs +++ b/scripts/lib/acquisition-private-output.mjs @@ -110,14 +110,19 @@ function neutralizeIdentityMatchedPath(path, expectedMetadata, fileSystem) { const writeOnly = fileSystem.constants?.O_WRONLY; const noFollow = fileSystem.constants?.O_NOFOLLOW; - if (!Number.isInteger(writeOnly) || !Number.isInteger(noFollow)) { + const nonBlocking = fileSystem.constants?.O_NONBLOCK; + if ( + !Number.isInteger(writeOnly) + || !Number.isInteger(noFollow) + || !Number.isInteger(nonBlocking) + ) { return; } let descriptor = null; try { assertAcquisitionPrivatePathParents(path, fileSystem); - descriptor = fileSystem.openSync(path, writeOnly | noFollow); + descriptor = fileSystem.openSync(path, writeOnly | noFollow | nonBlocking); const opened = fileSystem.fstatSync(descriptor); const retained = fileSystem.lstatSync(path, { throwIfNoEntry: false }) ?? null; assertAcquisitionPrivatePathParents(path, fileSystem); @@ -133,8 +138,8 @@ function neutralizeIdentityMatchedPath(path, expectedMetadata, fileSystem) { // Preserve the original write/validation failure. The cleanup descriptor is // bound before the final pathname check; if the pathname is concurrently // replaced, only the writer-owned inode can be truncated and the replacement - // remains untouched. An uncertain failed output therefore requires operator - // inspection instead of destructive pathname cleanup. + // remains untouched. O_NONBLOCK also prevents special-file replacements from + // stalling best-effort cleanup before descriptor type validation can run. } finally { if (descriptor !== null) { try { From 7ef8822a937062b5a63c4f99a8ddbed114d508f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:46:57 +0900 Subject: [PATCH 21/36] test(acquisition): keep FIFO regression hermetic --- ...isition-private-output-new-file-failure-cleanup.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/acquisition-private-output-new-file-failure-cleanup.test.ts b/test/acquisition-private-output-new-file-failure-cleanup.test.ts index a90210158..a947ea153 100644 --- a/test/acquisition-private-output-new-file-failure-cleanup.test.ts +++ b/test/acquisition-private-output-new-file-failure-cleanup.test.ts @@ -1,4 +1,4 @@ -import { execFileSync, spawnSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import { closeSync, constants, @@ -15,8 +15,7 @@ import { writeFileSync as fsWriteFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { writeAcquisitionPrivateFile } from "../scripts/lib/acquisition-private-output.mjs"; @@ -124,7 +123,7 @@ describe("acquisition private output new-file failure cleanup", () => { it.skipIf(process.platform === "win32")( "returns promptly when failed output is replaced by a FIFO before cleanup", () => { - const moduleUrl = pathToFileURL(resolve("scripts/lib/acquisition-private-output.mjs")).href; + const moduleUrl = new URL("../scripts/lib/acquisition-private-output.mjs", import.meta.url).href; const childScript = ` import { execFileSync } from "node:child_process"; import { From 8777585949053f0b1b7aca22a75080a99d29490d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 15:03:13 +0900 Subject: [PATCH 22/36] test(acquisition): require nonblocking cleanup capability --- ...ivate-output-filesystem-capability.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 test/acquisition-private-output-filesystem-capability.test.ts diff --git a/test/acquisition-private-output-filesystem-capability.test.ts b/test/acquisition-private-output-filesystem-capability.test.ts new file mode 100644 index 000000000..91543c1cd --- /dev/null +++ b/test/acquisition-private-output-filesystem-capability.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import { writeAcquisitionPrivateFile } from "../scripts/lib/acquisition-private-output.mjs"; + +function fileMetadata() { + return { + dev: 1, + ino: 2, + mode: 0o100600, + size: 5, + mtimeMs: 1, + ctimeMs: 1, + nlink: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false, + }; +} + +function directoryMetadata() { + return { + ...fileMetadata(), + isFile: () => false, + isDirectory: () => true, + }; +} + +describe("acquisition private output filesystem capability", () => { + it("rejects adapters without non-blocking cleanup support before output creation", () => { + let outputReads = 0; + const openSync = vi.fn(() => 17); + const fileSystem = { + constants: { + O_RDONLY: 16, + O_WRONLY: 1, + O_CREAT: 2, + O_EXCL: 4, + O_NOFOLLOW: 8, + }, + lstatSync: vi.fn((path: string) => { + if (path === "output") { + outputReads += 1; + return outputReads === 1 ? null : fileMetadata(); + } + return directoryMetadata(); + }), + openSync, + fstatSync: vi.fn(() => fileMetadata()), + fchmodSync: vi.fn(), + ftruncateSync: vi.fn(), + writeFileSync: vi.fn(), + closeSync: vi.fn(), + renameSync: vi.fn(), + unlinkSync: vi.fn(), + }; + + expect(() => writeAcquisitionPrivateFile("output", "value", fileSystem as never)) + .toThrow("non-blocking filesystem support"); + expect(openSync).not.toHaveBeenCalled(); + }); +}); From 8fa71e547e450e0912a408306d9f635aa78f0940 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 15:04:48 +0900 Subject: [PATCH 23/36] fix(acquisition): require nonblocking cleanup capability --- scripts/lib/acquisition-private-output.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/lib/acquisition-private-output.mjs b/scripts/lib/acquisition-private-output.mjs index 71ec4d062..c5edf02fb 100644 --- a/scripts/lib/acquisition-private-output.mjs +++ b/scripts/lib/acquisition-private-output.mjs @@ -346,8 +346,11 @@ export function writeAcquisitionPrivateFile( const create = fileSystem.constants?.O_CREAT; const exclusive = fileSystem.constants?.O_EXCL; const noFollow = fileSystem.constants?.O_NOFOLLOW; - if (![readOnly, writeOnly, create, exclusive, noFollow].every(Number.isInteger)) { - throw new Error("acquisition output requires no-follow filesystem support"); + const nonBlocking = fileSystem.constants?.O_NONBLOCK; + if (![readOnly, writeOnly, create, exclusive, noFollow, nonBlocking].every(Number.isInteger)) { + throw new Error( + "acquisition output requires no-follow filesystem support; non-blocking filesystem support is required for cleanup", + ); } assertAcquisitionPrivatePathParents(path, fileSystem); From e3fea3506d1f9f41fe2ea6f109c606714d85b2f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 15:07:14 +0900 Subject: [PATCH 24/36] test(acquisition): model nonblocking cleanup capability --- test/acquisition-private-output-close-cleanup.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/acquisition-private-output-close-cleanup.test.ts b/test/acquisition-private-output-close-cleanup.test.ts index be416bafd..1767a704c 100644 --- a/test/acquisition-private-output-close-cleanup.test.ts +++ b/test/acquisition-private-output-close-cleanup.test.ts @@ -31,7 +31,14 @@ function newFileSystem({ writeFails = false } = {}) { let outputReads = 0; let descriptorReads = 0; return { - constants: { O_RDONLY: 16, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: { + O_RDONLY: 16, + O_WRONLY: 1, + O_CREAT: 2, + O_EXCL: 4, + O_NOFOLLOW: 8, + O_NONBLOCK: 32, + }, lstatSync: vi.fn((path: string) => { if (path === "output") { outputReads += 1; From 63372c187257c81071be39c4baf33b472469d00d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 15:07:49 +0900 Subject: [PATCH 25/36] test(acquisition): expose nonblocking adapter capability --- test/acquisition-private-output.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/acquisition-private-output.test.ts b/test/acquisition-private-output.test.ts index 76c9a5128..edcb1fe0a 100644 --- a/test/acquisition-private-output.test.ts +++ b/test/acquisition-private-output.test.ts @@ -71,7 +71,14 @@ function mockFileSystem({ }); const fstat = vi.fn(() => descriptorValues[descriptorReads++] ?? afterDescriptor); return { - constants: { O_RDONLY: 16, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: { + O_RDONLY: 16, + O_WRONLY: 1, + O_CREAT: 2, + O_EXCL: 4, + O_NOFOLLOW: 8, + O_NONBLOCK: 32, + }, lstatSync: lstat, openSync: vi.fn(() => 17), fstatSync: fstat, From 253da4fb5b3dffb7583861ed069d715ff4c871f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 15:08:05 +0900 Subject: [PATCH 26/36] test(acquisition): carry nonblocking parent-race capability --- test/acquisition-private-output-parent-race.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/acquisition-private-output-parent-race.test.ts b/test/acquisition-private-output-parent-race.test.ts index 70584ce72..a7f842da7 100644 --- a/test/acquisition-private-output-parent-race.test.ts +++ b/test/acquisition-private-output-parent-race.test.ts @@ -27,7 +27,14 @@ describe("acquisition private output parent integrity", () => { it("fails closed without path cleanup when a parent becomes a symbolic link after exclusive leaf open", () => { let parentBecameSymbolicLink = false; const fileSystem = { - constants: { O_RDONLY: 16, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: { + O_RDONLY: 16, + O_WRONLY: 1, + O_CREAT: 2, + O_EXCL: 4, + O_NOFOLLOW: 8, + O_NONBLOCK: 32, + }, lstatSync: vi.fn((path: string) => { if (path === "output") { return parentBecameSymbolicLink ? fileMetadata() : null; From 9d099a5b43ef1cffeec03599fc098fa9db8eba92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 15:08:35 +0900 Subject: [PATCH 27/36] test(acquisition): preserve nonblocking cleanup semantics --- ...isition-private-output-version-race.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/test/acquisition-private-output-version-race.test.ts b/test/acquisition-private-output-version-race.test.ts index d0674fec0..46dcd6a2e 100644 --- a/test/acquisition-private-output-version-race.test.ts +++ b/test/acquisition-private-output-version-race.test.ts @@ -25,6 +25,15 @@ function parentMetadata() { }; } +const adapterConstants = { + O_RDONLY: 16, + O_WRONLY: 1, + O_CREAT: 2, + O_EXCL: 4, + O_NOFOLLOW: 8, + O_NONBLOCK: 32, +}; + function replacementFileSystem({ opened = fileMetadata(), currentTarget = fileMetadata(), @@ -60,7 +69,7 @@ function replacementFileSystem({ return staged; }); return { - constants: { O_RDONLY: 16, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: adapterConstants, lstatSync, openSync: vi.fn(() => 17), fstatSync, @@ -102,7 +111,7 @@ describe("acquisition private output replacement version authority", () => { let targetReads = 0; let descriptorReads = 0; const fileSystem = { - constants: { O_RDONLY: 16, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: adapterConstants, lstatSync: vi.fn((path: string) => { if (path === "output") { targetReads += 1; @@ -147,7 +156,7 @@ describe("acquisition private output replacement version authority", () => { let outputReads = 0; let descriptorReads = 0; const fileSystem = { - constants: { O_RDONLY: 16, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: adapterConstants, lstatSync: vi.fn((path: string) => { if (path === "output") { outputReads += 1; @@ -173,6 +182,7 @@ describe("acquisition private output replacement version authority", () => { expect(() => writeAcquisitionPrivateFile("output", "replacement\n", fileSystem as never)) .toThrow("changed while writing"); - expect(fileSystem.unlinkSync).toHaveBeenCalledWith("output"); + expect(fileSystem.ftruncateSync).toHaveBeenCalledTimes(2); + expect(fileSystem.unlinkSync).not.toHaveBeenCalledWith("output"); }); }); From b8cc38811fe4e4652ea92fb9655954a72620530f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 15:09:02 +0900 Subject: [PATCH 28/36] test(acquisition): carry nonblocking staging capability --- ...ion-private-output-staging-parent-race.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/test/acquisition-private-output-staging-parent-race.test.ts b/test/acquisition-private-output-staging-parent-race.test.ts index 491138aff..82c424728 100644 --- a/test/acquisition-private-output-staging-parent-race.test.ts +++ b/test/acquisition-private-output-staging-parent-race.test.ts @@ -23,6 +23,15 @@ function directoryMetadata({ symbolicLink = false } = {}) { }; } +const adapterConstants = { + O_RDONLY: 16, + O_WRONLY: 1, + O_CREAT: 2, + O_EXCL: 4, + O_NOFOLLOW: 8, + O_NONBLOCK: 32, +}; + describe("acquisition private output staging parent integrity", () => { it("never path-unlinks a staged inode after parent authority is lost", () => { let openCount = 0; @@ -30,7 +39,7 @@ describe("acquisition private output staging parent integrity", () => { const existing = fileMetadata(2); const staged = fileMetadata(4); const fileSystem = { - constants: { O_RDONLY: 16, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: adapterConstants, lstatSync: vi.fn((path: string) => { if (path === "output") { return existing; @@ -74,7 +83,7 @@ describe("acquisition private output staging parent integrity", () => { const existing = fileMetadata(2); const unsafeStaged = { ...fileMetadata(4), nlink: 2 }; const fileSystem = { - constants: { O_RDONLY: 16, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: adapterConstants, lstatSync: vi.fn((path: string) => { if (path === "output") { return existing; @@ -111,7 +120,7 @@ describe("acquisition private output staging parent integrity", () => { const staged = fileMetadata(4); const hardLinkedStaged = { ...staged, nlink: 2 }; const fileSystem = { - constants: { O_RDONLY: 16, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: adapterConstants, lstatSync: vi.fn((path: string) => { if (path === "output") { return existing; From 19db095ccded10aefea6250d3d22ee3a014638d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 15:09:16 +0900 Subject: [PATCH 29/36] test(acquisition): add nonblocking replacement capability --- test/acquisition-private-output-existing-target-metadata.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/acquisition-private-output-existing-target-metadata.test.ts b/test/acquisition-private-output-existing-target-metadata.test.ts index 56320fa95..4ad63837f 100644 --- a/test/acquisition-private-output-existing-target-metadata.test.ts +++ b/test/acquisition-private-output-existing-target-metadata.test.ts @@ -7,6 +7,7 @@ const constants = { O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8, + O_NONBLOCK: 16, }; function directoryMetadata() { From e6da3edadcf8378ca6d3bf750fc33763ccb94316 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 15:10:01 +0900 Subject: [PATCH 30/36] test(acquisition): provide nonblocking atomic adapter capability --- test/acquisition-private-output-atomic-coverage.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/acquisition-private-output-atomic-coverage.test.ts b/test/acquisition-private-output-atomic-coverage.test.ts index 9d2da8e39..3885a5887 100644 --- a/test/acquisition-private-output-atomic-coverage.test.ts +++ b/test/acquisition-private-output-atomic-coverage.test.ts @@ -44,7 +44,14 @@ function existingFileSystem({ let outputRead = 0; let fstatRead = 0; return { - constants: { O_RDONLY: 0, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: { + O_RDONLY: 0, + O_WRONLY: 1, + O_CREAT: 2, + O_EXCL: 4, + O_NOFOLLOW: 8, + O_NONBLOCK: 16, + }, lstatSync: vi.fn((path: string) => { if (path === "output") { return outputReads[outputRead++] ?? null; From 2c5d6378f4842008e250f00b1a1aa8c79f76c811 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 15:10:25 +0900 Subject: [PATCH 31/36] test(operations): expose nonblocking report capability --- test/actions-runner-assignment-write-io-boundary.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/actions-runner-assignment-write-io-boundary.test.ts b/test/actions-runner-assignment-write-io-boundary.test.ts index 264ed0f3f..ff0492d98 100644 --- a/test/actions-runner-assignment-write-io-boundary.test.ts +++ b/test/actions-runner-assignment-write-io-boundary.test.ts @@ -7,6 +7,7 @@ const constants = { O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8, + O_NONBLOCK: 16, }; function directoryMetadata() { From 8b67a93f9ffcf16e9ca517968496e3d5b652be3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:20:09 +0900 Subject: [PATCH 32/36] test(acquisition): align atomic-failure cleanup with descriptor-bound neutralization --- .../acquisition-private-output-atomic-replace.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/test/acquisition-private-output-atomic-replace.test.ts b/test/acquisition-private-output-atomic-replace.test.ts index 6465a4d45..015020bba 100644 --- a/test/acquisition-private-output-atomic-replace.test.ts +++ b/test/acquisition-private-output-atomic-replace.test.ts @@ -4,7 +4,6 @@ import { fchmodSync, fstatSync, ftruncateSync, - lstatSync, mkdtempSync, openSync, readFileSync, @@ -32,7 +31,7 @@ describe.skipIf(process.platform === "win32")( fchmodSync, fstatSync, ftruncateSync, - lstatSync, + lstatSync: (await import("node:fs")).lstatSync, openSync, renameSync, unlinkSync, @@ -52,7 +51,7 @@ describe.skipIf(process.platform === "win32")( } }); - it("fails closed if the staged inode changes after atomic rename", () => { + it("neutralizes the writer-owned replacement if its version changes after atomic rename", () => { const root = mkdtempSync(join(tmpdir(), "noema-private-post-rename-")); const output = join(root, "evidence.json"); try { @@ -64,7 +63,7 @@ describe.skipIf(process.platform === "win32")( fchmodSync, fstatSync, ftruncateSync, - lstatSync, + lstatSync: (await import("node:fs")).lstatSync, openSync, renameSync(source: string, destination: string) { renameSync(source, destination); @@ -89,7 +88,7 @@ describe.skipIf(process.platform === "win32")( mutatingFileSystem as never, )).toThrow("acquisition output path changed during atomic replacement"); expect(renameObserved).toBe(true); - expect(lstatSync(output, { throwIfNoEntry: false })).toBeUndefined(); + expect(readFileSync(output, "utf8")).toBe(""); } finally { rmSync(root, { recursive: true, force: true }); } @@ -107,7 +106,7 @@ describe.skipIf(process.platform === "win32")( fchmodSync, fstatSync, ftruncateSync, - lstatSync, + lstatSync: (await import("node:fs")).lstatSync, openSync, renameSync(source: string, destination: string) { try { From eaebeec1a26d2dd4ef1ca7f5210ad44c91258de9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:20:42 +0900 Subject: [PATCH 33/36] fix(test): keep descriptor-bound failure evidence non-authoritative --- test/acquisition-private-output-atomic-replace.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/acquisition-private-output-atomic-replace.test.ts b/test/acquisition-private-output-atomic-replace.test.ts index 015020bba..87b1f8aa1 100644 --- a/test/acquisition-private-output-atomic-replace.test.ts +++ b/test/acquisition-private-output-atomic-replace.test.ts @@ -4,6 +4,7 @@ import { fchmodSync, fstatSync, ftruncateSync, + lstatSync, mkdtempSync, openSync, readFileSync, @@ -31,7 +32,7 @@ describe.skipIf(process.platform === "win32")( fchmodSync, fstatSync, ftruncateSync, - lstatSync: (await import("node:fs")).lstatSync, + lstatSync, openSync, renameSync, unlinkSync, @@ -63,7 +64,7 @@ describe.skipIf(process.platform === "win32")( fchmodSync, fstatSync, ftruncateSync, - lstatSync: (await import("node:fs")).lstatSync, + lstatSync, openSync, renameSync(source: string, destination: string) { renameSync(source, destination); @@ -88,6 +89,7 @@ describe.skipIf(process.platform === "win32")( mutatingFileSystem as never, )).toThrow("acquisition output path changed during atomic replacement"); expect(renameObserved).toBe(true); + expect(lstatSync(output, { throwIfNoEntry: false })).toBeDefined(); expect(readFileSync(output, "utf8")).toBe(""); } finally { rmSync(root, { recursive: true, force: true }); @@ -106,7 +108,7 @@ describe.skipIf(process.platform === "win32")( fchmodSync, fstatSync, ftruncateSync, - lstatSync: (await import("node:fs")).lstatSync, + lstatSync, openSync, renameSync(source: string, destination: string) { try { From fbed32caa36d5caa71b3c4e818907f3e24443623 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 08:43:17 +0000 Subject: [PATCH 34/36] test: cover neutralize-cleanup and afterClosePath fail-closed branches Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4 --- ...quisition-data-room-integrity-branches.test.ts | 11 +++++++++++ test/acquisition-private-output.test.ts | 15 +++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/test/acquisition-data-room-integrity-branches.test.ts b/test/acquisition-data-room-integrity-branches.test.ts index 4903ad75e..356294ef8 100644 --- a/test/acquisition-data-room-integrity-branches.test.ts +++ b/test/acquisition-data-room-integrity-branches.test.ts @@ -224,6 +224,17 @@ describe("acquisition data-room integrity defensive branches", () => { throw new Error("post-read path lookup failed"); }); expect(readStableFile("ignored", 8, afterPath)).toBeNull(); + + const afterClose = fileSystemFor(stable, stable); + afterClose.lstatSync + .mockReturnValueOnce(stable) + .mockReturnValueOnce(stable) + .mockImplementationOnce(() => { + throw new Error("post-close path lookup failed"); + }); + expect(readStableFile("ignored", 8, afterClose)).toBeNull(); + expect(afterClose.closeSync).toHaveBeenCalledWith(7); + expect(afterClose.lstatSync).toHaveBeenCalledTimes(3); }); it.each([ diff --git a/test/acquisition-private-output.test.ts b/test/acquisition-private-output.test.ts index edcb1fe0a..285b2cc23 100644 --- a/test/acquisition-private-output.test.ts +++ b/test/acquisition-private-output.test.ts @@ -270,4 +270,19 @@ describe("acquisition private output", () => { .toThrow("write failed"); expect(fileSystem.closeSync).toHaveBeenCalledWith(17); }); + + it("skips best-effort content neutralization when non-blocking support disappears mid-cleanup", () => { + const fileSystem = mockFileSystem({ writeError: new Error("write failed") }); + fileSystem.writeFileSync.mockImplementation(() => { + delete (fileSystem.constants as { O_NONBLOCK?: number }).O_NONBLOCK; + throw new Error("write failed"); + }); + expect(() => writeAcquisitionPrivateFile("output", "value", fileSystem as never)) + .toThrow("write failed"); + expect(fileSystem.closeSync).toHaveBeenCalledWith(17); + // The write's own descriptor open is the only one: the neutralization + // cleanup's re-open must never run once O_NONBLOCK is no longer an + // integer, even though the earlier top-level gate saw it as valid. + expect(fileSystem.openSync).toHaveBeenCalledTimes(1); + }); }); From 85f14cd3e248f39e0ae2b5ed3bb75af611afc9cc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 08:47:46 +0000 Subject: [PATCH 35/36] docs: log coverage-gate regression test additions in CHANGELOG Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 817f278c2..a452c4ca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- `readStableFile`의 close-후 재검증 단계(`afterClosePath` lookup 실패)와 `writeAcquisitionPrivateFile`의 cleanup-시점 `O_NONBLOCK` 소실 분기에 대한 fail-closed 회귀 테스트를 추가해 `scripts/lib/acquisition-data-room-integrity.mjs`/`scripts/lib/acquisition-private-output.mjs`의 100% coverage 게이트를 복구한다. 동작 변화는 없다. - Noema의 필수 PR 워크플로 `ci`, `reviewer-ci`, `patch-validator-image`를 부동 `ubuntu-latest` 대신 명시적 `ubuntu-24.04` GitHub-hosted runner에 고정하고, 인용 여부와 무관하게 `ubuntu-latest` 회귀를 탐지하는 계약 테스트를 추가해 pre-checkout runner-assignment stall의 repository-owned selector 원인을 제거한다. 중앙 `Security Scan`의 runner/control-plane 권한은 별도 `.github` owner 경계에 유지한다. - 비공개 취약점 보고 감사가 16 KiB 응답 상한, bounded stream 취소, canonical repository/source identity의 독립 검증, SHA-1/SHA-256 exact revision, symlink·retained-path 보호를 실패-폐쇄로 강제한다. 이 감사 결과는 live private reporting 활성화, notification staffing, 실제 advisory 대응 또는 release/deployment 완료 증거를 대신하지 않는다. - External scheduler evidence audits now retain source authority through final report publication: reports are owner-only, no-follow, exclusive one-shot receipts, so a concurrent rename cannot move the accepted source inode onto the report pathname and have it replaced. Source/report path and inode alias checks, single-link retained-source validation, and Unicode control sanitization remain fail closed. From 722c06dbe9b6aab172863d65e1809275dd813182 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:07:01 +0000 Subject: [PATCH 36/36] fix(acquisition): make existing-target verification read non-blocking writeAcquisitionPrivateFile's pre-replacement verification open of an existing target used O_RDONLY | O_NOFOLLOW without O_NONBLOCK, even though O_NONBLOCK was already required as a filesystem capability. A locally authorized actor racing the prior lstatSync regular-file check with a FIFO substitution could make this open block indefinitely waiting for a writer, wedging the writer lease and delaying report generation (CodeRabbit finding on PR #526). Add O_NONBLOCK to that open: it is a no-op on regular files, and on a FIFO the open now returns immediately so the existing descriptor-type check fails closed instead of hanging. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX --- CHANGELOG.md | 1 + scripts/lib/acquisition-private-output.mjs | 11 ++- ...output-existing-target-nonblocking.test.ts | 95 +++++++++++++++++++ test/acquisition-private-output.test.ts | 7 +- 4 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 test/acquisition-private-output-existing-target-nonblocking.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a452c4ca7..7678b1452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- `writeAcquisitionPrivateFile`의 기존 대상 사전-교체 검증 read(`existingDescriptor` open)에 `O_NONBLOCK`을 추가해 fail-closed를 강화한다. 이 open은 이미 필수 filesystem capability로 `O_NONBLOCK`을 검증했지만 실제로는 사용하지 않아, 로컬 권한을 가진 행위자가 사전 `lstatSync` 정규 파일 확인과 이 open 사이에 대상 경로를 FIFO로 교체하면 writer가 나타날 때까지 무한정 블로킹해 writer lease를 계속 점유할 수 있었다. `O_NONBLOCK`은 정규 파일에는 영향이 없고, FIFO에서는 open이 즉시 반환되어 이어지는 descriptor 타입 검증이 그대로 fail-closed로 거부한다. 회귀 테스트(`test/acquisition-private-output-existing-target-nonblocking.test.ts`)와 기존 open-flags 계약 테스트 갱신으로 고정했다. - `readStableFile`의 close-후 재검증 단계(`afterClosePath` lookup 실패)와 `writeAcquisitionPrivateFile`의 cleanup-시점 `O_NONBLOCK` 소실 분기에 대한 fail-closed 회귀 테스트를 추가해 `scripts/lib/acquisition-data-room-integrity.mjs`/`scripts/lib/acquisition-private-output.mjs`의 100% coverage 게이트를 복구한다. 동작 변화는 없다. - Noema의 필수 PR 워크플로 `ci`, `reviewer-ci`, `patch-validator-image`를 부동 `ubuntu-latest` 대신 명시적 `ubuntu-24.04` GitHub-hosted runner에 고정하고, 인용 여부와 무관하게 `ubuntu-latest` 회귀를 탐지하는 계약 테스트를 추가해 pre-checkout runner-assignment stall의 repository-owned selector 원인을 제거한다. 중앙 `Security Scan`의 runner/control-plane 권한은 별도 `.github` owner 경계에 유지한다. - 비공개 취약점 보고 감사가 16 KiB 응답 상한, bounded stream 취소, canonical repository/source identity의 독립 검증, SHA-1/SHA-256 exact revision, symlink·retained-path 보호를 실패-폐쇄로 강제한다. 이 감사 결과는 live private reporting 활성화, notification staffing, 실제 advisory 대응 또는 release/deployment 완료 증거를 대신하지 않는다. diff --git a/scripts/lib/acquisition-private-output.mjs b/scripts/lib/acquisition-private-output.mjs index c5edf02fb..931cf6d5f 100644 --- a/scripts/lib/acquisition-private-output.mjs +++ b/scripts/lib/acquisition-private-output.mjs @@ -302,8 +302,13 @@ function writeNewPrivateFile(path, contents, fileSystem, flags) { * Write one UTF-8 acquisition evidence file without following a pre-existing * symbolic link or silently switching filesystem objects during the write. * Existing regular files must have a single hard link and are version-checked - * through a read-only no-follow descriptor without mutating their bytes or - * metadata before replacement commits. Replacement bytes are written completely + * through a read-only no-follow non-blocking descriptor without mutating their + * bytes or metadata before replacement commits. The non-blocking open flag + * keeps a locally authorized actor from wedging the writer lease indefinitely + * by racing the pre-open regular-file check with a FIFO substitution: opening + * a FIFO for read-only without O_NONBLOCK blocks until a writer appears, but + * with O_NONBLOCK the open returns immediately and the subsequent descriptor + * type check then fails closed instead of hanging. Replacement bytes are written completely * to an owner-only, exclusive sibling file and atomically renamed over the * unchanged verified target only after the write succeeds. A same-target writer * lease is held from the first target inspection through replacement acceptance, @@ -383,7 +388,7 @@ export function writeAcquisitionPrivateFile( throw new Error("acquisition output replacement requires atomic rename filesystem support"); } - const existingDescriptor = fileSystem.openSync(path, readOnly | noFollow); + const existingDescriptor = fileSystem.openSync(path, readOnly | noFollow | nonBlocking); try { assertAcquisitionPrivatePathParents(path, fileSystem); const opened = fileSystem.fstatSync(existingDescriptor); diff --git a/test/acquisition-private-output-existing-target-nonblocking.test.ts b/test/acquisition-private-output-existing-target-nonblocking.test.ts new file mode 100644 index 000000000..a6427c41e --- /dev/null +++ b/test/acquisition-private-output-existing-target-nonblocking.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from "vitest"; +import { writeAcquisitionPrivateFile } from "../scripts/lib/acquisition-private-output.mjs"; + +const constants = { + O_RDONLY: 0, + O_WRONLY: 1, + O_CREAT: 2, + O_EXCL: 4, + O_NOFOLLOW: 8, + O_NONBLOCK: 16, +}; + +function directoryMetadata() { + return { + isDirectory: () => true, + isSymbolicLink: () => false, + }; +} + +function fileMetadata(ino: number) { + return { + dev: 1, + ino, + nlink: 1, + isFile: () => true, + isSymbolicLink: () => false, + }; +} + +function fifoMetadata() { + return { + dev: 1, + ino: 99, + nlink: 1, + isFile: () => false, + isSymbolicLink: () => false, + }; +} + +describe("acquisition private-output existing-target open is non-blocking", () => { + it("opens the pre-replacement verification read with O_NONBLOCK", () => { + const targetPath = "/tmp/noema-acquisition/report.json"; + const existing = fileMetadata(10); + const io = { + constants, + lstatSync: vi.fn((path: string) => (path === targetPath ? existing : directoryMetadata())), + openSync: vi.fn(() => 41), + fstatSync: vi.fn(() => existing), + fchmodSync: vi.fn(), + ftruncateSync: vi.fn(), + writeFileSync: vi.fn(() => { + throw new Error("stop after existing-target verification"); + }), + closeSync: vi.fn(), + renameSync: vi.fn(), + unlinkSync: vi.fn(), + }; + + expect(() => writeAcquisitionPrivateFile(targetPath, "replacement", io)).toThrow(); + + const existingTargetOpen = io.openSync.mock.calls.find(([path]) => path === targetPath); + expect(existingTargetOpen).toBeDefined(); + const [, flags] = existingTargetOpen as [string, number]; + expect(flags & constants.O_NONBLOCK).toBe(constants.O_NONBLOCK); + }); + + it("fails closed instead of hanging when the target is replaced with a FIFO before the verification open", () => { + // A locally authorized actor can race the pre-open lstat check (which still + // observed a regular file) with a substitution of the target path for a + // FIFO. Without O_NONBLOCK, a read-only open of a FIFO blocks until a + // writer appears -- wedging this call, and the writer lease it holds, + // indefinitely. With O_NONBLOCK the open returns immediately and the + // descriptor-type check below fails closed instead. + const targetPath = "/tmp/noema-acquisition/report.json"; + const existing = fileMetadata(10); + const fifo = fifoMetadata(); + const io = { + constants, + lstatSync: vi.fn((path: string) => (path === targetPath ? existing : directoryMetadata())), + openSync: vi.fn(() => 41), + fstatSync: vi.fn(() => fifo), + fchmodSync: vi.fn(), + ftruncateSync: vi.fn(), + writeFileSync: vi.fn(), + closeSync: vi.fn(), + renameSync: vi.fn(), + unlinkSync: vi.fn(), + }; + + expect(() => writeAcquisitionPrivateFile(targetPath, "replacement", io)).toThrow( + "acquisition output path changed before writing", + ); + expect(io.writeFileSync).not.toHaveBeenCalled(); + }); +}); diff --git a/test/acquisition-private-output.test.ts b/test/acquisition-private-output.test.ts index 285b2cc23..7414c90f8 100644 --- a/test/acquisition-private-output.test.ts +++ b/test/acquisition-private-output.test.ts @@ -228,11 +228,14 @@ describe("acquisition private output", () => { expect(fileSystem.closeSync).toHaveBeenCalledWith(17); }); - it("opens an existing file read-only without truncation and verifies its descriptor identity first", () => { + it("opens an existing file read-only, non-blocking, without truncation and verifies its descriptor identity first", () => { const before = metadata(); const fileSystem = mockFileSystem({ before }); writeAcquisitionPrivateFile("output", "value", fileSystem as never); - expect(fileSystem.openSync).toHaveBeenCalledWith("output", 16 | 8); + // O_NONBLOCK (32) keeps this verification open from hanging if a locally + // authorized actor races the pre-open regular-file check with a FIFO + // substitution -- see acquisition-private-output-existing-target-nonblocking.test.ts. + expect(fileSystem.openSync).toHaveBeenCalledWith("output", 16 | 8 | 32); expect(fileSystem.ftruncateSync).toHaveBeenCalledOnce(); });