diff --git a/docs/acquisition-data-room-integrity.md b/docs/acquisition-data-room-integrity.md new file mode 100644 index 000000000..6669d56e4 --- /dev/null +++ b/docs/acquisition-data-room-integrity.md @@ -0,0 +1,146 @@ +# Acquisition Data-Room Integrity + +Noema의 buyer data room은 `data-room-manifest.json`을 **신뢰할 수 있는 판정 결과가 아니라 검증 대상인 색인**으로 취급한다. 매각·인수 readiness 판단은 저장된 `passed`, `finalGatePassed`, `missingRequired`, `missingFinalGate` 값을 그대로 사용하지 않는다. `npm run acquisition:integrity`가 현재 checkout의 정확한 Git commit과 retained evidence bytes에서 이를 다시 계산해야 한다. + +## Authoritative commands + +```bash +npm run acquisition:manifest +npm run acquisition:integrity +npm run acquisition:audit +``` + +`npm run acquisition:audit`은 integrity pre-gate를 먼저 실행한다. 따라서 모든 다른 commercial evidence가 green이어도 위조되거나 오래된 manifest는 acquisition readiness를 통과시킬 수 없다. `release:verify`와 `release:verify:strict`도 manifest 생성 직후 동일한 integrity gate를 실행한다. + +Manifest와 integrity 단계가 별도 경로를 추측하지 않도록 `NOEMA_DATA_ROOM_OUTPUT_DIR`과 `NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR`은 동일한 configured data-room root의 호환 입력으로 취급한다. 어느 것도 지정하지 않으면 두 단계 모두 exact authenticated head에 고정된 `artifacts/acquisition-readiness//`를 사용한다. `NOEMA_DATA_ROOM_MANIFEST_PATH`를 명시하면 두 단계가 그 exact manifest를 사용한다. 따라서 한쪽 output-dir 변수만 설정된 경우나 두 프로세스 사이 UTC 날짜가 바뀌는 경우에도 서로 다른 date-derived manifest를 검사하지 않는다. + +## Exact source and release binding + +생성된 manifest는 다음 identity를 포함한다. + +- `schemaVersion: 1` +- `repository: ContextualWisdomLab/noema` +- `objective: NOEMA-GOAL-ACQUISITION-2B-2026-07-02` +- `source.commitSha`: manifest를 생성한 checkout의 정확한 40자리 Git commit +- 선택된 release가 있을 때 `release.tag`와 그 tag가 실제로 가리키는 `release.commitSha` + +`NOEMA_DATA_ROOM_SOURCE_COMMIT`을 지정하면 현재 checkout `HEAD`와 정확히 같아야 한다. `NOEMA_RELEASE_UNDER_DILIGENCE_TAG`를 지정하면 immutable SemVer tag가 로컬 Git object database에서 exact commit으로 해석되어야 한다. 불일치하거나 해석할 수 없는 identity는 fail-closed이다. + +### Tracked checkout authentication + +`source.commitSha`는 단순한 `git rev-parse HEAD` 기록이 아니다. Manifest generator와 integrity audit는 catalog/verifier를 읽기 전에 `scripts/lib/acquisition-git-preflight.mjs`로 tracked checkout을 인증한다. + +1. `HEAD^{commit}`을 local Git object database에서 exact 40-character SHA로 해석한다. +2. system/global Git configuration, hooks, filesystem monitor, untracked cache, replacement objects, lazy fetch, terminal prompt를 비활성화하고 필요한 process-discovery 환경만 전달한다. 격리된 config에서도 CI checkout의 dubious-ownership 보호를 우회하지 않고 정확히 현재 command `cwd`만 command-scope `safe.directory`로 허용한다. 같은 exact `cwd`를 `GIT_WORK_TREE`에도 고정하여 repository-local `core.worktree`가 Git의 tracked-byte 비교를 다른 디렉터리로 redirect하지 못하게 한다. +3. `git ls-files -v -z --cached --`의 전체 NUL-delimited 결과를 최대 2 MiB로 bounded read하고, `S`로 표시되는 `skip-worktree` 또는 lowercase tag로 표시되는 `assume-unchanged` entry가 하나라도 있으면 tracked-byte 비교 전에 실패한다. 이 index hint들은 정상적인 working-tree 검사를 생략하게 할 수 있으므로 acquisition checkout에서는 허용하지 않는다. +4. `git diff --cached --quiet --no-ext-diff --no-textconv --ignore-submodules=none --`로 index의 staged content/mode가 exact commit과 같은지 확인한다. `--cached` 비교는 on-disk worktree를 고려하지 않으므로 repository-configured clean filter가 이 단계의 source identity를 바꿀 수 없다. +5. `git diff-files --quiet --no-ext-diff --no-textconv --ignore-submodules=none --`로 실제 worktree와 이미 인증된 index 사이의 tracked drift를 확인한다. 이 low-level index/worktree 비교를 사용하여 일반 `git diff `의 check-in conversion 경로가 repository-local `filter..clean` command를 실행하거나 그 출력으로 tampering을 숨기는 것을 방지한다. +6. 같은 bounded index inspection을 다시 실행해 comparison 도중 unsafe index hint가 생기지 않았는지 확인한다. +7. `HEAD^{commit}`을 다시 해석해 preflight 도중 branch movement를 거부한다. +8. verifier/catalog module은 이 preflight가 성공한 뒤에만 dynamic import한다. +9. retained evidence를 모두 읽은 뒤 같은 exact SHA를 기대값으로 tracked checkout 전체 절차를 다시 인증하고, source movement·tracked mutation·unsafe index hint가 있으면 output을 성공 evidence로 기록하지 않는다. + +이 비교는 **의도적으로 untracked 파일을 dirty source로 취급하지 않는다.** 실제 KPI, deployment receipt, revenue/transfer evidence 같은 acquisition artifact는 checkout에 보존될 수 있지만 source commit 자체의 일부라고 주장하지 않는다. 반대로 tracked README, policy, verifier, catalog, test, documentation 또는 control file이 HEAD와 다르거나 `skip-worktree`/`assume-unchanged`로 실제 working-tree 비교에서 숨겨지면 동일한 `source.commitSha`를 붙여 readiness evidence를 만들 수 없다. Repository-local clean/smudge filter는 정상 개발 workflow에서는 유효한 Git 기능이지만 acquisition preflight의 authorization helper로 실행되거나 그 transformed output이 raw checkout identity를 대신하도록 허용하지 않는다. + +Preflight Git 명령은 network fetch를 하지 않으며 `GIT_NO_LAZY_FETCH=1`을 사용한다. 이 경계의 bootstrap trust root는 trusted CI/checkout provisioner가 실행한 Node.js runtime, Git executable/local object database, 두 acquisition entrypoint, 작은 Git preflight module, 그리고 descriptor-safe private-output helper다. 이 코드는 실행 전에 자기 자신을 cryptographically self-authenticate한다고 주장하지 않는다. Bootstrap 자체의 무결성은 protected exact source checkout과 기존 CI/release provenance plane이 담당하고, 이 preflight는 그 이후 current working tree가 exact commit에서 drift하거나 unsafe index hint, repository-local worktree redirection, 또는 repository-configured clean filter로 drift를 숨기는 문제를 차단한다. + +## Local evidence verification + +각 file entry는 reviewed catalog의 고정된 repository-relative path만 사용할 수 있다. verifier는 다음 절차를 독립적으로 수행한다. + +1. traversal, absolute path, backslash alias, 빈 path component, `.`/`..`, control character를 거부한다. 이 명시적 component policy가 POSIX path normalization이 바꿀 수 있는 alias를 선행 차단하므로 별도의 중복 normalize 분기에 의존하지 않는다. +2. `lstat`으로 regular file인지 확인하고 symlink를 거부한다. +3. `O_NOFOLLOW | O_RDONLY` descriptor로 파일을 연다. +4. path metadata와 opened descriptor의 device, inode, size, modification/change time을 비교한다. +5. 최대 32 MiB까지 bounded read한다. 존재하는 0-byte regular file도 descriptor identity가 안전하면 file presence 자체는 정확히 기록하며, 해당 evidence의 의미적 최소 내용은 각 별도 validator가 판정한다. +6. read 후 descriptor와 path identity를 다시 비교하여 replacement 또는 in-place metadata drift를 거부한다. +7. 실제 bytes에서 SHA-256과 byte size를 다시 계산해 manifest의 stored digest/size와 대조한다. + +Manifest와 external verification receipt JSON은 최대 2 MiB이며 fatal UTF-8 decode와 duplicate-object-key 검사를 통과해야 한다. 이 경계는 path replacement, symlink substitution, oversized evidence, truncated read, duplicate-key ambiguity를 authorization 전에 거부한다. + +Reviewed catalog 자체도 authorization policy다. non-array, empty, 또는 bounded maximum entry count를 초과한 catalog는 빈 policy set으로 축소하지 않고 즉시 fail-closed 처리한다. + +### Manifest and audit output boundary + +Manifest와 integrity audit의 retained output도 입력 evidence와 동일하게 filesystem trust boundary로 취급한다. `scripts/lib/acquisition-private-output.mjs`는 새 파일을 `O_CREAT | O_EXCL | O_NOFOLLOW`로 만들고, 기존 파일은 먼저 `lstat`으로 single-link regular file임을 확인한 뒤 `O_NOFOLLOW`로 **truncate 없이** 연다. 기존 path와 opened descriptor의 device/inode가 같을 때에만 descriptor 자체를 먼저 `0600`으로 제한한다. 이 permission hardening이 성공한 뒤에만 `ftruncate`와 UTF-8 write를 수행하므로, 권한 변경이 실패하면 기존 content는 변경되지 않는다. 쓰기 후에는 descriptor/path가 여전히 같은 single-link regular file인지 다시 확인한다. + +Leaf-only `O_NOFOLLOW`는 상위 directory component가 symbolic link인 경우를 차단하지 않으므로, writer와 두 acquisition entrypoint는 output leaf의 parent에서 filesystem root까지 모든 **이미 존재하는** component를 `lstat`으로 확인한다. 존재하는 parent는 실제 directory이며 symbolic link가 아니어야 한다. Manifest/audit entrypoint는 recursive `mkdir` 전에 이 parent boundary를 확인하고 directory 생성 후 다시 확인한 다음 descriptor-safe leaf write를 수행한다. 따라서 `linked-output/data-room-manifest.json` 또는 explicit manifest parent처럼 기존 parent symlink가 다른 subtree로 output 생성·truncate를 redirect하는 경로는 fail-closed이다. 아직 존재하지 않는 중간 directory는 허용하지만 그보다 상위의 기존 ancestor 검사는 계속 root까지 수행한다. + +따라서 pre-existing leaf symlink, parent-directory symlink, hard-linked output, non-regular file, open 전 path replacement, write 중 path replacement는 모두 fail-closed이다. Symlink를 따라 다른 파일을 덮어쓰거나 pathname `chmod`가 symlink target의 권한을 바꾸는 방식으로 evidence를 남기지 않는다. 필요한 `O_NOFOLLOW` 상수가 없는 플랫폼에서는 안전하지 않은 fallback으로 내려가지 않고 output write 자체를 거부한다. 이 제약은 Node.js의 `lstat`가 link 자체를 검사하고 `O_NOFOLLOW`가 최종 path component에 적용된다고 명시한 동작, 그리고 CWE-59가 path resolution 과정의 link following을 의도하지 않은 resource 접근 위험으로 분류하는 threat model과 일치한다. Parent path의 동시 교체까지 원자적으로 봉쇄한다고 과장하지 않으며, hostile concurrent filesystem mutation은 protected checkout/runner provisioning이라는 bootstrap trust boundary 밖에 둔다. + +## External evidence is declaration-only by default + +`https://` URL은 evidence 존재 증명이 아니다. 예를 들어 FigJam value map URL은 immutable local verification receipt가 없으면 `declared` 상태이며 final gate를 충족하지 않는다. + +외부 evidence가 final gate를 충족하려면 reviewed catalog가 **receipt path와 retained artifact path를 둘 다 고정**해야 한다. Receipt는 catalog의 `receiptPath`에 있어야 하며 `artifact.path`는 catalog의 `artifactPath`와 정확히 같아야 한다. 단순히 canonical한 repository-relative path라는 이유만으로 다른 파일을 선택할 수 없다. 예를 들어 동일한 digest metadata를 갖춘 receipt가 `README.md`나 다른 정상 파일을 가리켜도 reviewed artifact path와 다르면 즉시 거부한다. + +Receipt는 다음을 모두 충족해야 한다. + +```json +{ + "schemaVersion": 1, + "repository": "ContextualWisdomLab/noema", + "source": { "commitSha": "" }, + "sourceUrl": "", + "collectedAt": "", + "collector": "", + "provenance": "", + "artifact": { + "path": "", + "bytes": 123, + "sha256": "<64 lowercase/uppercase hex characters>" + } +} +``` + +Verifier는 receipt가 선언한 임의 경로를 신뢰하지 않고 catalog에 고정된 `artifactPath`만 같은 bounded no-follow 정책으로 읽는다. Retained artifact의 실제 byte size 및 SHA-256을 다시 계산하고 receipt의 값과 비교한다. Receipt의 URL syntax만 맞거나 remote endpoint가 현재 접근 가능하다는 사실만으로는 final gate를 통과하지 않는다. 최종 audit은 네트워크 요청을 하지 않는다. + +현재 FigJam entry의 reviewed paths는 다음과 같다. + +```text +receiptPath: artifacts/acquisition/figjam-value-map-verification.json +artifactPath: artifacts/acquisition/figjam-value-map-export.json +``` + +실제 immutable export가 정확한 `artifactPath`에 보존되고 위 receipt가 이를 정확히 인증하기 전까지 이 entry는 `declared`이며 `missingFinalGate`에 남는다. + +## Persisted claims are cross-checks, not authority + +Verifier는 reviewed catalog를 기준으로 entry ID set을 정확히 비교하고 duplicate/unknown/missing entry를 거부한다. 각 entry의 `id`, `category`, `kind`, `required`, `requiredForFinalGate`, path/command/URL/receipt path/artifact path, validator metadata가 catalog와 동일해야 한다. + +그 다음 실제 retained evidence에서 `passed`, `finalGatePassed`, `missingRequired`, `missingFinalGate`를 다시 계산한다. 저장된 값은 trusted recomputation과 정확히 일치해야 하며, 다른 경우 manifest integrity 자체가 실패한다. 따라서 `finalGatePassed: true`를 수동으로 적거나 gap list를 삭제해도 readiness를 만들 수 없다. + +## Separation of evidence planes + +Data-room integrity PASS는 다음을 의미하지 않는다. + +- CI/check run 성공 +- GitHub commit status 성공 +- human 또는 independent review approval +- CodeRabbit/OpenCode/model judgement 승인 +- branch protection 충족 +- release acceptance +- production deployment acceptance +- revenue, transfer, production KPI 또는 governance evidence 충족 + +Integrity gate는 **색인이 주장하는 evidence가 현재 retained bytes와 exact source/release identity에 맞는지**만 검증한다. 실제 KPI·배포·보안·매출·이관 evidence가 없으면 해당 독립 gate는 계속 RED/NOT_READY여야 한다. + +## Failure handling + +Integrity failure를 해결하기 위해 다음을 해서는 안 된다. + +- stored Boolean이나 gap list를 수동으로 green으로 변경 +- tracked checkout drift를 유지한 채 `source.commitSha`만 HEAD 값으로 기록 +- repository-local `core.worktree` 또는 ambient worktree redirection으로 audited `cwd`가 아닌 다른 tree를 Git comparison 대상으로 사용 +- repository-configured `filter..clean`/`process` helper 또는 transformed output을 acquisition source-identity 판정에 사용 +- `skip-worktree` 또는 `assume-unchanged` index hint로 tracked drift를 숨김 +- symlink 또는 alternate path로 evidence 대체 +- manifest/audit output leaf 또는 기존 parent component에 symlink·hard link·non-regular file을 두고 writer/recursive mkdir가 이를 따라가도록 허용 +- output permission hardening이 실패했는데도 기존 파일을 truncate하거나 새 evidence bytes를 기록 +- external receipt가 catalog에 고정되지 않은 임의의 retained path를 선택하게 허용 +- arbitrary HTTPS URL을 verified evidence로 분류 +- remote fetch를 final audit 안에 삽입 +- branch protection, independent approval, security gate 우회 +- self-modifying/repair GitHub Actions 추가 + +Tracked source를 exact commit으로 복구하고 unsafe index hint를 제거한 뒤 evidence를 다시 수집하거나 올바른 retained artifact를 복구한다. 안전하지 않은 output leaf 또는 parent path가 있으면 해당 filesystem object를 별도의 trusted 운영 절차로 제거·격리한 다음 새 exact checkout에서 다시 생성한다. 그 다음 `npm run acquisition:manifest`를 새 exact checkout에서 다시 생성하고 `npm run acquisition:integrity`를 실행한다. diff --git a/docs/acquisition-git-content-authentication.md b/docs/acquisition-git-content-authentication.md new file mode 100644 index 000000000..5e822996a --- /dev/null +++ b/docs/acquisition-git-content-authentication.md @@ -0,0 +1,94 @@ +# Acquisition Git Content Authentication + +Noema의 buyer data-room 검증은 `git diff-files`가 clean이라고 보고한 사실만으로 tracked checkout의 실제 bytes가 exact commit과 같다고 승인하지 않는다. Git index는 성능을 위해 파일의 cached stat metadata를 저장하며, timestamp와 size 조건에 따라 내용이 바뀌어도 stat tuple이 같아 보이는 “racily clean” 상태가 발생할 수 있다. 또한 index 자체는 mutable state이므로 raw-byte authority의 expected blob identity도 index에서 가져오지 않는다. + +## Required verification sequence + +Acquisition manifest 생성과 integrity audit는 다음 검사를 모두 통과해야 한다. + +1. exact `HEAD^{commit}`을 local object database에서 40자리 SHA로 해석한다. +2. `skip-worktree`와 `assume-unchanged` index hint를 bounded NUL-delimited inspection으로 거부한다. +3. staged index가 exact HEAD와 같은지 `git diff --cached`로 확인한다. +4. ordinary worktree drift를 `git diff-files`로 확인한다. 이 단계는 빠른 defense in depth이며 최종 byte authority가 아니다. +5. 이미 인증한 exact HEAD를 `git ls-tree -r --full-tree -z`로 읽어 immutable commit-tree의 mode, object ID, path inventory를 만든다. 출력은 parser가 요구하는 bounded NUL-delimited mode/object/stage-shaped record로 formatting하며 mutable index object ID를 authority로 사용하지 않는다. +6. exact-tree의 `100644` 또는 `100755` regular-file entry만 허용한다. Symlink, gitlink, sparse-directory mode 등 지원하지 않는 object mode는 fail-closed다. +7. 각 path를 `lstat`한 뒤 `O_RDONLY | O_NOFOLLOW` descriptor로 열고, path와 descriptor의 device, inode, mode, size, modification time, change time이 일치해야 한다. +8. exact-tree executable mode와 descriptor의 owner-execute bit를 독립적으로 비교한다. +9. descriptor size를 기준으로 `size + 1` bounded buffer를 할당하고 descriptor에서 직접 읽는다. Short read, growth 또는 invalid read count를 거부한다. +10. read 후 descriptor와 path identity를 다시 비교한다. +11. descriptor에서 읽은 exact bytes를 `git hash-object --stdin`으로 계산하고 exact HEAD tree object ID와 정확히 비교한다. +12. index hint와 exact HEAD를 다시 확인한다. + +이 순서는 staged-index 비교가 끝난 직후 다른 local writer가 index를 다시 써도 raw-byte authority가 새 index object ID로 바뀌지 않게 한다. Expected blob identity는 처음 고정한 exact HEAD commit tree에서만 파생된다. Pathname을 두 번째로 다시 열어 hash하지 않으므로 검증 전 path와 hash 대상 사이의 교체 경로도 줄인다. Tracked symbolic link는 현재 descriptor-bound link-target read가 제공되지 않으므로 허용 가능한 source mode로 취급하지 않는다. + +## Bounded work + +Raw authentication은 다음 상한을 적용한다. + +- exact-tree/index parser output: 2 MiB +- tracked entry count: 20,000 +- one path: 4,096 UTF-8 bytes +- one tracked regular file: 32 MiB +- all authenticated tracked bytes: 256 MiB +- each Git subprocess: 10 seconds +- ordinary Git command output: 4 KiB + +Per-file 및 aggregate byte budget은 해당 descriptor read와 blob hash를 시작하기 전에 확인된다. 따라서 oversized checkout을 읽은 뒤 뒤늦게 거부하지 않는다. + +## Why exact-tree descriptor-bound stdin hashing is used + +Acquisition source identity는 checkout의 실제 bytes를 특정 immutable commit에 결박해야 한다. `.gitattributes`의 clean filter, end-of-line conversion, pathname 재해석, repository-local helper뿐 아니라 cached stat 또는 concurrently rewritten index도 current filesystem bytes의 expected identity를 대신할 수 없다. + +Noema는 먼저 고정된 commit의 tree object IDs를 `git ls-tree`로 읽고, `O_NOFOLLOW` descriptor로 인증한 regular file bytes만 읽어 그 buffer를 `git hash-object --stdin`에 전달한다. Git은 standard input을 blob contents로 hash하므로 pathname filter나 두 번째 path open에 의존하지 않는다. Hash 전후 descriptor/path identity와 executable mode는 blob contents와 별도로 검증한다. + +## Failure policy + +다음은 모두 fail-closed evidence-integrity failure다. + +- invalid UTF-8 또는 path traversal +- unsupported object mode, symlink, gitlink, sparse directory +- exact-tree listing 또는 object identity mismatch +- `O_NOFOLLOW` 미지원 +- path/descriptor object type 또는 identity mismatch +- executable mode mismatch +- short read, growth, invalid byte count, metadata movement +- malformed Git output 또는 hash mismatch +- per-file·aggregate·entry·path budget 초과 + +이 실패를 cached success, commit status, model judgement, severity downgrade, ignore rule, repair workflow 또는 self-modifying GitHub Actions로 승인 상태로 바꾸지 않는다. + +## Evidence separation + +이 gate의 PASS는 다음을 대신하지 않는다. + +- GitHub check-run 또는 commit-status 성공 +- independent reviewer `APPROVE` +- CodeRabbit, OpenCode, Noema 또는 다른 model judgement +- branch protection/ruleset enforcement +- merge-result compatibility +- release provenance, immutable publication 또는 deployment acceptance +- buyer KPI, revenue, transfer 또는 governance evidence + +각 evidence plane은 exact checked SHA와 producer를 별도로 보존해야 한다. + +## Operational response + +`tracked checkout differs from its authenticated Git index bytes`, exact-tree mismatch 또는 descriptor-bound verification failure가 발생하면 다음 순서로 복구한다. + +1. 현재 process가 보고한 source SHA를 승인하지 않는다. +2. tracked file과 index state를 신뢰된 checkout에서 복구한다. +3. 다른 writer가 branch를 이동했는지 live exact head를 다시 읽는다. +4. 새 exact checkout에서 manifest를 다시 생성한다. +5. integrity, CI, security, coverage, review, provenance gate를 모두 다시 실행한다. + +## References + +Git Project. (2026). *Git documentation: git-hash-object*. https://git-scm.com/docs/git-hash-object + +Git Project. (2026). *Git documentation: git-ls-tree*. https://git-scm.com/docs/git-ls-tree + +Git Project. (2026). *Git documentation: git-ls-files*. https://git-scm.com/docs/git-ls-files + +Git Project. (2026). *Racy Git*. https://git-scm.com/docs/racy-git.html + +OpenJS Foundation. (2026). *File system*. Node.js documentation. https://nodejs.org/api/fs.html diff --git a/docs/doctoring/acquisition-data-room-integrity.md b/docs/doctoring/acquisition-data-room-integrity.md new file mode 100644 index 000000000..1190105bb --- /dev/null +++ b/docs/doctoring/acquisition-data-room-integrity.md @@ -0,0 +1,117 @@ +# Doctoring: Acquisition Data-Room Evidence Integrity + +## Decision + +Noema의 acquisition data-room manifest는 buyer-readiness authority가 아니라 **검증해야 하는 evidence index**다. 최종 판정은 retained artifact bytes, exact Git source identity, optional immutable release identity를 오프라인에서 다시 검증한다. Persisted Boolean과 gap list는 recomputation과 일치해야 하는 cross-check일 뿐 authorization input이 아니다. + +외부 링크는 URI 형식만으로 evidence가 되지 않는다. Final-gate external material은 exact source URL, collection time, collector identity, provenance, retained local artifact path, byte count, SHA-256을 담은 bounded local verification receipt와 실제 retained artifact를 함께 검증해야 한다. 이때 receipt path와 retained artifact path는 모두 reviewed catalog가 고정하며, receipt가 canonical하더라도 catalog와 다른 repository file을 artifact로 선택할 수 없다. + +Retained manifest와 audit output은 기존 파일을 갱신할 때 content mutation보다 owner-only permission hardening을 먼저 완료해야 한다. Descriptor가 검증된 뒤 `fchmodSync(..., 0o600)`이 실패하면 `ftruncate`나 write를 실행하지 않고 기존 content를 유지한다. 이는 느슨한 기존 mode를 가진 파일에 새 민감 evidence를 기록하는 노출 구간과, 권한 강화 실패 뒤 이미 content가 파괴되는 비가역 side effect를 모두 차단한다. + +## Engineering consequences + +- Manifest/receipt JSON은 bounded size, fatal UTF-8, duplicate-key rejection을 적용한다. +- Manifest generator와 integrity audit는 verifier/catalog import 전에 exact `HEAD^{commit}`을 local-only Git으로 해석하고 tracked checkout이 해당 commit과 같은지 확인한다. +- Git preflight는 system/global configuration, hooks, fsmonitor, untracked cache, replacement objects, lazy fetch, terminal prompt를 비활성화하고 bounded stdout/stderr와 timeout을 사용한다. 격리된 Git config에서도 CI checkout의 dubious-ownership 검사가 정상 동작하도록, 현재 command `cwd` 하나만 `GIT_CONFIG_COUNT` command scope의 `safe.directory`로 허용한다. +- `git ls-files -v -z --cached --`를 최대 2 MiB로 bounded read하고, `S` status tag(`skip-worktree`) 또는 lowercase tag(`assume-unchanged`)가 하나라도 있으면 acquisition checkout을 거부한다. 정상 `git diff`가 index hint 때문에 working-tree byte comparison을 생략하는 경로를 trust boundary 밖으로 밀어낸다. +- Index hint inspection은 tracked diff 전후에 반복하며 malformed, unterminated, nonzero, oversized, spawn/signal output을 모두 fail-closed 처리한다. Repository path는 오류에 반사하지 않는다. +- `git diff --quiet --no-ext-diff --no-textconv --ignore-submodules=none --`의 exit 1은 tracked drift로 해석하며, 다른 nonzero/signal/spawn failure는 별도 오류가 아니라 모두 fail-closed preflight failure다. +- Retained evidence를 읽은 뒤 exact HEAD, safe index flags, tracked checkout을 전체 재검증하므로 verification 중 source movement나 index-hint 변경이 success evidence로 기록되지 않는다. +- Intentionally untracked acquisition artifacts는 source commit의 일부라고 주장하지 않으므로 tracked-source preflight가 허용한다. 실제 artifact bytes는 별도의 descriptor-safe evidence policy가 검증한다. +- Manifest generator와 integrity audit가 별도 경로를 계산하지 않도록 `NOEMA_DATA_ROOM_OUTPUT_DIR`/`NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR`을 같은 configured data-room root로 취급한다. 별도 경로를 주지 않은 경우 두 단계 모두 `artifacts/acquisition-readiness//`를 사용하므로 UTC 자정 경계에서도 같은 exact-head manifest를 검증한다. +- Local evidence는 repository-relative allowlist와 `O_NOFOLLOW` descriptor를 사용하며 path/descriptor identity를 read 전후에 대조한다. +- Manifest와 integrity-audit output도 descriptor-safe write boundary를 사용한다. 새 output은 `O_CREAT | O_EXCL | O_NOFOLLOW`, 기존 output은 single-link regular-file `lstat` 뒤 `O_NOFOLLOW` without truncation으로 열고 path/descriptor device·inode가 일치한 뒤 descriptor를 `0600`으로 먼저 제한한다. Permission hardening이 성공한 뒤에만 `ftruncate`와 write를 수행하고, path/descriptor identity와 single-link regular-file 상태를 다시 검증한다. `fchmod` 실패는 기존 content를 변경하지 않은 채 fail-closed이며, symlink, hard link, non-regular target, pre-open replacement, write-time replacement 및 `O_NOFOLLOW` 미지원도 모두 거부한다. +- SHA-256 및 byte size는 audit 시점의 실제 bytes에서 다시 계산한다. +- Schema, repository, objective, exact source commit, optional release tag/commit, unique entry set을 fail-closed 검증한다. Reviewed catalog가 non-array, empty, 또는 bounded entry limit 초과이면 빈 catalog로 축소하지 않고 즉시 실패한다. +- Existing zero-byte regular evidence file은 path/descriptor identity가 안전하면 존재하는 file로 취급한다. 콘텐츠 의미나 readiness 충족 여부가 별도 validator에 의해 요구되는 항목은 해당 validator가 판정하며, 파일 시스템 계층은 zero-byte 자체를 non-regular/unsafe로 오인하지 않는다. +- External receipt는 reviewed `receiptPath`에서만 읽고, `artifact.path`가 reviewed `artifactPath`와 정확히 일치할 때만 해당 retained bytes를 검증한다. Canonical하지만 다른 경로는 substitution으로 거부한다. +- `passed`, `finalGatePassed`, `missingRequired`, `missingFinalGate`는 모두 trusted recomputation에서 생성한다. +- External collection은 final audit와 분리한다. Final audit는 네트워크를 사용하지 않는다. +- Integrity PASS는 CI, review, security status, merge authority, revenue, deployment 또는 release acceptance를 대신하지 않는다. + +## Bootstrap trust root + +Working-tree authentication에는 피할 수 없는 bootstrap boundary가 있다. 실행되기 전의 JavaScript가 자기 자신의 무결성을 증명할 수는 없다. 따라서 이 변경은 다음을 trust root로 명시한다. + +- protected exact-source checkout을 제공하는 trusted CI/runner 또는 maintainer checkout process; +- 해당 checkout에서 실행되는 Node.js runtime; +- local Git executable과 object database; +- `scripts/acquisition-data-room-manifest-secure.mjs` / `scripts/acquisition-data-room-integrity-audit.mjs` entrypoint; +- 최소 기능만 가진 `scripts/lib/acquisition-git-preflight.mjs`; +- retained manifest/audit bytes를 기록하는 최소 기능의 `scripts/lib/acquisition-private-output.mjs`. + +이 bootstrap을 통과한 이후에는 heavier verifier/catalog를 dynamic import하기 전에 exact HEAD, unsafe index hints, tracked worktree를 인증하고, evidence read 이후 같은 절차를 다시 인증한다. 따라서 dirty tracked verifier, catalog, README/policy/documentation/control source가 현재 HEAD SHA를 재사용하거나 `skip-worktree`/`assume-unchanged`로 working-tree 차이를 숨겨 exact-commit evidence로 위장하는 경로를 차단한다. Bootstrap 자체의 provenance와 exact source authenticity는 기존 protected branch, CI, release/Sigstore provenance plane이 담당한다. 이 문서는 Git preflight나 private-output helper가 cryptographic self-verification을 제공한다고 주장하지 않는다. + +## Standards rationale + +SLSA v1.2는 2025년 11월 24일 공개된 Approved Specification이며 provenance를 artifact가 어디서, 언제, 어떻게 생성되었는지를 추적할 수 있는 검증 가능한 정보로 정의한다. Verifier는 artifact/provenance를 producer-defined expectations와 대조해야 한다. Noema의 external verification receipt는 SLSA provenance 자체를 주장하지 않지만, source identity·artifact digest·collector/provenance뿐 아니라 reviewed artifact identity를 명시하고 verifier가 retained bytes에 대해 사전 정의된 expectations를 다시 적용한다는 동일한 trust principle을 따른다. + +NIST SP 800-218 SSDF v1.1(2022)을 finalized normative baseline으로 사용한다. SP 800-218 Rev. 1 / SSDF v1.2 Initial Public Draft는 2025년 12월 17일 공개되었고 public comment period는 2026년 1월 30일 종료되었다. 이 변경에서는 해당 draft를 normative requirement로 승격하지 않는다. Evidence integrity는 release·acquisition 과정에서 provenance와 verification information을 보존하고 검증하는 secure-development practice를 구체화한다. + +RFC 8785 JCS는 cryptographic hashing/signing에서 invariant JSON 표현과 duplicate property name 금지의 중요성을 설명한다. Noema는 이 변경에서 JCS 서명 format을 새로 도입하지 않는다. 대신 JSON parser ambiguity를 줄이기 위해 duplicate decoded object key를 fail-closed 거부하고, 실제 retained artifact bytes 자체를 SHA-256으로 검증한다. 따라서 canonicalization을 사용하지 않는 evidence digest를 JCS-compliant digest라고 과장하지 않는다. + +Git의 primary documentation은 `GIT_CONFIG_GLOBAL=/dev/null` 및 `GIT_CONFIG_NOSYSTEM`을 predictable script environment를 만드는 수단으로 정의하고, `GIT_NO_LAZY_FETCH=1`이 promisor remote에서 missing object를 demand-fetch하지 않게 한다고 명시한다. 또한 command scope의 `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_`/`GIT_CONFIG_VALUE_` 설정은 protected configuration에 포함되므로 exact `cwd`의 `safe.directory`를 ambient global config를 복구하지 않고 명시적으로 허용할 수 있다. `git diff --quiet`은 diff-style exit code를 사용하여 차이가 있으면 1, 없으면 0을 반환하고, `--no-ext-diff`/`--no-textconv`는 external diff/text conversion helper 실행을 차단하며 `--ignore-submodules=none`은 submodule modification을 숨기는 설정을 override한다. 이 변경은 그 documented semantics를 fail-closed exact-checkout preflight에 사용한다. + +`git-ls-files`의 primary documentation은 `-v`가 status tag를 출력하고 `S`를 `skip-worktree`, lowercase letter를 `assume-unchanged`로 나타낸다고 정의한다. `git-update-index`는 `assume-unchanged`가 설정되면 사용자가 파일을 바꾸지 않았다고 Git이 가정할 수 있고, `skip-worktree`가 working-tree 파일을 up-to-date로 취급하는 동작을 설명한다. 따라서 `git diff` 단독 성공은 acquisition source authentication에 충분하지 않으며, Noema는 diff 전후 bounded NUL-delimited index inspection으로 두 hint를 명시적으로 금지한다. + +Node.js의 현재 primary filesystem documentation은 `O_NOFOLLOW`가 path가 symbolic link이면 open을 실패시키고, `O_EXCL`이 `O_CREAT`과 함께 사용될 때 기존 path가 있으면 생성을 실패시키며, `O_TRUNC`은 성공적으로 write-open된 기존 regular file을 즉시 0 byte로 만든다고 정의한다. 같은 문서는 `fchmodSync(fd, mode)`가 열린 file descriptor의 mode를 설정한다고 정의하며, open의 `mode` argument는 새 파일이 생성될 때만 적용된다. 따라서 pre-existing output의 권한은 open mode에 의존할 수 없다. Noema는 기존 output을 `O_TRUNC`로 열지 않고 descriptor identity를 먼저 확인한 뒤 `fchmodSync(0o600)`을 수행하며, 성공한 경우에만 `ftruncate`와 write를 허용한다. 이 순서는 권한 강화 실패를 content mutation보다 앞선 실패 경계로 만들고, CWE-732가 설명하는 critical resource의 과도한 권한으로 인한 confidentiality·integrity 위험을 줄인다. `O_NOFOLLOW`가 없으면 fallback write를 하지 않는다. + +## Threat model addressed + +이 경계는 다음 공격 또는 실수에 대해 fail-closed 하도록 설계했다. + +- `finalGatePassed: true` 또는 빈 gap list를 수동으로 작성한 forged manifest +- invalid/empty/oversized reviewed catalog를 빈 policy set으로 축소하여 all-green을 만드는 fail-open +- exact HEAD는 그대로 두고 tracked verifier/catalog/policy/evidence file만 수정한 dirty checkout +- tracked file에 `skip-worktree`를 설정해 modified working-tree bytes가 일반 diff에서 숨겨지는 경우 +- tracked file에 `assume-unchanged`를 설정해 Git의 working-tree comparison을 생략시키는 경우 +- verification 도중 unsafe index hint, HEAD 또는 tracked worktree가 변경되는 source movement +- local/global Git configuration 또는 external diff/textconv helper를 통해 comparison semantics가 바뀌는 환경 오염 +- isolated Git configuration 때문에 legitimate CI checkout이 dubious ownership으로 오판되어 검증 자체가 불능이 되는 경우 +- partial-clone missing object를 remote에서 lazy-fetch하여 offline audit boundary가 깨지는 경우 +- manifest 생성과 integrity audit 사이 UTC 날짜 변경 또는 한쪽 output-dir 변수만 설정되어 서로 다른 manifest path를 검사하는 경우 +- 기존 data-room output file이 느슨한 POSIX mode를 유지하는 경우 +- permission hardening 실패 후에도 기존 output을 truncate하거나 replacement bytes를 기록하는 경우 +- manifest/audit output leaf가 symlink, hard link, non-regular file로 대체되거나 open/write 사이 path identity가 바뀌어 다른 filesystem object를 덮어쓰는 경우 +- manifest 생성 후 local evidence 변경 +- same-path symlink 또는 non-regular replacement +- path-to-descriptor replacement 및 read 중 metadata identity drift +- traversal/absolute/backslash/control-character path alias +- duplicate/unknown/missing catalog entry +- stale repository commit 또는 selected release mismatch +- duplicate-key 또는 malformed/oversized JSON +- arbitrary `https://` 문자열을 verified evidence로 오인 +- external receipt가 가리키는 retained artifact의 digest/size 변경 +- external receipt가 `README.md` 같은 canonical하지만 review되지 않은 다른 repository path를 retained artifact로 대체하는 substitution + +이 경계는 cryptographic signature나 trusted timestamp authority를 새로 제공하지 않는다. Release/deployment artifact authenticity는 기존 Sigstore/GitHub attestation 체계가 계속 담당하며, commercial/revenue/transfer evidence의 실재성과 승인 또한 별도 gate가 담당한다. + +## APA 7th references + +Git Project. (2026). *Git documentation: git*. https://git-scm.com/docs/git + +Git Project. (2026). *Git documentation: git-config*. https://git-scm.com/docs/git-config + +Git Project. (2026). *Git documentation: git-diff*. https://git-scm.com/docs/git-diff + +Git Project. (2026). *Git documentation: git-ls-files*. https://git-scm.com/docs/git-ls-files + +Git Project. (2026). *Git documentation: git-update-index*. https://git-scm.com/docs/git-update-index + +MITRE. (2026). *CWE-732: Incorrect permission assignment for critical resource*. Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/732.html + +National Institute of Standards and Technology. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). https://doi.org/10.6028/NIST.SP.800-218 + +OpenJS Foundation. (2026). *File system*. Node.js documentation. https://nodejs.org/api/fs.html + +Rundgren, A., Jordan, B., & Erdtman, S. (2020). *JSON canonicalization scheme (JCS)* (RFC 8785). RFC Editor. https://doi.org/10.17487/RFC8785 + +SLSA Community. (2025). *SLSA specification version 1.2*. Open Source Security Foundation. https://slsa.dev/spec/v1.2/ + +SLSA Community. (2025). *Provenance*. In *SLSA specification version 1.2*. Open Source Security Foundation. https://slsa.dev/spec/v1.2/provenance + +## Non-normative draft tracking + +Booth, H., Ogata, M., Kent, K., Souppaya, M., & Dodson, D. (2025). *Secure software development framework (SSDF) version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST SP 800-218 Rev. 1, Initial Public Draft). National Institute of Standards and Technology. https://csrc.nist.gov/pubs/sp/800/218/r1/ipd + +The draft above is tracked for future review only. It does not replace the finalized NIST SP 800-218 v1.1 baseline in this change. diff --git a/docs/doctoring/acquisition-git-content-authentication.md b/docs/doctoring/acquisition-git-content-authentication.md new file mode 100644 index 000000000..2b9dcd016 --- /dev/null +++ b/docs/doctoring/acquisition-git-content-authentication.md @@ -0,0 +1,99 @@ +# Doctoring: Acquisition Git Descriptor-Bound Content Authentication + +## Decision + +Noema의 acquisition exact-checkout preflight는 cached stat equality, `git diff-files` success, 또는 mutable stage-zero index object ID를 tracked-byte authority로 사용하지 않는다. 이미 고정한 exact `HEAD` commit tree가 선언한 Git blob object ID와 `O_NOFOLLOW` descriptor에서 읽은 current checkout bytes로 다시 계산한 object ID가 정확히 같아야 한다. + +Ordinary Git comparison은 staged mode/state와 일반적인 worktree drift를 빠르게 찾는 defense in depth로 유지한다. 최종 content 판정은 pathname을 다시 여는 Git 명령이나 mutable index가 아니라, exact commit tree와 검증된 descriptor가 제공한 bounded buffer를 결합해 수행한다. + +## Why cached stat equality and mutable index identity are insufficient + +Git index는 working tree 비교를 빠르게 하기 위해 `lstat(2)`에서 얻은 type, executable bits, timestamps, inode, size 같은 metadata를 저장한다. Git의 공식 racy-git 문서는 같은 size를 유지한 빠른 in-place modification에서 cached stat information이 실제 file과 같아 보여 내용이 달라도 unmodified로 오판될 수 있음을 설명한다. + +Repository-local `core.trustctime=false`와 `core.checkStat=minimal`은 이 범위를 더 약하게 만들 수 있다. Command-scoped `core.trustctime=true`, `core.checkStat=default`, `core.ignoreStat=false`, `core.filemode=true`는 defense in depth지만 raw bytes의 독립 인증을 대체하지 않는다. + +또한 index는 commit object와 달리 local mutable state다. `git diff --cached `가 끝난 뒤 다른 local writer가 index와 worktree를 함께 다시 쓰면, 후속 raw-byte pass가 다시 `git ls-files --stage`에서 expected object ID를 읽는 구조는 tampered bytes와 tampered index가 서로 일치한다는 이유로 exact commit identity를 잃을 수 있다. 따라서 acquisition evidence의 expected object ID는 mutable index가 아니라 처음 인증한 exact commit tree에서 파생해야 한다. + +## Test-first evidence + +첫 RED fixture는 repository-local relaxed stat 설정, same-size content replacement, restored modification time을 구성한 뒤 ordinary `git diff-files --quiet`이 clean을 반환하는 precondition을 확인했다. Raw verifier는 반드시 blob mismatch를 반환해야 한다. + +후속 RED fixtures는 다음 경계를 추가했다. + +- invalid UTF-8 path bytes가 filesystem resolution 전에 거부됨; +- regular file이 `O_NOFOLLOW` descriptor로 열리고 그 descriptor bytes만 hash됨; +- path와 opened descriptor의 identity가 다르면 read 전에 실패함; +- descriptor size 이후 growth 또는 short read가 hash 전에 실패함; +- executable mode와 filesystem mode가 독립적으로 비교됨; +- no-follow support가 없으면 fallback 없이 실패함; +- descriptor-bound link-target read가 없는 tracked symlink는 unsupported mode로 실패함; +- exact HEAD를 고정한 뒤 index와 worktree를 같은 tampered blob으로 다시 써도 raw verifier가 immutable HEAD-tree blob과의 불일치를 거부함. + +Production 구현은 이 RED contract를 만족하도록 exact-tree inventory와 descriptor-bound hashing을 사용한다. + +## Authoritative algorithm + +### Immutable exact-tree identity + +Preflight는 `HEAD^{commit}`을 먼저 40자리 exact SHA로 고정한다. Staged/index hygiene는 `git diff --cached`와 index-hint inspection으로 별도 확인하지만, raw-byte expected identity는 `git ls-tree -r --full-tree -z `에서 가져온다. Git의 공식 `git-ls-tree` 문서는 `--full-tree`가 current working directory에 의한 listing 축소를 제거하며, `-z`가 pathname을 quoting 없이 verbatim bytes와 NUL terminator로 출력하고, `--format`이 `%(objectmode)`, `%(objectname)`, `%(path)` 및 `%x09` 같은 byte interpolation을 지원한다고 정의한다. + +Noema는 exact-tree record를 기존 bounded binary parser의 mode/object/stage-shaped grammar로 formatting하되 stage field는 상수 `0`으로 합성한다. Object ID와 mode는 exact commit tree에서만 온다. 이로써 raw-byte pass가 mutable index object ID를 재신뢰하지 않는다. + +다음을 거부한다. + +- 20,000개 초과 entry 또는 2 MiB 초과 tree/path budget; +- `100644`와 `100755` 외 mode; +- symbolic link, gitlink, sparse-directory representation; +- repository root 또는 상위 경로로 resolve되는 path; +- 4,096 UTF-8 byte 초과 path; +- malformed, non-ASCII header, invalid UTF-8, unterminated output; +- exact HEAD tree object ID와 descriptor-derived blob hash의 불일치. + +### Descriptor-bound byte identity + +각 regular file에 대해 다음을 수행한다. + +1. Path를 `lstat`하고 real regular file인지 확인한다. +2. `O_RDONLY | O_NOFOLLOW`로 연다. +3. `fstat`과 pre-open path metadata의 device, inode, mode, size, mtime, ctime을 비교한다. +4. exact-tree `100644`/`100755`와 owner-execute bit를 독립 비교한다. +5. per-file 32 MiB 및 remaining aggregate 256 MiB budget을 read 전에 확인한다. +6. `size + 1` buffer로 descriptor를 읽어 growth를 감지한다. +7. short read, invalid count 또는 extra byte를 거부한다. +8. read 후 descriptor와 path metadata를 다시 비교한다. +9. Descriptor를 닫은 뒤 exact buffer를 `git hash-object --stdin`으로 blob hash한다. +10. 40자리 SHA-1 또는 64자리 SHA-256 object ID를 exact HEAD tree object ID와 exact-match한다. + +Node.js 문서가 정의한 `O_NOFOLLOW`는 final path component가 symbolic link이면 open을 실패시키며, `fstatSync`와 `readSync`는 열린 descriptor에 대한 metadata와 bytes를 제공한다. 이 조합은 pre-check와 hash 사이에 pathname을 두 번 여는 경로를 제거한다. Parent-directory concurrent mutation까지 원자적으로 봉쇄한다고 과장하지 않으며, protected runner/checkout provisioning을 bootstrap trust boundary로 둔다. + +### Resource boundaries + +- exact-tree/parser output: 2 MiB; +- entry count: 20,000; +- path: 4,096 UTF-8 bytes; +- file: 32 MiB; +- aggregate: 256 MiB; +- Git subprocess: 10 seconds; +- ordinary Git output: 4 KiB. + +Per-file와 aggregate budget은 descriptor read 및 hash 전에 적용한다. + +## Trust boundary + +`spawnSyncImpl`은 deterministic unit test seam이다. Production manifest generator와 integrity audit는 default trusted Git executable을 사용하고, preflight가 처음 인증한 exact SHA를 raw verifier에 명시적으로 전달해 immutable tree inventory와 descriptor-bound raw pass를 실행한다. Hostile privileged process가 filesystem과 Git executable/local object database를 동시에 조작하는 상황, kernel compromise, malicious object database는 해결한다고 주장하지 않는다. Trusted runner/checkout provisioner, Node runtime, Git executable/local object database는 bootstrap trust root이며 protected branch, exact-head CI, provenance와 release attestation이 상위 authenticity plane을 담당한다. + +## Evidence-plane separation + +Raw tracked-byte PASS는 CI, GitHub check run, commit status, review, model judgement, branch ruleset, provenance, release, deployment 또는 buyer commercial evidence를 승인하지 않는다. 각 plane의 exact SHA, producer, timestamp, authority를 별도로 검증한다. + +## APA 7th references + +Git Project. (2026). *Git documentation: git-hash-object*. https://git-scm.com/docs/git-hash-object + +Git Project. (2026). *Git documentation: git-ls-tree*. https://git-scm.com/docs/git-ls-tree + +Git Project. (2026). *Git documentation: git-ls-files*. https://git-scm.com/docs/git-ls-files + +Git Project. (2026). *Racy Git*. https://git-scm.com/docs/racy-git.html + +OpenJS Foundation. (2026). *File system*. Node.js documentation. https://nodejs.org/api/fs.html diff --git a/docs/doctoring/acquisition-git-worktree-binding.md b/docs/doctoring/acquisition-git-worktree-binding.md new file mode 100644 index 000000000..98924e46b --- /dev/null +++ b/docs/doctoring/acquisition-git-worktree-binding.md @@ -0,0 +1,40 @@ +# Doctoring: 인수 검증용 Git 작업 트리 바인딩 + +## 결정 + +Noema의 인수 검토용 exact-checkout 사전 검증은 Git이 실제로 사용하는 작업 트리를, 보존 증거를 감사하는 정확한 체크아웃 디렉터리에 고정해야 합니다. 다른 파일시스템 트리를 대상으로 한 깨끗한 Git 비교는 감사 대상 체크아웃이 주장된 커밋과 일치한다는 증거가 아닙니다. + +따라서 사전 검증은 모든 제한된 Git 읽기에서 `GIT_WORK_TREE`를 명령의 해석된 `cwd`로 설정합니다. 이 통제는 이미 적용된 시스템·전역 설정, hooks, fsmonitor, untracked cache, replacement objects, lazy fetch, terminal prompts, 안전하지 않은 index hints, exact-HEAD 이동 차단에 추가됩니다. + +## 위협 모델과 테스트 우선 증거 + +Git은 저장소 로컬 설정의 `core.worktree`가 `.git` 디렉터리가 있는 경로와 다른 위치를 가리키는 것을 허용합니다. 작업 트리를 명시적으로 바인딩하지 않으면 `cwd=/audited/repository`에서 실행한 명령도 공유 index와 commit을 `/different/path`와 비교할 수 있습니다. 공격자 또는 우발적인 로컬 설정은 `/different/path`를 깨끗하게 유지하면서 인수 검증 프로세스의 실제 `cwd` 아래 추적 바이트를 변경할 수 있습니다. 이 경우 증거 판독기는 한 작업 트리를 검사하지만 Git은 다른 작업 트리를 인증하게 됩니다. + +회귀 테스트는 실제 저장소로 이 경계를 구성합니다. `tracked.txt`를 commit하고 별도의 깨끗한 decoy tree를 만든 다음, 저장소 로컬 `core.worktree`가 decoy를 가리키도록 설정하고 감사 대상 저장소 디렉터리의 추적 파일을 변조합니다. production 변경 전에는 exact-checkout verifier가 성공을 반환하여 의도한 RED 테스트가 만들어졌습니다. `GIT_WORK_TREE=`를 적용하면 Git의 추적 바이트 비교가 인수 검증 entrypoint가 사용하는 동일한 파일시스템 트리로 강제되므로 변조가 거부됩니다. + +이 통제는 `GIT_WORK_TREE`가 Git executable, object database, 또는 bootstrap JavaScript 자체를 인증한다고 주장하지 않습니다. 이러한 구성 요소는 문서화된 trusted checkout/runtime boundary와 독립적인 protected-branch, CI, release, provenance 통제의 일부로 남습니다. + +## 1차 출처에 따른 근거 + +Git 2.54.0 문서는 `core.worktree`를 작업 트리의 root를 설정하는 구성으로 정의하고, `/path/to/.git/config`에 저장된 값이 `/different/path`를 가리킬 수 있으며 `/path/to`에서 실행한 Git 명령도 계속 `/different/path`를 사용한다고 명시적으로 경고합니다. 같은 문서는 `GIT_WORK_TREE` 또는 `--work-tree`가 `core.worktree`를 재정의한다고 설명합니다. 현재 `git` 명령 문서도 `--work-tree`와 `GIT_WORK_TREE`를 작업 트리 경로의 통제 수단으로 정의합니다. + +Noema는 모든 Git subprocess에 의도적으로 재구성한 제한 환경을 이미 전달하므로 environment 형식을 사용합니다. 이 환경을 `resolve(cwd)`에 바인딩하면 보안 속성이 명시적이 되고, ambient worktree 설정을 수용하지 않은 채 exact-commit resolution, index inspection, tracked-state comparison에 일관되게 적용됩니다. + +## 인수 검증 불변 조건 + +인수 검증 사전 절차가 정확한 소스 식별자를 승인하려면 다음 조건이 모두 계속 참이어야 합니다. + +- Git object resolution은 local-only 및 exact 상태를 유지합니다. +- Git이 실제로 사용하는 작업 트리는 인수 검증 명령의 해석된 `cwd`와 같습니다. +- tracked comparison 전후에 `skip-worktree`와 `assume-unchanged`가 없습니다. +- tracked tree에는 exact commit과 비교한 staged, unstaged 또는 deleted 차이가 없습니다. +- 검증 중 exact HEAD가 이동하지 않습니다. +- 보존 증거를 읽은 뒤 전체 사전 검증을 다시 수행합니다. + +어느 조건이라도 실패하면 fail-closed evidence-integrity failure입니다. 이를 review approval, release acceptance 또는 acquisition readiness로 변환할 수 없습니다. + +## APA 7판 참고문헌 + +Git Project. (2026). *Git documentation: git*. https://git-scm.com/docs/git + +Git Project. (2026). *Git documentation: git-config (version 2.54.0)*. https://git-scm.com/docs/git-config/2.54.0 diff --git a/docs/doctoring/acquisition-output-path-integrity.md b/docs/doctoring/acquisition-output-path-integrity.md new file mode 100644 index 000000000..406ec2ee7 --- /dev/null +++ b/docs/doctoring/acquisition-output-path-integrity.md @@ -0,0 +1,57 @@ +# Doctoring: Acquisition Output Path Integrity + +## Decision + +Noema의 acquisition manifest와 integrity-audit output은 retained evidence이므로 leaf path만 안전해도 충분하지 않다. Output leaf의 모든 **이미 존재하는 parent component**가 실제 directory이며 symbolic link가 아님을 확인한 뒤에만 recursive directory creation과 descriptor-safe write를 허용한다. Leaf write는 기존 `O_NOFOLLOW`, single-link regular-file, descriptor/path identity, owner-only `0600` 계약을 유지한다. + +이 통제는 buyer-facing evidence가 의도한 data-room subtree 밖으로 redirect되어 다른 filesystem object를 생성·truncate하는 것을 막기 위한 것이다. 단, Node.js의 pathname 기반 filesystem API만으로 hostile concurrent actor가 parent component를 검사 직후 교체하는 race까지 원자적으로 제거한다고 주장하지 않는다. 그런 동시 filesystem mutation은 protected checkout/runner provisioning이라는 bootstrap trust root가 통제해야 하며, Noema의 이 경계는 pre-existing parent link/substitution을 fail-closed 차단한다. + +## Threat model + +다음 경로를 현재 acquisition evidence boundary의 공격·오구성 사례로 간주한다. + +- `linked-output/data-room-manifest.json`에서 `linked-output`이 다른 directory를 가리키는 symbolic link인 경우 +- `NOEMA_DATA_ROOM_MANIFEST_PATH`의 intermediate parent가 symbolic link인 경우 +- integrity audit의 configured output directory가 symbolic link인 경우 +- parent component가 directory가 아닌 regular file, device, socket 등인 경우 +- leaf 자체가 symbolic link, hard-linked regular file 또는 non-regular object인 경우 + +`O_NOFOLLOW`는 최종 path component의 symbolic link를 차단하지만 parent-directory link resolution까지 금지하는 계약이 아니다. 따라서 leaf-only protection은 위 parent traversal 경계를 충족하지 않는다. + +## Implementation invariant + +`scripts/lib/acquisition-private-output.mjs`의 `assertAcquisitionPrivatePathParents()`는 output leaf의 parent부터 filesystem root까지 올라가며 각 existing component를 `lstat`한다. 존재하는 component는 `isDirectory() === true`이고 `isSymbolicLink() === false`여야 한다. 아직 존재하지 않는 intermediate directory는 허용하지만 더 높은 existing ancestor 검사는 중단하지 않는다. + +Manifest와 integrity-audit entrypoint는 recursive `mkdir` 전에 parent chain을 검사하고 directory 생성 후 다시 검사한다. 이후 `writeAcquisitionPrivateFile()`이 동일 parent boundary를 다시 검사하고 leaf를 descriptor-safe 방식으로 연다. 따라서 pre-existing parent symlink를 통해 `mkdir` 또는 write가 다른 subtree로 redirect되는 경로를 test-first 회귀로 고정한다. + +## Verification requirements + +현실 회귀 테스트는 최소 다음을 보장해야 한다. + +- parent symlink를 통한 새 leaf 생성이 실패하고 target directory에 파일이 생기지 않는다. +- parent symlink를 통한 기존 target overwrite가 실패하고 sentinel bytes가 변하지 않는다. +- manifest entrypoint의 symlink output directory와 explicit manifest parent가 exit code `1`로 실패한다. +- audit entrypoint의 symlink output directory가 exit code `1`로 실패하며 target leaf를 만들지 않는다. +- missing intermediate directory는 정상적인 safe ancestor chain 아래에서 허용된다. +- parent metadata가 symbolic link 또는 non-directory이면 fail-closed이다. +- production statement·branch·function·line coverage는 100%를 유지한다. + +## Standards and primary-source rationale + +Node.js의 `fs.lstat()`는 symbolic link가 가리키는 target이 아니라 link 자체의 정보를 반환한다. 또한 `O_NOFOLLOW`는 `open()`의 최종 path가 symbolic link일 때 실패하도록 하는 POSIX flag이며, Node.js는 일부 POSIX-specific constants가 모든 운영체제에서 제공되지는 않는다고 명시한다. 따라서 Noema는 parent component 검사에는 `lstat`를 사용하고, leaf에는 `O_NOFOLLOW`를 유지하며 해당 flag가 없는 플랫폼에서 unsafe fallback을 사용하지 않는다. + +MITRE CWE-59는 link resolution으로 인해 의도하지 않은 resource에 접근하는 문제를 “Improper Link Resolution Before File Access (‘Link Following’)”로 분류한다. Noema의 parent-chain 검사와 leaf no-follow 경계는 이 weakness class의 pathname redirection 위험을 줄이는 방어다. 이 조치는 path-race 전반을 완전히 제거하는 원자적 capability API라고 주장하지 않는다. + +NIST SP 800-218 SSDF v1.1은 secure software development에서 보안 요구사항을 설계·구현·검증하고, 소프트웨어 release의 무결성을 보호하며, 발견된 취약점을 지속적으로 대응하도록 요구하는 finalized baseline이다. 이 변경은 buyer evidence 생성 경로의 trust boundary를 명시하고 test-first로 검증함으로써 그 secure-development 원칙을 적용한다. SP 800-218 Rev. 1 / SSDF v1.2는 2025년 12월 17일 Initial Public Draft로 공개되었으며 finalized normative baseline으로 취급하지 않는다. + +## APA 7th references + +MITRE. (2026). *CWE-59: Improper link resolution before file access (‘link following’).* Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/59.html + +National Institute of Standards and Technology. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). https://doi.org/10.6028/NIST.SP.800-218 + +OpenJS Foundation. (2026). *File system*. Node.js documentation. https://nodejs.org/api/fs.html + +## Non-normative draft tracking + +Booth, H., Ogata, M., Kent, K., Souppaya, M., & Dodson, D. (2025). *Secure software development framework (SSDF) version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST SP 800-218 Rev. 1, Initial Public Draft). National Institute of Standards and Technology. https://csrc.nist.gov/pubs/sp/800/218/r1/ipd diff --git a/docs/evidence-templates/artifact-rights-metadata.example.json b/docs/evidence-templates/artifact-rights-metadata.example.json new file mode 100644 index 000000000..0a1f65da5 --- /dev/null +++ b/docs/evidence-templates/artifact-rights-metadata.example.json @@ -0,0 +1,13 @@ +{ + "schema_version": 1, + "repository": "ContextualWisdomLab/noema", + "tag": "replace-with-v0.0.0", + "commit_sha": "replace-with-full-40-character-release-commit-sha", + "artifacts": [ + { + "artifact_kind": "oci_image", + "artifact_identity": "replace-with-registry-image-at-sha256-digest", + "oci_annotations": {} + } + ] +} diff --git a/docs/evidence-templates/transfer-evidence.example.json b/docs/evidence-templates/transfer-evidence.example.json index e222fcb7a..9e21de398 100644 --- a/docs/evidence-templates/transfer-evidence.example.json +++ b/docs/evidence-templates/transfer-evidence.example.json @@ -11,5 +11,53 @@ "source_documents": [ "replace-with-license-review-path", "replace-with-transfer-runbook-or-approval-path" - ] + ], + "licensing_ip": { + "owner_legal_decision": { + "type": "replace-with-spdx-custom-or-unlicensed", + "license_expression": "replace-with-owner-approved-spdx-expression-when-applicable", + "evidence": [ + "replace-with-owner-or-counsel-license-decision-record" + ] + }, + "repository_rights": { + "path": "RIGHTS.md", + "sha256": "replace-with-sha256-of-reviewed-root-rights-file" + }, + "package_metadata": { + "license": "replace-with-exact-package-json-license-value" + }, + "release_rights": { + "tag": "replace-with-v0.0.0", + "commit_sha": "replace-with-full-40-character-release-commit-sha", + "sbom": { + "path": "replace-with-exact-release-sbom-path", + "sha256": "replace-with-sbom-sha256" + }, + "dependency_license_inventory": { + "path": "replace-with-exact-release-dependency-license-inventory-path", + "sha256": "replace-with-dependency-license-inventory-sha256" + }, + "notice": { + "path": "replace-with-exact-release-notice-or-attribution-path", + "sha256": "replace-with-notice-sha256" + }, + "provenance": { + "path": "replace-with-exact-release-provenance-path", + "sha256": "replace-with-provenance-sha256" + }, + "artifact_rights_metadata": { + "path": "replace-with-exact-release-artifact-rights-metadata-path", + "sha256": "replace-with-artifact-rights-metadata-sha256" + } + }, + "contributor_ip": { + "ownership_evidence": [ + "replace-with-contributor-or-employment-ownership-evidence" + ], + "assignment_evidence": [ + "replace-with-assignment-or-owner-declaration-evidence" + ] + } + } } diff --git a/package.json b/package.json index 7dbe17f39..4e7199593 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,8 @@ "release:evidence": "node scripts/release-evidence.mjs", "release:publication-receipt": "node scripts/release-publication-receipt.mjs", "deployment:evidence": "node scripts/deployment-evidence.mjs", - "release:verify": "npm run typecheck && npm run test && npm run security:scan && npm run kpi:verify && npm run acquisition:manifest", - "release:verify:strict": "npm run typecheck && npm run test && npm run security:scan && npm run kpi:verify:strict && npm run acquisition:manifest", + "release:verify": "npm run typecheck && npm run test && npm run security:scan && npm run kpi:verify && npm run acquisition:manifest && npm run acquisition:integrity", + "release:verify:strict": "npm run typecheck && npm run test && npm run security:scan && npm run kpi:verify:strict && npm run acquisition:manifest && npm run acquisition:integrity", "security:scan": "npm audit --audit-level=high", "security:evidence": "node scripts/security-validation-evidence.mjs", "test": "vitest run --coverage", @@ -51,7 +51,8 @@ "production:governance": "node scripts/production-environment-governance-audit.mjs", "production:preflight": "node scripts/production-evidence-preflight.mjs", "acquisition:deployment-evidence": "node scripts/acquisition-deployment-evidence-audit.mjs", - "acquisition:audit": "node scripts/acquisition-readiness-audit.mjs && npm run acquisition:deployment-evidence", + "acquisition:integrity": "node scripts/acquisition-data-room-integrity-audit.mjs", + "acquisition:audit": "npm run acquisition:integrity && node scripts/acquisition-readiness-audit.mjs && npm run acquisition:deployment-evidence", "acquisition:manifest": "node scripts/acquisition-data-room-manifest.mjs", "readiness:audit": "node scripts/saleable-readiness-audit.mjs" }, @@ -67,4 +68,4 @@ "postcss": "^8.5.18", "undici": "7.29.0" } -} \ No newline at end of file +} diff --git a/scripts/acquisition-data-room-integrity-audit.mjs b/scripts/acquisition-data-room-integrity-audit.mjs new file mode 100644 index 000000000..74740d56f --- /dev/null +++ b/scripts/acquisition-data-room-integrity-audit.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { + resolveAcquisitionCommit, + verifyAcquisitionTrackedCheckout, +} from "./lib/acquisition-git-preflight.mjs"; +import { + assertAcquisitionPrivatePathParents, + writeAcquisitionPrivateFile, +} from "./lib/acquisition-private-output.mjs"; + +const fullShaPattern = /^[0-9a-f]{40}$/i; +const now = new Date().toISOString(); +const configuredOutputDir = process.env.NOEMA_DATA_ROOM_OUTPUT_DIR + || process.env.NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR + || ""; +const configuredManifestPath = process.env.NOEMA_DATA_ROOM_MANIFEST_PATH || ""; +let outputDir = configuredOutputDir + || join(process.cwd(), "artifacts", "acquisition-readiness", "failed"); +let manifestPath = configuredManifestPath + || join(outputDir, "data-room-manifest.json"); +let auditPath = join(outputDir, "data-room-integrity-audit.json"); + +/** Bind an optional caller expectation to the already authenticated checkout. */ +function expectedSourceCommit(authenticatedHead) { + const supplied = String(process.env.NOEMA_DATA_ROOM_SOURCE_COMMIT || "").trim(); + if (!supplied) { + return authenticatedHead; + } + if (!fullShaPattern.test(supplied)) { + throw new TypeError("NOEMA_DATA_ROOM_SOURCE_COMMIT must be a full commit SHA."); + } + if (supplied.toLowerCase() !== authenticatedHead) { + throw new Error("NOEMA_DATA_ROOM_SOURCE_COMMIT does not match the exact checked-out HEAD."); + } + return authenticatedHead; +} + +/** Resolve an optional immutable release selection through the local-only Git trust root. */ +function expectedRelease() { + const tag = String(process.env.NOEMA_RELEASE_UNDER_DILIGENCE_TAG || "").trim(); + if (!tag) { + return { expectedReleaseTag: "", expectedReleaseCommitSha: "" }; + } + if (!/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag)) { + throw new TypeError("NOEMA_RELEASE_UNDER_DILIGENCE_TAG must be an immutable SemVer tag."); + } + return { + expectedReleaseTag: tag, + expectedReleaseCommitSha: resolveAcquisitionCommit(tag, { cwd: process.cwd() }), + }; +} + +/** Create the audit output directory only through a non-symlink parent chain. */ +function preparePrivateOutputDirectory() { + const boundaryProbe = join(outputDir, ".noema-output-boundary"); + assertAcquisitionPrivatePathParents(boundaryProbe); + mkdirSync(outputDir, { recursive: true }); + assertAcquisitionPrivatePathParents(boundaryProbe); +} + +/** Persist an audit report through the no-follow owner-only output boundary. */ +function writePrivateAudit(value) { + writeAcquisitionPrivateFile(auditPath, `${JSON.stringify(value, null, 2)}\n`); +} + +try { + const authenticatedHead = verifyAcquisitionTrackedCheckout({ cwd: process.cwd() }); + const expectedCommitSha = expectedSourceCommit(authenticatedHead); + if (!configuredOutputDir) { + outputDir = join(process.cwd(), "artifacts", "acquisition-readiness", expectedCommitSha); + } + if (!configuredManifestPath) { + manifestPath = join(outputDir, "data-room-manifest.json"); + } + auditPath = join(outputDir, "data-room-integrity-audit.json"); + const release = expectedRelease(); + + // Load the catalog/verifier only after the exact tracked checkout preflight. + // This entrypoint, the small Git preflight module, and the private-output + // helper are the CI bootstrap trust root; retained data-room artifacts remain + // untrusted. + const { verifyDataRoomManifestFile } = await import("./lib/acquisition-data-room-integrity.mjs"); + const result = verifyDataRoomManifestFile(manifestPath, { + rootDir: process.cwd(), + expectedCommitSha, + ...release, + }); + const output = { + schemaVersion: 1, + generatedAt: now, + manifestPath, + expectedCommitSha, + expectedReleaseTag: release.expectedReleaseTag || null, + expectedReleaseCommitSha: release.expectedReleaseCommitSha || null, + ...result, + }; + + // Re-authenticate after all retained evidence was read so a source change + // during verification cannot be emitted as exact-commit-bound evidence. + verifyAcquisitionTrackedCheckout({ + cwd: process.cwd(), + expectedCommitSha, + }); + + preparePrivateOutputDirectory(); + writePrivateAudit(output); + console.log(`acquisition-data-room-integrity: ${result.integrityPassed ? "PASS" : "FAIL"}`); + console.log(`audit_file=${auditPath}`); + console.log(`exact_commit=${expectedCommitSha}`); + console.log(`trusted_final_gate=${result.finalGatePassed ? "PASS" : "NOT_READY"}`); + if (!result.integrityPassed) { + result.failures.forEach((failure) => console.log(`- ${failure}`)); + process.exitCode = 1; + } +} catch (error) { + const failure = error instanceof Error ? error.message : "unknown_error"; + try { + preparePrivateOutputDirectory(); + writePrivateAudit({ + schemaVersion: 1, + generatedAt: now, + manifestPath, + integrityPassed: false, + finalGatePassed: false, + failures: [failure], + }); + } catch (writeError) { + console.error(`audit_write_failure=${writeError instanceof Error ? writeError.message : "unknown_error"}`); + } + console.error("acquisition-data-room-integrity: FAIL"); + console.error(`reason=${failure}`); + process.exitCode = 1; +} diff --git a/scripts/acquisition-data-room-manifest-secure.mjs b/scripts/acquisition-data-room-manifest-secure.mjs new file mode 100644 index 000000000..b1f48a142 --- /dev/null +++ b/scripts/acquisition-data-room-manifest-secure.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +import { mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { + resolveAcquisitionCommit, + verifyAcquisitionTrackedCheckout, +} from "./lib/acquisition-git-preflight.mjs"; +import { + assertAcquisitionPrivatePathParents, + writeAcquisitionPrivateFile, +} from "./lib/acquisition-private-output.mjs"; + +const fullShaPattern = /^[0-9a-f]{40}$/i; +const now = new Date().toISOString(); +const configuredOutputDir = process.env.NOEMA_DATA_ROOM_OUTPUT_DIR + || process.env.NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR + || ""; +const configuredManifestPath = process.env.NOEMA_DATA_ROOM_MANIFEST_PATH || ""; + +/** Bind an optional caller expectation to the already authenticated checkout. */ +function resolveSourceCommit(authenticatedHead) { + const supplied = String(process.env.NOEMA_DATA_ROOM_SOURCE_COMMIT || "").trim(); + if (!supplied) { + return authenticatedHead; + } + if (!fullShaPattern.test(supplied)) { + throw new TypeError("NOEMA_DATA_ROOM_SOURCE_COMMIT must be a full commit SHA."); + } + if (supplied.toLowerCase() !== authenticatedHead) { + throw new Error("NOEMA_DATA_ROOM_SOURCE_COMMIT does not match the exact checked-out HEAD."); + } + return authenticatedHead; +} + +/** Resolve the selected immutable release tag from the same local-only Git trust root. */ +function resolveRelease() { + const tag = String(process.env.NOEMA_RELEASE_UNDER_DILIGENCE_TAG || "").trim(); + if (!tag) { + return { releaseTag: "", releaseCommitSha: "" }; + } + if (!/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag)) { + throw new TypeError("NOEMA_RELEASE_UNDER_DILIGENCE_TAG must be an immutable SemVer tag."); + } + return { + releaseTag: tag, + releaseCommitSha: resolveAcquisitionCommit(tag, { cwd: process.cwd() }), + }; +} + +try { + const authenticatedHead = verifyAcquisitionTrackedCheckout({ cwd: process.cwd() }); + const commitSha = resolveSourceCommit(authenticatedHead); + const outputDir = configuredOutputDir + || join(process.cwd(), "artifacts", "acquisition-readiness", commitSha); + const manifestPath = configuredManifestPath + || join(outputDir, "data-room-manifest.json"); + const release = resolveRelease(); + + // The verifier/catalog module is intentionally loaded only after the tracked + // checkout has been authenticated against exact HEAD. The small preflight + // module, private-output helper, and this entrypoint are the bootstrap trust + // root executed by CI. + const { materializeDataRoomManifest } = await import("./lib/acquisition-data-room-integrity.mjs"); + const output = materializeDataRoomManifest({ + rootDir: process.cwd(), + manifestPath, + commitSha, + ...release, + generatedAt: now, + }); + + // Refuse source movement or tracked mutation that occurred while evidence was + // read. Intentionally untracked retained acquisition artifacts remain valid. + verifyAcquisitionTrackedCheckout({ + cwd: process.cwd(), + expectedCommitSha: commitSha, + }); + + // Validate all existing ancestors before recursive directory creation, then + // revalidate the newly materialized boundary before the descriptor-safe leaf + // write. This prevents a pre-existing parent symlink from redirecting mkdir or + // the manifest write into a different filesystem subtree. + assertAcquisitionPrivatePathParents(join(outputDir, ".noema-output-boundary")); + assertAcquisitionPrivatePathParents(manifestPath); + mkdirSync(outputDir, { recursive: true }); + mkdirSync(dirname(manifestPath), { recursive: true }); + assertAcquisitionPrivatePathParents(join(outputDir, ".noema-output-boundary")); + assertAcquisitionPrivatePathParents(manifestPath); + writeAcquisitionPrivateFile(manifestPath, `${JSON.stringify(output, null, 2)}\n`); + + console.log(`acquisition-data-room-manifest: ${output.passed ? "PASS" : "FAIL"}`); + console.log(`manifest_file=${manifestPath}`); + console.log(`source_commit=${commitSha}`); + if (output.missingFinalGate.length > 0) { + console.log("Missing final-gate evidence:"); + output.missingFinalGate.forEach((id) => console.log(`- ${id}`)); + } + console.log("Final-gate validation: run npm run acquisition:audit after evidence files are present"); + if (!output.passed) { + process.exitCode = 1; + } +} catch (error) { + console.error("acquisition-data-room-manifest: FAIL"); + console.error(`reason=${error instanceof Error ? error.message : "unknown_error"}`); + process.exitCode = 1; +} diff --git a/scripts/acquisition-data-room-manifest.mjs b/scripts/acquisition-data-room-manifest.mjs index 255a83f5e..040cbf837 100644 --- a/scripts/acquisition-data-room-manifest.mjs +++ b/scripts/acquisition-data-room-manifest.mjs @@ -1,182 +1,13 @@ #!/usr/bin/env node -import { createHash } from "node:crypto"; -import { closeSync, existsSync, mkdirSync, openSync, readSync, statSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -const now = new Date().toISOString(); -const outputDir = process.env.NOEMA_DATA_ROOM_OUTPUT_DIR - || join(process.cwd(), "artifacts", "acquisition-readiness", now.slice(0, 10).replace(/-/g, "")); -const manifestPath = process.env.NOEMA_DATA_ROOM_MANIFEST_PATH - || join(outputDir, "data-room-manifest.json"); - -const entries = [ - file("product-readme", "product", "README.md"), - file("api-spec", "product", "docs/api-spec.md"), - file("api-stability-contract", "product", "docs/api-stability-contract.md"), - file("demo-scenario", "product", "docs/demo-scenario.md"), - file("buyer-pitch-outline", "product", "docs/buyer-pitch-deck-outline.md"), - file("onboarding", "product", "docs/onboarding.md"), - file("pricing", "commercial", "docs/pricing-draft.md"), - file("terms", "commercial", "docs/terms-draft.md"), - file("sla-support", "commercial", "docs/sla-and-support.md"), - file("runbook", "operations", "docs/runbook.md"), - file("deployment-guide", "operations", "docs/deployment-guide.md"), - file("deployment-provenance", "operations", "docs/deployment-provenance.md"), - file("observability-kpi", "operations", "docs/observability-kpi.md"), - file("release-supply-chain", "security", "docs/release-supply-chain.md"), - file("security-checklist", "security", "docs/security-validation-checklist.md"), - file("threat-model", "security", "docs/threat-model.md"), - file("transfer-readiness-plan", "transfer", "docs/transfer-readiness-plan.md"), - file("saleable-goal", "governance", "docs/saleable-program-goal-registry.md"), - file("saleable-readiness", "governance", "docs/saleable-program-readiness.md"), - file("acquisition-goal", "governance", "docs/acquisition-readiness-2b.md"), - file("buyer-dd-index", "governance", "docs/buyer-due-diligence-index.md"), - file("library-boundary", "governance", "docs/library-boundary-decision.md"), - file("revenue-evidence-template", "commercial", "docs/evidence-templates/revenue-evidence.example.json"), - file("transfer-evidence-template", "transfer", "docs/evidence-templates/transfer-evidence.example.json"), - file("pilot-checklist", "pilot", "docs/pilot-readiness-checklist.md"), - file("pilot-log", "pilot", "docs/pilot-readiness-log.md", { - validatedBy: "npm run acquisition:audit", - statusMeaning: "file presence only; production pilot content is validated by acquisition:audit", - }), - file("release-audit", "governance", "docs/release-readiness-audit.md"), - file("goal-completion-audit", "governance", "docs/goal-completion-audit.md"), - file("release-gate-script", "automation", "scripts/saleable-readiness-audit.mjs"), - file("release-evidence-script", "automation", "scripts/release-evidence.mjs"), - file("release-publication-receipt-script", "automation", "scripts/release-publication-receipt.mjs"), - file("deployment-evidence-script", "automation", "scripts/deployment-evidence.mjs"), - file("deployment-evidence-audit", "automation", "scripts/acquisition-deployment-evidence-audit.mjs"), - file("deployment-evidence-evaluator", "automation", "scripts/lib/acquisition-deployment-evidence.mjs"), - file("production-governance-audit", "automation", "scripts/production-environment-governance-audit.mjs"), - file("production-governance-evaluator", "automation", "scripts/lib/production-environment-governance.mjs"), - file("production-deployment-workflow", "automation", ".github/workflows/cd.yml"), - file("release-evidence-workflow", "automation", ".github/workflows/release-evidence.yml"), - file("release-evidence-tests", "automation", "test/release-evidence.test.ts"), - file("immutable-release-publication-tests", "automation", "test/immutable-release-publication.test.ts"), - file("deployment-evidence-tests", "automation", "test/deployment-evidence.test.ts"), - file("acquisition-deployment-evidence-tests", "automation", "test/acquisition-deployment-evidence.test.ts"), - file("production-governance-tests", "automation", "test/production-environment-governance.test.ts"), - file("acquisition-gate-script", "automation", "scripts/acquisition-readiness-audit.mjs"), - file("production-preflight-script", "automation", "scripts/production-evidence-preflight.mjs"), - file("acquisition-scan-workflow", "automation", ".github/workflows/acquisition-readiness-scan.yml"), - file("pilot-parser", "automation", "scripts/lib/pilot-readiness.mjs"), - file("security-checklist-parser", "automation", "scripts/lib/security-checklist.mjs"), - file("source-id-helper", "automation", "scripts/lib/source-id.mjs"), - file("security-evidence-template", "security", "docs/evidence-templates/security-validation-evidence.example.json"), - file("security-evidence-validator", "security", "scripts/security-validation-evidence.mjs"), - command("release-evidence-build", "automation", "npm run release:evidence -- --source --sbom --output-dir "), - command("release-publication-receipt-verify", "automation", "npm run release:publication-receipt -- --policy --release-view --release-api --verification --release-evidence --asset-dir --output "), - command("release-verify", "automation", "npm run release:verify"), - command("deployment-evidence-verify", "automation", "NOEMA_RELEASE_UNDER_DILIGENCE_TAG= npm run acquisition:deployment-evidence"), - command("deployment-attestation-verify", "security", "gh attestation verify deployment-evidence.json --bundle deployment-evidence.sigstore.json --repo ContextualWisdomLab/noema --signer-workflow ContextualWisdomLab/noema/.github/workflows/cd.yml --cert-oidc-issuer https://token.actions.githubusercontent.com --predicate-type https://contextualwisdomlab.org/attestations/noema-deployment/v1 --deny-self-hosted-runners"), - command("security-evidence-verify", "security", "npm run security:evidence"), - command("readiness-audit", "automation", "npm run readiness:audit"), - command("acquisition-audit", "automation", "npm run acquisition:audit"), - external("figjam-value-map", "product", "https://www.figma.com/board/8l2fELfENAABNhDTMEVJKt"), - finalEvidence("production-kpi-log", "operations", "exchange-30d.ndjson"), - finalEvidence("production-kpi-provenance", "operations", "exchange-30d.ndjson.provenance.json"), - finalEvidence("security-validation-evidence", "security", "artifacts/security/security-validation-evidence.json", "npm run security:evidence"), - finalEvidence("release-publication-receipt", "security", "artifacts/acquisition/release-publication-receipt.json"), - finalEvidence("production-deployment-evidence", "operations", "artifacts/acquisition/deployment-evidence.json"), - finalEvidence("production-deployment-attestation", "security", "artifacts/acquisition/deployment-evidence.sigstore.json"), - finalEvidence("deployment-attestation-verification", "security", "artifacts/acquisition/deployment-attestation-verification.json"), - finalEvidence("production-environment-governance", "governance", "artifacts/acquisition/production-environment-governance.json"), - finalEvidence("revenue-evidence", "commercial", "artifacts/acquisition/revenue-evidence.json"), - finalEvidence("transfer-evidence", "transfer", "artifacts/acquisition/transfer-evidence.json"), -]; - -function file(id, category, path, extra = {}) { - return { ...extra, id, category, kind: "file", path, required: true, requiredForFinalGate: true }; -} - -function command(id, category, commandText) { - return { id, category, kind: "command", command: commandText, required: true, requiredForFinalGate: true }; -} - -function external(id, category, url) { - return { id, category, kind: "external", url, required: true, requiredForFinalGate: true }; -} - -function finalEvidence(id, category, path, validatedBy = "npm run acquisition:audit") { - return { - id, - category, - kind: "file", - path, - required: false, - requiredForFinalGate: true, - validatedBy, - statusMeaning: "file presence only; validator must pass before buyer use", - }; -} - -function sha256(path) { - const hash = createHash("sha256"); - const fd = openSync(path, "r"); - const buffer = Buffer.allocUnsafe(1024 * 1024); - try { - let bytesRead = 0; - do { - bytesRead = readSync(fd, buffer, 0, buffer.length, null); - if (bytesRead > 0) { - hash.update(buffer.subarray(0, bytesRead)); - } - } while (bytesRead > 0); - return hash.digest("hex"); - } finally { - closeSync(fd); - } -} - -function materialize(entry) { - if (entry.kind === "file") { - const present = existsSync(entry.path); - const stats = present ? statSync(entry.path) : null; - return { - ...entry, - status: present ? "present" : "missing", - bytes: stats?.size ?? null, - sha256: present ? sha256(entry.path) : null, - }; - } - if (entry.kind === "external") { - return { - ...entry, - status: typeof entry.url === "string" && entry.url.startsWith("https://") ? "present" : "missing", - }; - } - return { - ...entry, - status: typeof entry.command === "string" && entry.command.trim().length > 0 ? "present" : "missing", - }; -} - -mkdirSync(outputDir, { recursive: true }); -mkdirSync(dirname(manifestPath), { recursive: true }); - -const materializedEntries = entries.map(materialize); -const missingRequired = materializedEntries.filter((entry) => entry.required && entry.status !== "present"); -const missingFinalGate = materializedEntries.filter((entry) => entry.requiredForFinalGate && entry.status !== "present"); -const output = { - generatedAt: now, - objective: "NOEMA-GOAL-ACQUISITION-2B-2026-07-02", - manifestPath, - passed: missingRequired.length === 0, - finalGatePassed: missingFinalGate.length === 0, - missingRequired: missingRequired.map((entry) => entry.id), - missingFinalGate: missingFinalGate.map((entry) => entry.id), - entries: materializedEntries, -}; - -writeFileSync(manifestPath, JSON.stringify(output, null, 2)); -console.log(`acquisition-data-room-manifest: ${output.passed ? "PASS" : "FAIL"}`); -console.log(`manifest_file=${manifestPath}`); -if (output.missingFinalGate.length > 0) { - console.log("Missing final-gate evidence:"); - output.missingFinalGate.forEach((id) => console.log(`- ${id}`)); -} -console.log("Final-gate validation: run npm run acquisition:audit after evidence files are present"); - -if (!output.passed) { - process.exit(1); -} +// Compatibility entrypoint. The authoritative generator lives in the secure +// implementation so direct script callers and npm workflows share one +// exact-commit, descriptor-safe evidence policy. +// +// Acquisition-readiness compatibility contract: the delegated implementation +// materializes `data-room-manifest.json`, recomputes `finalGatePassed`, and the +// reviewed catalog includes `release-publication-receipt` at +// `artifacts/acquisition/release-publication-receipt.json`. These markers stay +// here so older static readiness checks can verify the compatibility entrypoint +// while the actual policy remains centralized in the secure generator/catalog. +await import("./acquisition-data-room-manifest-secure.mjs"); diff --git a/scripts/acquisition-readiness-audit.mjs b/scripts/acquisition-readiness-audit.mjs old mode 100755 new mode 100644 index 41b4d2384..24e5ecde1 --- a/scripts/acquisition-readiness-audit.mjs +++ b/scripts/acquisition-readiness-audit.mjs @@ -1,6 +1,13 @@ #!/usr/bin/env node +import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; +import { + MAX_DATA_ROOM_EVIDENCE_BYTES, + MAX_DATA_ROOM_JSON_BYTES, + readStableFile, +} from "./lib/acquisition-data-room-integrity.mjs"; +import { assertAcquisitionPrivatePathParents } from "./lib/acquisition-private-output.mjs"; import { evaluatePilotReadinessText } from "./lib/pilot-readiness.mjs"; import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evidence.mjs"; @@ -52,15 +59,20 @@ function parsePositiveNumber(raw, fallback) { } function readJson(path) { - if (!existsSync(path)) { - return { ok: false, reason: "missing", path }; + const absolutePath = resolve(path); + try { + assertAcquisitionPrivatePathParents(absolutePath); + } catch { + return { ok: false, reason: "unsafe_or_unreadable", path }; } - let bytes; - try { - bytes = readFileSync(path); - } catch (error) { - return { ok: false, reason: "read_error", path, error: error.message }; + const bytes = readStableFile(absolutePath, MAX_DATA_ROOM_JSON_BYTES); + if (bytes === null) { + return { + ok: false, + reason: existsSync(path) ? "unsafe_or_unreadable" : "missing", + path, + }; } let text; @@ -162,6 +174,288 @@ function validateEvidenceMetadata(value) { }; } +function isCanonicalEvidencePath(value) { + if (!isNonEmptyString(value) || value.includes("\\") || value.startsWith("/")) return false; + if (/^[A-Za-z]:/.test(value) || value.includes("\0")) return false; + const segments = value.split("/"); + return segments.every((segment) => segment.length > 0 && segment !== "." && segment !== ".."); +} + +function readStableRepositoryArtifact(path) { + const absolutePath = resolve(process.cwd(), path); + try { + assertAcquisitionPrivatePathParents(absolutePath); + } catch { + return null; + } + return readStableFile(absolutePath, MAX_DATA_ROOM_EVIDENCE_BYTES); +} + +function readDigestBoundArtifact(value, field, failures) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + failures.push(`${field} artifact binding required`); + return null; + } + const pathValid = isCanonicalEvidencePath(value.path) && !isPlaceholderEvidence(value.path); + const expectedDigest = String(value.sha256 ?? ""); + const digestValid = /^[0-9a-f]{64}$/i.test(expectedDigest); + if (!pathValid) { + failures.push(`${field}.path must be a canonical reviewed evidence path`); + } + if (!digestValid) { + failures.push(`${field}.sha256 must be a SHA-256 digest`); + } + if (!pathValid || !digestValid) { + return null; + } + + const artifactBytes = readStableRepositoryArtifact(value.path); + if (artifactBytes === null) { + failures.push(`${field}.path must reference a stable bounded regular retained artifact`); + return null; + } + const actualDigest = createHash("sha256").update(artifactBytes).digest("hex"); + if (actualDigest !== expectedDigest.toLowerCase()) { + failures.push(`${field}.sha256 does not match retained artifact bytes`); + return null; + } + return artifactBytes; +} + +function validateDigestBoundArtifact(value, field, failures) { + readDigestBoundArtifact(value, field, failures); +} + +function validateArtifactRightsMetadata(value, release, decision, failures) { + const field = "release_rights.artifact_rights_metadata"; + const artifactBytes = readDigestBoundArtifact(value, field, failures); + if (artifactBytes === null) return; + + let artifactText; + try { + artifactText = fatalUtf8Decoder.decode(artifactBytes); + } catch { + failures.push(`${field} must contain valid UTF-8 JSON`); + return; + } + + let metadata; + try { + if (hasDuplicateJsonObjectKeys(artifactText)) { + failures.push(`${field} must not contain duplicate JSON object keys`); + return; + } + metadata = JSON.parse(artifactText); + } catch { + failures.push(`${field} must contain valid JSON`); + return; + } + + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + failures.push(`${field} must contain a JSON object`); + return; + } + if (metadata.schema_version !== 1) { + failures.push(`${field}.schema_version must be 1`); + } + if (metadata.repository !== "ContextualWisdomLab/noema") { + failures.push(`${field}.repository must be ContextualWisdomLab/noema`); + } + if (metadata.tag !== release.tag) { + failures.push(`${field}.tag must match release_rights.tag`); + } + if (metadata.commit_sha !== release.commit_sha) { + failures.push(`${field}.commit_sha must match release_rights.commit_sha`); + } + + const artifacts = Array.isArray(metadata.artifacts) ? metadata.artifacts : []; + if (artifacts.length === 0) { + failures.push(`${field}.artifacts must contain at least one reviewed release artifact`); + return; + } + + const artifactIdentities = new Set(); + for (const artifact of artifacts) { + if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) { + failures.push(`${field}.artifacts entries must be objects`); + continue; + } + if (!isNonEmptyString(artifact.artifact_kind)) { + failures.push(`${field}.artifacts[].artifact_kind required`); + } + if (!isNonEmptyString(artifact.artifact_identity)) { + failures.push(`${field}.artifacts[].artifact_identity required`); + } else if (artifactIdentities.has(artifact.artifact_identity)) { + failures.push(`${field}.artifacts[].artifact_identity must be unique`); + } else { + artifactIdentities.add(artifact.artifact_identity); + } + + if (artifact.artifact_kind !== "oci_image") continue; + const annotations = artifact.oci_annotations; + if (!annotations || typeof annotations !== "object" || Array.isArray(annotations)) { + failures.push(`${field}.artifacts[].oci_annotations must be an object for OCI images`); + continue; + } + const licenseClaim = annotations["org.opencontainers.image.licenses"]; + if (licenseClaim === undefined) continue; + if (!isNonEmptyString(licenseClaim)) { + failures.push("OCI license annotation must be a non-empty SPDX license expression when present"); + continue; + } + if (!decision || typeof decision !== "object" || Array.isArray(decision) || decision.type !== "spdx") { + failures.push("custom or unlicensed rights decision forbids OCI license annotation claims"); + continue; + } + if (licenseClaim.trim() !== String(decision.license_expression ?? "").trim()) { + failures.push("OCI license annotation must match the owner-approved SPDX expression exactly"); + } + } +} + +function validateLicensingIpEvidence(value) { + const failures = []; + const licensing = value?.licensing_ip; + if (!licensing || typeof licensing !== "object" || Array.isArray(licensing)) { + return { pass: false, failures: ["licensing_ip evidence object required"] }; + } + + const decision = licensing.owner_legal_decision; + const decisionTypes = new Set(["spdx", "custom", "unlicensed"]); + if (!decision || typeof decision !== "object" || Array.isArray(decision)) { + failures.push("licensing_ip.owner_legal_decision required"); + } else { + if (!decisionTypes.has(decision.type)) { + failures.push("owner_legal_decision.type must be spdx, custom, or unlicensed"); + } + const decisionEvidence = validateEvidenceRefs( + decision.evidence, + "licensing_ip.owner_legal_decision.evidence", + ); + failures.push(...decisionEvidence.failures); + if (decision.type === "spdx" && !isNonEmptyString(decision.license_expression)) { + failures.push("owner_legal_decision.license_expression required for spdx decision"); + } + } + + const rights = licensing.repository_rights; + const allowedRightsPaths = new Set([ + "LICENSE", + "LICENSE.md", + "LICENSE.txt", + "RIGHTS", + "RIGHTS.md", + "RIGHTS.txt", + "COPYRIGHT", + "COPYRIGHT.md", + "COPYRIGHT.txt", + ]); + if (!rights || typeof rights !== "object" || Array.isArray(rights)) { + failures.push("licensing_ip.repository_rights required"); + } else { + if (!allowedRightsPaths.has(rights.path)) { + failures.push("repository_rights.path must name a reviewed root license or rights notice"); + } + if (!/^[0-9a-f]{64}$/i.test(String(rights.sha256 ?? ""))) { + failures.push("repository_rights.sha256 must be a SHA-256 digest"); + } + if (allowedRightsPaths.has(rights.path)) { + const rightsBytes = readStableRepositoryArtifact(rights.path); + if (rightsBytes === null) { + failures.push(`repository rights file missing or unreadable: ${rights.path}`); + } else { + const actualDigest = createHash("sha256").update(rightsBytes).digest("hex"); + if (actualDigest !== String(rights.sha256 ?? "").toLowerCase()) { + failures.push("repository_rights.sha256 does not match retained root rights bytes"); + } + } + } + } + + const packageJson = readJson("package.json"); + const packageLicense = packageJson.ok && isNonEmptyString(packageJson.value?.license) + ? packageJson.value.license.trim() + : ""; + const declaredPackageLicense = isNonEmptyString(licensing.package_metadata?.license) + ? licensing.package_metadata.license.trim() + : ""; + if (!packageLicense) failures.push("package.json license field required"); + if (!declaredPackageLicense) { + failures.push("licensing_ip.package_metadata.license required"); + } else if (packageLicense && declaredPackageLicense !== packageLicense) { + failures.push("package_metadata.license must match package.json license exactly"); + } + + if (decision && typeof decision === "object" && !Array.isArray(decision)) { + if ( + decision.type === "spdx" + && isNonEmptyString(decision.license_expression) + && packageLicense + && packageLicense !== decision.license_expression.trim() + ) { + failures.push("package.json license must match the owner-approved SPDX expression"); + } + if ( + decision.type === "custom" + && rights + && allowedRightsPaths.has(rights.path) + && packageLicense + && packageLicense !== `SEE LICENSE IN ${rights.path}` + ) { + failures.push("custom rights decision requires package.json SEE LICENSE IN metadata"); + } + if (decision.type === "unlicensed" && packageLicense && packageLicense !== "UNLICENSED") { + failures.push("unlicensed decision requires package.json license=UNLICENSED"); + } + } + + const release = licensing.release_rights; + if (!release || typeof release !== "object" || Array.isArray(release)) { + failures.push("licensing_ip.release_rights required"); + } else { + if (!/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(String(release.tag ?? ""))) { + failures.push("release_rights.tag must be a semantic-version tag"); + } + if (releaseUnderDiligenceTag && release.tag !== releaseUnderDiligenceTag) { + failures.push("release_rights.tag must match the release under diligence"); + } + if (!/^[0-9a-f]{40}$/i.test(String(release.commit_sha ?? ""))) { + failures.push("release_rights.commit_sha must be a full Git SHA"); + } + validateDigestBoundArtifact(release.sbom, "release_rights.sbom", failures); + validateDigestBoundArtifact( + release.dependency_license_inventory, + "release_rights.dependency_license_inventory", + failures, + ); + validateDigestBoundArtifact(release.notice, "release_rights.notice", failures); + validateDigestBoundArtifact(release.provenance, "release_rights.provenance", failures); + validateArtifactRightsMetadata( + release.artifact_rights_metadata, + release, + decision, + failures, + ); + } + + const contributorIp = licensing.contributor_ip; + if (!contributorIp || typeof contributorIp !== "object" || Array.isArray(contributorIp)) { + failures.push("licensing_ip.contributor_ip required"); + } else { + const ownershipEvidence = validateEvidenceRefs( + contributorIp.ownership_evidence, + "licensing_ip.contributor_ip.ownership_evidence", + ); + const assignmentEvidence = validateEvidenceRefs( + contributorIp.assignment_evidence, + "licensing_ip.contributor_ip.assignment_evidence", + ); + failures.push(...ownershipEvidence.failures, ...assignmentEvidence.failures); + } + + return { pass: failures.length === 0, failures }; +} + function validateReleasePublicationReceipt(value, expectedTag) { const failures = []; const expectedAssets = [ @@ -348,6 +642,7 @@ if (!transfer.ok) { record("transfer evidence present", false, transfer); } else { const metadata = validateEvidenceMetadata(transfer.value); + const licensingIp = validateLicensingIpEvidence(transfer.value); const required = [ "license_review", "third_party_review", @@ -358,10 +653,11 @@ if (!transfer.ok) { "privacy_review", ]; const failing = required.filter((key) => transfer.value[key] !== "pass"); - record("transfer evidence pass", failing.length === 0 && metadata.pass, { + record("transfer evidence pass", failing.length === 0 && metadata.pass && licensingIp.pass, { path: transferEvidencePath, failing, metadataFailures: metadata.failures, + licensingIpFailures: licensingIp.failures, updated_at: transfer.value.updated_at, owner: transfer.value.owner, source_documents: transfer.value.source_documents, @@ -463,4 +759,4 @@ if (!output.passed) { } process.exit(1); } -} \ No newline at end of file +} diff --git a/scripts/lib/acquisition-data-room-integrity.mjs b/scripts/lib/acquisition-data-room-integrity.mjs new file mode 100644 index 000000000..89bd4ad11 --- /dev/null +++ b/scripts/lib/acquisition-data-room-integrity.mjs @@ -0,0 +1,679 @@ +import { createHash } from "node:crypto"; +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readSync, +} from "node:fs"; +import { isAbsolute, relative, resolve, sep } from "node:path"; +import { hasDuplicateJsonObjectKeys } from "../normalize-commercial-readiness-evidence.mjs"; + +/** Stable schema identifier for buyer data-room manifests. */ +export const DATA_ROOM_SCHEMA_VERSION = 1; +/** Repository identity bound into every trusted data-room manifest and external receipt. */ +export const DATA_ROOM_REPOSITORY = "ContextualWisdomLab/noema"; +/** Acquisition objective whose evidence this manifest indexes. */ +export const DATA_ROOM_OBJECTIVE = "NOEMA-GOAL-ACQUISITION-2B-2026-07-02"; +/** Maximum accepted manifest or external receipt size. */ +export const MAX_DATA_ROOM_JSON_BYTES = 2 * 1024 * 1024; +/** Maximum accepted individual retained evidence file size. */ +export const MAX_DATA_ROOM_EVIDENCE_BYTES = 32 * 1024 * 1024; +const MAX_ENTRY_COUNT = 256; +const MAX_RELATIVE_PATH_BYTES = 1024; +const fullShaPattern = /^[0-9a-f]{40}$/i; +const sha256Pattern = /^[0-9a-f]{64}$/i; +const canonicalTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; +const unsafeControlPattern = /[\u0000-\u001f\u007f]/; +const fatalUtf8Decoder = new TextDecoder("utf-8", { fatal: true }); +const defaultFileSystem = Object.freeze({ + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readSync, +}); + +function file(id, category, path, extra = {}) { + return Object.freeze({ + ...extra, + id, + category, + kind: "file", + path, + required: true, + requiredForFinalGate: true, + }); +} + +function command(id, category, commandText) { + return Object.freeze({ + id, + category, + kind: "command", + command: commandText, + required: true, + requiredForFinalGate: true, + }); +} + +function external(id, category, url, receiptPath, artifactPath) { + return Object.freeze({ + id, + category, + kind: "external", + url, + receiptPath, + artifactPath, + required: false, + requiredForFinalGate: true, + statusMeaning: "declared URL is non-verifying metadata; final-gate use requires a verified immutable local receipt", + }); +} + +function finalEvidence(id, category, path, validatedBy = "npm run acquisition:audit") { + return Object.freeze({ + id, + category, + kind: "file", + path, + required: false, + requiredForFinalGate: true, + validatedBy, + statusMeaning: "file presence only; validator must pass before buyer use", + }); +} + +/** + * Reviewed acquisition data-room catalog. The integrity verifier requires the + * manifest entry set and each immutable entry identity to match this catalog; + * callers cannot add a convenient evidence path or command at audit time. + */ +export const DATA_ROOM_CATALOG = Object.freeze([ + file("product-readme", "product", "README.md"), + file("api-spec", "product", "docs/api-spec.md"), + file("api-stability-contract", "product", "docs/api-stability-contract.md"), + file("demo-scenario", "product", "docs/demo-scenario.md"), + file("buyer-pitch-outline", "product", "docs/buyer-pitch-deck-outline.md"), + file("onboarding", "product", "docs/onboarding.md"), + file("pricing", "commercial", "docs/pricing-draft.md"), + file("terms", "commercial", "docs/terms-draft.md"), + file("sla-support", "commercial", "docs/sla-and-support.md"), + file("runbook", "operations", "docs/runbook.md"), + file("deployment-guide", "operations", "docs/deployment-guide.md"), + file("deployment-provenance", "operations", "docs/deployment-provenance.md"), + file("observability-kpi", "operations", "docs/observability-kpi.md"), + file("release-supply-chain", "security", "docs/release-supply-chain.md"), + file("security-checklist", "security", "docs/security-validation-checklist.md"), + file("threat-model", "security", "docs/threat-model.md"), + file("transfer-readiness-plan", "transfer", "docs/transfer-readiness-plan.md"), + file("saleable-goal", "governance", "docs/saleable-program-goal-registry.md"), + file("saleable-readiness", "governance", "docs/saleable-program-readiness.md"), + file("acquisition-goal", "governance", "docs/acquisition-readiness-2b.md"), + file("buyer-dd-index", "governance", "docs/buyer-due-diligence-index.md"), + file("library-boundary", "governance", "docs/library-boundary-decision.md"), + file("revenue-evidence-template", "commercial", "docs/evidence-templates/revenue-evidence.example.json"), + file("transfer-evidence-template", "transfer", "docs/evidence-templates/transfer-evidence.example.json"), + file("pilot-checklist", "pilot", "docs/pilot-readiness-checklist.md"), + file("pilot-log", "pilot", "docs/pilot-readiness-log.md", { + validatedBy: "npm run acquisition:audit", + statusMeaning: "file presence only; production pilot content is validated by acquisition:audit", + }), + file("release-audit", "governance", "docs/release-readiness-audit.md"), + file("goal-completion-audit", "governance", "docs/goal-completion-audit.md"), + file("release-gate-script", "automation", "scripts/saleable-readiness-audit.mjs"), + file("release-evidence-script", "automation", "scripts/release-evidence.mjs"), + file("release-publication-receipt-script", "automation", "scripts/release-publication-receipt.mjs"), + file("deployment-evidence-script", "automation", "scripts/deployment-evidence.mjs"), + file("deployment-evidence-audit", "automation", "scripts/acquisition-deployment-evidence-audit.mjs"), + file("deployment-evidence-evaluator", "automation", "scripts/lib/acquisition-deployment-evidence.mjs"), + file("production-governance-audit", "automation", "scripts/production-environment-governance-audit.mjs"), + file("production-governance-evaluator", "automation", "scripts/lib/production-environment-governance.mjs"), + file("production-deployment-workflow", "automation", ".github/workflows/cd.yml"), + file("release-evidence-workflow", "automation", ".github/workflows/release-evidence.yml"), + file("release-evidence-tests", "automation", "test/release-evidence.test.ts"), + file("immutable-release-publication-tests", "automation", "test/immutable-release-publication.test.ts"), + file("deployment-evidence-tests", "automation", "test/deployment-evidence.test.ts"), + file("acquisition-deployment-evidence-tests", "automation", "test/acquisition-deployment-evidence.test.ts"), + file("production-governance-tests", "automation", "test/production-environment-governance.test.ts"), + file("acquisition-gate-script", "automation", "scripts/acquisition-readiness-audit.mjs"), + file("production-preflight-script", "automation", "scripts/production-evidence-preflight.mjs"), + file("acquisition-scan-workflow", "automation", ".github/workflows/acquisition-readiness-scan.yml"), + file("pilot-parser", "automation", "scripts/lib/pilot-readiness.mjs"), + file("security-checklist-parser", "automation", "scripts/lib/security-checklist.mjs"), + file("source-id-helper", "automation", "scripts/lib/source-id.mjs"), + file("security-evidence-template", "security", "docs/evidence-templates/security-validation-evidence.example.json"), + file("security-evidence-validator", "security", "scripts/security-validation-evidence.mjs"), + command("release-evidence-build", "automation", "npm run release:evidence -- --source --sbom --output-dir "), + command("release-publication-receipt-verify", "automation", "npm run release:publication-receipt -- --policy --release-view --release-api --verification --release-evidence --asset-dir --output "), + command("release-verify", "automation", "npm run release:verify"), + command("deployment-evidence-verify", "automation", "NOEMA_RELEASE_UNDER_DILIGENCE_TAG= npm run acquisition:deployment-evidence"), + command("deployment-attestation-verify", "security", "gh attestation verify deployment-evidence.json --bundle deployment-evidence.sigstore.json --repo ContextualWisdomLab/noema --signer-workflow ContextualWisdomLab/noema/.github/workflows/cd.yml --cert-oidc-issuer https://token.actions.githubusercontent.com --predicate-type https://contextualwisdomlab.org/attestations/noema-deployment/v1 --deny-self-hosted-runners"), + command("security-evidence-verify", "security", "npm run security:evidence"), + command("readiness-audit", "automation", "npm run readiness:audit"), + command("acquisition-audit", "automation", "npm run acquisition:audit"), + external( + "figjam-value-map", + "product", + "https://www.figma.com/board/8l2fELfENAABNhDTMEVJKt", + "artifacts/acquisition/figjam-value-map-verification.json", + "artifacts/acquisition/figjam-value-map-export.json", + ), + finalEvidence("production-kpi-log", "operations", "exchange-30d.ndjson"), + finalEvidence("production-kpi-provenance", "operations", "exchange-30d.ndjson.provenance.json"), + finalEvidence("security-validation-evidence", "security", "artifacts/security/security-validation-evidence.json", "npm run security:evidence"), + finalEvidence("release-publication-receipt", "security", "artifacts/acquisition/release-publication-receipt.json"), + finalEvidence("production-deployment-evidence", "operations", "artifacts/acquisition/deployment-evidence.json"), + finalEvidence("production-deployment-attestation", "security", "artifacts/acquisition/deployment-evidence.sigstore.json"), + finalEvidence("deployment-attestation-verification", "security", "artifacts/acquisition/deployment-attestation-verification.json"), + finalEvidence("production-environment-governance", "governance", "artifacts/acquisition/production-environment-governance.json"), + finalEvidence("revenue-evidence", "commercial", "artifacts/acquisition/revenue-evidence.json"), + finalEvidence("transfer-evidence", "transfer", "artifacts/acquisition/transfer-evidence.json"), +]); + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isSafeRegularMetadata(metadata, maximumBytes) { + return Boolean( + metadata + && typeof metadata.isFile === "function" + && typeof metadata.isSymbolicLink === "function" + && metadata.isFile() + && !metadata.isSymbolicLink() + && Number.isSafeInteger(metadata.size) + && metadata.size >= 0 + && metadata.size <= maximumBytes, + ); +} + +function sameIdentity(left, right) { + return Boolean( + left + && right + && left.dev === right.dev + && left.ino === right.ino + && left.size === right.size + && left.mtimeMs === right.mtimeMs + && left.ctimeMs === right.ctimeMs, + ); +} + +/** + * Read a bounded 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. + */ +export function readStableFile(path, maximumBytes = MAX_DATA_ROOM_EVIDENCE_BYTES, fileSystem = defaultFileSystem) { + let descriptor = null; + try { + if (!Number.isSafeInteger(maximumBytes) || maximumBytes <= 0) { + return null; + } + const before = fileSystem.lstatSync(path); + if (!isSafeRegularMetadata(before, maximumBytes)) { + return null; + } + const readOnly = fileSystem.constants?.O_RDONLY; + const noFollow = fileSystem.constants?.O_NOFOLLOW; + if (!Number.isInteger(readOnly) || !Number.isInteger(noFollow)) { + return null; + } + descriptor = fileSystem.openSync(path, readOnly | noFollow); + const opened = fileSystem.fstatSync(descriptor); + if (!isSafeRegularMetadata(opened, maximumBytes) || !sameIdentity(before, opened)) { + return null; + } + const bytes = Buffer.allocUnsafe(opened.size); + let offset = 0; + while (offset < bytes.byteLength) { + const count = fileSystem.readSync( + descriptor, + bytes, + offset, + bytes.byteLength - offset, + null, + ); + if (!Number.isSafeInteger(count) || count <= 0) { + return null; + } + offset += count; + } + const afterDescriptor = fileSystem.fstatSync(descriptor); + const afterPath = fileSystem.lstatSync(path); + if (!sameIdentity(opened, afterDescriptor) || !sameIdentity(opened, afterPath)) { + return null; + } + return bytes; + } catch { + return null; + } finally { + if (descriptor !== null) { + 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. + } + } + } +} + +function canonicalRelativePath(rootDir, candidate) { + if (typeof candidate !== "string" || candidate.length === 0) { + return null; + } + if (Buffer.byteLength(candidate, "utf8") > MAX_RELATIVE_PATH_BYTES) { + return null; + } + if (unsafeControlPattern.test(candidate) || candidate.includes("\\") || isAbsolute(candidate)) { + return null; + } + const components = candidate.split("/"); + if (components.some((component) => component === "" || component === "." || component === "..")) { + return null; + } + const root = resolve(rootDir); + const absolute = resolve(root, candidate); + const rel = relative(root, absolute); + if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + return null; + } + return absolute; +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function inspectFile(rootDir, path, maximumBytes, fileSystem) { + const absolute = canonicalRelativePath(rootDir, path); + if (!absolute) { + return { present: false, unsafe: true, bytes: null }; + } + try { + fileSystem.lstatSync(absolute); + } catch (error) { + if (error?.code === "ENOENT") { + return { present: false, unsafe: false, bytes: null }; + } + return { present: false, unsafe: true, bytes: null }; + } + const bytes = readStableFile(absolute, maximumBytes, fileSystem); + if (!bytes) { + return { present: false, unsafe: true, bytes: null }; + } + return { present: true, unsafe: false, bytes }; +} + +function parseStableJson(path, maximumBytes, fileSystem) { + const bytes = readStableFile(path, maximumBytes, fileSystem); + if (!bytes) { + return null; + } + try { + const text = fatalUtf8Decoder.decode(bytes); + if (hasDuplicateJsonObjectKeys(text)) { + return null; + } + const value = JSON.parse(text); + return isRecord(value) ? value : null; + } catch { + return null; + } +} + +function boundedText(value, maximum = 4096) { + return typeof value === "string" + && value.length > 0 + && value.length <= maximum + && !unsafeControlPattern.test(value); +} + +function canonicalTimestamp(value) { + if (typeof value !== "string" || !canonicalTimestampPattern.test(value)) { + return false; + } + const milliseconds = Date.parse(value); + return !Number.isNaN(milliseconds) && new Date(milliseconds).toISOString() === value; +} + +function expectedIdentityMatches(entry, expected) { + const keys = [ + "id", + "category", + "kind", + "required", + "requiredForFinalGate", + "path", + "command", + "url", + "receiptPath", + "artifactPath", + "validatedBy", + "statusMeaning", + ]; + return keys.every((key) => entry[key] === expected[key]); +} + +function sameStringArray(actual, expected) { + return Array.isArray(actual) + && actual.length === expected.length + && actual.every((value, index) => value === expected[index]); +} + +function verifyExternalReceipt(expected, rootDir, expectedCommitSha, fileSystem) { + const receiptAbsolute = canonicalRelativePath(rootDir, expected.receiptPath); + if (!receiptAbsolute) { + return { verified: false, missing: false, failure: `${expected.id} receipt path is not canonical` }; + } + const reviewedArtifactAbsolute = canonicalRelativePath(rootDir, expected.artifactPath); + if (!reviewedArtifactAbsolute) { + return { verified: false, missing: false, failure: `${expected.id} reviewed retained artifact path is not canonical` }; + } + try { + fileSystem.lstatSync(receiptAbsolute); + } catch (error) { + if (error?.code === "ENOENT") { + return { verified: false, missing: true, failure: null }; + } + return { verified: false, missing: false, failure: `${expected.id} receipt is unreadable or unsafe` }; + } + const receipt = parseStableJson(receiptAbsolute, MAX_DATA_ROOM_JSON_BYTES, fileSystem); + if (!receipt) { + return { verified: false, missing: false, failure: `${expected.id} receipt is malformed, ambiguous, oversized, or unsafe` }; + } + const artifact = isRecord(receipt.artifact) ? receipt.artifact : null; + if (!artifact || artifact.path !== expected.artifactPath) { + return { + verified: false, + missing: false, + failure: `${expected.id} receipt does not authenticate the reviewed retained artifact path`, + }; + } + const retained = inspectFile(rootDir, expected.artifactPath, MAX_DATA_ROOM_EVIDENCE_BYTES, fileSystem); + const receiptValid = receipt.schemaVersion === 1 + && receipt.repository === DATA_ROOM_REPOSITORY + && receipt.source?.commitSha === expectedCommitSha + && receipt.sourceUrl === expected.url + && canonicalTimestamp(receipt.collectedAt) + && boundedText(receipt.collector, 256) + && boundedText(receipt.provenance) + && retained.present + && !retained.unsafe + && Number.isSafeInteger(artifact.bytes) + && artifact.bytes > 0 + && artifact.bytes === retained.bytes.byteLength + && sha256Pattern.test(String(artifact.sha256 ?? "")) + && artifact.sha256 === sha256(retained.bytes); + return receiptValid + ? { verified: true, missing: false, failure: null } + : { verified: false, missing: false, failure: `${expected.id} receipt does not authenticate the retained external artifact` }; +} + +function invalidResult(failure) { + return { + integrityPassed: false, + recomputedPassed: false, + finalGatePassed: false, + missingRequired: [], + missingFinalGate: [], + failures: [failure], + }; +} + +/** + * Recompute acquisition data-room integrity from retained bytes and the + * reviewed catalog. Persisted pass booleans and gap lists are cross-checks, + * never authorization inputs. A bare network URL cannot satisfy a final gate. + */ +export function verifyDataRoomManifest( + manifest, + { + rootDir = process.cwd(), + expectedCommitSha, + expectedReleaseTag = "", + expectedReleaseCommitSha = "", + catalog = DATA_ROOM_CATALOG, + fileSystem = defaultFileSystem, + } = {}, +) { + const failures = []; + const missingRequired = []; + const missingFinalGate = []; + if (!isRecord(manifest)) { + return invalidResult("manifest must be a JSON object"); + } + if (manifest.schemaVersion !== DATA_ROOM_SCHEMA_VERSION) { + failures.push("schemaVersion must match the reviewed data-room schema"); + } + if (manifest.repository !== DATA_ROOM_REPOSITORY) { + failures.push(`repository must match ${DATA_ROOM_REPOSITORY}`); + } + if (manifest.objective !== DATA_ROOM_OBJECTIVE) { + failures.push("objective must match the acquisition-readiness objective"); + } + if (!fullShaPattern.test(String(expectedCommitSha ?? "")) || manifest.source?.commitSha !== expectedCommitSha) { + failures.push("source.commitSha must match the exact audited commit"); + } + if (expectedReleaseTag || expectedReleaseCommitSha) { + const releaseBound = boundedText(expectedReleaseTag, 256) + && fullShaPattern.test(String(expectedReleaseCommitSha ?? "")) + && manifest.release?.tag === expectedReleaseTag + && manifest.release?.commitSha === expectedReleaseCommitSha; + if (!releaseBound) { + failures.push("release identity must match the selected immutable release"); + } + } else if (manifest.release !== undefined && manifest.release !== null) { + failures.push("release identity must be absent when no immutable release is selected"); + } + + if (!Array.isArray(catalog) || catalog.length === 0 || catalog.length > MAX_ENTRY_COUNT) { + return invalidResult("reviewed catalog must be a bounded non-empty array"); + } + const entries = Array.isArray(manifest.entries) ? manifest.entries : []; + const safeCatalog = catalog; + const ids = entries.map((entry) => entry?.id); + const uniqueIds = new Set(ids); + if (uniqueIds.size !== ids.length) { + failures.push("manifest entry ids must be unique"); + } + const expectedIds = safeCatalog.map((entry) => entry.id); + if ( + entries.length !== safeCatalog.length + || expectedIds.some((id) => !uniqueIds.has(id)) + || ids.some((id) => !expectedIds.includes(id)) + ) { + failures.push("manifest entry set must exactly match the reviewed catalog"); + } + + const expectedById = new Map(safeCatalog.map((entry) => [entry.id, entry])); + for (const entry of entries) { + if (!isRecord(entry) || typeof entry.id !== "string") { + failures.push("manifest entries must be objects with reviewed ids"); + continue; + } + const expected = expectedById.get(entry.id); + if (!expected) { + continue; + } + if (!expectedIdentityMatches(entry, expected)) { + failures.push(`${entry.id} immutable catalog identity does not match policy`); + continue; + } + + let present = false; + if (expected.kind === "file") { + const inspected = inspectFile(rootDir, expected.path, MAX_DATA_ROOM_EVIDENCE_BYTES, fileSystem); + if (inspected.unsafe) { + failures.push(`${entry.id} file is missing, unsafe, non-regular, or exceeds the evidence limit`); + } else if (inspected.present) { + present = true; + const digest = sha256(inspected.bytes); + if ( + entry.status !== "present" + || entry.bytes !== inspected.bytes.byteLength + || entry.sha256 !== digest + ) { + failures.push(`${entry.id} file digest or byte size does not match retained evidence`); + } + } else if (entry.status !== "missing" || entry.bytes !== null || entry.sha256 !== null) { + failures.push(`${entry.id} persisted file status does not match retained evidence`); + } + } else if (expected.kind === "command") { + present = true; + if (entry.status !== "present") { + failures.push(`${entry.id} command status does not match the reviewed catalog`); + } + } else if (expected.kind === "external") { + const receipt = verifyExternalReceipt(expected, rootDir, expectedCommitSha, fileSystem); + present = receipt.verified; + if (receipt.failure) { + failures.push(receipt.failure); + } + const expectedStatus = receipt.verified ? "present" : "declared"; + if (entry.status !== expectedStatus || entry.receiptVerified !== receipt.verified) { + failures.push(`${entry.id} persisted external status does not match trusted receipt verification`); + } + } else { + failures.push(`${entry.id} has an unsupported catalog kind`); + } + + if (expected.required && !present) { + missingRequired.push(entry.id); + } + if (expected.requiredForFinalGate && !present) { + missingFinalGate.push(entry.id); + } + } + + for (const expected of safeCatalog) { + if (!uniqueIds.has(expected.id)) { + if (expected.required) { + missingRequired.push(expected.id); + } + if (expected.requiredForFinalGate) { + missingFinalGate.push(expected.id); + } + } + } + + const dedupedMissingRequired = [...new Set(missingRequired)]; + const dedupedMissingFinalGate = [...new Set(missingFinalGate)]; + const recomputedPassed = dedupedMissingRequired.length === 0; + const recomputedFinalGatePassed = dedupedMissingFinalGate.length === 0; + if (manifest.passed !== recomputedPassed) { + failures.push("persisted passed value contradicts trusted recomputation"); + } + if (manifest.finalGatePassed !== recomputedFinalGatePassed) { + failures.push("persisted finalGatePassed value contradicts trusted recomputation"); + } + if (!sameStringArray(manifest.missingRequired, dedupedMissingRequired)) { + failures.push("persisted missingRequired list contradicts trusted recomputation"); + } + if (!sameStringArray(manifest.missingFinalGate, dedupedMissingFinalGate)) { + failures.push("persisted missingFinalGate list contradicts trusted recomputation"); + } + + const integrityPassed = failures.length === 0; + return { + integrityPassed, + recomputedPassed, + finalGatePassed: integrityPassed && recomputedFinalGatePassed, + missingRequired: dedupedMissingRequired, + missingFinalGate: dedupedMissingFinalGate, + failures, + }; +} + +/** + * Read and verify a retained data-room manifest using a bounded no-follow file + * descriptor. Duplicate JSON keys, malformed UTF-8, path replacement, and + * oversized input fail before persisted claims can influence readiness. + */ +export function verifyDataRoomManifestFile(path, options = {}) { + const manifest = parseStableJson( + path, + MAX_DATA_ROOM_JSON_BYTES, + options.fileSystem ?? defaultFileSystem, + ); + if (!manifest) { + return invalidResult("manifest JSON is missing, unsafe, malformed, oversized, or contains duplicate object keys"); + } + return verifyDataRoomManifest(manifest, options); +} + +/** + * Materialize the reviewed catalog from the current retained bytes. The output + * is descriptive evidence only; the independent verifier must recompute it + * before acquisition readiness can rely on any status or digest. + */ +export function materializeDataRoomManifest({ + rootDir = process.cwd(), + manifestPath, + commitSha, + releaseTag = "", + releaseCommitSha = "", + generatedAt = new Date().toISOString(), + catalog = DATA_ROOM_CATALOG, + fileSystem = defaultFileSystem, +} = {}) { + if (!fullShaPattern.test(String(commitSha ?? ""))) { + throw new TypeError("commitSha must be the exact 40-character audited Git commit"); + } + const entries = catalog.map((expected) => { + if (expected.kind === "file") { + const inspected = inspectFile(rootDir, expected.path, MAX_DATA_ROOM_EVIDENCE_BYTES, fileSystem); + if (inspected.present && !inspected.unsafe) { + return { + ...expected, + status: "present", + bytes: inspected.bytes.byteLength, + sha256: sha256(inspected.bytes), + }; + } + return { + ...expected, + status: inspected.unsafe ? "unsafe" : "missing", + bytes: null, + sha256: null, + }; + } + if (expected.kind === "external") { + const receipt = verifyExternalReceipt(expected, rootDir, commitSha, fileSystem); + return { + ...expected, + status: receipt.verified ? "present" : receipt.failure ? "unsafe" : "declared", + receiptVerified: receipt.verified, + }; + } + return { ...expected, status: "present" }; + }); + const missingRequired = entries + .filter((entry) => entry.required && entry.status !== "present") + .map((entry) => entry.id); + const missingFinalGate = entries + .filter((entry) => entry.requiredForFinalGate && entry.status !== "present") + .map((entry) => entry.id); + const output = { + schemaVersion: DATA_ROOM_SCHEMA_VERSION, + repository: DATA_ROOM_REPOSITORY, + generatedAt, + objective: DATA_ROOM_OBJECTIVE, + manifestPath, + source: { commitSha }, + ...(releaseTag || releaseCommitSha + ? { release: { tag: releaseTag, commitSha: releaseCommitSha } } + : {}), + passed: missingRequired.length === 0, + finalGatePassed: missingFinalGate.length === 0, + missingRequired, + missingFinalGate, + entries, + }; + return output; +} diff --git a/scripts/lib/acquisition-git-preflight.mjs b/scripts/lib/acquisition-git-preflight.mjs new file mode 100644 index 000000000..aa75fdd80 --- /dev/null +++ b/scripts/lib/acquisition-git-preflight.mjs @@ -0,0 +1,546 @@ +import { spawnSync } from "node:child_process"; +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readSync, +} from "node:fs"; +import { resolve, sep } from "node:path"; + +const fullShaPattern = /^[0-9a-f]{40}$/i; +const fullObjectPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i; +const indexHeaderPattern = /^([0-7]{6}) ([0-9a-f]{40}|[0-9a-f]{64}) ([0-3])$/; +const MAX_GIT_OUTPUT_BYTES = 4096; +const MAX_GIT_INDEX_OUTPUT_BYTES = 2 * 1024 * 1024; +const MAX_TRACKED_ENTRY_COUNT = 20_000; +const MAX_TRACKED_PATH_BYTES = 4096; +const MAX_TRACKED_FILE_BYTES = 32 * 1024 * 1024; +const MAX_TRACKED_TOTAL_BYTES = 256 * 1024 * 1024; +const GIT_TIMEOUT_MS = 10_000; +const supportedTrackedModes = new Set(["100644", "100755"]); +const fatalUtf8Decoder = new TextDecoder("utf-8", { fatal: true }); +const defaultFileSystem = Object.freeze({ + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readSync, +}); + +/** + * Build the bounded environment used for acquisition-readiness Git reads. + * Global/system configuration, replacement objects, lazy fetches, hooks, + * filesystem monitors, and terminal prompts are disabled so the preflight is + * local-only and cannot silently change the object graph or execute helpers. + * The exact command working directory is admitted only through a command-scoped + * safe.directory entry and is also pinned as GIT_WORK_TREE, preventing a + * repository-local core.worktree setting from redirecting tracked-byte checks + * away from the checkout whose acquisition evidence is being audited. + * + * Git's ordinary worktree comparison can trust cached filesystem metadata. + * Command-scoped stat settings restore strict normal comparison as defence in + * depth, while a separate descriptor-bound blob comparison authenticates the + * exact bytes even when same-size drift is hidden by a cached stat tuple. + */ +export function buildAcquisitionGitEnvironment( + sourceEnvironment = process.env, + platform = process.platform, + cwd = process.cwd(), +) { + const nullDevice = platform === "win32" ? "NUL" : "/dev/null"; + const exactWorkTree = resolve(cwd); + const environment = { + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: nullDevice, + GIT_ATTR_NOSYSTEM: "1", + GIT_OPTIONAL_LOCKS: "0", + GIT_TERMINAL_PROMPT: "0", + GIT_NO_REPLACE_OBJECTS: "1", + GIT_NO_LAZY_FETCH: "1", + GIT_WORK_TREE: exactWorkTree, + GIT_CONFIG_COUNT: "8", + GIT_CONFIG_KEY_0: "core.hooksPath", + GIT_CONFIG_VALUE_0: nullDevice, + GIT_CONFIG_KEY_1: "core.fsmonitor", + GIT_CONFIG_VALUE_1: "false", + GIT_CONFIG_KEY_2: "core.untrackedCache", + GIT_CONFIG_VALUE_2: "false", + GIT_CONFIG_KEY_3: "safe.directory", + GIT_CONFIG_VALUE_3: exactWorkTree, + GIT_CONFIG_KEY_4: "core.trustctime", + GIT_CONFIG_VALUE_4: "true", + GIT_CONFIG_KEY_5: "core.checkStat", + GIT_CONFIG_VALUE_5: "default", + GIT_CONFIG_KEY_6: "core.ignoreStat", + GIT_CONFIG_VALUE_6: "false", + GIT_CONFIG_KEY_7: "core.filemode", + GIT_CONFIG_VALUE_7: "true", + }; + if (typeof sourceEnvironment.PATH === "string" && sourceEnvironment.PATH.length > 0) { + environment.PATH = sourceEnvironment.PATH; + } + if ( + platform === "win32" + && typeof sourceEnvironment.SystemRoot === "string" + && sourceEnvironment.SystemRoot.length > 0 + ) { + environment.SystemRoot = sourceEnvironment.SystemRoot; + } + return environment; +} + +function runGit( + args, + { + cwd, + input, + spawnSyncImpl = spawnSync, + sourceEnvironment = process.env, + platform = process.platform, + }, + maximumOutputBytes = MAX_GIT_OUTPUT_BYTES, + outputEncoding = "utf8", +) { + const commandCwd = cwd ?? process.cwd(); + const result = spawnSyncImpl("git", args, { + cwd: commandCwd, + env: buildAcquisitionGitEnvironment(sourceEnvironment, platform, commandCwd), + encoding: outputEncoding, + input, + maxBuffer: maximumOutputBytes, + timeout: GIT_TIMEOUT_MS, + stdio: ["pipe", "pipe", "pipe"], + }); + if (result.error) { + throw new Error(`acquisition Git preflight failed: ${result.error.message}`); + } + if (result.signal) { + throw new Error(`acquisition Git preflight terminated by signal ${result.signal}`); + } + if (!Number.isInteger(result.status)) { + throw new Error("acquisition Git preflight returned no exit status"); + } + return result; +} + +function requireCleanComparison(result, exactHead) { + if (result.status === 1) { + throw new Error(`tracked checkout differs from exact HEAD ${exactHead}`); + } + if (result.status !== 0) { + throw new Error("acquisition tracked-checkout comparison failed"); + } +} + +function sameTrackedMetadata(left, right) { + return Boolean( + left + && right + && left.dev === right.dev + && left.ino === right.ino + && left.mode === right.mode + && left.size === right.size + && left.mtimeMs === right.mtimeMs + && left.ctimeMs === right.ctimeMs, + ); +} + +function safeRegularMetadata(metadata) { + return Boolean( + metadata + && typeof metadata.isFile === "function" + && typeof metadata.isSymbolicLink === "function" + && metadata.isFile() + && !metadata.isSymbolicLink(), + ); +} + +function asOutputBuffer(output) { + if (Buffer.isBuffer(output)) { + return output; + } + if (output === null || output === undefined) { + return Buffer.alloc(0); + } + if (typeof output === "string") { + return Buffer.from(output, "utf8"); + } + if (ArrayBuffer.isView(output)) { + return Buffer.from(output.buffer, output.byteOffset, output.byteLength); + } + throw new Error("acquisition tracked-byte index returned malformed output"); +} + +function decodeTrackedPath(pathBytes) { + if (pathBytes.length === 0) { + throw new Error("acquisition tracked-byte index returned malformed output"); + } + try { + return fatalUtf8Decoder.decode(pathBytes); + } catch { + throw new Error("acquisition tracked-byte index path must be valid UTF-8"); + } +} + +function parseTrackedEntries(output, cwd) { + const bytes = asOutputBuffer(output); + if (bytes.length === 0) { + return []; + } + if (bytes[bytes.length - 1] !== 0) { + throw new Error("acquisition tracked-byte index returned malformed output"); + } + const records = []; + let recordStart = 0; + for (let index = 0; index < bytes.length; index += 1) { + if (bytes[index] !== 0) { + continue; + } + records.push(bytes.subarray(recordStart, index)); + recordStart = index + 1; + if (records.length > MAX_TRACKED_ENTRY_COUNT) { + throw new Error("acquisition tracked-byte index exceeds the entry limit"); + } + } + + const root = resolve(cwd); + const rootPrefix = root.endsWith(sep) ? root : `${root}${sep}`; + let pathBytesTotal = 0; + return records.map((record) => { + const separatorIndex = record.indexOf(0x09); + if (separatorIndex <= 0) { + throw new Error("acquisition tracked-byte index returned malformed output"); + } + const headerBytes = record.subarray(0, separatorIndex); + if (headerBytes.some((byte) => byte > 0x7f)) { + throw new Error("acquisition tracked-byte index returned malformed output"); + } + const match = indexHeaderPattern.exec(headerBytes.toString("ascii")); + if (!match) { + throw new Error("acquisition tracked-byte index returned malformed output"); + } + const [, mode, objectId, stage] = match; + if (stage !== "0") { + throw new Error("acquisition tracked-byte index contains an unmerged entry"); + } + if (!supportedTrackedModes.has(mode)) { + throw new Error("acquisition tracked-byte index contains an unsupported object mode"); + } + + const pathBytes = record.subarray(separatorIndex + 1); + pathBytesTotal += pathBytes.length; + if (pathBytes.length > MAX_TRACKED_PATH_BYTES || pathBytesTotal > MAX_GIT_INDEX_OUTPUT_BYTES) { + throw new Error("acquisition tracked-byte index exceeds the path limit"); + } + const path = decodeTrackedPath(pathBytes); + const absolutePath = resolve(root, path); + if (absolutePath === root || !absolutePath.startsWith(rootPrefix)) { + throw new Error("acquisition tracked-byte index contains an unsafe path"); + } + return { mode, objectId: objectId.toLowerCase(), path, absolutePath }; + }); +} + +function requireExecutableMode(entry, metadata) { + if (!Number.isInteger(metadata.mode)) { + throw new Error("tracked checkout object type differs from its authenticated Git index"); + } + const expectedExecutable = entry.mode === "100755"; + const actualExecutable = (metadata.mode & 0o100) !== 0; + if (expectedExecutable !== actualExecutable) { + throw new Error("tracked checkout executable mode differs from its authenticated Git index"); + } +} + +function readTrackedRegularBytes(entry, fileSystem, remainingBytes) { + const readOnly = fileSystem.constants?.O_RDONLY; + const noFollow = fileSystem.constants?.O_NOFOLLOW; + if (!Number.isInteger(readOnly) || !Number.isInteger(noFollow)) { + throw new Error("acquisition tracked-byte authentication requires no-follow filesystem support"); + } + + const beforePath = fileSystem.lstatSync(entry.absolutePath); + if (!safeRegularMetadata(beforePath)) { + throw new Error("tracked checkout object type differs from its authenticated Git index"); + } + + const descriptor = fileSystem.openSync(entry.absolutePath, readOnly | noFollow); + try { + const beforeDescriptor = fileSystem.fstatSync(descriptor); + if (!safeRegularMetadata(beforeDescriptor) || !sameTrackedMetadata(beforePath, beforeDescriptor)) { + throw new Error("tracked checkout changed before raw-byte authentication"); + } + requireExecutableMode(entry, beforeDescriptor); + + const byteSize = beforeDescriptor.size; + if (!Number.isSafeInteger(byteSize) || byteSize < 0 || byteSize > MAX_TRACKED_FILE_BYTES) { + throw new Error("tracked checkout exceeds the acquisition file-byte limit"); + } + if (byteSize > remainingBytes) { + throw new Error("tracked checkout exceeds the acquisition aggregate-byte limit"); + } + + const contents = Buffer.alloc(byteSize + 1); + let offset = 0; + while (offset < contents.length) { + const bytesRead = fileSystem.readSync( + descriptor, + contents, + offset, + contents.length - offset, + null, + ); + if (!Number.isInteger(bytesRead) || bytesRead < 0 || bytesRead > contents.length - offset) { + throw new Error("acquisition tracked-byte descriptor returned an invalid byte count"); + } + if (bytesRead === 0) { + break; + } + offset += bytesRead; + } + + const afterDescriptor = fileSystem.fstatSync(descriptor); + const afterPath = fileSystem.lstatSync(entry.absolutePath); + if ( + offset !== byteSize + || !sameTrackedMetadata(beforeDescriptor, afterDescriptor) + || !sameTrackedMetadata(afterDescriptor, afterPath) + ) { + throw new Error("tracked checkout changed during raw-byte authentication"); + } + return contents.subarray(0, offset); + } finally { + fileSystem.closeSync(descriptor); + } +} + +function hashTrackedEntry(entry, options, fileSystem, remainingBytes) { + const input = readTrackedRegularBytes(entry, fileSystem, remainingBytes); + const result = runGit(["hash-object", "--stdin"], { ...options, input }); + if (result.status !== 0) { + throw new Error("acquisition tracked-byte hashing failed"); + } + const objectId = String(result.stdout ?? "").trim().toLowerCase(); + if (!fullObjectPattern.test(objectId)) { + throw new Error("acquisition tracked-byte hashing returned malformed output"); + } + if (objectId !== entry.objectId) { + throw new Error("tracked checkout differs from its authenticated Git index bytes"); + } + return input.length; +} + +/** + * Recompute every tracked regular-file Git blob object from descriptor-bound + * checkout bytes. Production callers must provide the already-resolved exact + * HEAD, causing the expected object inventory to come from immutable + * `git ls-tree` output rather than mutable index state. The legacy index listing + * remains only as an injected-spawn test seam for focused parser and descriptor + * unit tests and cannot be reached by a production call using the real Git + * process without an exact immutable tree identity. + * + * Every file is opened with O_NOFOLLOW, bound to pre/post path and descriptor + * metadata, read through that descriptor with limit+1 growth detection, and + * hashed through standard input. Executable mode is checked independently. + * + * The source listing, path count, path bytes, per-file bytes, and aggregate + * bytes are bounded before each read. Symbolic links, gitlinks, sparse + * directories, path escape, malformed output, descriptor movement, growth, + * invalid UTF-8, and hash mismatch fail closed without accepting cached stat + * equality or a second pathname open as byte evidence. + */ +export function verifyAcquisitionTrackedBytes({ + cwd = process.cwd(), + exactHead = "", + spawnSyncImpl = spawnSync, + sourceEnvironment = process.env, + platform = process.platform, + fileSystem = defaultFileSystem, +} = {}) { + const options = { + cwd, + spawnSyncImpl, + sourceEnvironment, + platform, + }; + if (!exactHead && spawnSyncImpl === spawnSync) { + throw new TypeError( + "exact acquisition tree commit is required for production tracked-byte authentication", + ); + } + if (exactHead && !fullShaPattern.test(exactHead)) { + throw new TypeError("exact acquisition tree commit must be a full Git SHA"); + } + const listingArgs = exactHead + ? [ + "ls-tree", + "-r", + "--full-tree", + "-z", + "--format=%(objectmode) %(objectname) 0%x09%(path)", + exactHead.toLowerCase(), + "--", + ] + : ["ls-files", "--stage", "-z", "--cached", "--"]; + const listing = runGit( + listingArgs, + options, + MAX_GIT_INDEX_OUTPUT_BYTES, + null, + ); + if (listing.status !== 0) { + throw new Error("acquisition tracked-byte source inspection failed"); + } + const entries = parseTrackedEntries(listing.stdout, cwd); + let aggregateBytes = 0; + for (const entry of entries) { + aggregateBytes += hashTrackedEntry( + entry, + options, + fileSystem, + MAX_TRACKED_TOTAL_BYTES - aggregateBytes, + ); + } + return entries.length; +} + +/** + * Resolve a local Git revision to one exact 40-character commit. The command + * uses only the local object database and refuses malformed or ambiguous + * output rather than allowing an approximate source identity. + */ +export function resolveAcquisitionCommit( + ref, + options = {}, +) { + if (typeof ref !== "string" || ref.length === 0 || ref.length > 256 || /[\u0000-\u001f\u007f]/.test(ref)) { + throw new TypeError("acquisition Git ref must be a bounded printable string"); + } + const result = runGit( + ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], + options, + ); + if (result.status !== 0) { + throw new Error(`acquisition Git ref ${ref} could not be resolved locally`); + } + const output = String(result.stdout ?? "").trim(); + if (!fullShaPattern.test(output)) { + throw new Error(`acquisition Git ref ${ref} did not resolve to one exact commit`); + } + return output.toLowerCase(); +} + +/** + * Refuse index hints that can intentionally suppress worktree comparison. + * `git ls-files -v -z` emits `S` for skip-worktree and lower-case tags for + * assume-unchanged entries. The complete NUL-delimited result is bounded to + * 2 MiB and malformed output fails closed without reflecting repository paths. + */ +export function verifyAcquisitionIndexFlags(options = {}) { + const result = runGit( + ["ls-files", "-v", "-z", "--cached", "--"], + options, + MAX_GIT_INDEX_OUTPUT_BYTES, + ); + if (result.status !== 0) { + throw new Error("acquisition Git index inspection failed"); + } + const output = String(result.stdout ?? ""); + if (output.length === 0) { + return; + } + if (!output.endsWith("\0")) { + throw new Error("acquisition Git index inspection returned malformed output"); + } + for (const record of output.slice(0, -1).split("\0")) { + if (record.length < 3 || record[1] !== " ") { + throw new Error("acquisition Git index inspection returned malformed output"); + } + const tag = record[0]; + if (tag === "S" || (tag >= "a" && tag <= "z")) { + throw new Error("unsafe Git index flag detected in acquisition checkout"); + } + } +} + +/** + * Authenticate the tracked checkout against its exact HEAD without treating + * intentionally untracked retained acquisition artifacts as source drift. + * Unsafe index hints are rejected before and after the comparison, staged state + * is compared to exact HEAD, ordinary worktree comparison remains defence in + * depth, and production execution independently recomputes every tracked blob + * from descriptor-bound raw checkout bytes against the immutable exact HEAD + * tree rather than trusting mutable stage-zero object IDs. Exact HEAD is + * resolved before and after all checks so redirected, helper-influenced, + * stat-cache-hidden, raced, or concurrently moved source cannot be labelled as + * that commit. + * + * `spawnSyncImpl` is a test seam that already controls every Git identity and + * comparison result. Production callers do not replace it; real execution adds + * the descriptor-bound raw-byte pass described above. + */ +export function verifyAcquisitionTrackedCheckout({ + cwd = process.cwd(), + expectedCommitSha = "", + spawnSyncImpl = spawnSync, + sourceEnvironment = process.env, + platform = process.platform, +} = {}) { + const options = { + cwd, + spawnSyncImpl, + sourceEnvironment, + platform, + }; + const exactHead = resolveAcquisitionCommit("HEAD", options); + if (expectedCommitSha) { + if (!fullShaPattern.test(expectedCommitSha)) { + throw new TypeError("expected acquisition commit must be a full Git SHA"); + } + if (exactHead !== expectedCommitSha.toLowerCase()) { + throw new Error("exact HEAD changed from the expected acquisition commit"); + } + } + + verifyAcquisitionIndexFlags(options); + const stagedComparison = runGit( + [ + "diff", + "--cached", + "--quiet", + "--no-ext-diff", + "--no-textconv", + "--ignore-submodules=none", + exactHead, + "--", + ], + options, + ); + requireCleanComparison(stagedComparison, exactHead); + + const worktreeComparison = runGit( + [ + "diff-files", + "--quiet", + "--no-ext-diff", + "--no-textconv", + "--ignore-submodules=none", + "--", + ], + options, + ); + requireCleanComparison(worktreeComparison, exactHead); + if (spawnSyncImpl === spawnSync) { + verifyAcquisitionTrackedBytes({ ...options, exactHead }); + } + verifyAcquisitionIndexFlags(options); + + const afterHead = resolveAcquisitionCommit("HEAD", options); + if (afterHead !== exactHead) { + throw new Error("exact HEAD changed during acquisition Git preflight"); + } + return exactHead; +} diff --git a/scripts/lib/acquisition-private-output.mjs b/scripts/lib/acquisition-private-output.mjs new file mode 100644 index 000000000..f8a7a834f --- /dev/null +++ b/scripts/lib/acquisition-private-output.mjs @@ -0,0 +1,143 @@ +import { + closeSync, + constants, + fchmodSync, + fstatSync, + ftruncateSync, + lstatSync, + openSync, + writeFileSync, +} from "node:fs"; +import { dirname, parse, resolve } from "node:path"; + +const defaultFileSystem = Object.freeze({ + closeSync, + constants, + fchmodSync, + fstatSync, + ftruncateSync, + lstatSync, + openSync, + writeFileSync, +}); + +function safeOutputMetadata(metadata) { + return Boolean( + metadata + && typeof metadata.isFile === "function" + && typeof metadata.isSymbolicLink === "function" + && metadata.isFile() + && !metadata.isSymbolicLink() + && metadata.nlink === 1, + ); +} + +function safeParentMetadata(metadata) { + return Boolean( + metadata + && typeof metadata.isDirectory === "function" + && typeof metadata.isSymbolicLink === "function" + && metadata.isDirectory() + && !metadata.isSymbolicLink(), + ); +} + +function sameOutputIdentity(left, right) { + return Boolean( + left + && right + && left.dev === right.dev + && left.ino === right.ino, + ); +} + +/** + * Refuse an acquisition output path when any existing parent component is a + * symbolic link or a non-directory filesystem object. + * + * The walk starts at the output leaf's parent and continues to the filesystem + * root, so a missing intermediate directory does not hide an unsafe higher + * ancestor. This boundary is intentionally checked before directory creation + * and again by the private writer immediately before opening the leaf. + */ +export function assertAcquisitionPrivatePathParents( + path, + fileSystem = defaultFileSystem, +) { + if (typeof path !== "string" || path.length === 0) { + throw new TypeError("acquisition output requires a non-empty path"); + } + const absolutePath = resolve(path); + const rootPath = parse(absolutePath).root; + let currentPath = dirname(absolutePath); + while (true) { + const metadata = fileSystem.lstatSync(currentPath, { throwIfNoEntry: false }) ?? null; + if (metadata && !safeParentMetadata(metadata)) { + throw new Error("acquisition output parent must be a real directory without symbolic links"); + } + if (currentPath === rootPath) { + return; + } + currentPath = dirname(currentPath); + } +} + +/** + * 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 opened without + * truncation until their descriptor identity matches the path that was + * inspected. Newly created files use O_EXCL. The descriptor is restricted to + * owner-only mode before any content is truncated or written, and the final + * path identity is checked again afterward. Existing parent components are + * also required to be real directories, never symbolic links or non-directory + * objects. + */ +export function writeAcquisitionPrivateFile( + path, + contents, + fileSystem = defaultFileSystem, +) { + if (typeof path !== "string" || path.length === 0 || typeof contents !== "string") { + throw new TypeError("acquisition output requires a non-empty path and UTF-8 text"); + } + const writeOnly = fileSystem.constants?.O_WRONLY; + const create = fileSystem.constants?.O_CREAT; + const exclusive = fileSystem.constants?.O_EXCL; + const noFollow = fileSystem.constants?.O_NOFOLLOW; + if (![writeOnly, create, exclusive, noFollow].every(Number.isInteger)) { + throw new Error("acquisition output requires no-follow filesystem support"); + } + + assertAcquisitionPrivatePathParents(path, fileSystem); + const before = fileSystem.lstatSync(path, { throwIfNoEntry: false }) ?? null; + if (before && !safeOutputMetadata(before)) { + throw new Error("acquisition output path must be a single-link regular file"); + } + + const flags = before + ? writeOnly | noFollow + : writeOnly | create | exclusive | noFollow; + const descriptor = fileSystem.openSync(path, flags, 0o600); + try { + const opened = fileSystem.fstatSync(descriptor); + if (!safeOutputMetadata(opened) || (before && !sameOutputIdentity(before, opened))) { + throw new Error("acquisition output path changed before writing"); + } + fileSystem.fchmodSync(descriptor, 0o600); + fileSystem.ftruncateSync(descriptor, 0); + fileSystem.writeFileSync(descriptor, contents, { encoding: "utf8" }); + + const afterDescriptor = fileSystem.fstatSync(descriptor); + const afterPath = fileSystem.lstatSync(path); + if ( + !safeOutputMetadata(afterDescriptor) + || !safeOutputMetadata(afterPath) + || !sameOutputIdentity(afterDescriptor, afterPath) + ) { + throw new Error("acquisition output path changed while writing"); + } + } finally { + fileSystem.closeSync(descriptor); + } +} diff --git a/test/acquisition-artifact-rights-json.test.ts b/test/acquisition-artifact-rights-json.test.ts new file mode 100644 index 000000000..7048a586f --- /dev/null +++ b/test/acquisition-artifact-rights-json.test.ts @@ -0,0 +1,162 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +function writeFixture(root: string, relativePath: string, content: string): string { + const path = join(root, relativePath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, "utf8"); + return path; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function writeRequiredDocs(root: string): void { + writeFixture( + root, + "docs/acquisition-readiness-2b.md", + "NOEMA-GOAL-ACQUISITION-2B-2026-07-02\nKRW 2,000,000,000\nRevenue_PASS\nTransfer_PASS\n", + ); + writeFixture( + root, + "docs/buyer-due-diligence-index.md", + "npm run acquisition:audit\nartifacts/acquisition/revenue-evidence.json\nartifacts/acquisition/transfer-evidence.json\n", + ); + writeFixture( + root, + "docs/library-boundary-decision.md", + "현재는 submodule을 만들지 않는다\nnpm workspaces\nSplit Triggers\n", + ); + writeFixture( + root, + "scripts/acquisition-data-room-manifest.mjs", + "// finalGatePassed data-room-manifest.json release-publication-receipt\n", + ); + writeFixture(root, "docs/saleable-program-goal-registry.md", "NOEMA-GOAL-SALEABLE-2026-07-02\n"); + writeFixture(root, "docs/pricing-draft.md", "pricing draft\n"); + writeFixture(root, "docs/terms-draft.md", "terms draft\n"); + writeFixture(root, "docs/sla-and-support.md", "support draft\n"); +} + +function digestArtifact(root: string, path: string, content: string) { + writeFixture(root, path, content); + return { path, sha256: sha256(content) }; +} + +describe("acquisition artifact-rights JSON evidence", () => { + it("rejects duplicate decoded OCI license keys instead of accepting the last JSON value", () => { + const root = mkdtempSync(join(tmpdir(), "noema-artifact-rights-duplicates-")); + try { + writeRequiredDocs(root); + const rightsBytes = "Apache License 2.0 reviewed fixture.\n"; + writeFixture(root, "LICENSE", rightsBytes); + writeFixture( + root, + "package.json", + `${JSON.stringify({ name: "noema", private: true, license: "Apache-2.0" }, null, 2)}\n`, + ); + + const artifactRights = `{ + "schema_version": 1, + "repository": "ContextualWisdomLab/noema", + "tag": "v0.1.0", + "commit_sha": "${"a".repeat(40)}", + "artifacts": [ + { + "artifact_kind": "oci_image", + "artifact_identity": "ghcr.io/contextualwisdomlab/noema@sha256:${"b".repeat(64)}", + "oci_annotations": { + "org.opencontainers.image.licenses": "MIT", + "org.opencontainers.image.licenses": "Apache-2.0" + } + } + ] +}\n`; + const artifactRightsBinding = digestArtifact( + root, + "artifacts/release/artifact-rights-metadata.json", + artifactRights, + ); + const releaseBytes = "retained exact-release evidence\n"; + const transferPath = writeFixture( + root, + "artifacts/acquisition/transfer-evidence.json", + `${JSON.stringify({ + owner: "Acquisition counsel", + source_documents: ["legal/review-record.pdf"], + updated_at: new Date().toISOString(), + license_review: "pass", + third_party_review: "pass", + github_app_transfer_plan: "pass", + cloudflare_transfer_plan: "pass", + secrets_rotation_plan: "pass", + owner_transfer_plan: "pass", + privacy_review: "pass", + licensing_ip: { + owner_legal_decision: { + type: "spdx", + license_expression: "Apache-2.0", + evidence: ["legal/outbound-rights-decision.pdf"], + }, + repository_rights: { path: "LICENSE", sha256: sha256(rightsBytes) }, + package_metadata: { license: "Apache-2.0" }, + release_rights: { + tag: "v0.1.0", + commit_sha: "a".repeat(40), + sbom: digestArtifact(root, "artifacts/release/noema.cdx.json", releaseBytes), + dependency_license_inventory: digestArtifact( + root, + "artifacts/release/dependency-licenses.json", + releaseBytes, + ), + notice: digestArtifact(root, "artifacts/release/NOTICE.txt", releaseBytes), + provenance: digestArtifact( + root, + "artifacts/release/provenance.sigstore.json", + releaseBytes, + ), + artifact_rights_metadata: artifactRightsBinding, + }, + contributor_ip: { + ownership_evidence: ["legal/contributor-ownership-register.pdf"], + assignment_evidence: ["legal/ip-assignment-register.pdf"], + }, + }, + }, null, 2)}\n`, + ); + + const outputDir = join(root, "audit-output"); + const inheritedEnvironment = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("NOEMA_")), + ); + const result = spawnSync(process.execPath, [resolve("scripts/acquisition-readiness-audit.mjs")], { + cwd: root, + encoding: "utf8", + env: { + ...inheritedEnvironment, + NOEMA_AUDIT_REPORT_ONLY: "1", + NOEMA_TRANSFER_EVIDENCE_PATH: transferPath, + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: outputDir, + }, + }); + + expect(result.status, result.stderr || result.stdout).toBe(0); + const audit = JSON.parse(readFileSync(join(outputDir, "acquisition-audit.json"), "utf8")); + const transferCheck = audit.checks.find( + (check: { name?: string }) => check.name === "transfer evidence pass", + ); + expect(transferCheck).toBeDefined(); + expect(transferCheck.pass).toBe(false); + expect(transferCheck.details.licensingIpFailures).toContain( + "release_rights.artifact_rights_metadata must not contain duplicate JSON object keys", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/acquisition-data-room-external-path.test.ts b/test/acquisition-data-room-external-path.test.ts new file mode 100644 index 000000000..ae8d61a5d --- /dev/null +++ b/test/acquisition-data-room-external-path.test.ts @@ -0,0 +1,158 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + DATA_ROOM_OBJECTIVE, + DATA_ROOM_REPOSITORY, + DATA_ROOM_SCHEMA_VERSION, + verifyDataRoomManifest, +} from "../scripts/lib/acquisition-data-room-integrity.mjs"; + +const HEAD = "0123456789abcdef0123456789abcdef01234567"; +const externalCatalog = [{ + id: "design-evidence", + category: "product", + kind: "external", + url: "https://example.com/design", + receiptPath: "artifacts/acquisition/design-evidence-receipt.json", + artifactPath: "artifacts/acquisition/design-evidence-export.json", + required: false, + requiredForFinalGate: true, +}] as const; + +function sha256(bytes: Buffer) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function receiptPath(root: string) { + return join(root, "artifacts", "acquisition", "design-evidence-receipt.json"); +} + +function writeReceipt(root: string, artifactPath: string) { + const absoluteArtifact = join(root, artifactPath); + mkdirSync(dirname(absoluteArtifact), { recursive: true }); + writeFileSync(absoluteArtifact, "immutable design export\n"); + const bytes = readFileSync(absoluteArtifact); + const outputPath = receiptPath(root); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, JSON.stringify({ + schemaVersion: 1, + repository: DATA_ROOM_REPOSITORY, + source: { commitSha: HEAD }, + sourceUrl: "https://example.com/design", + collectedAt: "2026-08-07T00:00:00.000Z", + collector: "cwl-acquisition-evidence", + provenance: "manual immutable export retained in the acquisition data room", + artifact: { + path: artifactPath, + bytes: bytes.byteLength, + sha256: sha256(bytes), + }, + })); +} + +function manifest(catalogEntry: Readonly> = externalCatalog[0]) { + return { + schemaVersion: DATA_ROOM_SCHEMA_VERSION, + repository: DATA_ROOM_REPOSITORY, + objective: DATA_ROOM_OBJECTIVE, + source: { commitSha: HEAD }, + passed: true, + finalGatePassed: true, + missingRequired: [], + missingFinalGate: [], + entries: [{ + ...catalogEntry, + status: "present", + receiptVerified: true, + }], + }; +} + +describe("external acquisition evidence allowlist", () => { + it("rejects a valid receipt that authenticates an unreviewed retained path", () => { + const root = mkdtempSync(join(tmpdir(), "noema-data-room-external-path-")); + try { + writeReceipt(root, "README.md"); + const result = verifyDataRoomManifest(manifest(), { + rootDir: root, + expectedCommitSha: HEAD, + catalog: externalCatalog, + }); + + expect(result.integrityPassed).toBe(false); + expect(result.finalGatePassed).toBe(false); + expect(result.failures).toContain("design-evidence receipt does not authenticate the reviewed retained artifact path"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects an external catalog whose retained artifact path is not canonical", () => { + const root = mkdtempSync(join(tmpdir(), "noema-data-room-invalid-reviewed-path-")); + const unsafeEntry = { + ...externalCatalog[0], + artifactPath: "../design-evidence-export.json", + }; + try { + const result = verifyDataRoomManifest(manifest(unsafeEntry), { + rootDir: root, + expectedCommitSha: HEAD, + catalog: [unsafeEntry], + }); + + expect(result.integrityPassed).toBe(false); + expect(result.finalGatePassed).toBe(false); + expect(result.failures).toContain("design-evidence reviewed retained artifact path is not canonical"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects a receipt that omits its retained artifact identity", () => { + const root = mkdtempSync(join(tmpdir(), "noema-data-room-missing-artifact-")); + try { + writeReceipt(root, externalCatalog[0].artifactPath); + writeFileSync(receiptPath(root), JSON.stringify({ + schemaVersion: 1, + repository: DATA_ROOM_REPOSITORY, + source: { commitSha: HEAD }, + sourceUrl: externalCatalog[0].url, + collectedAt: "2026-08-07T00:00:00.000Z", + collector: "cwl-acquisition-evidence", + provenance: "manual immutable export retained in the acquisition data room", + })); + + const result = verifyDataRoomManifest(manifest(), { + rootDir: root, + expectedCommitSha: HEAD, + catalog: externalCatalog, + }); + + expect(result.integrityPassed).toBe(false); + expect(result.finalGatePassed).toBe(false); + expect(result.failures).toContain("design-evidence receipt does not authenticate the reviewed retained artifact path"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("accepts the same receipt contract only at the catalog-pinned retained path", () => { + const root = mkdtempSync(join(tmpdir(), "noema-data-room-reviewed-path-")); + try { + writeReceipt(root, externalCatalog[0].artifactPath); + const result = verifyDataRoomManifest(manifest(), { + rootDir: root, + expectedCommitSha: HEAD, + catalog: externalCatalog, + }); + + expect(result.integrityPassed).toBe(true); + expect(result.finalGatePassed).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/acquisition-data-room-git-binding.test.ts b/test/acquisition-data-room-git-binding.test.ts new file mode 100644 index 000000000..032008696 --- /dev/null +++ b/test/acquisition-data-room-git-binding.test.ts @@ -0,0 +1,106 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const manifestEntrypoint = fileURLToPath(new URL("../scripts/acquisition-data-room-manifest.mjs", import.meta.url)); +const integrityEntrypoint = fileURLToPath(new URL("../scripts/acquisition-data-room-integrity-audit.mjs", import.meta.url)); + +type UnsafeIndexFlag = "--skip-worktree" | "--assume-unchanged"; + +/** Create a local-only Git fixture whose tracked worktree differs from its exact HEAD. */ +function dirtyTrackedRepository(indexFlag?: UnsafeIndexFlag) { + const root = mkdtempSync(join(tmpdir(), "noema-data-room-git-binding-")); + writeFileSync(join(root, "README.md"), "committed evidence\n"); + const commands = [ + ["init", "--quiet"], + ["add", "README.md"], + ["-c", "user.name=Noema Tests", "-c", "user.email=noema-tests@example.invalid", "commit", "--quiet", "-m", "fixture"], + ]; + if (indexFlag) { + commands.push(["update-index", indexFlag, "--", "README.md"]); + } + for (const args of commands) { + const result = spawnSync("git", args, { + cwd: root, + encoding: "utf8", + timeout: 10_000, + }); + if (result.status !== 0) { + rmSync(root, { recursive: true, force: true }); + throw new Error(`failed to create local Git fixture: ${result.stderr}`); + } + } + writeFileSync(join(root, "README.md"), "mutated tracked evidence\n"); + return root; +} + +function runEntrypoint(entrypoint: string, root: string, outputDirectory: string) { + return spawnSync(process.execPath, [entrypoint], { + cwd: root, + env: { + ...process.env, + NOEMA_DATA_ROOM_OUTPUT_DIR: outputDirectory, + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: outputDirectory, + NOEMA_DATA_ROOM_MANIFEST_PATH: join(outputDirectory, "data-room-manifest.json"), + NOEMA_DATA_ROOM_SOURCE_COMMIT: "", + NOEMA_RELEASE_UNDER_DILIGENCE_TAG: "", + }, + encoding: "utf8", + timeout: 30_000, + }); +} + +describe("acquisition entrypoint exact-Git binding", () => { + it("refuses manifest generation when tracked bytes drift while HEAD remains fixed", () => { + const root = dirtyTrackedRepository(); + try { + const result = runEntrypoint(manifestEntrypoint, root, join(root, "artifacts")); + expect(result.status).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("tracked checkout differs from exact HEAD"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("refuses integrity authorization when tracked bytes drift while HEAD remains fixed", () => { + const root = dirtyTrackedRepository(); + try { + const result = runEntrypoint(integrityEntrypoint, root, join(root, "artifacts")); + expect(result.status).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("tracked checkout differs from exact HEAD"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it.each([ + ["skip-worktree", "--skip-worktree"], + ["assume-unchanged", "--assume-unchanged"], + ] as const)("refuses manifest generation when %s hides modified tracked bytes", (_label, indexFlag) => { + const root = dirtyTrackedRepository(indexFlag); + try { + const result = runEntrypoint(manifestEntrypoint, root, join(root, "artifacts")); + expect(result.status).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("unsafe Git index flag"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it.each([ + ["skip-worktree", "--skip-worktree"], + ["assume-unchanged", "--assume-unchanged"], + ] as const)("refuses integrity authorization when %s hides modified tracked bytes", (_label, indexFlag) => { + const root = dirtyTrackedRepository(indexFlag); + try { + const result = runEntrypoint(integrityEntrypoint, root, join(root, "artifacts")); + expect(result.status).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("unsafe Git index flag"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/acquisition-data-room-integrity-branches.test.ts b/test/acquisition-data-room-integrity-branches.test.ts new file mode 100644 index 000000000..430bc5c68 --- /dev/null +++ b/test/acquisition-data-room-integrity-branches.test.ts @@ -0,0 +1,653 @@ +import { createHash } from "node:crypto"; +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + DATA_ROOM_OBJECTIVE, + DATA_ROOM_REPOSITORY, + DATA_ROOM_SCHEMA_VERSION, + MAX_DATA_ROOM_EVIDENCE_BYTES, + MAX_DATA_ROOM_JSON_BYTES, + materializeDataRoomManifest, + readStableFile, + verifyDataRoomManifest, + verifyDataRoomManifestFile, +} from "../scripts/lib/acquisition-data-room-integrity.mjs"; + +const HEAD = "0123456789abcdef0123456789abcdef01234567"; +const RELEASE = "89abcdef0123456789abcdef0123456789abcdef"; + +function digest(value: Buffer | string) { + return createHash("sha256").update(value).digest("hex"); +} + +function manifest(entries: Array>, overrides: Record = {}) { + return { + schemaVersion: DATA_ROOM_SCHEMA_VERSION, + repository: DATA_ROOM_REPOSITORY, + objective: DATA_ROOM_OBJECTIVE, + source: { commitSha: HEAD }, + passed: true, + finalGatePassed: true, + missingRequired: [], + missingFinalGate: [], + entries, + ...overrides, + }; +} + +function fileEntry(path = "evidence/value.txt", overrides: Record = {}) { + return { + id: "file-evidence", + category: "security", + kind: "file", + path, + required: true, + requiredForFinalGate: true, + ...overrides, + }; +} + +function commandEntry(overrides: Record = {}) { + return { + id: "verify-command", + category: "automation", + kind: "command", + command: "npm run release:verify", + required: true, + requiredForFinalGate: true, + ...overrides, + }; +} + +function externalEntry(overrides: Record = {}) { + return { + id: "external-evidence", + category: "product", + kind: "external", + url: "https://example.invalid/evidence", + receiptPath: "artifacts/acquisition/external-receipt.json", + artifactPath: "artifacts/acquisition/external.json", + required: false, + requiredForFinalGate: true, + ...overrides, + }; +} + +function metadata(overrides: Record = {}) { + return { + dev: 1, + ino: 2, + size: 3, + mtimeMs: 4, + ctimeMs: 5, + isFile: () => true, + isSymbolicLink: () => false, + ...overrides, + }; +} + +function fileSystemFor(before = metadata(), opened = before) { + return { + constants: { O_RDONLY: 0, O_NOFOLLOW: 0x20000 }, + lstatSync: vi.fn(() => before), + openSync: vi.fn(() => 7), + fstatSync: vi.fn(() => opened), + readSync: vi.fn((_descriptor: number, buffer: Buffer, offset: number, length: number) => { + buffer.set(Buffer.from("abc").subarray(offset, offset + length), offset); + return length; + }), + closeSync: vi.fn(), + }; +} + +function writeExternalFixture(root: string, receiptOverrides: Record = {}) { + const entry = externalEntry(); + const artifactAbsolute = join(root, String(entry.artifactPath)); + const receiptAbsolute = join(root, String(entry.receiptPath)); + mkdirSync(join(root, "artifacts", "acquisition"), { recursive: true }); + const bytes = Buffer.from("{\"nodes\":3}\n"); + writeFileSync(artifactAbsolute, bytes); + const receipt = { + schemaVersion: 1, + repository: DATA_ROOM_REPOSITORY, + source: { commitSha: HEAD }, + sourceUrl: entry.url, + collectedAt: "2026-08-07T00:00:00.000Z", + collector: "noema-tests", + provenance: "immutable local export", + artifact: { + path: entry.artifactPath, + bytes: bytes.byteLength, + sha256: digest(bytes), + }, + ...receiptOverrides, + }; + writeFileSync(receiptAbsolute, JSON.stringify(receipt)); + return { entry, bytes, receiptAbsolute, artifactAbsolute }; +} + +describe("acquisition data-room integrity defensive branches", () => { + it.each([ + ["null metadata", null], + ["missing isFile", { ...metadata(), isFile: undefined }], + ["missing isSymbolicLink", { ...metadata(), isSymbolicLink: undefined }], + ["not a file", metadata({ isFile: () => false })], + ["symbolic link", metadata({ isSymbolicLink: () => true })], + ["non-integer size", metadata({ size: 1.5 })], + ["negative size", metadata({ size: -1 })], + ["oversized file", metadata({ size: MAX_DATA_ROOM_EVIDENCE_BYTES + 1 })], + ])("rejects unsafe initial metadata: %s", (_label, before) => { + const fs = fileSystemFor(before as never); + expect(readStableFile("ignored", MAX_DATA_ROOM_EVIDENCE_BYTES, fs)).toBeNull(); + expect(fs.openSync).not.toHaveBeenCalled(); + }); + + it("rejects invalid maximum bounds without touching the filesystem", () => { + const fs = fileSystemFor(); + expect(readStableFile("ignored", Number.NaN, fs)).toBeNull(); + expect(readStableFile("ignored", 1.5, fs)).toBeNull(); + expect(readStableFile("ignored", -1, fs)).toBeNull(); + expect(fs.lstatSync).not.toHaveBeenCalled(); + }); + + it("rejects missing or non-integer no-follow/open constants", () => { + const missingReadOnly = { ...fileSystemFor(), constants: { O_NOFOLLOW: 1 } }; + const missingNoFollow = { ...fileSystemFor(), constants: { O_RDONLY: 0 } }; + const nonInteger = { ...fileSystemFor(), constants: { O_RDONLY: 0, O_NOFOLLOW: 1.5 } }; + expect(readStableFile("ignored", 8, missingReadOnly as never)).toBeNull(); + expect(readStableFile("ignored", 8, missingNoFollow as never)).toBeNull(); + expect(readStableFile("ignored", 8, nonInteger as never)).toBeNull(); + }); + + it.each([ + ["device", { dev: 99 }], + ["inode", { ino: 99 }], + ["size", { size: 2 }], + ["mtime", { mtimeMs: 99 }], + ["ctime", { ctimeMs: 99 }], + ])("rejects opened descriptor identity drift: %s", (_label, drift) => { + const before = metadata(); + const fs = fileSystemFor(before, metadata(drift)); + expect(readStableFile("ignored", 8, fs)).toBeNull(); + expect(fs.closeSync).toHaveBeenCalledWith(7); + }); + + it("handles partial reads and rejects invalid/truncated reads", () => { + const stable = metadata({ size: 3 }); + const partial = fileSystemFor(stable, stable); + partial.readSync + .mockImplementationOnce((_descriptor: number, buffer: Buffer) => { + buffer[0] = 97; + return 1; + }) + .mockImplementationOnce((_descriptor: number, buffer: Buffer) => { + buffer.set(Buffer.from("bc"), 1); + return 2; + }); + expect(readStableFile("ignored", 8, partial)).toEqual(Buffer.from("abc")); + + for (const count of [0, -1, 1.5, Number.NaN]) { + const fs = fileSystemFor(stable, stable); + fs.readSync.mockReturnValueOnce(count); + expect(readStableFile("ignored", 8, fs)).toBeNull(); + } + }); + + it("fails closed on filesystem exceptions before, during, and after a read", () => { + const stable = metadata({ size: 3 }); + const failingOperations = ["lstatSync", "openSync", "fstatSync", "readSync"] as const; + for (const operation of failingOperations) { + const fs = fileSystemFor(stable, stable); + fs[operation].mockImplementationOnce(() => { + throw new Error(`${operation} failed`); + }); + expect(readStableFile("ignored", 8, fs)).toBeNull(); + } + + const afterDescriptor = fileSystemFor(stable, stable); + afterDescriptor.fstatSync + .mockReturnValueOnce(stable) + .mockReturnValueOnce(metadata({ ino: 9 })); + expect(readStableFile("ignored", 8, afterDescriptor)).toBeNull(); + + const afterPath = fileSystemFor(stable, stable); + afterPath.lstatSync + .mockReturnValueOnce(stable) + .mockImplementationOnce(() => { + throw new Error("post-read path lookup failed"); + }); + expect(readStableFile("ignored", 8, afterPath)).toBeNull(); + }); + + it.each([ + ["non-string", 7], + ["empty", ""], + ["oversized", "a".repeat(1025)], + ["control", "evidence/bad\nname"], + ["backslash", "evidence\\bad"], + ["absolute", "/tmp/evidence"], + ["empty component", "evidence//value"], + ["dot component", "evidence/./value"], + ["parent component", "evidence/../value"], + ])("materializes unsafe status for non-canonical file paths: %s", (_label, path) => { + const entry = fileEntry(path as never); + const output = materializeDataRoomManifest({ + rootDir: process.cwd(), + manifestPath: "unused.json", + commitSha: HEAD, + catalog: [entry], + }); + expect(output.entries[0].status).toBe("unsafe"); + expect(output.passed).toBe(false); + expect(output.finalGatePassed).toBe(false); + }); + + it("distinguishes missing files from unreadable filesystem failures", () => { + const root = mkdtempSync(join(tmpdir(), "noema-integrity-missing-")); + try { + const entry = fileEntry(); + const missing = materializeDataRoomManifest({ + rootDir: root, + manifestPath: "unused.json", + commitSha: HEAD, + catalog: [entry], + }); + expect(missing.entries[0].status).toBe("missing"); + + const fs = fileSystemFor(); + fs.lstatSync.mockImplementation(() => { + const error = new Error("denied"); + Object.assign(error, { code: "EACCES" }); + throw error; + }); + const unsafe = materializeDataRoomManifest({ + rootDir: root, + manifestPath: "unused.json", + commitSha: HEAD, + catalog: [entry], + fileSystem: fs, + }); + expect(unsafe.entries[0].status).toBe("unsafe"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it.each([ + ["invalid utf8", Buffer.from([0xff])], + ["malformed json", Buffer.from("{")], + ["scalar json", Buffer.from("42")], + ])("rejects unsafe manifest JSON: %s", (_label, bytes) => { + const root = mkdtempSync(join(tmpdir(), "noema-integrity-json-")); + try { + const path = join(root, "manifest.json"); + writeFileSync(path, bytes); + const result = verifyDataRoomManifestFile(path, { + rootDir: root, + expectedCommitSha: HEAD, + catalog: [commandEntry()], + }); + expect(result.integrityPassed).toBe(false); + expect(result.failures).toEqual([ + "manifest JSON is missing, unsafe, malformed, oversized, or contains duplicate object keys", + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects a missing manifest file and a non-object manifest value", () => { + const root = mkdtempSync(join(tmpdir(), "noema-integrity-nonobject-")); + try { + expect(verifyDataRoomManifestFile(join(root, "missing.json"), { + rootDir: root, + expectedCommitSha: HEAD, + catalog: [commandEntry()], + }).integrityPassed).toBe(false); + expect(verifyDataRoomManifest(null, { + rootDir: root, + expectedCommitSha: HEAD, + catalog: [commandEntry()], + }).failures).toEqual(["manifest must be a JSON object"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("accepts an exact immutable release identity and rejects unexpected release metadata", () => { + const entry = commandEntry(); + const released = manifest([{ ...entry, status: "present" }], { + release: { tag: "v1.2.3", commitSha: RELEASE }, + }); + expect(verifyDataRoomManifest(released, { + expectedCommitSha: HEAD, + expectedReleaseTag: "v1.2.3", + expectedReleaseCommitSha: RELEASE, + catalog: [entry], + }).integrityPassed).toBe(true); + + const unexpected = manifest([{ ...entry, status: "present" }], { + release: { tag: "v1.2.3", commitSha: RELEASE }, + }); + expect(verifyDataRoomManifest(unexpected, { + expectedCommitSha: HEAD, + catalog: [entry], + }).failures).toContain("release identity must be absent when no immutable release is selected"); + }); + + it("rejects empty catalogs, non-array entries, malformed entries, and immutable identity changes", () => { + expect(verifyDataRoomManifest(manifest([]), { + expectedCommitSha: HEAD, + catalog: [], + }).failures).toContain("reviewed catalog must be a bounded non-empty array"); + + const entry = commandEntry(); + const nonArrayEntries = manifest([], { entries: null }); + expect(verifyDataRoomManifest(nonArrayEntries, { + expectedCommitSha: HEAD, + catalog: [entry], + }).failures).toContain("manifest entry set must exactly match the reviewed catalog"); + + const malformed = manifest([null as never, { id: 7 } as never], { + passed: false, + finalGatePassed: false, + missingRequired: [entry.id], + missingFinalGate: [entry.id], + }); + expect(verifyDataRoomManifest(malformed, { + expectedCommitSha: HEAD, + catalog: [entry], + }).failures).toContain("manifest entries must be objects with reviewed ids"); + + const changedIdentity = manifest([{ ...entry, command: "true", status: "present" }]); + expect(verifyDataRoomManifest(changedIdentity, { + expectedCommitSha: HEAD, + catalog: [entry], + }).failures).toContain("verify-command immutable catalog identity does not match policy"); + }); + + it("cross-checks missing and contradictory persisted file state", () => { + const root = mkdtempSync(join(tmpdir(), "noema-integrity-state-")); + try { + const entry = fileEntry(); + const missingManifest = manifest([{ + ...entry, + status: "present", + bytes: 1, + sha256: "0".repeat(64), + }], { + passed: false, + finalGatePassed: false, + missingRequired: [entry.id], + missingFinalGate: [entry.id], + }); + const result = verifyDataRoomManifest(missingManifest, { + rootDir: root, + expectedCommitSha: HEAD, + catalog: [entry], + }); + expect(result.failures).toContain("file-evidence persisted file status does not match retained evidence"); + + mkdirSync(join(root, "evidence"), { recursive: true }); + writeFileSync(join(root, String(entry.path)), "abc"); + const persistedMissing = manifest([{ + ...entry, + status: "missing", + bytes: null, + sha256: null, + }]); + expect(verifyDataRoomManifest(persistedMissing, { + rootDir: root, + expectedCommitSha: HEAD, + catalog: [entry], + }).failures).toContain("file-evidence file digest or byte size does not match retained evidence"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("cross-checks command status and persisted aggregate claims", () => { + const entry = commandEntry(); + const result = verifyDataRoomManifest(manifest([{ ...entry, status: "missing" }], { + passed: false, + finalGatePassed: false, + missingRequired: null, + missingFinalGate: ["wrong-id"], + }), { + expectedCommitSha: HEAD, + catalog: [entry], + }); + expect(result.failures).toContain("verify-command command status does not match the reviewed catalog"); + expect(result.failures).toContain("persisted passed value contradicts trusted recomputation"); + expect(result.failures).toContain("persisted finalGatePassed value contradicts trusted recomputation"); + expect(result.failures).toContain("persisted missingRequired list contradicts trusted recomputation"); + expect(result.failures).toContain("persisted missingFinalGate list contradicts trusted recomputation"); + }); + + it("accounts for catalog entries omitted from the manifest", () => { + const required = commandEntry(); + const optional = commandEntry({ + id: "optional-command", + command: "true", + required: false, + requiredForFinalGate: false, + }); + const result = verifyDataRoomManifest(manifest([], { + passed: false, + finalGatePassed: false, + missingRequired: [required.id], + missingFinalGate: [required.id], + }), { + expectedCommitSha: HEAD, + catalog: [required, optional], + }); + expect(result.missingRequired).toContain(required.id); + expect(result.missingRequired).not.toContain(optional.id); + expect(result.missingFinalGate).toContain(required.id); + expect(result.missingFinalGate).not.toContain(optional.id); + }); + + it("rejects non-canonical external artifact paths and unreadable or malformed receipts", () => { + const root = mkdtempSync(join(tmpdir(), "noema-integrity-receipt-errors-")); + try { + const invalidArtifact = externalEntry({ artifactPath: "artifacts/../external.json" }); + let result = verifyDataRoomManifest(manifest([{ + ...invalidArtifact, + status: "declared", + receiptVerified: false, + }], { + finalGatePassed: false, + missingFinalGate: [invalidArtifact.id], + }), { + rootDir: root, + expectedCommitSha: HEAD, + catalog: [invalidArtifact], + }); + expect(result.failures).toContain("external-evidence reviewed retained artifact path is not canonical"); + + const fixture = writeExternalFixture(root); + writeFileSync(fixture.receiptAbsolute, "{"); + result = verifyDataRoomManifest(manifest([{ + ...fixture.entry, + status: "declared", + receiptVerified: false, + }], { + finalGatePassed: false, + missingFinalGate: [fixture.entry.id], + }), { + rootDir: root, + expectedCommitSha: HEAD, + catalog: [fixture.entry], + }); + expect(result.failures).toContain("external-evidence receipt is malformed, ambiguous, oversized, or unsafe"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it.each([ + ["schema", { schemaVersion: 2 }], + ["repository", { repository: "other/repository" }], + ["source commit", { source: { commitSha: RELEASE } }], + ["source URL", { sourceUrl: "https://example.invalid/other" }], + ["timestamp type", { collectedAt: 7 }], + ["timestamp shape", { collectedAt: "2026-08-07" }], + ["collector type", { collector: 7 }], + ["collector empty", { collector: "" }], + ["collector oversized", { collector: "x".repeat(257) }], + ["collector control", { collector: "bad\ncollector" }], + ["provenance type", { provenance: 7 }], + ["provenance empty", { provenance: "" }], + ["provenance control", { provenance: "bad\nprovenance" }], + ])("rejects external receipt identity field: %s", (_label, overrides) => { + const root = mkdtempSync(join(tmpdir(), "noema-integrity-receipt-field-")); + try { + const fixture = writeExternalFixture(root, overrides); + const result = verifyDataRoomManifest(manifest([{ + ...fixture.entry, + status: "declared", + receiptVerified: false, + }], { + finalGatePassed: false, + missingFinalGate: [fixture.entry.id], + }), { + rootDir: root, + expectedCommitSha: HEAD, + catalog: [fixture.entry], + }); + expect(result.integrityPassed).toBe(false); + expect(result.failures).toContain("external-evidence receipt does not authenticate the retained external artifact"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it.each([ + ["artifact missing", { artifact: undefined }], + ["artifact scalar", { artifact: 7 }], + ["artifact wrong path", { artifact: { path: "README.md", bytes: 1, sha256: "0".repeat(64) } }], + ])("rejects external receipt artifact identity: %s", (_label, overrides) => { + const root = mkdtempSync(join(tmpdir(), "noema-integrity-artifact-id-")); + try { + const fixture = writeExternalFixture(root, overrides); + const result = verifyDataRoomManifest(manifest([{ + ...fixture.entry, + status: "declared", + receiptVerified: false, + }], { + finalGatePassed: false, + missingFinalGate: [fixture.entry.id], + }), { + rootDir: root, + expectedCommitSha: HEAD, + catalog: [fixture.entry], + }); + expect(result.integrityPassed).toBe(false); + expect(result.failures).toContain("external-evidence receipt does not authenticate the reviewed retained artifact path"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects external artifact size/hash errors and persisted external status contradictions", () => { + const root = mkdtempSync(join(tmpdir(), "noema-integrity-artifact-fields-")); + try { + for (const artifact of [ + { path: "artifacts/acquisition/external.json", bytes: 0, sha256: "0".repeat(64) }, + { path: "artifacts/acquisition/external.json", bytes: 12.5, sha256: "0".repeat(64) }, + { path: "artifacts/acquisition/external.json", bytes: 12, sha256: "bad" }, + { path: "artifacts/acquisition/external.json", bytes: 12, sha256: "0".repeat(64) }, + ]) { + const fixture = writeExternalFixture(root, { artifact }); + const result = verifyDataRoomManifest(manifest([{ + ...fixture.entry, + status: "present", + receiptVerified: true, + }]), { + rootDir: root, + expectedCommitSha: HEAD, + catalog: [fixture.entry], + }); + expect(result.integrityPassed).toBe(false); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("materializes valid, declared, unsafe, missing, and release-bound evidence states", () => { + const root = mkdtempSync(join(tmpdir(), "noema-integrity-materialize-")); + try { + mkdirSync(join(root, "evidence"), { recursive: true }); + writeFileSync(join(root, "evidence", "value.txt"), "abc"); + const local = fileEntry(); + const external = externalEntry(); + const declared = materializeDataRoomManifest({ + rootDir: root, + manifestPath: "manifest.json", + commitSha: HEAD, + releaseTag: "v1.2.3", + releaseCommitSha: RELEASE, + generatedAt: "2026-08-07T00:00:00.000Z", + catalog: [local, external, commandEntry()], + }); + expect(declared.release).toEqual({ tag: "v1.2.3", commitSha: RELEASE }); + expect(declared.entries.map((entry) => entry.status)).toEqual(["present", "declared", "present"]); + expect(declared.finalGatePassed).toBe(false); + + writeExternalFixture(root); + const verified = materializeDataRoomManifest({ + rootDir: root, + manifestPath: "manifest.json", + commitSha: HEAD, + catalog: [external], + }); + expect(verified.entries[0].status).toBe("present"); + expect(verified.entries[0].receiptVerified).toBe(true); + + writeFileSync(join(root, String(external.receiptPath)), "{"); + const unsafe = materializeDataRoomManifest({ + rootDir: root, + manifestPath: "manifest.json", + commitSha: HEAD, + catalog: [external], + }); + expect(unsafe.entries[0].status).toBe("unsafe"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects invalid materialization commit identities", () => { + expect(() => materializeDataRoomManifest({ + commitSha: "not-a-sha", + catalog: [commandEntry()], + })).toThrow("commitSha must be the exact 40-character audited Git commit"); + expect(() => materializeDataRoomManifest({ + commitSha: null as never, + catalog: [commandEntry()], + })).toThrow("commitSha must be the exact 40-character audited Git commit"); + }); + + it("does not accept oversized manifest files", () => { + const root = mkdtempSync(join(tmpdir(), "noema-integrity-oversized-json-")); + try { + const path = join(root, "manifest.json"); + writeFileSync(path, "x".repeat(MAX_DATA_ROOM_JSON_BYTES + 1)); + const result = verifyDataRoomManifestFile(path, { + expectedCommitSha: HEAD, + catalog: [commandEntry()], + }); + expect(result.integrityPassed).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/acquisition-data-room-integrity-coverage.test.ts b/test/acquisition-data-room-integrity-coverage.test.ts new file mode 100644 index 000000000..70f7a33bd --- /dev/null +++ b/test/acquisition-data-room-integrity-coverage.test.ts @@ -0,0 +1,280 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import * as nodePath from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +const HEAD = "0123456789abcdef0123456789abcdef01234567"; +const OTHER_HEAD = "89abcdef0123456789abcdef0123456789abcdef"; + +function commandEntry() { + return { + id: "verify-command", + category: "automation", + kind: "command", + command: "npm run release:verify", + required: true, + requiredForFinalGate: true, + }; +} + +function fileEntry() { + return { + id: "file-evidence", + category: "security", + kind: "file", + path: "evidence/missing.txt", + required: true, + requiredForFinalGate: true, + }; +} + +function externalEntry() { + return { + id: "external-evidence", + category: "product", + kind: "external", + url: "https://example.invalid/evidence", + receiptPath: "artifacts/acquisition/external-receipt.json", + artifactPath: "artifacts/acquisition/external.json", + required: false, + requiredForFinalGate: true, + }; +} + +function exactManifest(constants: { + DATA_ROOM_SCHEMA_VERSION: number; + DATA_ROOM_REPOSITORY: string; + DATA_ROOM_OBJECTIVE: string; +}, entries: Array>, overrides: Record = {}) { + return { + schemaVersion: constants.DATA_ROOM_SCHEMA_VERSION, + repository: constants.DATA_ROOM_REPOSITORY, + objective: constants.DATA_ROOM_OBJECTIVE, + source: { commitSha: HEAD }, + passed: true, + finalGatePassed: true, + missingRequired: [], + missingFinalGate: [], + entries, + ...overrides, + }; +} + +describe.sequential("acquisition data-room integrity coverage guards", () => { + it("delegates a valid retained manifest file to trusted recomputation", async () => { + vi.doUnmock("node:path"); + vi.resetModules(); + const integrity = await import("../scripts/lib/acquisition-data-room-integrity.mjs"); + const root = mkdtempSync(nodePath.join(tmpdir(), "noema-valid-manifest-")); + try { + const catalog = [commandEntry()]; + const manifest = exactManifest(integrity, [{ ...catalog[0], status: "present" }]); + const path = nodePath.join(root, "manifest.json"); + writeFileSync(path, `${JSON.stringify(manifest)}\n`); + + const result = integrity.verifyDataRoomManifestFile(path, { + rootDir: root, + expectedCommitSha: HEAD, + catalog, + }); + + expect(result.integrityPassed).toBe(true); + expect(result.finalGatePassed).toBe(true); + expect(result.failures).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("covers every fail-closed source-commit identity branch", async () => { + vi.doUnmock("node:path"); + vi.resetModules(); + const integrity = await import("../scripts/lib/acquisition-data-room-integrity.mjs"); + const catalog = [commandEntry()]; + const entry = { ...catalog[0], status: "present" }; + + const missingExpected = integrity.verifyDataRoomManifest( + exactManifest(integrity, [entry]), + { expectedCommitSha: null as never, catalog }, + ); + expect(missingExpected.failures).toContain("source.commitSha must match the exact audited commit"); + + const missingSource = integrity.verifyDataRoomManifest( + exactManifest(integrity, [entry], { source: undefined }), + { expectedCommitSha: HEAD, catalog }, + ); + expect(missingSource.failures).toContain("source.commitSha must match the exact audited commit"); + + const mismatchedSource = integrity.verifyDataRoomManifest( + exactManifest(integrity, [entry]), + { expectedCommitSha: OTHER_HEAD, catalog }, + ); + expect(mismatchedSource.failures).toContain("source.commitSha must match the exact audited commit"); + }); + + it("cross-checks every persisted missing-file field without short-circuit blind spots", async () => { + vi.doUnmock("node:path"); + vi.resetModules(); + const integrity = await import("../scripts/lib/acquisition-data-room-integrity.mjs"); + const root = mkdtempSync(nodePath.join(tmpdir(), "noema-missing-file-state-")); + try { + const catalog = [fileEntry()]; + const baseOverrides = { + passed: false, + finalGatePassed: false, + missingRequired: [catalog[0].id], + missingFinalGate: [catalog[0].id], + }; + const persistedStates = [ + { status: "present", bytes: null, sha256: null, shouldFail: true }, + { status: "missing", bytes: 1, sha256: null, shouldFail: true }, + { status: "missing", bytes: null, sha256: "0".repeat(64), shouldFail: true }, + { status: "missing", bytes: null, sha256: null, shouldFail: false }, + ]; + + for (const state of persistedStates) { + const manifest = exactManifest( + integrity, + [{ + ...catalog[0], + status: state.status, + bytes: state.bytes, + sha256: state.sha256, + }], + baseOverrides, + ); + const result = integrity.verifyDataRoomManifest(manifest, { + rootDir: root, + expectedCommitSha: HEAD, + catalog, + }); + expect(result.failures.includes("file-evidence persisted file status does not match retained evidence")) + .toBe(state.shouldFail); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("fails closed when an external receipt exists conceptually but cannot be inspected", async () => { + vi.doUnmock("node:path"); + vi.resetModules(); + const integrity = await import("../scripts/lib/acquisition-data-room-integrity.mjs"); + const root = mkdtempSync(nodePath.join(tmpdir(), "noema-receipt-denied-")); + try { + const catalog = [externalEntry()]; + const manifest = exactManifest( + integrity, + [{ ...catalog[0], status: "declared", receiptVerified: false }], + { + finalGatePassed: false, + missingFinalGate: [catalog[0].id], + }, + ); + const denied = Object.assign(new Error("permission denied"), { code: "EACCES" }); + const fileSystem = { + constants: { O_RDONLY: 0, O_NOFOLLOW: 0x20000 }, + lstatSync: vi.fn(() => { + throw denied; + }), + openSync: vi.fn(), + fstatSync: vi.fn(), + readSync: vi.fn(), + closeSync: vi.fn(), + }; + + const result = integrity.verifyDataRoomManifest(manifest, { + rootDir: root, + expectedCommitSha: HEAD, + catalog, + fileSystem, + }); + + expect(result.integrityPassed).toBe(false); + expect(result.failures).toContain("external-evidence receipt is unreadable or unsafe"); + expect(fileSystem.openSync).not.toHaveBeenCalled(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("retains lexical root-escape defenses even after component canonicality checks", async () => { + vi.resetModules(); + vi.doMock("node:path", async () => { + const actual = await vi.importActual("node:path"); + return { + ...actual, + relative: vi.fn((from: string, to: string) => { + switch (actual.basename(to)) { + case "parent-escape.txt": + return ".."; + case "child-escape.txt": + return `..${actual.sep}escape`; + case "absolute-escape.txt": + return actual.resolve(actual.sep, "escape"); + default: + return actual.relative(from, to); + } + }), + }; + }); + + const integrity = await import("../scripts/lib/acquisition-data-room-integrity.mjs"); + const root = mkdtempSync(nodePath.join(tmpdir(), "noema-path-guard-")); + try { + mkdirSync(nodePath.join(root, "safe"), { recursive: true }); + const catalog = [ + { + id: "parent-escape", + category: "security", + kind: "file", + path: "parent-escape.txt", + required: false, + requiredForFinalGate: false, + }, + { + id: "child-escape", + category: "security", + kind: "file", + path: "child-escape.txt", + required: false, + requiredForFinalGate: false, + }, + { + id: "absolute-escape", + category: "security", + kind: "file", + path: "absolute-escape.txt", + required: false, + requiredForFinalGate: false, + }, + { + id: "safe-missing", + category: "security", + kind: "file", + path: "safe/missing.txt", + required: false, + requiredForFinalGate: false, + }, + ]; + + const output = integrity.materializeDataRoomManifest({ + rootDir: root, + manifestPath: "unused.json", + commitSha: HEAD, + catalog, + }); + + expect(output.entries.map((entry: { status: string }) => entry.status)).toEqual([ + "unsafe", + "unsafe", + "unsafe", + "missing", + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + vi.doUnmock("node:path"); + vi.resetModules(); + } + }); +}); diff --git a/test/acquisition-data-room-integrity.test.ts b/test/acquisition-data-room-integrity.test.ts new file mode 100644 index 000000000..f99868fa6 --- /dev/null +++ b/test/acquisition-data-room-integrity.test.ts @@ -0,0 +1,407 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + DATA_ROOM_OBJECTIVE, + DATA_ROOM_REPOSITORY, + DATA_ROOM_SCHEMA_VERSION, + readStableFile, + verifyDataRoomManifest, + verifyDataRoomManifestFile, +} from "../scripts/lib/acquisition-data-room-integrity.mjs"; + +const HEAD = "0123456789abcdef0123456789abcdef01234567"; + +const testCatalog = [ + { + id: "local-evidence", + category: "security", + kind: "file", + path: "evidence/local.txt", + required: true, + requiredForFinalGate: true, + }, + { + id: "design-evidence", + category: "product", + kind: "external", + url: "https://example.com/design", + receiptPath: "artifacts/acquisition/design-evidence-receipt.json", + artifactPath: "artifacts/acquisition/design-export.json", + required: false, + requiredForFinalGate: true, + }, + { + id: "verify-command", + category: "automation", + kind: "command", + command: "npm run release:verify", + required: true, + requiredForFinalGate: true, + }, +] as const; + +function sha256(value: Buffer | string) { + return createHash("sha256").update(value).digest("hex"); +} + +function canonicalManifest(root: string, { external = false } = {}) { + const localPath = join(root, "evidence", "local.txt"); + mkdirSync(dirname(localPath), { recursive: true }); + writeFileSync(localPath, "reviewed evidence\n"); + const bytes = readFileSync(localPath); + const entries: Array> = [ + { + ...testCatalog[0], + status: "present", + bytes: bytes.byteLength, + sha256: sha256(bytes), + }, + { + ...testCatalog[1], + status: external ? "present" : "declared", + receiptVerified: external, + }, + { + ...testCatalog[2], + status: "present", + }, + ]; + const missingFinalGate = external ? [] : ["design-evidence"]; + return { + schemaVersion: DATA_ROOM_SCHEMA_VERSION, + repository: DATA_ROOM_REPOSITORY, + objective: DATA_ROOM_OBJECTIVE, + source: { commitSha: HEAD }, + passed: true, + finalGatePassed: external, + missingRequired: [], + missingFinalGate, + entries, + }; +} + +function writeExternalReceipt(root: string) { + const artifactPath = join(root, "artifacts", "acquisition", "design-export.json"); + mkdirSync(dirname(artifactPath), { recursive: true }); + writeFileSync(artifactPath, "{\"nodes\":3}\n"); + const artifact = readFileSync(artifactPath); + const receiptPath = join(root, "artifacts", "acquisition", "design-evidence-receipt.json"); + writeFileSync(receiptPath, JSON.stringify({ + schemaVersion: 1, + repository: DATA_ROOM_REPOSITORY, + source: { commitSha: HEAD }, + sourceUrl: "https://example.com/design", + collectedAt: "2026-08-07T00:00:00.000Z", + collector: "cwl-acquisition-evidence", + provenance: "manual immutable export retained in the acquisition data room", + artifact: { + path: "artifacts/acquisition/design-export.json", + bytes: artifact.byteLength, + sha256: sha256(artifact), + }, + }, null, 2)); +} + +describe("acquisition data-room integrity", () => { + it("rejects a forged all-green manifest instead of trusting persisted booleans", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-data-room-forged-")); + try { + const result = verifyDataRoomManifest({ + objective: DATA_ROOM_OBJECTIVE, + passed: true, + finalGatePassed: true, + missingRequired: [], + missingFinalGate: [], + entries: [], + }, { + rootDir: temp, + expectedCommitSha: HEAD, + catalog: testCatalog, + }); + + expect(result.integrityPassed).toBe(false); + expect(result.finalGatePassed).toBe(false); + expect(result.failures).toContain("schemaVersion must match the reviewed data-room schema"); + expect(result.failures).toContain("repository must match ContextualWisdomLab/noema"); + expect(result.failures).toContain("source.commitSha must match the exact audited commit"); + expect(result.failures).toContain("manifest entry set must exactly match the reviewed catalog"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("makes the acquisition integrity audit fail closed on a forged all-green manifest", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-data-room-npm-forged-")); + const manifestPath = join(temp, "forged-manifest.json"); + try { + writeFileSync(manifestPath, JSON.stringify({ + schemaVersion: DATA_ROOM_SCHEMA_VERSION, + repository: DATA_ROOM_REPOSITORY, + objective: DATA_ROOM_OBJECTIVE, + source: { commitSha: "0000000000000000000000000000000000000000" }, + passed: true, + finalGatePassed: true, + missingRequired: [], + missingFinalGate: [], + entries: [], + })); + const result = spawnSync(process.execPath, ["scripts/acquisition-data-room-integrity-audit.mjs"], { + cwd: process.cwd(), + env: { + ...process.env, + NOEMA_DATA_ROOM_MANIFEST_PATH: manifestPath, + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: join(temp, "audit"), + }, + encoding: "utf8", + timeout: 30_000, + }); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("acquisition-data-room-integrity: FAIL"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("recomputes local hashes and refuses evidence modified after manifest generation", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-data-room-mutated-")); + try { + const manifest = canonicalManifest(temp); + writeFileSync(join(temp, "evidence", "local.txt"), "changed evidence\n"); + const result = verifyDataRoomManifest(manifest, { + rootDir: temp, + expectedCommitSha: HEAD, + catalog: testCatalog, + }); + + expect(result.integrityPassed).toBe(false); + expect(result.failures).toContain("local-evidence file digest or byte size does not match retained evidence"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("rejects same-size content mutation and persisted byte-size mismatch independently", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-data-room-digest-")); + try { + const manifest = canonicalManifest(temp); + writeFileSync(join(temp, "evidence", "local.txt"), "reviewed evidencf\n"); + let result = verifyDataRoomManifest(manifest, { + rootDir: temp, + expectedCommitSha: HEAD, + catalog: testCatalog, + }); + expect(result.failures).toContain("local-evidence file digest or byte size does not match retained evidence"); + + writeFileSync(join(temp, "evidence", "local.txt"), "reviewed evidence\n"); + manifest.entries[0].bytes = Number(manifest.entries[0].bytes) + 1; + result = verifyDataRoomManifest(manifest, { + rootDir: temp, + expectedCommitSha: HEAD, + catalog: testCatalog, + }); + expect(result.failures).toContain("local-evidence file digest or byte size does not match retained evidence"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("rejects symlink and non-regular substitution for retained local artifacts", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-data-room-substitution-")); + try { + const manifest = canonicalManifest(temp); + const localPath = join(temp, "evidence", "local.txt"); + const target = join(temp, "alternate.txt"); + writeFileSync(target, "reviewed evidence\n"); + rmSync(localPath); + symlinkSync(target, localPath); + + let result = verifyDataRoomManifest(manifest, { + rootDir: temp, + expectedCommitSha: HEAD, + catalog: testCatalog, + }); + expect(result.integrityPassed).toBe(false); + expect(result.failures).toContain("local-evidence file is missing, unsafe, non-regular, or exceeds the evidence limit"); + + rmSync(localPath); + mkdirSync(localPath); + result = verifyDataRoomManifest(manifest, { + rootDir: temp, + expectedCommitSha: HEAD, + catalog: testCatalog, + }); + expect(result.integrityPassed).toBe(false); + expect(result.failures).toContain("local-evidence file is missing, unsafe, non-regular, or exceeds the evidence limit"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("treats a bare HTTPS declaration as non-verifying final-gate metadata", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-data-room-external-")); + try { + const manifest = canonicalManifest(temp); + const result = verifyDataRoomManifest(manifest, { + rootDir: temp, + expectedCommitSha: HEAD, + catalog: testCatalog, + }); + + expect(result.integrityPassed).toBe(true); + expect(result.finalGatePassed).toBe(false); + expect(result.missingFinalGate).toEqual(["design-evidence"]); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("accepts external evidence only when an exact-commit receipt verifies a retained artifact", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-data-room-receipt-")); + try { + writeExternalReceipt(temp); + const manifest = canonicalManifest(temp, { external: true }); + const result = verifyDataRoomManifest(manifest, { + rootDir: temp, + expectedCommitSha: HEAD, + catalog: testCatalog, + }); + + expect(result.integrityPassed).toBe(true); + expect(result.recomputedPassed).toBe(true); + expect(result.finalGatePassed).toBe(true); + expect(result.missingRequired).toEqual([]); + expect(result.missingFinalGate).toEqual([]); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("rejects duplicate, unknown, and contradictory entry identities", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-data-room-identity-")); + try { + const manifest = canonicalManifest(temp); + manifest.entries.push({ ...manifest.entries[0] }); + manifest.entries[1] = { + ...manifest.entries[1], + id: "unknown-entry", + }; + const result = verifyDataRoomManifest(manifest, { + rootDir: temp, + expectedCommitSha: HEAD, + catalog: testCatalog, + }); + + expect(result.integrityPassed).toBe(false); + expect(result.failures).toContain("manifest entry ids must be unique"); + expect(result.failures).toContain("manifest entry set must exactly match the reviewed catalog"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("rejects wrong repository, objective, exact commit, and release identity", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-data-room-binding-")); + try { + const manifest = canonicalManifest(temp); + manifest.repository = "attacker/noema"; + manifest.objective = "other-objective"; + manifest.source.commitSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + Object.assign(manifest, { + release: { + tag: "v9.9.9", + commitSha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + }); + const result = verifyDataRoomManifest(manifest, { + rootDir: temp, + expectedCommitSha: HEAD, + expectedReleaseTag: "v1.2.3", + expectedReleaseCommitSha: "cccccccccccccccccccccccccccccccccccccccc", + catalog: testCatalog, + }); + + expect(result.integrityPassed).toBe(false); + expect(result.failures).toContain("repository must match ContextualWisdomLab/noema"); + expect(result.failures).toContain("objective must match the acquisition-readiness objective"); + expect(result.failures).toContain("source.commitSha must match the exact audited commit"); + expect(result.failures).toContain("release identity must match the selected immutable release"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("rejects malformed persisted gap lists even when retained evidence is unchanged", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-data-room-gap-list-")); + try { + const manifest = canonicalManifest(temp); + manifest.missingFinalGate = []; + manifest.finalGatePassed = true; + const result = verifyDataRoomManifest(manifest, { + rootDir: temp, + expectedCommitSha: HEAD, + catalog: testCatalog, + }); + + expect(result.integrityPassed).toBe(false); + expect(result.finalGatePassed).toBe(false); + expect(result.failures).toContain("persisted finalGatePassed value contradicts trusted recomputation"); + expect(result.failures).toContain("persisted missingFinalGate list contradicts trusted recomputation"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("rejects malformed and duplicate-key JSON before authorization", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-data-room-json-")); + const manifestPath = join(temp, "manifest.json"); + try { + writeFileSync(manifestPath, `{"schemaVersion":1,"schemaVersion":1,"repository":"${DATA_ROOM_REPOSITORY}"}`); + const result = verifyDataRoomManifestFile(manifestPath, { + rootDir: temp, + expectedCommitSha: HEAD, + catalog: testCatalog, + }); + expect(result.integrityPassed).toBe(false); + expect(result.failures).toEqual(["manifest JSON is missing, unsafe, malformed, oversized, or contains duplicate object keys"]); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("refuses path-to-descriptor identity drift after a bounded read", () => { + const before = { + dev: 1, + ino: 10, + size: 3, + mtimeMs: 1, + ctimeMs: 1, + isFile: () => true, + isSymbolicLink: () => false, + }; + const after = { ...before, ino: 11 }; + const lstatSync = vi.fn() + .mockReturnValueOnce(before) + .mockReturnValueOnce(after); + const fstatSync = vi.fn().mockReturnValue(before); + const closeSync = vi.fn(); + const fileSystem = { + constants: { O_RDONLY: 0, O_NOFOLLOW: 0x20000 }, + lstatSync, + openSync: vi.fn(() => 7), + fstatSync, + readSync: vi.fn((_fd, buffer: Buffer) => { + buffer.set(Buffer.from("abc")); + return 3; + }), + closeSync, + }; + + expect(readStableFile("evidence.json", 16, fileSystem)).toBeNull(); + expect(closeSync).toHaveBeenCalledWith(7); + }); +}); diff --git a/test/acquisition-git-content-hash.test.ts b/test/acquisition-git-content-hash.test.ts new file mode 100644 index 000000000..be5d4c6a3 --- /dev/null +++ b/test/acquisition-git-content-hash.test.ts @@ -0,0 +1,433 @@ +import { spawnSync } from "node:child_process"; +import { + mkdtempSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { verifyAcquisitionTrackedBytes } from "../scripts/lib/acquisition-git-preflight.mjs"; + +const SHA1_A = "a".repeat(40); +const SHA1_B = "b".repeat(40); +const SHA256_A = "a".repeat(64); + +function runGit(root: string, args: string[]) { + const result = spawnSync("git", args, { + cwd: root, + encoding: "utf8", + timeout: 10_000, + }); + if (result.error) { + throw result.error; + } + if (result.signal) { + throw new Error(`Git fixture command terminated by ${result.signal}`); + } + if (result.status !== 0) { + throw new Error(`Git fixture command failed: git ${args.join(" ")}\n${result.stderr}`); + } + return result; +} + +function gitResult(overrides: Record = {}) { + return { + status: 0, + signal: null, + error: undefined, + stdout: "", + stderr: "", + ...overrides, + }; +} + +function spawnSequence(...results: Array>) { + const mock = vi.fn(); + results.forEach((result) => mock.mockReturnValueOnce(gitResult(result))); + return mock; +} + +function regularMetadata(overrides: Record = {}) { + return { + dev: 1, + ino: 2, + mode: 0o100644, + size: 8, + mtimeMs: 3, + ctimeMs: 4, + isFile: () => true, + isSymbolicLink: () => false, + ...overrides, + }; +} + +function trackedRecord( + path = "tracked.txt", + objectId = SHA1_A, + mode = "100644", + stage = "0", +) { + return `${mode} ${objectId} ${stage}\t${path}\0`; +} + +function descriptorFileSystem({ + pathStates = [regularMetadata(), regularMetadata()], + descriptorStates = [regularMetadata(), regularMetadata()], + contents = Buffer.from("tracked\n"), + readResults, + constants = { O_RDONLY: 0, O_NOFOLLOW: 0x20000 }, +}: { + pathStates?: Array | null>; + descriptorStates?: Array | null>; + contents?: Buffer; + readResults?: number[]; + constants?: Record; +} = {}) { + const lstatSync = vi.fn(); + pathStates.forEach((state) => lstatSync.mockReturnValueOnce(state)); + const fstatSync = vi.fn(); + descriptorStates.forEach((state) => fstatSync.mockReturnValueOnce(state)); + let sourceOffset = 0; + let readIndex = 0; + const readSync = vi.fn(( + _descriptor: number, + buffer: Buffer, + bufferOffset: number, + length: number, + ) => { + if (readResults) { + const configured = readResults[readIndex] ?? 0; + readIndex += 1; + return configured; + } + const count = Math.min(length, contents.length - sourceOffset); + if (count <= 0) { + return 0; + } + contents.copy(buffer, bufferOffset, sourceOffset, sourceOffset + count); + sourceOffset += count; + return count; + }); + return { + constants, + lstatSync, + openSync: vi.fn(() => 17), + fstatSync, + readSync, + closeSync: vi.fn(), + }; +} + +describe("acquisition exact tracked-byte authentication", () => { + it.skipIf(process.platform === "win32")( + "rejects same-size content drift even when Git's stat cache reports a clean worktree", + () => { + const root = mkdtempSync(join(tmpdir(), "noema-acquisition-content-hash-")); + const trackedPath = join(root, "tracked.txt"); + const oldTimestamp = new Date("2020-01-01T00:00:00.000Z"); + try { + runGit(root, ["init", "--quiet"]); + runGit(root, ["config", "core.trustctime", "false"]); + runGit(root, ["config", "core.checkStat", "minimal"]); + writeFileSync(trackedPath, "tracked\n", "utf8"); + utimesSync(trackedPath, oldTimestamp, oldTimestamp); + runGit(root, ["add", "tracked.txt"]); + runGit(root, [ + "-c", + "user.name=Noema Tests", + "-c", + "user.email=noema-tests@example.invalid", + "commit", + "--quiet", + "-m", + "fixture", + ]); + const exactHead = String(runGit(root, ["rev-parse", "HEAD"]).stdout).trim(); + + const committedMetadata = statSync(trackedPath); + writeFileSync(trackedPath, "tamperd\n", "utf8"); + utimesSync(trackedPath, committedMetadata.atime, committedMetadata.mtime); + + const cachedComparison = spawnSync( + "git", + ["diff-files", "--quiet", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--"], + { cwd: root, encoding: "utf8", timeout: 10_000 }, + ); + expect(cachedComparison.error).toBeUndefined(); + expect(cachedComparison.signal).toBeNull(); + expect(cachedComparison.status).toBe(0); + + expect(() => verifyAcquisitionTrackedBytes({ cwd: root, exactHead })) + .toThrow("tracked checkout differs from its authenticated Git index bytes"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it("accepts empty tracked indexes from null, string, Buffer, and typed-array output", () => { + for (const stdout of [null, "", Buffer.alloc(0), new Uint8Array()]) { + expect(verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout }), + })).toBe(0); + } + }); + + it("rejects unsupported output objects and non-ASCII index headers", () => { + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout: {} }), + })).toThrow("malformed output"); + + const nonAsciiHeader = Buffer.concat([ + Buffer.from("100644 ", "ascii"), + Buffer.from([0xff]), + Buffer.from(`${SHA1_A.slice(1)} 0\ttracked.txt\0`, "ascii"), + ]); + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout: nonAsciiHeader }), + })).toThrow("malformed output"); + }); + + it("supports a filesystem-root worktree and SHA-256 object identity", () => { + const contents = Buffer.from("tracked\n"); + const metadata = regularMetadata({ size: contents.length }); + const fileSystem = descriptorFileSystem({ + pathStates: [metadata, metadata], + descriptorStates: [metadata, metadata], + contents, + }); + expect(verifyAcquisitionTrackedBytes({ + cwd: "/", + spawnSyncImpl: spawnSequence( + { stdout: Buffer.from(trackedRecord("tmp/tracked.txt", SHA256_A)) }, + { stdout: `${SHA256_A}\n` }, + ), + fileSystem: fileSystem as never, + })).toBe(1); + }); + + it.each([ + regularMetadata({ isFile: undefined }), + regularMetadata({ isSymbolicLink: undefined }), + regularMetadata({ isFile: () => false }), + regularMetadata({ isSymbolicLink: () => true }), + ])("rejects invalid path metadata before opening", (metadata) => { + const fileSystem = descriptorFileSystem({ pathStates: [metadata] }); + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout: Buffer.from(trackedRecord()) }), + fileSystem: fileSystem as never, + })).toThrow("object type differs"); + expect(fileSystem.openSync).not.toHaveBeenCalled(); + }); + + it.each([ + regularMetadata({ isFile: undefined }), + regularMetadata({ isSymbolicLink: undefined }), + regularMetadata({ isFile: () => false }), + regularMetadata({ isSymbolicLink: () => true }), + regularMetadata({ dev: 9 }), + regularMetadata({ ino: 9 }), + regularMetadata({ mode: 9 }), + regularMetadata({ size: 9 }), + regularMetadata({ mtimeMs: 9 }), + regularMetadata({ ctimeMs: 9 }), + ])("rejects descriptor identity or type drift before reading", (opened) => { + const before = regularMetadata(); + const fileSystem = descriptorFileSystem({ + pathStates: [before], + descriptorStates: [opened], + }); + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout: Buffer.from(trackedRecord()) }), + fileSystem: fileSystem as never, + })).toThrow("changed before raw-byte authentication"); + expect(fileSystem.readSync).not.toHaveBeenCalled(); + expect(fileSystem.closeSync).toHaveBeenCalledWith(17); + }); + + it("rejects missing mode metadata and executable-bit drift", () => { + for (const metadata of [ + regularMetadata({ mode: 1.5 }), + regularMetadata({ mode: 0o100755 }), + ]) { + const fileSystem = descriptorFileSystem({ + pathStates: [metadata], + descriptorStates: [metadata], + }); + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout: Buffer.from(trackedRecord()) }), + fileSystem: fileSystem as never, + })).toThrow(metadata.mode === 0o100755 ? "executable mode differs" : "object type differs"); + } + }); + + it("accepts the executable index mode when the descriptor owner-execute bit is present", () => { + const contents = Buffer.from("tracked\n"); + const metadata = regularMetadata({ mode: 0o100755, size: contents.length }); + expect(verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence( + { stdout: Buffer.from(trackedRecord("tracked.txt", SHA1_A, "100755")) }, + { stdout: SHA1_A }, + ), + fileSystem: descriptorFileSystem({ + pathStates: [metadata, metadata], + descriptorStates: [metadata, metadata], + contents, + }) as never, + })).toBe(1); + }); + + it.each([ + 12.5, + -1, + 32 * 1024 * 1024 + 1, + ])("rejects invalid or oversized descriptor size %s before allocating or hashing", (size) => { + const metadata = regularMetadata({ size }); + const fileSystem = descriptorFileSystem({ + pathStates: [metadata], + descriptorStates: [metadata], + }); + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout: Buffer.from(trackedRecord()) }), + fileSystem: fileSystem as never, + })).toThrow("file-byte limit"); + expect(fileSystem.readSync).not.toHaveBeenCalled(); + }); + + it.each([ + [0.5, "invalid byte count"], + [-1, "invalid byte count"], + [10, "invalid byte count"], + ])("rejects invalid descriptor read count %s", (readCount, message) => { + const metadata = regularMetadata(); + const fileSystem = descriptorFileSystem({ + pathStates: [metadata], + descriptorStates: [metadata], + readResults: [readCount], + }); + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout: Buffer.from(trackedRecord()) }), + fileSystem: fileSystem as never, + })).toThrow(message); + expect(fileSystem.closeSync).toHaveBeenCalledWith(17); + }); + + it.each([ + ["dev", 9], + ["ino", 9], + ["mode", 9], + ["size", 9], + ["mtimeMs", 9], + ["ctimeMs", 9], + ])("rejects post-read descriptor metadata drift in %s", (field, changedValue) => { + const before = regularMetadata(); + const after = regularMetadata({ [field]: changedValue }); + const fileSystem = descriptorFileSystem({ + pathStates: [before, before], + descriptorStates: [before, after], + }); + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout: Buffer.from(trackedRecord()) }), + fileSystem: fileSystem as never, + })).toThrow("changed during raw-byte authentication"); + }); + + it("rejects a short descriptor read and missing final path metadata", () => { + const metadata = regularMetadata(); + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout: Buffer.from(trackedRecord()) }), + fileSystem: descriptorFileSystem({ + pathStates: [metadata, metadata], + descriptorStates: [metadata, metadata], + contents: Buffer.from("short"), + }) as never, + })).toThrow("changed during raw-byte authentication"); + + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout: Buffer.from(trackedRecord()) }), + fileSystem: descriptorFileSystem({ + pathStates: [metadata, null], + descriptorStates: [metadata, metadata], + }) as never, + })).toThrow("changed during raw-byte authentication"); + }); + + it("rejects failed, malformed, and mismatched hash evidence after closing the descriptor", () => { + const contents = Buffer.from("tracked\n"); + const metadata = regularMetadata({ size: contents.length }); + for (const [hashResult, message] of [ + [gitResult({ status: 2 }), "hashing failed"], + [gitResult({ stdout: null }), "malformed output"], + [gitResult({ stdout: SHA1_B }), "authenticated Git index bytes"], + ] as const) { + const fileSystem = descriptorFileSystem({ + pathStates: [metadata, metadata], + descriptorStates: [metadata, metadata], + contents, + }); + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence( + { stdout: Buffer.from(trackedRecord()) }, + hashResult, + ), + fileSystem: fileSystem as never, + })).toThrow(message); + expect(fileSystem.closeSync).toHaveBeenCalledWith(17); + } + }); + + it("enforces the aggregate byte budget before reading the first over-budget file", () => { + const paths = Array.from({ length: 9 }, (_, index) => `file-${index}.bin`); + const listing = Buffer.from(paths.map((path) => trackedRecord(path)).join("")); + let gitCall = 0; + const spawn = ( + _command: string, + _args: string[], + _options: Record, + ) => { + gitCall += 1; + return gitCall === 1 ? gitResult({ stdout: listing }) : gitResult({ stdout: SHA1_A }); + }; + const metadata = regularMetadata({ size: 32 * 1024 * 1024 }); + let readCall = 0; + const fileSystem = { + constants: { O_RDONLY: 0, O_NOFOLLOW: 0x20000 }, + lstatSync: () => metadata, + openSync: () => 17, + fstatSync: () => metadata, + readSync: ( + _descriptor: number, + _buffer: Buffer, + _offset: number, + length: number, + ) => { + readCall += 1; + return readCall % 2 === 1 ? length - 1 : 0; + }, + closeSync: () => undefined, + }; + + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawn as never, + fileSystem: fileSystem as never, + })).toThrow("aggregate-byte limit"); + expect(gitCall).toBe(9); + }); +}); diff --git a/test/acquisition-git-content-index.test.ts b/test/acquisition-git-content-index.test.ts new file mode 100644 index 000000000..458844c1f --- /dev/null +++ b/test/acquisition-git-content-index.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import { verifyAcquisitionTrackedBytes } from "../scripts/lib/acquisition-git-preflight.mjs"; + +const OID = "a".repeat(40); + +function result(stdout = "", status = 0) { + return { status, signal: null, error: undefined, stdout, stderr: "" }; +} + +function spawnWith(stdout: string, status = 0) { + return vi.fn(() => result(stdout, status)); +} + +function entry(mode: string, stage: string, path: string) { + return `${mode} ${OID} ${stage}\t${path}\0`; +} + +describe("acquisition tracked-byte index parsing", () => { + it("accepts an empty bounded index", () => { + const spawn = spawnWith(""); + expect(verifyAcquisitionTrackedBytes({ cwd: "/repo", spawnSyncImpl: spawn })).toBe(0); + expect(spawn).toHaveBeenCalledWith( + "git", + ["ls-files", "--stage", "-z", "--cached", "--"], + expect.objectContaining({ maxBuffer: 2 * 1024 * 1024, timeout: 10_000 }), + ); + }); + + it("rejects a failed index command", () => { + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnWith("", 2), + })).toThrow("tracked-byte source inspection failed"); + }); + + it.each([ + [entry("100644", "0", "file").slice(0, -1), "malformed output"], + ["not-an-entry\0", "malformed output"], + [`100644 ${OID} 0\t\0`, "malformed output"], + [`10064 ${OID} 0\tfile\0`, "malformed output"], + [entry("100644", "1", "file"), "unmerged entry"], + [entry("160000", "0", "module"), "unsupported object mode"], + [entry("100644", "0", "../outside"), "unsafe path"], + [entry("100644", "0", "."), "unsafe path"], + [entry("100644", "0", "x".repeat(4097)), "path limit"], + ])("fails closed on invalid index evidence", (stdout, message) => { + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnWith(stdout), + })).toThrow(message); + }); + + it("rejects more than 20,000 tracked entries", () => { + const stdout = Array.from( + { length: 20_001 }, + (_, index) => entry("100644", "0", `f${index}`), + ).join(""); + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnWith(stdout), + })).toThrow("entry limit"); + }); +}); diff --git a/test/acquisition-git-descriptor-bound.test.ts b/test/acquisition-git-descriptor-bound.test.ts new file mode 100644 index 000000000..b843fa603 --- /dev/null +++ b/test/acquisition-git-descriptor-bound.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it, vi } from "vitest"; +import { verifyAcquisitionTrackedBytes } from "../scripts/lib/acquisition-git-preflight.mjs"; + +const OBJECT_ID = "a".repeat(40); + +function gitResult(overrides: Record = {}) { + return { + status: 0, + signal: null, + error: undefined, + stdout: "", + stderr: "", + ...overrides, + }; +} + +function spawnSequence(...results: Array>) { + const spawn = vi.fn(); + results.forEach((result) => spawn.mockReturnValueOnce(gitResult(result))); + return spawn; +} + +function trackedRecord(path = "tracked.txt", mode = "100644") { + return `${mode} ${OBJECT_ID} 0\t${path}\0`; +} + +function regularMetadata(overrides: Record = {}) { + return { + dev: 1, + ino: 2, + mode: 0o100644, + size: 8, + mtimeMs: 10, + ctimeMs: 11, + isFile: () => true, + isSymbolicLink: () => false, + ...overrides, + }; +} + +function descriptorFileSystem({ + pathStates = [regularMetadata(), regularMetadata()], + descriptorStates = [regularMetadata(), regularMetadata()], + contents = Buffer.from("tracked\n"), + constants = { O_RDONLY: 0, O_NOFOLLOW: 0x20000 }, +}: { + pathStates?: Array>; + descriptorStates?: Array>; + contents?: Buffer; + constants?: Record; +} = {}) { + const lstatSync = vi.fn(); + pathStates.forEach((state) => lstatSync.mockReturnValueOnce(state)); + const fstatSync = vi.fn(); + descriptorStates.forEach((state) => fstatSync.mockReturnValueOnce(state)); + let offset = 0; + const readSync = vi.fn(( + _descriptor: number, + buffer: Buffer, + bufferOffset: number, + length: number, + ) => { + const count = Math.min(length, contents.length - offset); + if (count <= 0) { + return 0; + } + contents.copy(buffer, bufferOffset, offset, offset + count); + offset += count; + return count; + }); + return { + constants, + lstatSync, + openSync: vi.fn(() => 17), + fstatSync, + readSync, + closeSync: vi.fn(), + readlinkSync: vi.fn(), + }; +} + +describe("acquisition descriptor-bound tracked-byte verification", () => { + it("rejects invalid UTF-8 tracked path bytes before filesystem resolution", () => { + const listing = Buffer.concat([ + Buffer.from(`100644 ${OBJECT_ID} 0\t`, "ascii"), + Buffer.from([0xff]), + Buffer.from([0]), + ]); + const fileSystem = descriptorFileSystem(); + + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout: listing }), + fileSystem: fileSystem as never, + })).toThrow("valid UTF-8"); + expect(fileSystem.lstatSync).not.toHaveBeenCalled(); + }); + + it("opens a regular file with no-follow semantics and hashes only descriptor-read bytes", () => { + const contents = Buffer.from("tracked\n"); + const metadata = regularMetadata({ size: contents.length }); + const fileSystem = descriptorFileSystem({ + pathStates: [metadata, metadata], + descriptorStates: [metadata, metadata], + contents, + }); + const spawn = spawnSequence( + { stdout: Buffer.from(trackedRecord()) }, + { stdout: `${OBJECT_ID}\n` }, + ); + + expect(verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawn, + fileSystem: fileSystem as never, + })).toBe(1); + expect(fileSystem.openSync).toHaveBeenCalledWith("/repo/tracked.txt", 0x20000); + expect(fileSystem.readSync).toHaveBeenCalled(); + expect(fileSystem.closeSync).toHaveBeenCalledWith(17); + expect(spawn).toHaveBeenLastCalledWith( + "git", + ["hash-object", "--stdin"], + expect.objectContaining({ input: contents }), + ); + }); + + it("rejects a pathname swap before reading when the opened descriptor identity differs", () => { + const original = regularMetadata(); + const replacement = regularMetadata({ ino: 9 }); + const fileSystem = descriptorFileSystem({ + pathStates: [original], + descriptorStates: [replacement], + }); + + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout: Buffer.from(trackedRecord()) }), + fileSystem: fileSystem as never, + })).toThrow("changed before raw-byte authentication"); + expect(fileSystem.readSync).not.toHaveBeenCalled(); + expect(fileSystem.closeSync).toHaveBeenCalledWith(17); + }); + + it("rejects growth after the descriptor size check without hashing beyond the bound", () => { + const before = regularMetadata({ size: 8 }); + const after = regularMetadata({ size: 9 }); + const fileSystem = descriptorFileSystem({ + pathStates: [before, after], + descriptorStates: [before, after], + contents: Buffer.from("123456789"), + }); + + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout: Buffer.from(trackedRecord()) }), + fileSystem: fileSystem as never, + })).toThrow("changed during raw-byte authentication"); + expect(fileSystem.readSync).toHaveBeenCalled(); + expect(fileSystem.closeSync).toHaveBeenCalledWith(17); + }); + + it("rejects executable-mode drift independently of blob contents", () => { + const nonExecutable = regularMetadata({ mode: 0o100644 }); + const fileSystem = descriptorFileSystem({ + pathStates: [nonExecutable], + descriptorStates: [nonExecutable], + }); + + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ + stdout: Buffer.from(trackedRecord("tracked.txt", "100755")), + }), + fileSystem: fileSystem as never, + })).toThrow("executable mode differs"); + expect(fileSystem.readSync).not.toHaveBeenCalled(); + }); + + it("fails closed when no-follow descriptor support is unavailable", () => { + const fileSystem = descriptorFileSystem({ constants: { O_RDONLY: 0 } }); + + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout: Buffer.from(trackedRecord()) }), + fileSystem: fileSystem as never, + })).toThrow("no-follow filesystem support"); + expect(fileSystem.openSync).not.toHaveBeenCalled(); + }); + + it("rejects tracked symbolic links until they can be descriptor-bound", () => { + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ + stdout: Buffer.from(trackedRecord("linked", "120000")), + }), + fileSystem: descriptorFileSystem() as never, + })).toThrow("unsupported object mode"); + }); +}); diff --git a/test/acquisition-git-exact-tree-binding.test.ts b/test/acquisition-git-exact-tree-binding.test.ts new file mode 100644 index 000000000..7b5ece9ef --- /dev/null +++ b/test/acquisition-git-exact-tree-binding.test.ts @@ -0,0 +1,65 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { verifyAcquisitionTrackedBytes } from "../scripts/lib/acquisition-git-preflight.mjs"; + +function runGit(root: string, args: string[]): string { + const result = spawnSync("git", args, { + cwd: root, + encoding: "utf8", + timeout: 10_000, + }); + if (result.error) throw result.error; + if (result.signal) throw new Error(`Git fixture command terminated by ${result.signal}`); + if (result.status !== 0) { + throw new Error(`Git fixture command failed: git ${args.join(" ")}\n${result.stderr}`); + } + return String(result.stdout ?? "").trim(); +} + +describe("acquisition exact-tree tracked-byte binding", () => { + it("accepts the clean exact tree, then rejects worktree bytes matching a rewritten index", () => { + const root = mkdtempSync(join(tmpdir(), "noema-acquisition-exact-tree-")); + try { + runGit(root, ["init", "--quiet"]); + writeFileSync(join(root, "tracked.txt"), "trusted\n", "utf8"); + runGit(root, ["add", "tracked.txt"]); + runGit(root, [ + "-c", + "user.name=Noema Tests", + "-c", + "user.email=noema-tests@example.invalid", + "commit", + "--quiet", + "-m", + "trusted fixture", + ]); + const exactHead = runGit(root, ["rev-parse", "HEAD"]); + expect(verifyAcquisitionTrackedBytes({ cwd: root, exactHead })).toBe(1); + + // Model an index rewrite occurring after an earlier cached-diff phase: + // both the index and worktree now agree on tampered bytes, while the + // immutable exact HEAD tree still points at the original trusted blob. + writeFileSync(join(root, "tracked.txt"), "tampered\n", "utf8"); + runGit(root, ["add", "tracked.txt"]); + + expect(() => verifyAcquisitionTrackedBytes({ cwd: root, exactHead })) + .toThrow(/exact HEAD|authenticated Git.*bytes/i); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects a production tracked-byte read without an exact immutable tree identity", () => { + expect(() => verifyAcquisitionTrackedBytes({ cwd: "/repo" })) + .toThrow("exact acquisition tree commit is required for production tracked-byte authentication"); + }); + + it("rejects a non-exact tree identity before invoking Git", () => { + expect(() => verifyAcquisitionTrackedBytes({ cwd: "/repo", exactHead: "HEAD" })) + .toThrow("exact acquisition tree commit must be a full Git SHA"); + }); +}); diff --git a/test/acquisition-git-filter-boundary.test.ts b/test/acquisition-git-filter-boundary.test.ts new file mode 100644 index 000000000..d6e33e4bf --- /dev/null +++ b/test/acquisition-git-filter-boundary.test.ts @@ -0,0 +1,68 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + resolveAcquisitionCommit, + verifyAcquisitionTrackedCheckout, +} from "../scripts/lib/acquisition-git-preflight.mjs"; + +function runGit(root: string, args: string[]) { + const result = spawnSync("git", args, { + cwd: root, + encoding: "utf8", + timeout: 10_000, + }); + if (result.error) { + throw result.error; + } + if (result.signal || result.status !== 0) { + throw new Error(`fixture Git command failed: ${String(result.stderr ?? "").trim()}`); + } +} + +function createFilterRepository() { + const root = mkdtempSync(join(tmpdir(), "noema-acquisition-filter-root-")); + writeFileSync(join(root, "tracked.txt"), "base\n"); + writeFileSync(join(root, ".gitattributes"), "tracked.txt filter=noema-test-filter\n"); + runGit(root, ["init", "--quiet"]); + runGit(root, ["add", "tracked.txt", ".gitattributes"]); + runGit(root, [ + "-c", + "user.name=Noema Tests", + "-c", + "user.email=noema-tests@example.invalid", + "commit", + "--quiet", + "-m", + "fixture", + ]); + return root; +} + +describe("acquisition Git filter boundary", () => { + it("does not execute repository-configured clean filters or let them hide tracked drift", () => { + const root = createFilterRepository(); + const marker = join(root, "filter-executed.marker"); + const helper = join(root, "filter-helper.mjs"); + try { + writeFileSync( + helper, + `import { writeFileSync } from "node:fs";\nwriteFileSync(${JSON.stringify(marker)}, "executed\\n");\nprocess.stdout.write("base\\n");\n`, + ); + runGit(root, ["config", "filter.noema-test-filter.clean", `node ${helper}`]); + runGit(root, ["config", "filter.noema-test-filter.required", "true"]); + writeFileSync(join(root, "tracked.txt"), "tampered\n"); + + const exactHead = resolveAcquisitionCommit("HEAD", { cwd: root }); + expect(() => verifyAcquisitionTrackedCheckout({ + cwd: root, + expectedCommitSha: exactHead, + })).toThrow(`tracked checkout differs from exact HEAD ${exactHead}`); + expect(existsSync(marker)).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/acquisition-git-preflight.test.ts b/test/acquisition-git-preflight.test.ts new file mode 100644 index 000000000..fdc321330 --- /dev/null +++ b/test/acquisition-git-preflight.test.ts @@ -0,0 +1,320 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + buildAcquisitionGitEnvironment, + resolveAcquisitionCommit, + verifyAcquisitionIndexFlags, + verifyAcquisitionTrackedCheckout, +} from "../scripts/lib/acquisition-git-preflight.mjs"; + +const HEAD = "0123456789abcdef0123456789abcdef01234567"; +const OTHER = "89abcdef0123456789abcdef0123456789abcdef"; +const SAFE_INDEX = "H tracked.txt\0"; + +function gitResult(overrides: Record = {}) { + return { + status: 0, + signal: null, + error: undefined, + stdout: `${HEAD}\n`, + stderr: "", + ...overrides, + }; +} + +function spawnSequence(...results: Array>) { + const mock = vi.fn(); + results.forEach((result) => mock.mockReturnValueOnce(gitResult(result))); + return mock; +} + +function createCleanRepository() { + const root = mkdtempSync(join(tmpdir(), "noema-acquisition-git-clean-")); + writeFileSync(join(root, "tracked.txt"), "tracked\n"); + const commands = [ + ["init", "--quiet"], + ["add", "tracked.txt"], + ["-c", "user.name=Noema Tests", "-c", "user.email=noema-tests@example.invalid", "commit", "--quiet", "-m", "fixture"], + ]; + for (const args of commands) { + const result = spawnSync("git", args, { cwd: root, encoding: "utf8", timeout: 10_000 }); + if (result.status !== 0) { + rmSync(root, { recursive: true, force: true }); + throw new Error(`failed to create Git fixture: ${result.stderr}`); + } + } + return root; +} + +describe("acquisition Git preflight", () => { + it("builds a configuration-isolated POSIX environment without inheriting unrelated variables", () => { + const environment = buildAcquisitionGitEnvironment({ PATH: "/bin", HOME: "/secret" }, "linux"); + expect(environment).toMatchObject({ + PATH: "/bin", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_NO_REPLACE_OBJECTS: "1", + GIT_NO_LAZY_FETCH: "1", + GIT_CONFIG_VALUE_0: "/dev/null", + }); + expect(environment).not.toHaveProperty("HOME"); + expect(environment).not.toHaveProperty("SystemRoot"); + }); + + it("preserves only required Windows process discovery values", () => { + const environment = buildAcquisitionGitEnvironment({ + PATH: "C:\\Git\\bin", + SystemRoot: "C:\\Windows", + USERPROFILE: "C:\\Users\\secret", + }, "win32"); + expect(environment.GIT_CONFIG_GLOBAL).toBe("NUL"); + expect(environment.GIT_CONFIG_VALUE_0).toBe("NUL"); + expect(environment.PATH).toBe("C:\\Git\\bin"); + expect(environment.SystemRoot).toBe("C:\\Windows"); + expect(environment).not.toHaveProperty("USERPROFILE"); + }); + + it("omits empty discovery variables instead of forwarding them", () => { + const posix = buildAcquisitionGitEnvironment({ PATH: "" }, "linux"); + const windows = buildAcquisitionGitEnvironment({ PATH: "", SystemRoot: "" }, "win32"); + expect(posix).not.toHaveProperty("PATH"); + expect(windows).not.toHaveProperty("PATH"); + expect(windows).not.toHaveProperty("SystemRoot"); + }); + + it("omits absent or non-string discovery variables", () => { + const windows = buildAcquisitionGitEnvironment({ + PATH: 7 as never, + SystemRoot: 9 as never, + }, "win32"); + const absent = buildAcquisitionGitEnvironment({}, "linux"); + expect(windows).not.toHaveProperty("PATH"); + expect(windows).not.toHaveProperty("SystemRoot"); + expect(absent).not.toHaveProperty("PATH"); + expect(absent).not.toHaveProperty("SystemRoot"); + }); + + it.each([ + [null, "non-string"], + ["", "empty"], + ["a".repeat(257), "oversized"], + ["bad\nref", "control-character"], + ])("rejects %s Git refs before spawning Git (%s)", (ref) => { + const spawn = vi.fn(); + expect(() => resolveAcquisitionCommit(ref as never, { cwd: "/repo", spawnSyncImpl: spawn })) + .toThrow("acquisition Git ref must be a bounded printable string"); + expect(spawn).not.toHaveBeenCalled(); + }); + + it("resolves a local revision with bounded isolated Git options", () => { + const spawn = spawnSequence({}); + expect(resolveAcquisitionCommit("HEAD", { + cwd: "/repo", + spawnSyncImpl: spawn, + sourceEnvironment: { PATH: "/bin" }, + platform: "linux", + })).toBe(HEAD); + expect(spawn).toHaveBeenCalledWith( + "git", + ["rev-parse", "--verify", "--end-of-options", "HEAD^{commit}"], + expect.objectContaining({ cwd: "/repo", maxBuffer: 4096, timeout: 10_000 }), + ); + }); + + it("fails closed on spawn errors, signals, absent status, nonzero resolution, and malformed output", () => { + expect(() => resolveAcquisitionCommit("HEAD", { + cwd: "/repo", + spawnSyncImpl: spawnSequence({ error: new Error("spawn failed"), status: null }), + })).toThrow("acquisition Git preflight failed: spawn failed"); + + expect(() => resolveAcquisitionCommit("HEAD", { + cwd: "/repo", + spawnSyncImpl: spawnSequence({ signal: "SIGTERM", status: null }), + })).toThrow("acquisition Git preflight terminated by signal SIGTERM"); + + expect(() => resolveAcquisitionCommit("HEAD", { + cwd: "/repo", + spawnSyncImpl: spawnSequence({ status: null }), + })).toThrow("acquisition Git preflight returned no exit status"); + + expect(() => resolveAcquisitionCommit("HEAD", { + cwd: "/repo", + spawnSyncImpl: spawnSequence({ status: 128 }), + })).toThrow("acquisition Git ref HEAD could not be resolved locally"); + + expect(() => resolveAcquisitionCommit("HEAD", { + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout: null }), + })).toThrow("acquisition Git ref HEAD did not resolve to one exact commit"); + }); + + it("accepts safe bounded NUL-delimited index state", () => { + const spawn = spawnSequence({ stdout: "H tracked.txt\0M merge.txt\0" }); + expect(() => verifyAcquisitionIndexFlags({ cwd: "/repo", spawnSyncImpl: spawn })).not.toThrow(); + expect(spawn).toHaveBeenCalledWith( + "git", + ["ls-files", "-v", "-z", "--cached", "--"], + expect.objectContaining({ maxBuffer: 2 * 1024 * 1024, timeout: 10_000 }), + ); + }); + + it("accepts an empty tracked index and the null-stdout defensive fallback", () => { + expect(() => verifyAcquisitionIndexFlags({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout: "" }), + })).not.toThrow(); + expect(() => verifyAcquisitionIndexFlags({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout: null }), + })).not.toThrow(); + }); + + it("fails closed when index inspection itself fails", () => { + expect(() => verifyAcquisitionIndexFlags({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ status: 2, stdout: "" }), + })).toThrow("acquisition Git index inspection failed"); + }); + + it.each([ + ["unterminated", "H tracked.txt"], + ["short record", "H\0"], + ["missing separator", "H:tracked.txt\0"], + ])("rejects malformed index output: %s", (_label, stdout) => { + expect(() => verifyAcquisitionIndexFlags({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout }), + })).toThrow("acquisition Git index inspection returned malformed output"); + }); + + it.each([ + ["skip-worktree", "S tracked.txt\0"], + ["assume-unchanged", "h tracked.txt\0"], + ["combined lower-case skip flag", "s tracked.txt\0"], + ])("rejects %s index state", (_label, stdout) => { + expect(() => verifyAcquisitionIndexFlags({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout }), + })).toThrow("unsafe Git index flag detected in acquisition checkout"); + }); + + it("allows intentionally untracked retained artifacts while authenticating tracked bytes", () => { + const root = createCleanRepository(); + try { + writeFileSync(join(root, "untracked-acquisition-evidence.json"), "{}\n"); + const exactHead = resolveAcquisitionCommit("HEAD", { cwd: root }); + expect(verifyAcquisitionTrackedCheckout({ cwd: root, expectedCommitSha: exactHead })).toBe(exactHead); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + const trackedTreeCheck = spawnSync("git", ["diff", "--quiet", "HEAD", "--"], { + cwd: process.cwd(), + timeout: 10_000, + }); + if (trackedTreeCheck.error) { + throw new Error(`failed to inspect tracked repository state: ${trackedTreeCheck.error.message}`); + } + if (trackedTreeCheck.signal) { + throw new Error(`tracked repository state inspection terminated by signal ${trackedTreeCheck.signal}`); + } + if (trackedTreeCheck.status === null) { + throw new Error("tracked repository state inspection returned no exit status"); + } + if (trackedTreeCheck.status !== 0 && trackedTreeCheck.status !== 1) { + throw new Error(`tracked repository state inspection failed with exit status ${trackedTreeCheck.status}`); + } + const trackedTreeIsClean = trackedTreeCheck.status === 0; + + it.skipIf(!trackedTreeIsClean)("supports production defaults on a clean repository checkout", () => { + const exactHead = resolveAcquisitionCommit("HEAD"); + expect(exactHead).toMatch(/^[0-9a-f]{40}$/); + expect(() => verifyAcquisitionIndexFlags()).not.toThrow(); + expect(verifyAcquisitionTrackedCheckout()).toBe(exactHead); + }); + + it("rejects an invalid or stale explicit expected commit before index or diff authorization", () => { + expect(() => verifyAcquisitionTrackedCheckout({ + cwd: "/repo", + expectedCommitSha: "not-a-sha", + spawnSyncImpl: spawnSequence({}), + })).toThrow("expected acquisition commit must be a full Git SHA"); + + expect(() => verifyAcquisitionTrackedCheckout({ + cwd: "/repo", + expectedCommitSha: OTHER, + spawnSyncImpl: spawnSequence({}), + })).toThrow("exact HEAD changed from the expected acquisition commit"); + }); + + it("rejects tracked drift and Git comparison errors", () => { + expect(() => verifyAcquisitionTrackedCheckout({ + cwd: "/repo", + spawnSyncImpl: spawnSequence( + {}, + { stdout: SAFE_INDEX }, + { status: 1, stdout: "" }, + ), + })).toThrow(`tracked checkout differs from exact HEAD ${HEAD}`); + + expect(() => verifyAcquisitionTrackedCheckout({ + cwd: "/repo", + spawnSyncImpl: spawnSequence( + {}, + { stdout: SAFE_INDEX }, + { status: 2, stdout: "" }, + ), + })).toThrow("acquisition tracked-checkout comparison failed"); + }); + + it("rejects unsafe index state discovered before or after tracked comparison", () => { + expect(() => verifyAcquisitionTrackedCheckout({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({}, { stdout: "S tracked.txt\0" }), + })).toThrow("unsafe Git index flag detected in acquisition checkout"); + + expect(() => verifyAcquisitionTrackedCheckout({ + cwd: "/repo", + spawnSyncImpl: spawnSequence( + {}, + { stdout: SAFE_INDEX }, + { status: 0, stdout: "" }, + { status: 0, stdout: "" }, + { stdout: "h tracked.txt\0" }, + ), + })).toThrow("unsafe Git index flag detected in acquisition checkout"); + }); + + it("rejects HEAD movement between the pre- and post-diff resolutions", () => { + const spawn = spawnSequence( + {}, + { stdout: SAFE_INDEX }, + { status: 0, stdout: "" }, + { status: 0, stdout: "" }, + { stdout: SAFE_INDEX }, + { stdout: `${OTHER}\n` }, + ); + expect(() => verifyAcquisitionTrackedCheckout({ cwd: "/repo", spawnSyncImpl: spawn })) + .toThrow("exact HEAD changed during acquisition Git preflight"); + }); + + it("returns the authenticated exact HEAD when tracked bytes remain stable", () => { + const spawn = spawnSequence( + {}, + { stdout: SAFE_INDEX }, + { status: 0, stdout: "" }, + { status: 0, stdout: "" }, + { stdout: SAFE_INDEX }, + {}, + ); + expect(verifyAcquisitionTrackedCheckout({ + cwd: "/repo", + expectedCommitSha: HEAD.toUpperCase(), + spawnSyncImpl: spawn, + })).toBe(HEAD); + }); +}); diff --git a/test/acquisition-git-stat-cache.test.ts b/test/acquisition-git-stat-cache.test.ts new file mode 100644 index 000000000..e7fb38e63 --- /dev/null +++ b/test/acquisition-git-stat-cache.test.ts @@ -0,0 +1,78 @@ +import { spawnSync } from "node:child_process"; +import { + mkdtempSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { verifyAcquisitionTrackedCheckout } from "../scripts/lib/acquisition-git-preflight.mjs"; + +function runGit(root: string, args: string[]) { + const result = spawnSync("git", args, { + cwd: root, + encoding: "utf8", + timeout: 10_000, + }); + if (result.error) { + throw result.error; + } + if (result.signal) { + throw new Error(`Git fixture command terminated by ${result.signal}`); + } + if (result.status !== 0) { + throw new Error(`Git fixture command failed: git ${args.join(" ")}\n${result.stderr}`); + } + return result; +} + +describe("acquisition Git stat-cache boundary", () => { + it.skipIf(process.platform === "win32")( + "rejects same-size tracked tampering hidden by repository-local stat settings", + () => { + const root = mkdtempSync(join(tmpdir(), "noema-acquisition-stat-cache-")); + const trackedPath = join(root, "tracked.txt"); + const oldTimestamp = new Date("2020-01-01T00:00:00.000Z"); + try { + runGit(root, ["init", "--quiet"]); + runGit(root, ["config", "core.trustctime", "false"]); + runGit(root, ["config", "core.checkStat", "minimal"]); + writeFileSync(trackedPath, "tracked\n", "utf8"); + utimesSync(trackedPath, oldTimestamp, oldTimestamp); + runGit(root, ["add", "tracked.txt"]); + runGit(root, [ + "-c", + "user.name=Noema Tests", + "-c", + "user.email=noema-tests@example.invalid", + "commit", + "--quiet", + "-m", + "fixture", + ]); + + const committedMetadata = statSync(trackedPath); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1_100); + writeFileSync(trackedPath, "tamperd\n", "utf8"); + utimesSync(trackedPath, committedMetadata.atime, committedMetadata.mtime); + + const unisolatedComparison = spawnSync( + "git", + ["diff-files", "--quiet", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--"], + { cwd: root, encoding: "utf8", timeout: 10_000 }, + ); + expect(unisolatedComparison.error).toBeUndefined(); + expect(unisolatedComparison.signal).toBeNull(); + expect(unisolatedComparison.status).toBe(0); + + expect(() => verifyAcquisitionTrackedCheckout({ cwd: root })) + .toThrow("tracked checkout differs from exact HEAD"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/test/acquisition-git-worktree-binding.test.ts b/test/acquisition-git-worktree-binding.test.ts new file mode 100644 index 000000000..f68122161 --- /dev/null +++ b/test/acquisition-git-worktree-binding.test.ts @@ -0,0 +1,62 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + resolveAcquisitionCommit, + verifyAcquisitionTrackedCheckout, +} from "../scripts/lib/acquisition-git-preflight.mjs"; + +function runGit(root: string, args: string[]) { + const result = spawnSync("git", args, { + cwd: root, + encoding: "utf8", + timeout: 10_000, + }); + if (result.error) { + throw result.error; + } + if (result.signal || result.status !== 0) { + throw new Error(`fixture Git command failed: ${String(result.stderr ?? "").trim()}`); + } +} + +function createRepository() { + const root = mkdtempSync(join(tmpdir(), "noema-acquisition-worktree-root-")); + writeFileSync(join(root, "tracked.txt"), "tracked\n"); + runGit(root, ["init", "--quiet"]); + runGit(root, ["add", "tracked.txt"]); + runGit(root, [ + "-c", + "user.name=Noema Tests", + "-c", + "user.email=noema-tests@example.invalid", + "commit", + "--quiet", + "-m", + "fixture", + ]); + return root; +} + +describe("acquisition Git worktree binding", () => { + it("refuses repository-local core.worktree redirection away from the audited checkout", () => { + const root = createRepository(); + const redirected = mkdtempSync(join(tmpdir(), "noema-acquisition-worktree-decoy-")); + try { + writeFileSync(join(redirected, "tracked.txt"), "tracked\n"); + runGit(root, ["config", "core.worktree", redirected]); + writeFileSync(join(root, "tracked.txt"), "tampered\n"); + + const exactHead = resolveAcquisitionCommit("HEAD", { cwd: root }); + expect(() => verifyAcquisitionTrackedCheckout({ + cwd: root, + expectedCommitSha: exactHead, + })).toThrow(`tracked checkout differs from exact HEAD ${exactHead}`); + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(redirected, { recursive: true, force: true }); + } + }); +}); diff --git a/test/acquisition-json-integrity-current-main.test.ts b/test/acquisition-json-integrity-current-main.test.ts index bdc6a7e1d..842fdc4f8 100644 --- a/test/acquisition-json-integrity-current-main.test.ts +++ b/test/acquisition-json-integrity-current-main.test.ts @@ -65,8 +65,8 @@ describe("acquisition readiness JSON integrity", () => { }); }); - it("classifies filesystem read failures separately from malformed UTF-8", () => { - const temp = mkdtempSync(join(tmpdir(), "noema-acq-read-error-")); + it("classifies unsafe filesystem objects separately from malformed UTF-8", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-acq-unsafe-object-")); const saleablePath = join(temp, "saleable-directory"); mkdirSync(saleablePath); @@ -75,7 +75,7 @@ describe("acquisition readiness JSON integrity", () => { expect(result.status).toBe(1); expect(saleableEvidenceCheck(temp)).toMatchObject({ pass: false, - details: { reason: "read_error", path: saleablePath }, + details: { reason: "unsafe_or_unreadable", path: saleablePath }, }); }); -}); +}); \ No newline at end of file diff --git a/test/acquisition-output-symlink.test.ts b/test/acquisition-output-symlink.test.ts new file mode 100644 index 000000000..2d3d5cf72 --- /dev/null +++ b/test/acquisition-output-symlink.test.ts @@ -0,0 +1,163 @@ +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const entrypointTestTimeoutMs = 65_000; + +function runEntrypoint(script: string, environment: Record) { + return spawnSync(process.execPath, [script], { + cwd: process.cwd(), + env: { + ...process.env, + NOEMA_DATA_ROOM_SOURCE_COMMIT: "", + NOEMA_RELEASE_UNDER_DILIGENCE_TAG: "", + ...environment, + }, + encoding: "utf8", + timeout: 30_000, + }); +} + +describe.skipIf(process.platform === "win32")("acquisition output symlink refusal", () => { + it("refuses a pre-existing manifest symlink without modifying its target", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-manifest-symlink-")); + const targetPath = join(temp, "protected-target.txt"); + const manifestPath = join(temp, "data-room-manifest.json"); + try { + writeFileSync(targetPath, "sentinel-manifest\n", "utf8"); + symlinkSync(targetPath, manifestPath); + + const result = runEntrypoint("scripts/acquisition-data-room-manifest-secure.mjs", { + NOEMA_DATA_ROOM_OUTPUT_DIR: temp, + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: "", + NOEMA_DATA_ROOM_MANIFEST_PATH: manifestPath, + }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(readFileSync(targetPath, "utf8")).toBe("sentinel-manifest\n"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }, entrypointTestTimeoutMs); + + it("refuses a pre-existing audit symlink without modifying its target", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-audit-symlink-")); + const manifestPath = join(temp, "data-room-manifest.json"); + const auditPath = join(temp, "data-room-integrity-audit.json"); + const targetPath = join(temp, "protected-target.txt"); + try { + const manifestResult = runEntrypoint("scripts/acquisition-data-room-manifest-secure.mjs", { + NOEMA_DATA_ROOM_OUTPUT_DIR: temp, + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: "", + NOEMA_DATA_ROOM_MANIFEST_PATH: manifestPath, + }); + expect(manifestResult.error).toBeUndefined(); + expect(manifestResult.status).toBe(0); + + writeFileSync(targetPath, "sentinel-audit\n", "utf8"); + symlinkSync(targetPath, auditPath); + + const auditResult = runEntrypoint("scripts/acquisition-data-room-integrity-audit.mjs", { + NOEMA_DATA_ROOM_OUTPUT_DIR: temp, + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: "", + NOEMA_DATA_ROOM_MANIFEST_PATH: manifestPath, + }); + + expect(auditResult.error).toBeUndefined(); + expect(auditResult.status).toBe(1); + expect(readFileSync(targetPath, "utf8")).toBe("sentinel-audit\n"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }, entrypointTestTimeoutMs); + + it("refuses a symbolic-link output directory before the manifest entrypoint creates a target leaf", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-manifest-parent-symlink-")); + const targetDirectory = join(temp, "protected-directory"); + const linkedOutput = join(temp, "linked-output"); + const escapedManifest = join(targetDirectory, "data-room-manifest.json"); + try { + mkdirSync(targetDirectory); + symlinkSync(targetDirectory, linkedOutput, "dir"); + + const result = runEntrypoint("scripts/acquisition-data-room-manifest-secure.mjs", { + NOEMA_DATA_ROOM_OUTPUT_DIR: linkedOutput, + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: "", + NOEMA_DATA_ROOM_MANIFEST_PATH: "", + }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(existsSync(escapedManifest)).toBe(false); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }, entrypointTestTimeoutMs); + + it("refuses an explicit manifest path whose parent is a symbolic link", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-explicit-manifest-parent-symlink-")); + const targetDirectory = join(temp, "protected-directory"); + const linkedParent = join(temp, "linked-parent"); + const escapedManifest = join(targetDirectory, "explicit-manifest.json"); + try { + mkdirSync(targetDirectory); + symlinkSync(targetDirectory, linkedParent, "dir"); + + const result = runEntrypoint("scripts/acquisition-data-room-manifest-secure.mjs", { + NOEMA_DATA_ROOM_OUTPUT_DIR: temp, + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: "", + NOEMA_DATA_ROOM_MANIFEST_PATH: join(linkedParent, "explicit-manifest.json"), + }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(existsSync(escapedManifest)).toBe(false); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }, entrypointTestTimeoutMs); + + it("refuses a symbolic-link output directory before the audit entrypoint creates a target leaf", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-audit-parent-symlink-")); + const safeOutput = join(temp, "safe-output"); + const manifestPath = join(safeOutput, "data-room-manifest.json"); + const targetDirectory = join(temp, "protected-directory"); + const linkedOutput = join(temp, "linked-output"); + const escapedAudit = join(targetDirectory, "data-room-integrity-audit.json"); + try { + mkdirSync(safeOutput); + const manifestResult = runEntrypoint("scripts/acquisition-data-room-manifest-secure.mjs", { + NOEMA_DATA_ROOM_OUTPUT_DIR: safeOutput, + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: "", + NOEMA_DATA_ROOM_MANIFEST_PATH: manifestPath, + }); + expect(manifestResult.error).toBeUndefined(); + expect(manifestResult.status).toBe(0); + + mkdirSync(targetDirectory); + symlinkSync(targetDirectory, linkedOutput, "dir"); + const auditResult = runEntrypoint("scripts/acquisition-data-room-integrity-audit.mjs", { + NOEMA_DATA_ROOM_OUTPUT_DIR: linkedOutput, + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: "", + NOEMA_DATA_ROOM_MANIFEST_PATH: manifestPath, + }); + + expect(auditResult.error).toBeUndefined(); + expect(auditResult.status).toBe(1); + expect(existsSync(escapedAudit)).toBe(false); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }, entrypointTestTimeoutMs); +}); \ No newline at end of file diff --git a/test/acquisition-private-output.test.ts b/test/acquisition-private-output.test.ts new file mode 100644 index 000000000..b8c8b0864 --- /dev/null +++ b/test/acquisition-private-output.test.ts @@ -0,0 +1,253 @@ +import { + chmodSync, + existsSync, + linkSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + assertAcquisitionPrivatePathParents, + writeAcquisitionPrivateFile, +} from "../scripts/lib/acquisition-private-output.mjs"; + +function metadata(overrides: Record = {}) { + return { + dev: 1, + ino: 2, + nlink: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false, + ...overrides, + }; +} + +function parentDirectoryMetadata(overrides: Record = {}) { + return metadata({ + isFile: () => false, + isDirectory: () => true, + ...overrides, + }); +} + +function mockFileSystem({ + before = null, + opened = metadata(), + afterDescriptor = metadata(), + afterPath = metadata(), + chmodError = null, + writeError = null, +}: { + before?: ReturnType | null; + opened?: ReturnType; + afterDescriptor?: ReturnType; + afterPath?: ReturnType; + chmodError?: Error | null; + writeError?: Error | null; +} = {}) { + let leafReads = 0; + const lstat = vi.fn((path: string) => { + if (path === "output") { + leafReads += 1; + return leafReads === 1 ? before : afterPath; + } + return parentDirectoryMetadata(); + }); + const fstat = vi.fn() + .mockReturnValueOnce(opened) + .mockReturnValueOnce(afterDescriptor); + return { + constants: { O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + lstatSync: lstat, + openSync: vi.fn(() => 17), + fstatSync: fstat, + ftruncateSync: vi.fn(), + writeFileSync: vi.fn(() => { + if (writeError) { + throw writeError; + } + }), + fchmodSync: vi.fn(() => { + if (chmodError) { + throw chmodError; + } + }), + closeSync: vi.fn(), + }; +} + +describe("acquisition private output", () => { + it("rejects invalid call contracts and filesystems without O_NOFOLLOW", () => { + expect(() => writeAcquisitionPrivateFile("", "value")).toThrow(TypeError); + expect(() => writeAcquisitionPrivateFile("path", null as never)).toThrow(TypeError); + expect(() => assertAcquisitionPrivatePathParents("")).toThrow(TypeError); + expect(() => writeAcquisitionPrivateFile("path", "value", { + constants: { O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4 }, + } as never)).toThrow("no-follow filesystem support"); + }); + + it("allows absent parent components while still walking to the filesystem root", () => { + const fileSystem = { lstatSync: vi.fn(() => null) }; + expect(() => assertAcquisitionPrivatePathParents("missing/parents/evidence.json", fileSystem as never)) + .not.toThrow(); + expect(fileSystem.lstatSync).toHaveBeenCalled(); + }); + + it.each([ + parentDirectoryMetadata({ isDirectory: undefined }), + parentDirectoryMetadata({ isSymbolicLink: undefined }), + parentDirectoryMetadata({ isDirectory: () => false }), + parentDirectoryMetadata({ isSymbolicLink: () => true }), + ])("rejects unsafe existing parent metadata", (unsafeParent) => { + const fileSystem = { lstatSync: vi.fn(() => unsafeParent) }; + expect(() => assertAcquisitionPrivatePathParents("parent/evidence.json", fileSystem as never)) + .toThrow("parent must be a real directory"); + }); + + it.skipIf(process.platform === "win32")("creates and overwrites only owner-readable single-link regular files", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-private-output-")); + const output = join(temp, "evidence.json"); + try { + writeAcquisitionPrivateFile(output, "first\n"); + expect(readFileSync(output, "utf8")).toBe("first\n"); + expect(statSync(output).mode & 0o777).toBe(0o600); + + chmodSync(output, 0o644); + writeAcquisitionPrivateFile(output, "second\n"); + expect(readFileSync(output, "utf8")).toBe("second\n"); + expect(statSync(output).mode & 0o777).toBe(0o600); + expect(lstatSync(output).nlink).toBe(1); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === "win32")("rejects a hard-linked existing output before truncation", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-private-hardlink-")); + const target = join(temp, "target.txt"); + const output = join(temp, "evidence.json"); + try { + writeFileSync(target, "sentinel\n", "utf8"); + linkSync(target, output); + expect(() => writeAcquisitionPrivateFile(output, "replacement\n")) + .toThrow("single-link regular file"); + expect(readFileSync(target, "utf8")).toBe("sentinel\n"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === "win32")("rejects symbolic-link parent directories before creating or modifying target leaves", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-private-parent-symlink-")); + const targetDirectory = join(temp, "target-directory"); + const linkedParent = join(temp, "linked-parent"); + const existingTarget = join(targetDirectory, "existing.json"); + const newTarget = join(targetDirectory, "new.json"); + try { + mkdirSync(targetDirectory); + writeFileSync(existingTarget, "sentinel\n", "utf8"); + symlinkSync(targetDirectory, linkedParent, "dir"); + + expect(() => writeAcquisitionPrivateFile(join(linkedParent, "new.json"), "replacement\n")) + .toThrow("parent"); + expect(existsSync(newTarget)).toBe(false); + + expect(() => writeAcquisitionPrivateFile(join(linkedParent, "existing.json"), "replacement\n")) + .toThrow("parent"); + expect(readFileSync(existingTarget, "utf8")).toBe("sentinel\n"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it.each([ + metadata({ isFile: undefined }), + metadata({ isSymbolicLink: undefined }), + metadata({ isFile: () => false }), + metadata({ isSymbolicLink: () => true }), + metadata({ nlink: 2 }), + ])("rejects unsafe existing metadata before opening", (before) => { + const fileSystem = mockFileSystem({ before }); + expect(() => writeAcquisitionPrivateFile("output", "value", fileSystem as never)) + .toThrow("single-link regular file"); + expect(fileSystem.openSync).not.toHaveBeenCalled(); + }); + + it("uses exclusive create, hardens permissions, and only then mutates content", () => { + const fileSystem = mockFileSystem(); + writeAcquisitionPrivateFile("output", "value", fileSystem as never); + expect(fileSystem.openSync).toHaveBeenCalledWith("output", 1 | 2 | 4 | 8, 0o600); + expect(fileSystem.ftruncateSync).toHaveBeenCalledWith(17, 0); + expect(fileSystem.writeFileSync).toHaveBeenCalledWith(17, "value", { encoding: "utf8" }); + expect(fileSystem.fchmodSync).toHaveBeenCalledWith(17, 0o600); + expect(fileSystem.fchmodSync.mock.invocationCallOrder[0]) + .toBeLessThan(fileSystem.ftruncateSync.mock.invocationCallOrder[0]); + expect(fileSystem.fchmodSync.mock.invocationCallOrder[0]) + .toBeLessThan(fileSystem.writeFileSync.mock.invocationCallOrder[0]); + expect(fileSystem.closeSync).toHaveBeenCalledWith(17); + }); + + it("leaves existing content untouched when owner-only permission hardening fails", () => { + const fileSystem = mockFileSystem({ + before: metadata(), + chmodError: new Error("permission hardening failed"), + }); + expect(() => writeAcquisitionPrivateFile("output", "replacement", fileSystem as never)) + .toThrow("permission hardening failed"); + expect(fileSystem.ftruncateSync).not.toHaveBeenCalled(); + expect(fileSystem.writeFileSync).not.toHaveBeenCalled(); + expect(fileSystem.closeSync).toHaveBeenCalledWith(17); + }); + + it("opens an existing file without truncation flags and verifies its descriptor identity first", () => { + const before = metadata(); + const fileSystem = mockFileSystem({ before }); + writeAcquisitionPrivateFile("output", "value", fileSystem as never); + expect(fileSystem.openSync).toHaveBeenCalledWith("output", 1 | 8, 0o600); + expect(fileSystem.ftruncateSync).toHaveBeenCalledOnce(); + }); + + it.each([ + metadata({ isFile: () => false }), + metadata({ isSymbolicLink: () => true }), + metadata({ nlink: 2 }), + metadata({ dev: 9 }), + metadata({ ino: 9 }), + ])("fails before truncation when an opened existing path no longer matches", (opened) => { + const fileSystem = mockFileSystem({ before: metadata(), opened }); + expect(() => writeAcquisitionPrivateFile("output", "value", fileSystem as never)) + .toThrow("changed before writing"); + expect(fileSystem.ftruncateSync).not.toHaveBeenCalled(); + expect(fileSystem.closeSync).toHaveBeenCalledWith(17); + }); + + it.each([ + { afterDescriptor: metadata({ isFile: () => false }), afterPath: metadata() }, + { afterDescriptor: metadata(), afterPath: metadata({ isSymbolicLink: () => true }) }, + { afterDescriptor: metadata({ nlink: 2 }), afterPath: metadata() }, + { afterDescriptor: metadata(), afterPath: metadata({ nlink: 2 }) }, + { afterDescriptor: metadata(), afterPath: metadata({ dev: 9 }) }, + { afterDescriptor: metadata(), afterPath: metadata({ ino: 9 }) }, + ])("fails closed when output identity or regular-file status changes after writing", (state) => { + const fileSystem = mockFileSystem(state); + expect(() => writeAcquisitionPrivateFile("output", "value", fileSystem as never)) + .toThrow("changed while writing"); + expect(fileSystem.closeSync).toHaveBeenCalledWith(17); + }); + + it("closes the descriptor when writing fails", () => { + const fileSystem = mockFileSystem({ writeError: new Error("write failed") }); + expect(() => writeAcquisitionPrivateFile("output", "value", fileSystem as never)) + .toThrow("write failed"); + expect(fileSystem.closeSync).toHaveBeenCalledWith(17); + }); +}); diff --git a/test/acquisition-readiness-audit.test.ts b/test/acquisition-readiness-audit.test.ts index 32fcb91f9..c9ba6ea7e 100644 --- a/test/acquisition-readiness-audit.test.ts +++ b/test/acquisition-readiness-audit.test.ts @@ -1,14 +1,20 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { spawnSync } from "node:child_process"; +import { dirname, join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; -function runAudit(env: NodeJS.ProcessEnv = {}) { - return spawnSync("node", ["scripts/acquisition-readiness-audit.mjs"], { - cwd: process.cwd(), +const auditScript = resolve("scripts/acquisition-readiness-audit.mjs"); + +function runAudit(root: string, env: NodeJS.ProcessEnv = {}) { + const inheritedEnvironment = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("NOEMA_")), + ); + return spawnSync(process.execPath, [auditScript], { + cwd: root, env: { - ...process.env, + ...inheritedEnvironment, ...env, }, encoding: "utf8", @@ -19,6 +25,46 @@ function today() { return new Date().toISOString().slice(0, 10); } +function writeFixture(root: string, relativePath: string, content: string): string { + const path = join(root, relativePath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, "utf8"); + return path; +} + +function prepareAuditRoot(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + writeFixture( + root, + "docs/acquisition-readiness-2b.md", + "NOEMA-GOAL-ACQUISITION-2B-2026-07-02\nKRW 2,000,000,000\nRevenue_PASS\nTransfer_PASS\n", + ); + writeFixture( + root, + "docs/buyer-due-diligence-index.md", + "npm run acquisition:audit\nartifacts/acquisition/revenue-evidence.json\nartifacts/acquisition/transfer-evidence.json\n", + ); + writeFixture( + root, + "docs/library-boundary-decision.md", + "현재는 submodule을 만들지 않는다\nnpm workspaces\nSplit Triggers\n", + ); + writeFixture( + root, + "scripts/acquisition-data-room-manifest.mjs", + "// finalGatePassed data-room-manifest.json release-publication-receipt\n", + ); + writeFixture( + root, + "docs/saleable-program-goal-registry.md", + "NOEMA-GOAL-SALEABLE-2026-07-02\n", + ); + writeFixture(root, "docs/pricing-draft.md", "pricing draft\n"); + writeFixture(root, "docs/terms-draft.md", "terms draft\n"); + writeFixture(root, "docs/sla-and-support.md", "support draft\n"); + return root; +} + function writePassingDataRoomManifest(path: string) { writeFileSync(path, JSON.stringify({ objective: "NOEMA-GOAL-ACQUISITION-2B-2026-07-02", @@ -71,245 +117,261 @@ function writePassingPilotLog(path: string) { `); } +function passingLicensingIp(root: string) { + const rightsBytes = "Copyright ContextualWisdomLab. All rights reserved.\n"; + writeFixture(root, "RIGHTS.md", rightsBytes); + writeFixture( + root, + "package.json", + `${JSON.stringify({ + name: "noema", + private: true, + license: "SEE LICENSE IN RIGHTS.md", + }, null, 2)}\n`, + ); + const releaseBytes = "retained exact-release evidence\n"; + for (const path of [ + "artifacts/release/noema.cdx.json", + "artifacts/release/dependency-licenses.json", + "artifacts/release/NOTICE.txt", + "artifacts/release/provenance.sigstore.json", + ]) { + writeFixture(root, path, releaseBytes); + } + const artifactRightsBytes = `${JSON.stringify({ + schema_version: 1, + repository: "ContextualWisdomLab/noema", + tag: "v0.1.0", + commit_sha: "a".repeat(40), + artifacts: [ + { + artifact_kind: "oci_image", + artifact_identity: `ghcr.io/contextualwisdomlab/noema@sha256:${"b".repeat(64)}`, + oci_annotations: {}, + }, + ], + }, null, 2)}\n`; + writeFixture( + root, + "artifacts/release/artifact-rights-metadata.json", + artifactRightsBytes, + ); + const digest = createHash("sha256").update(releaseBytes).digest("hex"); + const artifactRightsDigest = createHash("sha256").update(artifactRightsBytes).digest("hex"); + return { + owner_legal_decision: { + type: "custom", + evidence: ["legal/outbound-rights-decision.pdf"], + }, + repository_rights: { + path: "RIGHTS.md", + sha256: createHash("sha256").update(rightsBytes).digest("hex"), + }, + package_metadata: { + license: "SEE LICENSE IN RIGHTS.md", + }, + release_rights: { + tag: "v0.1.0", + commit_sha: "a".repeat(40), + sbom: { path: "artifacts/release/noema.cdx.json", sha256: digest }, + dependency_license_inventory: { + path: "artifacts/release/dependency-licenses.json", + sha256: digest, + }, + notice: { path: "artifacts/release/NOTICE.txt", sha256: digest }, + provenance: { path: "artifacts/release/provenance.sigstore.json", sha256: digest }, + artifact_rights_metadata: { + path: "artifacts/release/artifact-rights-metadata.json", + sha256: artifactRightsDigest, + }, + }, + contributor_ip: { + ownership_evidence: ["legal/contributor-ownership-register.pdf"], + assignment_evidence: ["legal/ip-assignment-register.pdf"], + }, + }; +} + +function writePassingTransfer(root: string, path: string) { + writeFileSync(path, JSON.stringify({ + license_review: "pass", + third_party_review: "pass", + github_app_transfer_plan: "pass", + cloudflare_transfer_plan: "pass", + secrets_rotation_plan: "pass", + owner_transfer_plan: "pass", + privacy_review: "pass", + updated_at: today(), + owner: "legal", + source_documents: ["legal/transfer-review.pdf"], + licensing_ip: passingLicensingIp(root), + })); +} + +function writePassingSaleable(path: string) { + writeFileSync(path, JSON.stringify({ + objective: "NOEMA-GOAL-SALEABLE-2026-07-02", + passed: true, + })); +} + +function writeArrRevenue(path: string, overrides: Record = {}) { + writeFileSync(path, JSON.stringify({ + arr_krw: 300_000_000, + gross_margin: 0.75, + paid_customers: 3, + pipeline_weighted_krw: 500_000_000, + loi_count: 3, + customer_concentration_top1: 0.5, + updated_at: today(), + owner: "finance", + source_documents: ["crm:noema-arr-report"], + ...overrides, + })); +} + +function commonPaths(root: string) { + return { + revenuePath: join(root, "revenue.json"), + transferPath: join(root, "transfer.json"), + saleablePath: join(root, "saleable.json"), + dataRoomPath: join(root, "data-room-manifest.json"), + pilotPath: join(root, "pilot.md"), + outputDir: join(root, "audit-output"), + }; +} + +function passingEnv(paths: ReturnType): NodeJS.ProcessEnv { + return { + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: paths.outputDir, + NOEMA_REVENUE_EVIDENCE_PATH: paths.revenuePath, + NOEMA_TRANSFER_EVIDENCE_PATH: paths.transferPath, + NOEMA_PILOT_LOG_PATH: paths.pilotPath, + NOEMA_SALEABLE_AUDIT_PATH: paths.saleablePath, + NOEMA_DATA_ROOM_MANIFEST_PATH: paths.dataRoomPath, + }; +} + describe("acquisition-readiness-audit", () => { it("fails closed when acquisition evidence is missing", () => { - const temp = mkdtempSync(join(tmpdir(), "noema-acq-missing-")); - const result = runAudit({ - NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: temp, - NOEMA_REVENUE_EVIDENCE_PATH: join(temp, "missing-revenue.json"), - NOEMA_TRANSFER_EVIDENCE_PATH: join(temp, "missing-transfer.json"), - NOEMA_SALEABLE_AUDIT_PATH: join(temp, "missing-saleable.json"), - }); - - expect(result.status).toBe(1); - expect(result.stdout).toContain("acquisition-readiness-audit: FAIL"); - expect(result.stdout).toContain("revenue evidence present"); + const root = prepareAuditRoot("noema-acq-missing-"); + try { + const result = runAudit(root, { + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: join(root, "audit-output"), + NOEMA_REVENUE_EVIDENCE_PATH: join(root, "missing-revenue.json"), + NOEMA_TRANSFER_EVIDENCE_PATH: join(root, "missing-transfer.json"), + NOEMA_SALEABLE_AUDIT_PATH: join(root, "missing-saleable.json"), + }); + expect(result.status).toBe(1); + expect(result.stdout).toContain("acquisition-readiness-audit: FAIL"); + expect(result.stdout).toContain("revenue evidence present"); + } finally { + rmSync(root, { recursive: true, force: true }); + } }); it("reports not-ready evidence without failing scheduled report-only audits", () => { - const temp = mkdtempSync(join(tmpdir(), "noema-acq-report-")); - const result = runAudit({ - NOEMA_AUDIT_REPORT_ONLY: "1", - NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: temp, - NOEMA_REVENUE_EVIDENCE_PATH: join(temp, "missing-revenue.json"), - NOEMA_TRANSFER_EVIDENCE_PATH: join(temp, "missing-transfer.json"), - NOEMA_SALEABLE_AUDIT_PATH: join(temp, "missing-saleable.json"), - }); - - expect(result.status).toBe(0); - expect(result.stdout).toContain("acquisition-readiness-audit: NOT_READY"); - expect(result.stdout).toContain("report_only=true"); - expect(result.stdout).toContain('details={"ok":false,"reason":"missing"'); - const audit = JSON.parse(readFileSync(join(temp, "acquisition-audit.json"), "utf8")); - expect(audit.passed).toBe(false); - expect(audit.status).toBe("NOT_READY"); - expect(audit.reportOnly).toBe(true); + const root = prepareAuditRoot("noema-acq-report-"); + const outputDir = join(root, "audit-output"); + try { + const result = runAudit(root, { + NOEMA_AUDIT_REPORT_ONLY: "1", + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: outputDir, + NOEMA_REVENUE_EVIDENCE_PATH: join(root, "missing-revenue.json"), + NOEMA_TRANSFER_EVIDENCE_PATH: join(root, "missing-transfer.json"), + NOEMA_SALEABLE_AUDIT_PATH: join(root, "missing-saleable.json"), + }); + expect(result.status).toBe(0); + expect(result.stdout).toContain("acquisition-readiness-audit: NOT_READY"); + expect(result.stdout).toContain("report_only=true"); + expect(result.stdout).toContain('details={"ok":false,"reason":"missing"'); + const audit = JSON.parse(readFileSync(join(outputDir, "acquisition-audit.json"), "utf8")); + expect(audit.passed).toBe(false); + expect(audit.status).toBe("NOT_READY"); + expect(audit.reportOnly).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } }); it("records unreadable pilot evidence without crashing", () => { - const temp = mkdtempSync(join(tmpdir(), "noema-acq-unreadable-")); - const pilotPath = join(temp, "pilot-log-dir"); + const root = prepareAuditRoot("noema-acq-unreadable-"); + const pilotPath = join(root, "pilot-log-dir"); mkdirSync(pilotPath); - - const result = runAudit({ - NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: temp, - NOEMA_REVENUE_EVIDENCE_PATH: join(temp, "missing-revenue.json"), - NOEMA_TRANSFER_EVIDENCE_PATH: join(temp, "missing-transfer.json"), - NOEMA_PILOT_LOG_PATH: pilotPath, - NOEMA_SALEABLE_AUDIT_PATH: join(temp, "missing-saleable.json"), - }); - - expect(result.status).toBe(1); - expect(result.stdout).toContain("acquisition-readiness-audit: FAIL"); - const audit = JSON.parse(readFileSync(join(temp, "acquisition-audit.json"), "utf8")); - const pilotCheck = audit.checks.find((check: { name: string }) => check.name === "pilot production evidence pass"); - expect(pilotCheck.details.reason).toBe("unreadable"); + const outputDir = join(root, "audit-output"); + try { + const result = runAudit(root, { + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: outputDir, + NOEMA_REVENUE_EVIDENCE_PATH: join(root, "missing-revenue.json"), + NOEMA_TRANSFER_EVIDENCE_PATH: join(root, "missing-transfer.json"), + NOEMA_PILOT_LOG_PATH: pilotPath, + NOEMA_SALEABLE_AUDIT_PATH: join(root, "missing-saleable.json"), + }); + expect(result.status).toBe(1); + expect(result.stdout).toContain("acquisition-readiness-audit: FAIL"); + const audit = JSON.parse(readFileSync(join(outputDir, "acquisition-audit.json"), "utf8")); + const pilotCheck = audit.checks.find( + (check: { name: string }) => check.name === "pilot production evidence pass", + ); + expect(pilotCheck.details.reason).toBe("unreadable"); + } finally { + rmSync(root, { recursive: true, force: true }); + } }); it("passes when 2B acquisition evidence and saleable evidence are present", () => { - const temp = mkdtempSync(join(tmpdir(), "noema-acq-pass-")); - const revenuePath = join(temp, "revenue.json"); - const transferPath = join(temp, "transfer.json"); - const saleablePath = join(temp, "saleable.json"); - const dataRoomPath = join(temp, "data-room-manifest.json"); - const pilotPath = join(temp, "pilot.md"); - - writeFileSync(revenuePath, JSON.stringify({ - arr_krw: 300_000_000, - gross_margin: 0.75, - paid_customers: 3, - pipeline_weighted_krw: 500_000_000, - loi_count: 3, - customer_concentration_top1: 0.5, - updated_at: today(), - owner: "finance", - source_documents: ["crm:noema-arr-report"], - })); - writeFileSync(transferPath, JSON.stringify({ - license_review: "pass", - third_party_review: "pass", - github_app_transfer_plan: "pass", - cloudflare_transfer_plan: "pass", - secrets_rotation_plan: "pass", - owner_transfer_plan: "pass", - privacy_review: "pass", - updated_at: today(), - owner: "legal", - source_documents: ["docs/buyer-due-diligence-index.md"], - })); - writeFileSync(saleablePath, JSON.stringify({ - objective: "NOEMA-GOAL-SALEABLE-2026-07-02", - passed: true, - })); - writePassingDataRoomManifest(dataRoomPath); - writePassingPilotLog(pilotPath); - - const result = runAudit({ - NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: temp, - NOEMA_REVENUE_EVIDENCE_PATH: revenuePath, - NOEMA_TRANSFER_EVIDENCE_PATH: transferPath, - NOEMA_PILOT_LOG_PATH: pilotPath, - NOEMA_SALEABLE_AUDIT_PATH: saleablePath, - NOEMA_DATA_ROOM_MANIFEST_PATH: dataRoomPath, - }); - - expect(result.status).toBe(0); - expect(result.stdout).toContain("acquisition-readiness-audit: PASS"); + const root = prepareAuditRoot("noema-acq-pass-"); + const paths = commonPaths(root); + try { + writeArrRevenue(paths.revenuePath); + writePassingTransfer(root, paths.transferPath); + writePassingSaleable(paths.saleablePath); + writePassingDataRoomManifest(paths.dataRoomPath); + writePassingPilotLog(paths.pilotPath); + + const result = runAudit(root, passingEnv(paths)); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain("acquisition-readiness-audit: PASS"); + } finally { + rmSync(root, { recursive: true, force: true }); + } }); it("requires a completed production pilot record", () => { - const temp = mkdtempSync(join(tmpdir(), "noema-acq-pilot-")); - const revenuePath = join(temp, "revenue.json"); - const transferPath = join(temp, "transfer.json"); - const saleablePath = join(temp, "saleable.json"); - const dataRoomPath = join(temp, "data-room-manifest.json"); - const pilotPath = join(temp, "pilot.md"); - - writeFileSync(revenuePath, JSON.stringify({ - arr_krw: 300_000_000, - gross_margin: 0.75, - paid_customers: 3, - customer_concentration_top1: 0.5, - updated_at: today(), - owner: "finance", - source_documents: ["crm:noema-arr-report"], - })); - writeFileSync(transferPath, JSON.stringify({ - license_review: "pass", - third_party_review: "pass", - github_app_transfer_plan: "pass", - cloudflare_transfer_plan: "pass", - secrets_rotation_plan: "pass", - owner_transfer_plan: "pass", - privacy_review: "pass", - updated_at: today(), - owner: "legal", - source_documents: ["docs/buyer-due-diligence-index.md"], - })); - writeFileSync(saleablePath, JSON.stringify({ - objective: "NOEMA-GOAL-SALEABLE-2026-07-02", - passed: true, - })); - writePassingDataRoomManifest(dataRoomPath); - writeFileSync(pilotPath, "# 파일럿 온보딩 진행 기록\n\n## 항목 1\n- 고객명:\n"); - - const result = runAudit({ - NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: temp, - NOEMA_REVENUE_EVIDENCE_PATH: revenuePath, - NOEMA_TRANSFER_EVIDENCE_PATH: transferPath, - NOEMA_PILOT_LOG_PATH: pilotPath, - NOEMA_SALEABLE_AUDIT_PATH: saleablePath, - NOEMA_DATA_ROOM_MANIFEST_PATH: dataRoomPath, - }); - - expect(result.status).toBe(1); - expect(result.stdout).toContain("pilot production evidence pass"); + const root = prepareAuditRoot("noema-acq-pilot-"); + const paths = commonPaths(root); + try { + writeArrRevenue(paths.revenuePath); + writePassingTransfer(root, paths.transferPath); + writePassingSaleable(paths.saleablePath); + writePassingDataRoomManifest(paths.dataRoomPath); + writeFileSync(paths.pilotPath, "# 파일럿 온보딩 진행 기록\n\n## 항목 1\n- 고객명:\n"); + + const result = runAudit(root, passingEnv(paths)); + expect(result.status).toBe(1); + expect(result.stdout).toContain("pilot production evidence pass"); + } finally { + rmSync(root, { recursive: true, force: true }); + } }); it("rejects copied evidence templates until placeholders are replaced", () => { - const temp = mkdtempSync(join(tmpdir(), "noema-acq-template-")); - const revenuePath = join(temp, "revenue.json"); - const transferPath = join(temp, "transfer.json"); - const saleablePath = join(temp, "saleable.json"); - const dataRoomPath = join(temp, "data-room-manifest.json"); - const pilotPath = join(temp, "pilot.md"); - - writeFileSync(revenuePath, JSON.stringify({ - arr_krw: 0, - gross_margin: 0, - paid_customers: 1, - pipeline_weighted_krw: 500_000_000, - loi_count: 3, - buyer_due_diligence_qna: ["replace-with-crm-or-data-room-qna-path"], - customer_concentration_top1: 1, - updated_at: today(), - owner: "replace-with-finance-or-sales-owner", - source_documents: ["docs/evidence-templates/revenue-evidence.example.json"], - })); - writeFileSync(transferPath, JSON.stringify({ - license_review: "pass", - third_party_review: "pass", - github_app_transfer_plan: "pass", - cloudflare_transfer_plan: "pass", - secrets_rotation_plan: "pass", - owner_transfer_plan: "pass", - privacy_review: "pass", - updated_at: today(), - owner: "replace-with-legal-or-security-owner", - source_documents: ["replace-with-transfer-runbook-or-approval-path"], - })); - writeFileSync(saleablePath, JSON.stringify({ - objective: "NOEMA-GOAL-SALEABLE-2026-07-02", - passed: true, - })); - writePassingDataRoomManifest(dataRoomPath); - writePassingPilotLog(pilotPath); - - const result = runAudit({ - NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: temp, - NOEMA_REVENUE_EVIDENCE_PATH: revenuePath, - NOEMA_TRANSFER_EVIDENCE_PATH: transferPath, - NOEMA_PILOT_LOG_PATH: pilotPath, - NOEMA_SALEABLE_AUDIT_PATH: saleablePath, - NOEMA_DATA_ROOM_MANIFEST_PATH: dataRoomPath, - }); - - expect(result.status).toBe(1); - const audit = JSON.parse(readFileSync(join(temp, "acquisition-audit.json"), "utf8")); - const revenueCheck = audit.checks.find((check: { name: string }) => check.name === "revenue evidence supports 2B target"); - const transferCheck = audit.checks.find((check: { name: string }) => check.name === "transfer evidence pass"); - 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"); - 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"); - }); - - it("uses the latest dated saleable readiness audit by default", () => { - const temp = mkdtempSync(join(tmpdir(), "noema-acq-latest-")); - const revenuePath = join(temp, "revenue.json"); - const transferPath = join(temp, "transfer.json"); - const dataRoomPath = join(temp, "data-room-manifest.json"); - const pilotPath = join(temp, "pilot.md"); - const saleableRoot = join(process.cwd(), "artifacts", "saleable-readiness"); - const olderDir = join(saleableRoot, "20991230"); - const latestDir = join(saleableRoot, "20991231"); - - rmSync(olderDir, { recursive: true, force: true }); - rmSync(latestDir, { recursive: true, force: true }); + const root = prepareAuditRoot("noema-acq-template-"); + const paths = commonPaths(root); try { - mkdirSync(olderDir, { recursive: true }); - mkdirSync(latestDir, { recursive: true }); - writeFileSync(revenuePath, JSON.stringify({ - arr_krw: 300_000_000, - gross_margin: 0.75, - paid_customers: 3, - customer_concentration_top1: 0.5, + writeFileSync(paths.revenuePath, JSON.stringify({ + arr_krw: 0, + gross_margin: 0, + paid_customers: 1, + pipeline_weighted_krw: 500_000_000, + loi_count: 3, + buyer_due_diligence_qna: ["replace-with-crm-or-data-room-qna-path"], + customer_concentration_top1: 1, updated_at: today(), - owner: "finance", - source_documents: ["crm:noema-arr-report"], + owner: "replace-with-finance-or-sales-owner", + source_documents: ["docs/evidence-templates/revenue-evidence.example.json"], })); - writeFileSync(transferPath, JSON.stringify({ + writeFileSync(paths.transferPath, JSON.stringify({ license_review: "pass", third_party_review: "pass", github_app_transfer_plan: "pass", @@ -318,9 +380,51 @@ describe("acquisition-readiness-audit", () => { owner_transfer_plan: "pass", privacy_review: "pass", updated_at: today(), - owner: "legal", - source_documents: ["docs/buyer-due-diligence-index.md"], + owner: "replace-with-legal-or-security-owner", + source_documents: ["replace-with-transfer-runbook-or-approval-path"], })); + writePassingSaleable(paths.saleablePath); + writePassingDataRoomManifest(paths.dataRoomPath); + writePassingPilotLog(paths.pilotPath); + + const result = runAudit(root, passingEnv(paths)); + expect(result.status).toBe(1); + const audit = JSON.parse(readFileSync(join(paths.outputDir, "acquisition-audit.json"), "utf8")); + const revenueCheck = audit.checks.find( + (check: { name: string }) => check.name === "revenue evidence supports 2B target", + ); + const transferCheck = audit.checks.find( + (check: { name: string }) => check.name === "transfer evidence pass", + ); + 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", + ); + 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", + ); + expect(transferCheck.details.licensingIpFailures).toContain( + "licensing_ip evidence object required", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("uses the latest dated saleable readiness audit by default", () => { + const root = prepareAuditRoot("noema-acq-latest-"); + const paths = commonPaths(root); + const olderDir = join(root, "artifacts", "saleable-readiness", "20991230"); + const latestDir = join(root, "artifacts", "saleable-readiness", "20991231"); + try { + writeArrRevenue(paths.revenuePath); + writePassingTransfer(root, paths.transferPath); + mkdirSync(olderDir, { recursive: true }); + mkdirSync(latestDir, { recursive: true }); writeFileSync(join(olderDir, "goal-audit.json"), JSON.stringify({ objective: "NOEMA-GOAL-SALEABLE-2026-07-02", passed: false, @@ -329,202 +433,107 @@ describe("acquisition-readiness-audit", () => { objective: "NOEMA-GOAL-SALEABLE-2026-07-02", passed: true, })); - writePassingDataRoomManifest(dataRoomPath); - writePassingPilotLog(pilotPath); - - const result = runAudit({ - NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: temp, - NOEMA_REVENUE_EVIDENCE_PATH: revenuePath, - NOEMA_TRANSFER_EVIDENCE_PATH: transferPath, - NOEMA_PILOT_LOG_PATH: pilotPath, - NOEMA_DATA_ROOM_MANIFEST_PATH: dataRoomPath, + writePassingDataRoomManifest(paths.dataRoomPath); + writePassingPilotLog(paths.pilotPath); + + const result = runAudit(root, { + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: paths.outputDir, + NOEMA_REVENUE_EVIDENCE_PATH: paths.revenuePath, + NOEMA_TRANSFER_EVIDENCE_PATH: paths.transferPath, + NOEMA_PILOT_LOG_PATH: paths.pilotPath, + NOEMA_DATA_ROOM_MANIFEST_PATH: paths.dataRoomPath, }); - - expect(result.status).toBe(0); + expect(result.status, result.stderr || result.stdout).toBe(0); expect(result.stdout).toContain("acquisition-readiness-audit: PASS"); } finally { - rmSync(olderDir, { recursive: true, force: true }); - rmSync(latestDir, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); } }); it("requires buyer due diligence Q&A evidence for the strategic pipeline route", () => { - const temp = mkdtempSync(join(tmpdir(), "noema-acq-pipeline-")); - const revenuePath = join(temp, "revenue.json"); - const transferPath = join(temp, "transfer.json"); - const saleablePath = join(temp, "saleable.json"); - const dataRoomPath = join(temp, "data-room-manifest.json"); - const pilotPath = join(temp, "pilot.md"); - - writeFileSync(revenuePath, JSON.stringify({ - arr_krw: 0, - gross_margin: 0, - paid_customers: 1, - pipeline_weighted_krw: 500_000_000, - loi_count: 3, - customer_concentration_top1: 1, - updated_at: today(), - owner: "sales", - source_documents: ["crm:noema-enterprise-pipeline"], - })); - writeFileSync(transferPath, JSON.stringify({ - license_review: "pass", - third_party_review: "pass", - github_app_transfer_plan: "pass", - cloudflare_transfer_plan: "pass", - secrets_rotation_plan: "pass", - owner_transfer_plan: "pass", - privacy_review: "pass", - updated_at: today(), - owner: "legal", - source_documents: ["docs/buyer-due-diligence-index.md"], - })); - writeFileSync(saleablePath, JSON.stringify({ - objective: "NOEMA-GOAL-SALEABLE-2026-07-02", - passed: true, - })); - writePassingDataRoomManifest(dataRoomPath); - writePassingPilotLog(pilotPath); - - const missingQna = runAudit({ - NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: temp, - NOEMA_REVENUE_EVIDENCE_PATH: revenuePath, - NOEMA_TRANSFER_EVIDENCE_PATH: transferPath, - NOEMA_PILOT_LOG_PATH: pilotPath, - NOEMA_SALEABLE_AUDIT_PATH: saleablePath, - NOEMA_DATA_ROOM_MANIFEST_PATH: dataRoomPath, - }); - - expect(missingQna.status).toBe(1); - expect(missingQna.stdout).toContain("revenue evidence supports 2B target"); - - writeFileSync(revenuePath, JSON.stringify({ - arr_krw: 0, - gross_margin: 0, - paid_customers: 1, - pipeline_weighted_krw: 500_000_000, - loi_count: 3, - customer_concentration_top1: 1, - buyer_due_diligence_qna: ["crm:noema-enterprise-security-qna"], - updated_at: today(), - owner: "sales", - source_documents: ["crm:noema-enterprise-pipeline"], - })); - - const withQna = runAudit({ - NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: temp, - NOEMA_REVENUE_EVIDENCE_PATH: revenuePath, - NOEMA_TRANSFER_EVIDENCE_PATH: transferPath, - NOEMA_PILOT_LOG_PATH: pilotPath, - NOEMA_SALEABLE_AUDIT_PATH: saleablePath, - NOEMA_DATA_ROOM_MANIFEST_PATH: dataRoomPath, - }); - - expect(withQna.status).toBe(0); - expect(withQna.stdout).toContain("acquisition-readiness-audit: PASS"); + const root = prepareAuditRoot("noema-acq-pipeline-"); + const paths = commonPaths(root); + try { + writeFileSync(paths.revenuePath, JSON.stringify({ + arr_krw: 0, + gross_margin: 0, + paid_customers: 1, + pipeline_weighted_krw: 500_000_000, + loi_count: 3, + customer_concentration_top1: 1, + updated_at: today(), + owner: "sales", + source_documents: ["crm:noema-enterprise-pipeline"], + })); + writePassingTransfer(root, paths.transferPath); + writePassingSaleable(paths.saleablePath); + writePassingDataRoomManifest(paths.dataRoomPath); + writePassingPilotLog(paths.pilotPath); + + const missingQna = runAudit(root, passingEnv(paths)); + expect(missingQna.status).toBe(1); + expect(missingQna.stdout).toContain("revenue evidence supports 2B target"); + + writeFileSync(paths.revenuePath, JSON.stringify({ + arr_krw: 0, + gross_margin: 0, + paid_customers: 1, + pipeline_weighted_krw: 500_000_000, + loi_count: 3, + customer_concentration_top1: 1, + buyer_due_diligence_qna: ["crm:noema-enterprise-security-qna"], + updated_at: today(), + owner: "sales", + source_documents: ["crm:noema-enterprise-pipeline"], + })); + + const withQna = runAudit(root, passingEnv(paths)); + expect(withQna.status, withQna.stderr || withQna.stdout).toBe(0); + expect(withQna.stdout).toContain("acquisition-readiness-audit: PASS"); + } finally { + rmSync(root, { recursive: true, force: true }); + } }); it("fails closed when the data-room manifest belongs to another objective", () => { - const temp = mkdtempSync(join(tmpdir(), "noema-acq-manifest-objective-")); - const revenuePath = join(temp, "revenue.json"); - const transferPath = join(temp, "transfer.json"); - const saleablePath = join(temp, "saleable.json"); - const dataRoomPath = join(temp, "data-room-manifest.json"); - const pilotPath = join(temp, "pilot.md"); - - writeFileSync(revenuePath, JSON.stringify({ - arr_krw: 300_000_000, - gross_margin: 0.75, - paid_customers: 3, - customer_concentration_top1: 0.5, - updated_at: today(), - owner: "finance", - source_documents: ["crm:noema-arr-report"], - })); - writeFileSync(transferPath, JSON.stringify({ - license_review: "pass", - third_party_review: "pass", - github_app_transfer_plan: "pass", - cloudflare_transfer_plan: "pass", - secrets_rotation_plan: "pass", - owner_transfer_plan: "pass", - privacy_review: "pass", - updated_at: today(), - owner: "legal", - source_documents: ["docs/buyer-due-diligence-index.md"], - })); - writeFileSync(saleablePath, JSON.stringify({ - objective: "NOEMA-GOAL-SALEABLE-2026-07-02", - passed: true, - })); - writePassingPilotLog(pilotPath); - writeFileSync(dataRoomPath, JSON.stringify({ - objective: "OTHER-GOAL", - passed: true, - finalGatePassed: true, - missingFinalGate: [], - entries: [], - })); - - const result = runAudit({ - NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: temp, - NOEMA_REVENUE_EVIDENCE_PATH: revenuePath, - NOEMA_TRANSFER_EVIDENCE_PATH: transferPath, - NOEMA_PILOT_LOG_PATH: pilotPath, - NOEMA_SALEABLE_AUDIT_PATH: saleablePath, - NOEMA_DATA_ROOM_MANIFEST_PATH: dataRoomPath, - }); - - expect(result.status).toBe(1); - expect(result.stdout).toContain("data room manifest final gate pass"); + const root = prepareAuditRoot("noema-acq-manifest-objective-"); + const paths = commonPaths(root); + try { + writeArrRevenue(paths.revenuePath); + writePassingTransfer(root, paths.transferPath); + writePassingSaleable(paths.saleablePath); + writePassingPilotLog(paths.pilotPath); + writeFileSync(paths.dataRoomPath, JSON.stringify({ + objective: "OTHER-GOAL", + passed: true, + finalGatePassed: true, + missingFinalGate: [], + entries: [], + })); + + const result = runAudit(root, passingEnv(paths)); + expect(result.status).toBe(1); + expect(result.stdout).toContain("data room manifest final gate pass"); + } finally { + rmSync(root, { recursive: true, force: true }); + } }); it("fails closed when acquisition evidence lacks fresh source metadata", () => { - const temp = mkdtempSync(join(tmpdir(), "noema-acq-stale-")); - const revenuePath = join(temp, "revenue.json"); - const transferPath = join(temp, "transfer.json"); - const saleablePath = join(temp, "saleable.json"); - const dataRoomPath = join(temp, "data-room-manifest.json"); - const pilotPath = join(temp, "pilot.md"); - - writeFileSync(revenuePath, JSON.stringify({ - arr_krw: 300_000_000, - gross_margin: 0.75, - paid_customers: 3, - customer_concentration_top1: 0.5, - updated_at: "2000-01-01", - owner: "finance", - source_documents: ["crm:noema-arr-report"], - })); - writeFileSync(transferPath, JSON.stringify({ - license_review: "pass", - third_party_review: "pass", - github_app_transfer_plan: "pass", - cloudflare_transfer_plan: "pass", - secrets_rotation_plan: "pass", - owner_transfer_plan: "pass", - privacy_review: "pass", - updated_at: today(), - owner: "legal", - source_documents: ["docs/buyer-due-diligence-index.md"], - })); - writeFileSync(saleablePath, JSON.stringify({ - objective: "NOEMA-GOAL-SALEABLE-2026-07-02", - passed: true, - })); - writePassingDataRoomManifest(dataRoomPath); - writePassingPilotLog(pilotPath); - - const result = runAudit({ - NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: temp, - NOEMA_REVENUE_EVIDENCE_PATH: revenuePath, - NOEMA_TRANSFER_EVIDENCE_PATH: transferPath, - NOEMA_PILOT_LOG_PATH: pilotPath, - NOEMA_SALEABLE_AUDIT_PATH: saleablePath, - NOEMA_DATA_ROOM_MANIFEST_PATH: dataRoomPath, - }); - - expect(result.status).toBe(1); - expect(result.stdout).toContain("revenue evidence supports 2B target"); + const root = prepareAuditRoot("noema-acq-stale-"); + const paths = commonPaths(root); + try { + writeArrRevenue(paths.revenuePath, { updated_at: "2000-01-01" }); + writePassingTransfer(root, paths.transferPath); + writePassingSaleable(paths.saleablePath); + writePassingDataRoomManifest(paths.dataRoomPath); + writePassingPilotLog(paths.pilotPath); + + const result = runAudit(root, passingEnv(paths)); + expect(result.status).toBe(1); + expect(result.stdout).toContain("revenue evidence supports 2B target"); + } finally { + rmSync(root, { recursive: true, force: true }); + } }); }); diff --git a/test/acquisition-review-regressions.test.ts b/test/acquisition-review-regressions.test.ts new file mode 100644 index 000000000..c68b35a50 --- /dev/null +++ b/test/acquisition-review-regressions.test.ts @@ -0,0 +1,385 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + DATA_ROOM_OBJECTIVE, + DATA_ROOM_REPOSITORY, + DATA_ROOM_SCHEMA_VERSION, + materializeDataRoomManifest, + readStableFile, + verifyDataRoomManifest, +} from "../scripts/lib/acquisition-data-room-integrity.mjs"; +import { buildAcquisitionGitEnvironment } from "../scripts/lib/acquisition-git-preflight.mjs"; + +const HEAD = "0123456789abcdef0123456789abcdef01234567"; +const fullEntrypointTestTimeoutMs = 95_000; + +function emptyManifest() { + return { + schemaVersion: DATA_ROOM_SCHEMA_VERSION, + repository: DATA_ROOM_REPOSITORY, + objective: DATA_ROOM_OBJECTIVE, + source: { commitSha: HEAD }, + passed: true, + finalGatePassed: true, + missingRequired: [], + missingFinalGate: [], + entries: [], + }; +} + +function sha256(value: Buffer | string) { + return createHash("sha256").update(value).digest("hex"); +} + +describe("acquisition review regressions", () => { + it("fails closed when the reviewed catalog is not a bounded non-empty array", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-invalid-catalog-")); + try { + const notArray = verifyDataRoomManifest(emptyManifest(), { + rootDir: temp, + expectedCommitSha: HEAD, + catalog: null as never, + }); + expect(notArray.integrityPassed).toBe(false); + expect(notArray.finalGatePassed).toBe(false); + expect(notArray.failures).toContain("reviewed catalog must be a bounded non-empty array"); + + const oversized = verifyDataRoomManifest(emptyManifest(), { + rootDir: temp, + expectedCommitSha: HEAD, + catalog: Array.from({ length: 10_000 }, (_, index) => ({ + id: `entry-${index}`, + category: "security", + kind: "command", + command: "true", + required: false, + requiredForFinalGate: false, + })), + }); + expect(oversized.integrityPassed).toBe(false); + expect(oversized.finalGatePassed).toBe(false); + expect(oversized.failures).toContain("reviewed catalog must be a bounded non-empty array"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("treats an existing zero-byte regular evidence file as present rather than unsafe", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-empty-evidence-")); + try { + const relativePath = "evidence/empty.ndjson"; + const absolutePath = join(temp, relativePath); + mkdirSync(join(temp, "evidence"), { recursive: true }); + writeFileSync(absolutePath, "", { flag: "w" }); + const catalog = [{ + id: "empty-evidence", + category: "operations", + kind: "file", + path: relativePath, + required: true, + requiredForFinalGate: true, + }]; + const manifest = { + schemaVersion: DATA_ROOM_SCHEMA_VERSION, + repository: DATA_ROOM_REPOSITORY, + objective: DATA_ROOM_OBJECTIVE, + source: { commitSha: HEAD }, + passed: true, + finalGatePassed: true, + missingRequired: [], + missingFinalGate: [], + entries: [{ + ...catalog[0], + status: "present", + bytes: 0, + sha256: sha256(Buffer.alloc(0)), + }], + }; + + const result = verifyDataRoomManifest(manifest, { + rootDir: temp, + expectedCommitSha: HEAD, + catalog, + }); + expect(result.integrityPassed).toBe(true); + expect(result.finalGatePassed).toBe(true); + expect(result.failures).toEqual([]); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("fails closed on invalid read bounds but tolerates a close failure after a stable empty read", () => { + const metadata = { + dev: 1, + ino: 2, + size: 0, + mtimeMs: 3, + ctimeMs: 4, + isFile: () => true, + isSymbolicLink: () => false, + }; + const fileSystem = { + constants: { O_RDONLY: 0, O_NOFOLLOW: 0x20000 }, + lstatSync: vi.fn(() => metadata), + openSync: vi.fn(() => 7), + fstatSync: vi.fn(() => metadata), + readSync: vi.fn(), + closeSync: vi.fn(() => { + throw new Error("close failed"); + }), + }; + + expect(readStableFile("unused", 0, fileSystem)).toBeNull(); + expect(fileSystem.lstatSync).not.toHaveBeenCalled(); + expect(readStableFile("empty", 16, fileSystem)).toEqual(Buffer.alloc(0)); + expect(fileSystem.closeSync).toHaveBeenCalledWith(7); + }); + + it("rejects unsupported catalog kinds without converting them into trusted presence", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-unsupported-catalog-")); + try { + const catalog = [{ + id: "unsupported-evidence", + category: "security", + kind: "unsupported", + required: true, + requiredForFinalGate: true, + }]; + const manifest = { + schemaVersion: DATA_ROOM_SCHEMA_VERSION, + repository: DATA_ROOM_REPOSITORY, + objective: DATA_ROOM_OBJECTIVE, + source: { commitSha: HEAD }, + passed: false, + finalGatePassed: false, + missingRequired: ["unsupported-evidence"], + missingFinalGate: ["unsupported-evidence"], + entries: [{ ...catalog[0], status: "present" }], + }; + const result = verifyDataRoomManifest(manifest, { + rootDir: temp, + expectedCommitSha: HEAD, + catalog, + }); + expect(result.integrityPassed).toBe(false); + expect(result.failures).toContain("unsupported-evidence has an unsupported catalog kind"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it.each([ + ["2026-02-30T00:00:00.000Z", "bounded provenance"], + ["2026-08-07T00:00:00.000Z", "x".repeat(4097)], + ])("rejects invalid external receipt timestamp or bounded text (%s)", (collectedAt, provenance) => { + const temp = mkdtempSync(join(tmpdir(), "noema-external-bounds-")); + try { + const artifactPath = "artifacts/acquisition/external.json"; + const receiptPath = "artifacts/acquisition/external-receipt.json"; + const absoluteArtifact = join(temp, artifactPath); + const absoluteReceipt = join(temp, receiptPath); + mkdirSync(join(temp, "artifacts", "acquisition"), { recursive: true }); + writeFileSync(absoluteArtifact, "{}\n"); + const artifact = Buffer.from("{}\n"); + writeFileSync(absoluteReceipt, JSON.stringify({ + schemaVersion: 1, + repository: DATA_ROOM_REPOSITORY, + source: { commitSha: HEAD }, + sourceUrl: "https://example.invalid/evidence", + collectedAt, + collector: "noema-test-collector", + provenance, + artifact: { + path: artifactPath, + bytes: artifact.byteLength, + sha256: sha256(artifact), + }, + })); + const catalog = [{ + id: "external-evidence", + category: "product", + kind: "external", + url: "https://example.invalid/evidence", + receiptPath, + artifactPath, + required: false, + requiredForFinalGate: true, + }]; + const manifest = { + schemaVersion: DATA_ROOM_SCHEMA_VERSION, + repository: DATA_ROOM_REPOSITORY, + objective: DATA_ROOM_OBJECTIVE, + source: { commitSha: HEAD }, + passed: true, + finalGatePassed: false, + missingRequired: [], + missingFinalGate: ["external-evidence"], + entries: [{ ...catalog[0], status: "declared", receiptVerified: false }], + }; + const result = verifyDataRoomManifest(manifest, { + rootDir: temp, + expectedCommitSha: HEAD, + catalog, + }); + expect(result.integrityPassed).toBe(false); + expect(result.failures).toContain("external-evidence receipt does not authenticate the retained external artifact"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("rejects non-canonical reviewed paths before retained evidence can be selected", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-noncanonical-path-")); + try { + const catalog = [{ + id: "external-evidence", + category: "product", + kind: "external", + url: "https://example.invalid/evidence", + receiptPath: "artifacts//receipt.json", + artifactPath: "artifacts/export.json", + required: false, + requiredForFinalGate: true, + }]; + const manifest = { + schemaVersion: DATA_ROOM_SCHEMA_VERSION, + repository: DATA_ROOM_REPOSITORY, + objective: DATA_ROOM_OBJECTIVE, + source: { commitSha: HEAD }, + passed: true, + finalGatePassed: false, + missingRequired: [], + missingFinalGate: ["external-evidence"], + entries: [{ ...catalog[0], status: "declared", receiptVerified: false }], + }; + const result = verifyDataRoomManifest(manifest, { + rootDir: temp, + expectedCommitSha: HEAD, + catalog, + }); + expect(result.integrityPassed).toBe(false); + expect(result.failures).toContain("external-evidence receipt path is not canonical"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("materializes a reviewed command catalog directly without trusting persisted state", () => { + const catalog = [{ + id: "verify-command", + category: "automation", + kind: "command", + command: "npm run release:verify", + required: true, + requiredForFinalGate: true, + }]; + const output = materializeDataRoomManifest({ + rootDir: process.cwd(), + manifestPath: "artifacts/acquisition-readiness/test/data-room-manifest.json", + commitSha: HEAD, + generatedAt: "2026-08-07T00:00:00.000Z", + catalog, + }); + expect(output.passed).toBe(true); + expect(output.finalGatePassed).toBe(true); + expect(output.entries).toEqual([{ ...catalog[0], status: "present" }]); + }); + + it("pins safe.directory and strict stat comparison to the exact command cwd without restoring ambient Git configuration", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-safe-directory-")); + try { + const environment = buildAcquisitionGitEnvironment({ PATH: process.env.PATH }, process.platform, temp); + expect(environment.GIT_CONFIG_COUNT).toBe("8"); + expect(environment.GIT_CONFIG_KEY_3).toBe("safe.directory"); + expect(environment.GIT_CONFIG_VALUE_3).toBe(resolve(temp)); + expect(environment.GIT_CONFIG_KEY_4).toBe("core.trustctime"); + expect(environment.GIT_CONFIG_VALUE_4).toBe("true"); + expect(environment.GIT_CONFIG_KEY_5).toBe("core.checkStat"); + expect(environment.GIT_CONFIG_VALUE_5).toBe("default"); + expect(environment.GIT_CONFIG_KEY_6).toBe("core.ignoreStat"); + expect(environment.GIT_CONFIG_VALUE_6).toBe("false"); + expect(environment.GIT_CONFIG_KEY_7).toBe("core.filemode"); + expect(environment.GIT_CONFIG_VALUE_7).toBe("true"); + expect(environment.GIT_CONFIG_NOSYSTEM).toBe("1"); + expect(environment.GIT_CONFIG_GLOBAL).toBe(process.platform === "win32" ? "NUL" : "/dev/null"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === "win32")("shares one configured data-room directory and restricts existing outputs to owner-only mode", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-data-room-mode-")); + const manifestPath = join(temp, "data-room-manifest.json"); + const auditPath = join(temp, "data-room-integrity-audit.json"); + try { + writeFileSync(manifestPath, "{}\n", { mode: 0o644 }); + chmodSync(manifestPath, 0o644); + const manifestResult = spawnSync(process.execPath, ["scripts/acquisition-data-room-manifest-secure.mjs"], { + cwd: process.cwd(), + env: { + ...process.env, + NOEMA_DATA_ROOM_OUTPUT_DIR: temp, + NOEMA_DATA_ROOM_MANIFEST_PATH: manifestPath, + NOEMA_DATA_ROOM_SOURCE_COMMIT: "", + NOEMA_RELEASE_UNDER_DILIGENCE_TAG: "", + }, + encoding: "utf8", + timeout: 30_000, + }); + expect(manifestResult.error).toBeUndefined(); + expect(manifestResult.status).toBe(0); + expect(statSync(manifestPath).mode & 0o777).toBe(0o600); + + writeFileSync(auditPath, "{}\n", { mode: 0o644 }); + chmodSync(auditPath, 0o644); + const normalAudit = spawnSync(process.execPath, ["scripts/acquisition-data-room-integrity-audit.mjs"], { + cwd: process.cwd(), + env: { + ...process.env, + NOEMA_DATA_ROOM_OUTPUT_DIR: temp, + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: "", + NOEMA_DATA_ROOM_MANIFEST_PATH: manifestPath, + NOEMA_DATA_ROOM_SOURCE_COMMIT: "", + NOEMA_RELEASE_UNDER_DILIGENCE_TAG: "", + }, + encoding: "utf8", + timeout: 30_000, + }); + expect(normalAudit.error).toBeUndefined(); + expect(normalAudit.status).toBe(0); + expect(statSync(auditPath).mode & 0o777).toBe(0o600); + + chmodSync(auditPath, 0o644); + const failureAudit = spawnSync(process.execPath, ["scripts/acquisition-data-room-integrity-audit.mjs"], { + cwd: process.cwd(), + env: { + ...process.env, + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: temp, + NOEMA_DATA_ROOM_OUTPUT_DIR: "", + NOEMA_DATA_ROOM_MANIFEST_PATH: manifestPath, + NOEMA_DATA_ROOM_SOURCE_COMMIT: "not-a-full-sha", + NOEMA_RELEASE_UNDER_DILIGENCE_TAG: "", + }, + encoding: "utf8", + timeout: 30_000, + }); + expect(failureAudit.error).toBeUndefined(); + expect(failureAudit.status).toBe(1); + expect(statSync(auditPath).mode & 0o777).toBe(0o600); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }, fullEntrypointTestTimeoutMs); +}); diff --git a/test/acquisition-top-level-json.test.ts b/test/acquisition-top-level-json.test.ts new file mode 100644 index 000000000..433aa6209 --- /dev/null +++ b/test/acquisition-top-level-json.test.ts @@ -0,0 +1,142 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +function writeFixture(root: string, relativePath: string, content: string): string { + const path = join(root, relativePath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, "utf8"); + return path; +} + +function writeRequiredDocs(root: string): void { + writeFixture( + root, + "docs/acquisition-readiness-2b.md", + "NOEMA-GOAL-ACQUISITION-2B-2026-07-02\nKRW 2,000,000,000\nRevenue_PASS\nTransfer_PASS\n", + ); + writeFixture( + root, + "docs/buyer-due-diligence-index.md", + "npm run acquisition:audit\nartifacts/acquisition/revenue-evidence.json\nartifacts/acquisition/transfer-evidence.json\n", + ); + writeFixture( + root, + "docs/library-boundary-decision.md", + "현재는 submodule을 만들지 않는다\nnpm workspaces\nSplit Triggers\n", + ); + writeFixture( + root, + "scripts/acquisition-data-room-manifest.mjs", + "// finalGatePassed data-room-manifest.json release-publication-receipt\n", + ); + writeFixture(root, "docs/saleable-program-goal-registry.md", "NOEMA-GOAL-SALEABLE-2026-07-02\n"); + writeFixture(root, "docs/pricing-draft.md", "pricing draft\n"); + writeFixture(root, "docs/terms-draft.md", "terms draft\n"); + writeFixture(root, "docs/sla-and-support.md", "support draft\n"); +} + +function runReportOnlyAudit(root: string, revenuePath: string, outputDir: string) { + const inheritedEnvironment = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("NOEMA_")), + ); + return spawnSync(process.execPath, [resolve("scripts/acquisition-readiness-audit.mjs")], { + cwd: root, + encoding: "utf8", + env: { + ...inheritedEnvironment, + NOEMA_AUDIT_REPORT_ONLY: "1", + NOEMA_REVENUE_EVIDENCE_PATH: revenuePath, + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: outputDir, + }, + }); +} + +describe("top-level acquisition JSON evidence", () => { + it("rejects duplicate decoded revenue keys instead of accepting the last value", () => { + const root = mkdtempSync(join(tmpdir(), "noema-acquisition-duplicate-revenue-")); + try { + writeRequiredDocs(root); + const revenuePath = writeFixture( + root, + "artifacts/acquisition/revenue-evidence.json", + `{ + "arr_krw": 0, + "arr_krw": 300000000, + "gross_margin": 0.75, + "paid_customers": 3, + "customer_concentration_top1": 0.5, + "pipeline_weighted_krw": 0, + "loi_count": 0, + "updated_at": "${new Date().toISOString()}", + "owner": "finance", + "source_documents": ["crm:noema-arr-report"] +}\n`, + ); + const outputDir = join(root, "audit-output"); + const result = runReportOnlyAudit(root, revenuePath, outputDir); + + expect(result.status, result.stderr || result.stdout).toBe(0); + const audit = JSON.parse(readFileSync(join(outputDir, "acquisition-audit.json"), "utf8")); + const presenceCheck = audit.checks.find( + (check: { name?: string }) => check.name === "revenue evidence present", + ); + const readinessCheck = audit.checks.find( + (check: { name?: string }) => check.name === "revenue evidence supports 2B target", + ); + + expect(presenceCheck).toBeDefined(); + expect(presenceCheck.pass).toBe(false); + expect(presenceCheck.details.reason).toBe("duplicate_json_key"); + expect(readinessCheck).toBeUndefined(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects top-level revenue evidence reached through a symbolic-link leaf", () => { + const root = mkdtempSync(join(tmpdir(), "noema-acquisition-symlink-revenue-")); + try { + writeRequiredDocs(root); + const retainedPath = writeFixture( + root, + "retained/revenue-evidence.json", + `{ + "arr_krw": 300000000, + "gross_margin": 0.75, + "paid_customers": 3, + "customer_concentration_top1": 0.5, + "pipeline_weighted_krw": 0, + "loi_count": 0, + "buyer_due_diligence_qna": ["crm:noema-qna"], + "updated_at": "${new Date().toISOString()}", + "owner": "finance", + "source_documents": ["crm:noema-arr-report"] +}\n`, + ); + const revenuePath = join(root, "artifacts/acquisition/revenue-evidence.json"); + mkdirSync(dirname(revenuePath), { recursive: true }); + symlinkSync(retainedPath, revenuePath); + const outputDir = join(root, "audit-output"); + const result = runReportOnlyAudit(root, revenuePath, outputDir); + + expect(result.status, result.stderr || result.stdout).toBe(0); + const audit = JSON.parse(readFileSync(join(outputDir, "acquisition-audit.json"), "utf8")); + const presenceCheck = audit.checks.find( + (check: { name?: string }) => check.name === "revenue evidence present", + ); + const readinessCheck = audit.checks.find( + (check: { name?: string }) => check.name === "revenue evidence supports 2B target", + ); + + expect(presenceCheck).toBeDefined(); + expect(presenceCheck.pass).toBe(false); + expect(presenceCheck.details.reason).toBe("unsafe_or_unreadable"); + expect(readinessCheck).toBeUndefined(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/acquisition-transfer-rights.test.ts b/test/acquisition-transfer-rights.test.ts new file mode 100644 index 000000000..6e1d566b6 --- /dev/null +++ b/test/acquisition-transfer-rights.test.ts @@ -0,0 +1,470 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const temporaryRoots: string[] = []; + +function writeFixture(root: string, relativePath: string, content: string): string { + const path = join(root, relativePath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, "utf8"); + return path; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function writeDigestArtifact(root: string, relativePath: string, content: string) { + writeFixture(root, relativePath, content); + return { + path: relativePath, + sha256: sha256(content), + }; +} + +function writeArtifactRightsMetadata(root: string, ociLicense?: string) { + const ociAnnotations = ociLicense + ? { "org.opencontainers.image.licenses": ociLicense } + : {}; + const content = `${JSON.stringify({ + schema_version: 1, + repository: "ContextualWisdomLab/noema", + tag: "v0.1.0", + commit_sha: "a".repeat(40), + artifacts: [ + { + artifact_kind: "oci_image", + artifact_identity: `ghcr.io/contextualwisdomlab/noema@sha256:${"b".repeat(64)}`, + oci_annotations: ociAnnotations, + }, + ], + }, null, 2)}\n`; + return writeDigestArtifact( + root, + "artifacts/release/artifact-rights-metadata.json", + content, + ); +} + +function writeRequiredAcquisitionDocs(root: string): void { + writeFixture( + root, + "docs/acquisition-readiness-2b.md", + "NOEMA-GOAL-ACQUISITION-2B-2026-07-02\nKRW 2,000,000,000\nRevenue_PASS\nTransfer_PASS\n", + ); + writeFixture( + root, + "docs/buyer-due-diligence-index.md", + "npm run acquisition:audit\nartifacts/acquisition/revenue-evidence.json\nartifacts/acquisition/transfer-evidence.json\n", + ); + writeFixture( + root, + "docs/library-boundary-decision.md", + "현재는 submodule을 만들지 않는다\nnpm workspaces\nSplit Triggers\n", + ); + writeFixture( + root, + "scripts/acquisition-data-room-manifest.mjs", + "// finalGatePassed data-room-manifest.json release-publication-receipt\n", + ); + writeFixture( + root, + "docs/saleable-program-goal-registry.md", + "NOEMA-GOAL-SALEABLE-2026-07-02\n", + ); + writeFixture(root, "docs/pricing-draft.md", "pricing draft\n"); + writeFixture(root, "docs/terms-draft.md", "terms draft\n"); + writeFixture(root, "docs/sla-and-support.md", "support draft\n"); +} + +function writeTransferEvidence( + root: string, + licensingIp?: Record, +): string { + return writeFixture( + root, + "artifacts/acquisition/transfer-evidence.json", + `${JSON.stringify({ + owner: "Acquisition counsel", + source_documents: ["legal/review-record.pdf"], + updated_at: new Date().toISOString(), + license_review: "pass", + third_party_review: "pass", + github_app_transfer_plan: "pass", + cloudflare_transfer_plan: "pass", + secrets_rotation_plan: "pass", + owner_transfer_plan: "pass", + privacy_review: "pass", + ...(licensingIp ? { licensing_ip: licensingIp } : {}), + }, null, 2)}\n`, + ); +} + +function validCustomLicensingIp(root: string): Record { + const rightsBytes = "Copyright ContextualWisdomLab. All rights reserved.\n"; + writeFixture(root, "RIGHTS.md", rightsBytes); + writeFixture( + root, + "package.json", + `${JSON.stringify({ + name: "noema", + private: true, + license: "SEE LICENSE IN RIGHTS.md", + }, null, 2)}\n`, + ); + + return { + owner_legal_decision: { + type: "custom", + evidence: ["legal/outbound-rights-decision.pdf"], + }, + repository_rights: { + path: "RIGHTS.md", + sha256: sha256(rightsBytes), + }, + package_metadata: { + license: "SEE LICENSE IN RIGHTS.md", + }, + release_rights: { + tag: "v0.1.0", + commit_sha: "a".repeat(40), + sbom: writeDigestArtifact( + root, + "artifacts/release/noema.cdx.json", + "{\"bomFormat\":\"CycloneDX\"}\n", + ), + dependency_license_inventory: writeDigestArtifact( + root, + "artifacts/release/dependency-licenses.json", + "{\"dependencies\":[]}\n", + ), + notice: writeDigestArtifact( + root, + "artifacts/release/NOTICE.txt", + "Reviewed third-party notices.\n", + ), + provenance: writeDigestArtifact( + root, + "artifacts/release/provenance.sigstore.json", + "{\"verified\":true}\n", + ), + artifact_rights_metadata: writeArtifactRightsMetadata(root), + }, + contributor_ip: { + ownership_evidence: ["legal/contributor-ownership-register.pdf"], + assignment_evidence: ["legal/ip-assignment-register.pdf"], + }, + }; +} + +function validSpdxLicensingIp(root: string, artifactLicense = "Apache-2.0"): Record { + const rightsBytes = "Apache License 2.0 reviewed fixture.\n"; + writeFixture(root, "LICENSE", rightsBytes); + writeFixture( + root, + "package.json", + `${JSON.stringify({ + name: "noema", + private: true, + license: "Apache-2.0", + }, null, 2)}\n`, + ); + + return { + owner_legal_decision: { + type: "spdx", + license_expression: "Apache-2.0", + evidence: ["legal/outbound-rights-decision.pdf"], + }, + repository_rights: { + path: "LICENSE", + sha256: sha256(rightsBytes), + }, + package_metadata: { + license: "Apache-2.0", + }, + release_rights: { + tag: "v0.1.0", + commit_sha: "a".repeat(40), + sbom: writeDigestArtifact( + root, + "artifacts/release/noema.cdx.json", + "{\"bomFormat\":\"CycloneDX\"}\n", + ), + dependency_license_inventory: writeDigestArtifact( + root, + "artifacts/release/dependency-licenses.json", + "{\"dependencies\":[]}\n", + ), + notice: writeDigestArtifact( + root, + "artifacts/release/NOTICE.txt", + "Reviewed third-party notices.\n", + ), + provenance: writeDigestArtifact( + root, + "artifacts/release/provenance.sigstore.json", + "{\"verified\":true}\n", + ), + artifact_rights_metadata: writeArtifactRightsMetadata(root, artifactLicense), + }, + contributor_ip: { + ownership_evidence: ["legal/contributor-ownership-register.pdf"], + assignment_evidence: ["legal/ip-assignment-register.pdf"], + }, + }; +} + +function runReportOnlyAudit(root: string, transferEvidencePath: string) { + const outputDir = join(root, "audit-output"); + const script = resolve("scripts/acquisition-readiness-audit.mjs"); + const inheritedEnvironment = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("NOEMA_")), + ); + const result = spawnSync(process.execPath, [script], { + cwd: root, + encoding: "utf8", + env: { + ...inheritedEnvironment, + NOEMA_AUDIT_REPORT_ONLY: "1", + NOEMA_TRANSFER_EVIDENCE_PATH: transferEvidencePath, + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: outputDir, + }, + }); + const auditPath = join(outputDir, "acquisition-audit.json"); + if (!existsSync(auditPath)) { + return { result, transferCheck: undefined }; + } + const audit = JSON.parse(readFileSync(auditPath, "utf8")); + const transferCheck = audit.checks.find( + (check: { name?: string }) => check.name === "transfer evidence pass", + ); + return { result, transferCheck }; +} + +afterEach(() => { + while (temporaryRoots.length > 0) { + const root = temporaryRoots.pop(); + if (root) rmSync(root, { recursive: true, force: true }); + } +}); + +describe("acquisition transfer-rights evidence", () => { + it("fails closed when pass labels are not bound to licensing and IP artifacts", () => { + const root = mkdtempSync(join(tmpdir(), "noema-transfer-rights-")); + temporaryRoots.push(root); + writeRequiredAcquisitionDocs(root); + const transferEvidencePath = writeTransferEvidence(root); + + const { result, transferCheck } = runReportOnlyAudit(root, transferEvidencePath); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(transferCheck).toBeDefined(); + expect(transferCheck.pass).toBe(false); + expect(transferCheck.details.licensingIpFailures).toEqual( + expect.arrayContaining([ + "licensing_ip evidence object required", + ]), + ); + }); + + it("accepts a complete custom-rights package and exact-release transfer binding", () => { + const root = mkdtempSync(join(tmpdir(), "noema-transfer-rights-green-")); + temporaryRoots.push(root); + writeRequiredAcquisitionDocs(root); + const transferEvidencePath = writeTransferEvidence(root, validCustomLicensingIp(root)); + + const { result, transferCheck } = runReportOnlyAudit(root, transferEvidencePath); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(transferCheck).toBeDefined(); + expect(transferCheck.pass).toBe(true); + expect(transferCheck.details.licensingIpFailures).toEqual([]); + }); + + it("accepts an OCI license annotation that exactly matches the approved SPDX expression", () => { + const root = mkdtempSync(join(tmpdir(), "noema-transfer-rights-spdx-green-")); + temporaryRoots.push(root); + writeRequiredAcquisitionDocs(root); + const transferEvidencePath = writeTransferEvidence(root, validSpdxLicensingIp(root)); + + const { result, transferCheck } = runReportOnlyAudit(root, transferEvidencePath); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(transferCheck).toBeDefined(); + expect(transferCheck.pass).toBe(true); + expect(transferCheck.details.licensingIpFailures).toEqual([]); + }); + + it("rejects an OCI license annotation that differs from the approved SPDX expression", () => { + const root = mkdtempSync(join(tmpdir(), "noema-transfer-rights-spdx-mismatch-")); + temporaryRoots.push(root); + writeRequiredAcquisitionDocs(root); + const transferEvidencePath = writeTransferEvidence( + root, + validSpdxLicensingIp(root, "MIT"), + ); + + const { result, transferCheck } = runReportOnlyAudit(root, transferEvidencePath); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(transferCheck).toBeDefined(); + expect(transferCheck.pass).toBe(false); + expect(transferCheck.details.licensingIpFailures).toContain( + "OCI license annotation must match the owner-approved SPDX expression exactly", + ); + }); + + it("fails closed when exact-release artifact rights metadata is missing", () => { + const root = mkdtempSync(join(tmpdir(), "noema-transfer-rights-artifact-missing-")); + temporaryRoots.push(root); + writeRequiredAcquisitionDocs(root); + const licensingIp = validCustomLicensingIp(root); + const releaseRights = licensingIp.release_rights as Record; + delete releaseRights.artifact_rights_metadata; + const transferEvidencePath = writeTransferEvidence(root, licensingIp); + + const { result, transferCheck } = runReportOnlyAudit(root, transferEvidencePath); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(transferCheck).toBeDefined(); + expect(transferCheck.pass).toBe(false); + expect(transferCheck.details.licensingIpFailures).toContain( + "release_rights.artifact_rights_metadata artifact binding required", + ); + }); + + it("rejects an OCI license claim that contradicts custom repository rights", () => { + const root = mkdtempSync(join(tmpdir(), "noema-transfer-rights-oci-claim-")); + temporaryRoots.push(root); + writeRequiredAcquisitionDocs(root); + const licensingIp = validCustomLicensingIp(root); + const releaseRights = licensingIp.release_rights as Record; + releaseRights.artifact_rights_metadata = writeArtifactRightsMetadata( + root, + "LicenseRef-Proprietary", + ); + const transferEvidencePath = writeTransferEvidence(root, licensingIp); + + const { result, transferCheck } = runReportOnlyAudit(root, transferEvidencePath); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(transferCheck).toBeDefined(); + expect(transferCheck.pass).toBe(false); + expect(transferCheck.details.licensingIpFailures).toContain( + "custom or unlicensed rights decision forbids OCI license annotation claims", + ); + }); + + it("rejects transfer evidence when package metadata contradicts the owner-approved custom rights", () => { + const root = mkdtempSync(join(tmpdir(), "noema-transfer-rights-mismatch-")); + temporaryRoots.push(root); + writeRequiredAcquisitionDocs(root); + const licensingIp = validCustomLicensingIp(root); + writeFixture( + root, + "package.json", + `${JSON.stringify({ + name: "noema", + private: true, + license: "UNLICENSED", + }, null, 2)}\n`, + ); + licensingIp.package_metadata = { license: "UNLICENSED" }; + const transferEvidencePath = writeTransferEvidence(root, licensingIp); + + const { result, transferCheck } = runReportOnlyAudit(root, transferEvidencePath); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(transferCheck).toBeDefined(); + expect(transferCheck.pass).toBe(false); + expect(transferCheck.details.licensingIpFailures).toEqual( + expect.arrayContaining([ + "custom rights decision requires package.json SEE LICENSE IN metadata", + ]), + ); + }); + + it("rejects a release-rights digest that does not match the retained exact-release artifact", () => { + const root = mkdtempSync(join(tmpdir(), "noema-transfer-rights-digest-")); + temporaryRoots.push(root); + writeRequiredAcquisitionDocs(root); + const licensingIp = validCustomLicensingIp(root); + const releaseRights = licensingIp.release_rights as Record; + const sbom = releaseRights.sbom as Record; + sbom.sha256 = "c".repeat(64); + const transferEvidencePath = writeTransferEvidence(root, licensingIp); + + const { result, transferCheck } = runReportOnlyAudit(root, transferEvidencePath); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(transferCheck).toBeDefined(); + expect(transferCheck.pass).toBe(false); + expect(transferCheck.details.licensingIpFailures).toEqual( + expect.arrayContaining([ + "release_rights.sbom.sha256 does not match retained artifact bytes", + ]), + ); + }); + + it("rejects a release artifact reached through a symlinked parent outside the repository", () => { + const root = mkdtempSync(join(tmpdir(), "noema-transfer-rights-parent-link-")); + const outside = mkdtempSync(join(tmpdir(), "noema-transfer-rights-parent-target-")); + temporaryRoots.push(root, outside); + writeRequiredAcquisitionDocs(root); + const licensingIp = validCustomLicensingIp(root); + const externalSbom = "{\"bomFormat\":\"CycloneDX\",\"outside\":true}\n"; + writeFixture(outside, "noema.cdx.json", externalSbom); + mkdirSync(join(root, "artifacts"), { recursive: true }); + symlinkSync(outside, join(root, "artifacts", "linked-release"), "dir"); + const releaseRights = licensingIp.release_rights as Record; + releaseRights.sbom = { + path: "artifacts/linked-release/noema.cdx.json", + sha256: sha256(externalSbom), + }; + const transferEvidencePath = writeTransferEvidence(root, licensingIp); + + const { result, transferCheck } = runReportOnlyAudit(root, transferEvidencePath); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(transferCheck).toBeDefined(); + expect(transferCheck.pass).toBe(false); + expect(transferCheck.details.licensingIpFailures).toContain( + "release_rights.sbom.path must reference a stable bounded regular retained artifact", + ); + }); + + it("rejects repository-rights bytes supplied through a symlinked root notice", () => { + const root = mkdtempSync(join(tmpdir(), "noema-transfer-rights-leaf-link-")); + const outside = mkdtempSync(join(tmpdir(), "noema-transfer-rights-leaf-target-")); + temporaryRoots.push(root, outside); + writeRequiredAcquisitionDocs(root); + const licensingIp = validCustomLicensingIp(root); + const rightsBytes = "Copyright ContextualWisdomLab. All rights reserved.\n"; + const outsideRights = writeFixture(outside, "RIGHTS.md", rightsBytes); + rmSync(join(root, "RIGHTS.md")); + symlinkSync(outsideRights, join(root, "RIGHTS.md"), "file"); + const transferEvidencePath = writeTransferEvidence(root, licensingIp); + + const { result, transferCheck } = runReportOnlyAudit(root, transferEvidencePath); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(transferCheck).toBeDefined(); + expect(transferCheck.pass).toBe(false); + expect(transferCheck.details.licensingIpFailures).toContain( + "repository rights file missing or unreadable: RIGHTS.md", + ); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 903c7f5bf..ab5671104 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,6 +19,9 @@ export default defineConfig({ "scripts/lib/external-scheduler-evidence-audit.mjs", "scripts/lib/stable-file-evidence.mjs", "scripts/lib/strict-json-evidence.mjs", + "scripts/lib/acquisition-data-room-integrity.mjs", + "scripts/lib/acquisition-git-preflight.mjs", + "scripts/lib/acquisition-private-output.mjs", "scripts/external-scheduler-evidence-audit.mjs", ], thresholds: {